From 5b7eb080a3c2d56312484cc60eac321cf8b03a4a Mon Sep 17 00:00:00 2001 From: ciaranra Date: Wed, 16 Sep 2026 15:57:59 -0600 Subject: [PATCH 1/9] Expose Frontier and BP-Trellis in Python batch decoding --- Cargo.lock | 2 + crates/pecos-decoders/Cargo.toml | 6 +- crates/pecos-decoders/README.md | 16 ++ crates/pecos-decoders/src/spec.rs | 2 + crates/pecos-decoders/src/spec/build.rs | 82 ++++++ crates/pecos-decoders/src/spec/config.rs | 83 ++++++ crates/pecos-decoders/src/spec/parse.rs | 8 +- .../pecos-decoders/tests/bp_trellis_spec.rs | 41 +++ crates/pecos-decoders/tests/frontier_spec.rs | 41 +++ docs/experimental/decoders.md | 6 +- docs/user-guide/decoders.md | 70 ++++- docs/workflows/guppy-dem-decoding.md | 83 +++++- exp/pecos-bp-trellis/README.md | 7 + exp/pecos-frontier/README.md | 7 +- python/pecos-rslib/Cargo.toml | 2 + .../pecos-rslib/src/decoder_spec_bindings.rs | 253 ++++++++++++++++++ .../src/fault_tolerance_bindings.rs | 3 +- .../tests/test_bp_trellis_batch_decode.py | 164 ++++++++++++ .../tests/test_frontier_batch_decode.py | 145 ++++++++++ .../tests/test_sample_batch_decode.py | 7 +- .../src/pecos/decoders/__init__.py | 4 + 21 files changed, 1007 insertions(+), 25 deletions(-) create mode 100644 crates/pecos-decoders/tests/bp_trellis_spec.rs create mode 100644 crates/pecos-decoders/tests/frontier_spec.rs create mode 100644 python/pecos-rslib/tests/test_bp_trellis_batch_decode.py create mode 100644 python/pecos-rslib/tests/test_frontier_batch_decode.py diff --git a/Cargo.lock b/Cargo.lock index ac79667da..138cbc429 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4214,8 +4214,10 @@ name = "pecos-decoders" version = "0.2.0-dev.0" dependencies = [ "pecos-bp", + "pecos-bp-trellis", "pecos-chromobius", "pecos-decoder-core", + "pecos-frontier", "pecos-fusion-blossom", "pecos-ldpc-decoders", "pecos-mwpf", diff --git a/crates/pecos-decoders/Cargo.toml b/crates/pecos-decoders/Cargo.toml index 6d2da9b96..6199b2fdd 100644 --- a/crates/pecos-decoders/Cargo.toml +++ b/crates/pecos-decoders/Cargo.toml @@ -13,6 +13,8 @@ description = "Unified decoder meta-crate for PECOS" [dependencies] pecos-bp.workspace = true +pecos-bp-trellis = { workspace = true, optional = true } +pecos-frontier = { workspace = true, optional = true } pecos-decoder-core.workspace = true pecos-random.workspace = true pecos-ldpc-decoders = { workspace = true, optional = true } @@ -26,6 +28,8 @@ pecos-uf-decoder = { workspace = true, optional = true } [features] default = [] +frontier = ["dep:pecos-frontier"] +bp-trellis = ["dep:pecos-bp-trellis"] ldpc = ["dep:pecos-ldpc-decoders"] fusion-blossom = ["dep:pecos-fusion-blossom"] mwpf = ["dep:pecos-mwpf"] @@ -34,7 +38,7 @@ tesseract = ["dep:pecos-tesseract"] chromobius = ["dep:pecos-chromobius"] relay-bp = ["dep:pecos-relay-bp"] uf = ["dep:pecos-uf-decoder"] -all = ["ldpc", "fusion-blossom", "mwpf", "pymatching", "tesseract", "chromobius", "relay-bp", "uf"] +all = ["bp-trellis", "frontier", "ldpc", "fusion-blossom", "mwpf", "pymatching", "tesseract", "chromobius", "relay-bp", "uf"] [lints] workspace = true diff --git a/crates/pecos-decoders/README.md b/crates/pecos-decoders/README.md index 2e2dfabcd..5fda8d70e 100644 --- a/crates/pecos-decoders/README.md +++ b/crates/pecos-decoders/README.md @@ -25,3 +25,19 @@ Re-exports from `pecos-decoder-core`: - `BatchDecoder` trait - Batch decoding interface - `CssDecoder` trait - CSS code specific decoding - `SoftDecoder` trait - Soft information decoding + +## Frontier + +Enable the `frontier` feature to build the experimental native Rust Frontier +decoder through `DecoderSpec::Frontier(FrontierConfig::default())` or +`DecoderSpec::parse("frontier")`. It accepts raw DEMs with hyperedges and +arbitrary-width observables. Python builds enable this feature and expose +`pecos.decoders.frontier()` for sequential or parallel batch decoding. + +## BP-Trellis + +Enable the `bp-trellis` feature to build PECOS's experimental BP-guided trellis +decoder through `DecoderSpec::BpTrellis(BpTrellisConfig::default())` or +`DecoderSpec::parse("bp_trellis")`. Python exposes `pecos.decoders.bp_trellis()` +with all configuration options, including optional no-path escalation widths, +for sequential or parallel batch decoding. diff --git a/crates/pecos-decoders/src/spec.rs b/crates/pecos-decoders/src/spec.rs index 938702505..3623ce2dd 100644 --- a/crates/pecos-decoders/src/spec.rs +++ b/crates/pecos-decoders/src/spec.rs @@ -53,6 +53,8 @@ pub enum DecoderSpec { UnionFind, RelayBp(RelayBpConfig), MinSumBp(MinSumBpConfig), + Frontier(FrontierConfig), + BpTrellis(BpTrellisConfig), PecosUf(PecosUfPreset), BeliefMatching(BeliefMatchingConfig), Windowed(WindowedConfig), diff --git a/crates/pecos-decoders/src/spec/build.rs b/crates/pecos-decoders/src/spec/build.rs index 7bee40b77..3b1257b26 100644 --- a/crates/pecos-decoders/src/spec/build.rs +++ b/crates/pecos-decoders/src/spec/build.rs @@ -218,6 +218,8 @@ fn build_single(spec: &DecoderSpec, dem: &str) -> Result build_belief_find(dem), DecoderSpec::UnionFind => build_union_find(dem), DecoderSpec::RelayBp(config) => build_relay_bp(dem, config), + DecoderSpec::BpTrellis(config) => build_bp_trellis(dem, config), + DecoderSpec::Frontier(config) => build_frontier(dem, config), DecoderSpec::MinSumBp(config) => build_min_sum_bp(dem, config), DecoderSpec::PecosUf(preset) => build_pecos_uf(dem, *preset), DecoderSpec::BeliefMatching(config) => build_belief_matching(dem, config), @@ -254,6 +256,8 @@ fn family_name(spec: &DecoderSpec) -> &'static str { DecoderSpec::BeliefFind => "belief_find", DecoderSpec::UnionFind => "union_find", DecoderSpec::RelayBp(_) => "relay_bp", + DecoderSpec::BpTrellis(_) => "bp_trellis", + DecoderSpec::Frontier(_) => "frontier", DecoderSpec::MinSumBp(_) => "min_sum_bp", DecoderSpec::PecosUf(_) => "pecos_uf", DecoderSpec::BeliefMatching(_) => "belief_matching", @@ -1384,6 +1388,84 @@ fn build_ensemble( )) } +#[cfg(feature = "frontier")] +fn build_frontier( + dem: &str, + config: &super::config::FrontierConfig, +) -> Result, DecoderError> { + use super::config::{FrontierColumnOrder, FrontierMetricMode}; + use pecos_frontier::{FrontierConfig, FrontierDecoder, MetricMode, SparseDem}; + let dem = SparseDem::from_dem_str(dem)?; + let column_order = match &config.column_order { + FrontierColumnOrder::Deadline => Some(pecos_frontier::deadline_column_order(&dem)?), + FrontierColumnOrder::Time => None, + FrontierColumnOrder::BackwardDeadline => { + Some(pecos_frontier::backward_deadline_column_order(&dem)?) + } + FrontierColumnOrder::Explicit(order) => Some(order.clone()), + }; + let decoder = FrontierDecoder::from_sparse_dem( + &dem, + FrontierConfig { + k: config.k, + delta: config.delta, + score_alpha: config.score_alpha, + column_order, + merge_indistinguishable: config.merge_indistinguishable, + bp_score_iterations: config.bp_score_iterations, + metric_mode: match config.metric_mode { + FrontierMetricMode::LogSumExpFloat => MetricMode::LogSumExpFloat, + FrontierMetricMode::MaxLogInt => MetricMode::MaxLogInt, + }, + int_metric_scale: config.int_metric_scale, + }, + )?; + Ok(Box::new(decoder)) +} + +#[cfg(not(feature = "frontier"))] +fn build_frontier( + _dem: &str, + _config: &super::config::FrontierConfig, +) -> Result, DecoderError> { + unavailable("frontier", "frontier") +} + +#[cfg(feature = "bp-trellis")] +fn build_bp_trellis( + dem: &str, + config: &super::config::BpTrellisConfig, +) -> Result, DecoderError> { + use super::config::BpTrellisOrdering; + use pecos_bp_trellis::{BpTrellisConfig, BpTrellisDecoder, TrellisOrdering}; + let ordering = match &config.ordering { + BpTrellisOrdering::Deadline => TrellisOrdering::Deadline, + BpTrellisOrdering::BackwardDeadline => TrellisOrdering::BackwardDeadline, + BpTrellisOrdering::TimeOrder => TrellisOrdering::TimeOrder, + BpTrellisOrdering::Explicit(order) => TrellisOrdering::Explicit(order.clone()), + }; + Ok(Box::new(BpTrellisDecoder::from_dem_str( + dem, + BpTrellisConfig { + k: config.k, + delta: config.delta, + score_alpha: config.score_alpha, + bp_score_iterations: config.bp_score_iterations, + merge_indistinguishable: config.merge_indistinguishable, + ordering, + escalation_ks: config.escalation_ks.clone(), + }, + )?)) +} + +#[cfg(not(feature = "bp-trellis"))] +fn build_bp_trellis( + _dem: &str, + _config: &super::config::BpTrellisConfig, +) -> Result, DecoderError> { + unavailable("bp_trellis", "bp-trellis") +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/pecos-decoders/src/spec/config.rs b/crates/pecos-decoders/src/spec/config.rs index fc0040eb8..a3f2cbd3e 100644 --- a/crates/pecos-decoders/src/spec/config.rs +++ b/crates/pecos-decoders/src/spec/config.rs @@ -337,3 +337,86 @@ impl Default for BeamSearchConfig { pub struct EnsembleConfig { pub members: Vec, } + +/// Mechanism ordering for the Frontier decoder. +#[derive(Clone, Debug, Default, PartialEq)] +pub enum FrontierColumnOrder { + #[default] + Deadline, + Time, + BackwardDeadline, + Explicit(Vec), +} + +/// Route metric for the Frontier decoder. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum FrontierMetricMode { + #[default] + LogSumExpFloat, + MaxLogInt, +} + +/// Feature-independent Frontier options, matching the Python Frontier defaults. +#[derive(Clone, Debug, PartialEq)] +pub struct FrontierConfig { + pub k: usize, + pub delta: f64, + pub score_alpha: f64, + pub column_order: FrontierColumnOrder, + pub merge_indistinguishable: bool, + pub bp_score_iterations: usize, + pub metric_mode: FrontierMetricMode, + pub int_metric_scale: i32, +} + +impl Default for FrontierConfig { + fn default() -> Self { + Self { + k: 64, + delta: 50.0, + score_alpha: 0.8, + column_order: FrontierColumnOrder::Deadline, + merge_indistinguishable: false, + bp_score_iterations: 0, + metric_mode: FrontierMetricMode::LogSumExpFloat, + int_metric_scale: 1024, + } + } +} + +/// Mechanism ordering for the BP-Trellis decoder. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub enum BpTrellisOrdering { + #[default] + Deadline, + BackwardDeadline, + TimeOrder, + Explicit(Vec), +} + +/// Feature-independent options matching the native BP-Trellis facade. +#[derive(Clone, Debug, PartialEq)] +pub struct BpTrellisConfig { + pub k: usize, + pub delta: f64, + pub score_alpha: f64, + pub bp_score_iterations: usize, + pub merge_indistinguishable: bool, + pub ordering: BpTrellisOrdering, + /// Additional widths attempted only when the preceding decode has no path. + pub escalation_ks: Vec, +} + +impl Default for BpTrellisConfig { + fn default() -> Self { + Self { + k: 8, + delta: 100.0, + score_alpha: 0.8, + bp_score_iterations: 5, + merge_indistinguishable: true, + ordering: BpTrellisOrdering::Deadline, + escalation_ks: Vec::new(), + } + } +} diff --git a/crates/pecos-decoders/src/spec/parse.rs b/crates/pecos-decoders/src/spec/parse.rs index 0c1d1da7f..5a77de546 100644 --- a/crates/pecos-decoders/src/spec/parse.rs +++ b/crates/pecos-decoders/src/spec/parse.rs @@ -32,6 +32,12 @@ pub(super) fn parse(type_string: &str) -> Result { "belief_find" => Ok(DecoderSpec::BeliefFind), "union_find" => Ok(DecoderSpec::UnionFind), "relay_bp" => Ok(DecoderSpec::RelayBp(RelayBpConfig::default())), + "frontier" => Ok(DecoderSpec::Frontier( + super::config::FrontierConfig::default(), + )), + "bp_trellis" => Ok(DecoderSpec::BpTrellis( + super::config::BpTrellisConfig::default(), + )), "min_sum_bp" => Ok(DecoderSpec::MinSumBp(MinSumBpConfig::default())), "pecos_uf" | "pecos_uf:fast" => Ok(DecoderSpec::PecosUf(PecosUfPreset::Fast)), "pecos_uf:balanced" | "pecos_uf_correlated" => { @@ -74,7 +80,7 @@ pub(super) fn parse(type_string: &str) -> Result { "Unsupported decoder_type: {type_string}. \ Supported: pymatching, tesseract, mwpf, pecos_uf (or \ pecos_uf:fast/balanced/accurate), logical_subgraph, ensemble:d1,d2,..., \ - bp_osd, bp_lsd, union_find, relay_bp, min_sum_bp." + bp_osd, bp_lsd, union_find, relay_bp, min_sum_bp, frontier, bp_trellis." )), } } diff --git a/crates/pecos-decoders/tests/bp_trellis_spec.rs b/crates/pecos-decoders/tests/bp_trellis_spec.rs new file mode 100644 index 000000000..49b07fd31 --- /dev/null +++ b/crates/pecos-decoders/tests/bp_trellis_spec.rs @@ -0,0 +1,41 @@ +//! Feature-independent specs and native BpTrellis construction. +use pecos_decoders::spec::{BpTrellisConfig, DecodeModel, DecoderSpec}; + +#[test] +fn bp_trellis_spec_is_feature_independent() { + let spec = DecoderSpec::parse("bp_trellis").unwrap(); + assert_eq!(spec, DecoderSpec::BpTrellis(BpTrellisConfig::default())); + assert!(!spec.execution_traits().history_dependent); + assert!(!spec.execution_traits().wall_clock_dependent); + assert!(!spec.requires_graphlike_model()); +} + +#[cfg(not(feature = "bp-trellis"))] +#[test] +fn missing_feature_is_actionable() { + let result = DecoderSpec::parse("bp_trellis") + .unwrap() + .build(&DecodeModel::SingleDem("error(0.1) D0 L0".into())); + assert!(matches!( + result, + Err(pecos_decoders::DecoderError::BackendUnavailable { + family: "bp_trellis", + required_feature: "bp-trellis", + }) + )); +} + +#[cfg(feature = "bp-trellis")] +#[test] +fn raw_hyperedges_and_wide_observables() { + let spec = DecoderSpec::parse("bp_trellis").unwrap(); + let mut decoder = spec + .build(&DecodeModel::SingleDem("error(0.1) D0 D1 D2 L70\n".into())) + .unwrap(); + assert_eq!(decoder.num_detectors(), Some(3)); + let predictions = decoder + .decode_batch_to_observables(&[1, 1, 1, 0, 0, 0], 2, 3) + .unwrap(); + assert_eq!(predictions[0].words(), &[0, 1 << 6]); + assert!(predictions[1].words().iter().all(|&word| word == 0)); +} diff --git a/crates/pecos-decoders/tests/frontier_spec.rs b/crates/pecos-decoders/tests/frontier_spec.rs new file mode 100644 index 000000000..2448b2844 --- /dev/null +++ b/crates/pecos-decoders/tests/frontier_spec.rs @@ -0,0 +1,41 @@ +//! Feature-independent specs and native Frontier construction. +use pecos_decoders::spec::{DecodeModel, DecoderSpec, FrontierConfig}; + +#[test] +fn frontier_spec_is_feature_independent() { + let spec = DecoderSpec::parse("frontier").unwrap(); + assert_eq!(spec, DecoderSpec::Frontier(FrontierConfig::default())); + assert!(!spec.execution_traits().history_dependent); + assert!(!spec.execution_traits().wall_clock_dependent); + assert!(!spec.requires_graphlike_model()); +} + +#[cfg(not(feature = "frontier"))] +#[test] +fn missing_feature_is_actionable() { + let result = DecoderSpec::parse("frontier") + .unwrap() + .build(&DecodeModel::SingleDem("error(0.1) D0 L0".into())); + assert!(matches!( + result, + Err(pecos_decoders::DecoderError::BackendUnavailable { + family: "frontier", + required_feature: "frontier", + }) + )); +} + +#[cfg(feature = "frontier")] +#[test] +fn raw_hyperedges_and_wide_observables() { + let spec = DecoderSpec::parse("frontier").unwrap(); + let mut decoder = spec + .build(&DecodeModel::SingleDem("error(0.1) D0 D1 D2 L70\n".into())) + .unwrap(); + assert_eq!(decoder.num_detectors(), Some(3)); + let predictions = decoder + .decode_batch_to_observables(&[1, 1, 1, 0, 0, 0], 2, 3) + .unwrap(); + assert_eq!(predictions[0].words(), &[0, 1 << 6]); + assert!(predictions[1].words().iter().all(|&word| word == 0)); +} diff --git a/docs/experimental/decoders.md b/docs/experimental/decoders.md index d1bfb1eb6..e6951d4cd 100644 --- a/docs/experimental/decoders.md +++ b/docs/experimental/decoders.md @@ -1,7 +1,9 @@ # Experimental decoders -Decoders in `exp/` that are not yet part of the unified `pecos.decoders` surface. -They are reached through `pecos_rslib_exp` and may change without notice. +Experimental decoder engines live in `exp/` and may change without notice. +Frontier and BP-Trellis are available through `pecos.decoders.frontier()` and +`pecos.decoders.bp_trellis()` for unified parallel batch decoding. The detailed-result APIs described here are reached through +`pecos_rslib_exp`. Two capabilities live here that the production decoders do not offer: diff --git a/docs/user-guide/decoders.md b/docs/user-guide/decoders.md index d40bc0b4e..e699be53d 100644 --- a/docs/user-guide/decoders.md +++ b/docs/user-guide/decoders.md @@ -36,6 +36,8 @@ The following decoder APIs and supporting types are publicly re-exported from | API | Primary input | Description | |-----|---------------|-------------| | `MWPM2D` | QECC object | Legacy minimum-weight perfect matching for 2D codes. | +| `bp_trellis()` | Raw DEM text via `SampleBatch.decode` | Experimental native Rust BP-guided trellis, with parallel shots and optional no-path retries. | +| `frontier()` | Raw DEM text via `SampleBatch.decode` | Experimental native Rust Frontier, with parallel shot decoding. | | `DummyDecoder` | None | No-op decoder for tests and interface benchmarks. | | `PyMatchingDecoder` | Graph-like DEM text or `CheckMatrix` | PyMatching minimum-weight perfect matching, with optional correlated decoding. | | `FusionBlossomDecoder` | Check matrix, standard-code parameters, or a manual graph | Pure-Rust minimum-weight perfect matching. | @@ -74,6 +76,8 @@ The Rust API provides access to a broader set of decoders: - Fusion Blossom MWPM (feature: `fusion-blossom`) - PyMatching MWPM (feature: `pymatching`) - Tesseract (feature: `tesseract`) +- Frontier, experimental (feature: `frontier`) +- BP-Trellis, experimental (feature: `bp-trellis`) - Chromobius color code decoder (feature: `chromobius`) ## Installation and Setup @@ -331,6 +335,68 @@ match decoder.decode(&syndrome.view()) { - Use multiple threads for batch decoding - Consider memory layout for cache efficiency +## Rust-backed Frontier batch decoding + +```python +from pecos.decoders import frontier +from pecos_rslib.qec import SampleBatch + +dem = "error(0.1) D0 D1 D2 L0\n" +batch = SampleBatch([[1, 1, 1], [0, 0, 0]], [1, 0]) +result = batch.decode(dem, frontier(k=64), workers=2, predictions=True) +assert result.predictions == [1, 0] +assert result.num_errors == 0 +``` + +Frontier accepts raw DEMs, including hyperedges. `workers=None` selects the +worker count automatically; `workers=1` runs sequentially. Parallel execution +releases the Python GIL and uses one Rust decoder per worker, preserving shot +order. More workers and larger `k` increase memory use. + +Options match `pecos_rslib_exp.FrontierDecoder.from_dem`: `k`, `delta`, +`score_alpha`, `bp_score_iterations`, `column_order`, `merge_indistinguishable`, +`metric_mode`, and `int_metric_scale`. The default ordering is +`"deadline_reorder"`; `"time_order"`, `"backward_deadline_reorder"`, and explicit +column permutations are also accepted. Frontier remains experimental, and +pruning can make predictions approximate. For per-shot logical masses, pruning +status, and complementary gaps, use the direct experimental binding. + +## Rust-backed BP-Trellis batch decoding + +```python +from pecos.decoders import bp_trellis +from pecos_rslib.qec import SampleBatch + +dem = "error(0.1) D0 D1 D2 L0\n" +batch = SampleBatch([[1, 1, 1], [0, 0, 0]], [1, 0]) +spec = bp_trellis( + k=8, + delta=100.0, + score_alpha=0.8, + bp_score_iterations=5, + merge_indistinguishable=True, + ordering="deadline", + escalation_ks=[32, 128], +) +result = batch.decode(dem, spec, workers=2, predictions=True) +assert result.predictions == [1, 0] +assert result.num_errors == 0 +``` + +All seven native BP-Trellis configuration options are exposed. The example opts +into a retry ladder; the default `escalation_ks=None` disables retries. Retries +occur only after a no-path result, not after a successful but incorrect prediction. +Each worker prebuilds its own ladder, increasing construction time and memory. +`ordering` also accepts `"backward_deadline"`, `"time_order"`, or an explicit +mechanism permutation. BP-Trellis uses floating-point coset masses and does not +expose Frontier's integer metric options. + +Like Frontier, BP-Trellis accepts raw hyperedges and arbitrary-width observables, +releases the GIL during batch decoding, and supports automatic worker selection +and `DemSampler.decode(...)`. It remains experimental. Use +`pecos_rslib_exp.BpTrellisDecoder` for per-shot confidence, pruning status, and +retry telemetry; the unified batch result returns predictions and aggregate scores. + ## Hyperedge models and matching decoders Matching-style decoders (PyMatching, Fusion Blossom and its perturbed @@ -350,8 +416,8 @@ hyperedges such as bp_osd or tesseract. ``` Decode such a model with a decoder that represents hyperedges directly -- -`bp_osd()` or `tesseract()` -- or supply a decomposed projection (a model -written with `^` separators passes: each component is graphlike). See +`bp_osd()`, `tesseract()`, `frontier()`, or `bp_trellis()` -- or supply a +decomposed projection (a model written with `^` separators passes: each component is graphlike). See [Experimental Decoders](../experimental/decoders.md) for the Frontier and BP-Trellis decoders, which additionally report a per-shot complementary gap, and for provenance-based decomposition of a hyperedge model into a graphlike diff --git a/docs/workflows/guppy-dem-decoding.md b/docs/workflows/guppy-dem-decoding.md index 19b929228..85253bc5d 100644 --- a/docs/workflows/guppy-dem-decoding.md +++ b/docs/workflows/guppy-dem-decoding.md @@ -293,13 +293,14 @@ assert len(sim_shots) == 500 ## 5. Decode the samples and compute logical error rates Pass the same sampled batch to `batch.decode(...)` with a typed specification -for each decoder. A shot counts as a logical error when the predicted -observable flip disagrees with the flip the sample actually carried. The +for PyMatching, Tesseract, BP+OSD, and the Rust-backed Frontier and BP-Trellis +decoders. A shot counts as a logical error when the predicted observable flip +disagrees with the flip the sample actually carried. The returned `DecodeResult` supplies the aggregate count and rate directly. ```python -from pecos.decoders import bp_osd, pymatching, tesseract +from pecos.decoders import bp_osd, bp_trellis, frontier, pymatching, tesseract pymatching_result = batch.decode( terminal_graphlike_text, @@ -316,22 +317,64 @@ bp_osd_result = batch.decode( workers=None, ) +frontier_result = batch.decode( + raw_text, + frontier(k=64), + workers=4, + predictions=True, +) + +bp_trellis_result = batch.decode( + raw_text, + bp_trellis(k=8, escalation_ks=[32, 128]), + workers=4, + predictions=True, +) + pymatching_errors = pymatching_result.num_errors tesseract_errors = tesseract_result.num_errors bp_osd_errors = bp_osd_result.num_errors +frontier_errors = frontier_result.num_errors +bp_trellis_errors = bp_trellis_result.num_errors shots = batch.num_shots assert 0 < pymatching_errors < shots assert 0 < tesseract_errors < shots assert 0 < bp_osd_errors < shots +assert 0 < frontier_errors < shots +assert len(frontier_result.predictions) == shots +assert 0 < bp_trellis_errors < shots +assert len(bp_trellis_result.predictions) == shots print("DEM-sampled shots") print(f"pymatching {pymatching_errors:5} {pymatching_errors / shots:.4%}") print(f"tesseract {tesseract_errors:5} {tesseract_errors / shots:.4%}") print(f"bp_osd {bp_osd_errors:5} {bp_osd_errors / shots:.4%}") +print(f"frontier {frontier_errors:5} {frontier_result.logical_error_rate:.4%}") +print(f"bp_trellis {bp_trellis_errors:5} {bp_trellis_result.logical_error_rate:.4%}") print(f"pymatching execution path: {pymatching_result.execution_path}") +print(f"frontier execution path: {frontier_result.execution_path}") +assert frontier_result.execution_path == "parallel" +assert frontier_result.workers_used == 4 +print(f"bp_trellis execution path: {bp_trellis_result.execution_path}") +assert bp_trellis_result.execution_path == "parallel" +assert bp_trellis_result.workers_used == 4 ``` +`frontier()` uses the native Rust Frontier decoder and accepts the raw DEM, +including hyperedges. The example decodes shots across four Rust worker threads; +`predictions=True` also returns observable masks in original shot order. Each +worker owns a decoder, so memory use grows with the worker count and frontier +width `k`. Frontier remains experimental; pruning can make its answers approximate. + +`bp_trellis()` uses PECOS’s native BP-guided trellis decoder on the same raw +DEM. Its defaults are `k=8`, `delta=100.0`, `score_alpha=0.8`, +`bp_score_iterations=5`, `merge_indistinguishable=True`, `ordering="deadline"`, +and `escalation_ks=None`. The example opts into retry widths `[32, 128]`: these +are attempted only if the preceding search finds no path. Each worker prebuilds +the retry decoders, so enabling a ladder increases construction time and memory. +BP-Trellis also remains experimental; pruning can make predictions approximate. + With `workers=None`, PECOS automatically selects a native-batch, sequential, or parallel path based on the decoder and batch size. Pass `workers=N` to request an exact worker count. `result.execution_path` reports which path ran. Request @@ -359,23 +402,37 @@ sim_errors = sim_batch.decode( pymatching(correlated=True), ).num_errors +sim_frontier_result = sim_batch.decode( + raw_text, + frontier(k=64), + workers=4, +) + +sim_bp_trellis_result = sim_batch.decode( + raw_text, + bp_trellis(escalation_ks=[32, 128]), + workers=4, +) + print(f"simulated shots, pymatching: {sim_errors}/{len(sim_shots)}") +print(f"simulated shots, frontier: {sim_frontier_result.num_errors}/{len(sim_shots)}") +print(f"simulated shots, bp_trellis: {sim_bp_trellis_result.num_errors}/{len(sim_shots)}") ``` -At this noise level the three decoders land within about a percentage point of +At this noise level the five decoders land within about a percentage point of each other on this code; the gaps between decoders widen with code distance and -with genuinely hyperedge-like noise, which is where BP+OSD and Tesseract consume -the raw model rather than a graph-like projection. +with genuinely hyperedge-like noise. Frontier, BP-Trellis, and BP+OSD consume the raw model +in this example; Tesseract uses the source-informed decomposition chosen above. ## 6. Optional: per-shot confidence with an experimental decoder !!! warning "Experimental API" - The decoders below live in `exp/` and are reached through - `pecos_rslib_exp`. They are under active development, are not part of the - `pecos.decoders` surface, and may change without notice. They also do not - participate in the unified execution planning used above — call them - directly rather than through `batch.decode(...)`. + The detailed-result APIs below live in `pecos_rslib_exp` and may change + without notice. Use `pecos.decoders.frontier()` or `bp_trellis()` with `batch.decode(...)` + for parallel predictions and aggregate error rates. Use these direct APIs + when you need per-shot confidence data; that data is not returned by + `batch.decode(...)`. Every decoder in stage 5 answers "which observables flipped?". None of them reports how close the call was. The experimental Frontier and BP-Trellis @@ -393,8 +450,8 @@ missing gap is not a pruning signal. ```python from pecos_rslib_exp import FrontierDecoder -frontier = FrontierDecoder.from_dem(raw_text) -results = [frontier.decode_syndrome(batch.get_syndrome(shot)) for shot in range(200)] +frontier_decoder = FrontierDecoder.from_dem(raw_text) +results = [frontier_decoder.decode_syndrome(batch.get_syndrome(shot)) for shot in range(200)] assert all(result.status == "exact" for result in results) gaps = [result.runner_up_gap for result in results if result.runner_up_gap is not None] diff --git a/exp/pecos-bp-trellis/README.md b/exp/pecos-bp-trellis/README.md index d079c41d4..553b87b45 100644 --- a/exp/pecos-bp-trellis/README.md +++ b/exp/pecos-bp-trellis/README.md @@ -12,3 +12,10 @@ project. remain provisional pending broader validation. The shared trellis engine lives in `pecos-trellis`; this crate contains PECOS's configuration and decoder facade. + +The `pecos-decoders` meta-crate exposes this facade through its `bp-trellis` +feature and `DecoderSpec::BpTrellis`. Python builds enable it and provide +`pecos.decoders.bp_trellis(...)` for `SampleBatch.decode` and `DemSampler.decode`, +including parallel Rust workers. All seven configuration options are exposed. +The direct `pecos_rslib_exp.BpTrellisDecoder` API additionally returns detailed +per-shot confidence and retry telemetry. diff --git a/exp/pecos-frontier/README.md b/exp/pecos-frontier/README.md index 398bf41fc..94f262f18 100644 --- a/exp/pecos-frontier/README.md +++ b/exp/pecos-frontier/README.md @@ -6,9 +6,10 @@ decoder (Leverrier & Urbanke, arXiv:2606.20513). Not a wrap of the upstream **Experimental** (`exp/`): the algorithm core is enumeration- and upstream-verified (per-shot parity on matched models), but the crate has not yet accumulated real-user -mileage. Graduation to `crates/` and registration in the `pecos-decoders` meta-crate -are planned once it has been exercised more broadly (larger code families, Python -bindings, human users). +mileage. It is registered in the `pecos-decoders` meta-crate behind the `frontier` feature +and exposed in Python as `pecos.decoders.frontier()` for parallel +`SampleBatch.decode` execution. The detailed per-shot API remains available in +`pecos_rslib_exp`. Graduation to `crates/` awaits broader real-world use. Pruning ranks accumulated prefix log mass plus a `score_alpha`-weighted suffix-compatibility estimate. Unpruned results are exact and upstream-verified. diff --git a/python/pecos-rslib/Cargo.toml b/python/pecos-rslib/Cargo.toml index 09ed34b19..15f08f6bd 100644 --- a/python/pecos-rslib/Cargo.toml +++ b/python/pecos-rslib/Cargo.toml @@ -77,6 +77,8 @@ pecos-cppsparsestab.workspace = true # Decoders (all backends except mwpf, which remains opt in via the top-level # `mwpf` feature on this crate). pecos-decoders = { workspace = true, features = [ + "frontier", + "bp-trellis", "ldpc", "fusion-blossom", "pymatching", diff --git a/python/pecos-rslib/src/decoder_spec_bindings.rs b/python/pecos-rslib/src/decoder_spec_bindings.rs index dc12b5537..b93aefbc6 100644 --- a/python/pecos-rslib/src/decoder_spec_bindings.rs +++ b/python/pecos-rslib/src/decoder_spec_bindings.rs @@ -656,6 +656,8 @@ fn spec_family_name(spec: &pecos_decoders::DecoderSpec) -> &'static str { pecos_decoders::DecoderSpec::BeliefFind => "belief_find", pecos_decoders::DecoderSpec::UnionFind => "union_find", pecos_decoders::DecoderSpec::RelayBp(_) => "relay_bp", + pecos_decoders::DecoderSpec::BpTrellis(_) => "bp_trellis", + pecos_decoders::DecoderSpec::Frontier(_) => "frontier", pecos_decoders::DecoderSpec::MinSumBp(_) => "min_sum_bp", pecos_decoders::DecoderSpec::PecosUf(_) => "pecos_uf", pecos_decoders::DecoderSpec::BeliefMatching(_) => "belief_matching", @@ -735,6 +737,8 @@ fn spec_repr(spec: &pecos_decoders::DecoderSpec) -> String { pecos_decoders::DecoderSpec::BeliefFind => finish_repr("belief_find", Vec::new()), pecos_decoders::DecoderSpec::UnionFind => finish_repr("union_find", Vec::new()), pecos_decoders::DecoderSpec::RelayBp(config) => relay_bp_repr(config), + pecos_decoders::DecoderSpec::BpTrellis(config) => bp_trellis_repr(config), + pecos_decoders::DecoderSpec::Frontier(config) => frontier_repr(config), pecos_decoders::DecoderSpec::MinSumBp(config) => { let default = MinSumBpConfig::default(); let mut args = Vec::new(); @@ -1001,6 +1005,8 @@ fn stopping_repr(value: RelayStoppingCriterion) -> String { /// Register typed decoder-spec factories on `pecos_rslib.decoders`. pub fn register_decoder_specs(module: &Bound<'_, PyModule>) -> PyResult<()> { module.add_class::()?; + module.add_function(wrap_pyfunction!(bp_trellis, module)?)?; + module.add_function(wrap_pyfunction!(frontier, module)?)?; module.add_function(wrap_pyfunction!(pymatching, module)?)?; module.add_function(wrap_pyfunction!(tesseract, module)?)?; module.add_function(wrap_pyfunction!(bp_osd, module)?)?; @@ -1023,3 +1029,250 @@ pub fn register_decoder_specs(module: &Bound<'_, PyModule>) -> PyResult<()> { module.add_function(wrap_pyfunction!(perturbed_fb_corr, module)?)?; Ok(()) } + +#[derive(FromPyObject)] +enum FrontierOrderArgument { + Name(String), + Explicit(Vec), +} + +impl Default for FrontierOrderArgument { + fn default() -> Self { + Self::Name("deadline_reorder".to_owned()) + } +} + +/// Native Rust Frontier decoder for raw DEMs, including hyperedges. +/// Batch decoding supports independent Rust workers. Pruning makes predictions +/// approximate; use pecos_rslib_exp.FrontierDecoder for per-shot confidence data. +#[pyfunction] +#[pyo3(signature = (*, k=64, delta=50.0, score_alpha=0.8, bp_score_iterations=0, column_order=FrontierOrderArgument::default(), merge_indistinguishable=false, metric_mode="logsumexp_float", int_metric_scale=1024), + text_signature = "(*, k=64, delta=50.0, score_alpha=0.8, bp_score_iterations=0, column_order='deadline_reorder', merge_indistinguishable=False, metric_mode='logsumexp_float', int_metric_scale=1024)")] +fn frontier( + k: usize, + delta: f64, + score_alpha: f64, + bp_score_iterations: usize, + column_order: FrontierOrderArgument, + merge_indistinguishable: bool, + metric_mode: &str, + int_metric_scale: i32, +) -> PyResult { + use pecos_decoders::spec::{FrontierColumnOrder, FrontierConfig, FrontierMetricMode}; + if k == 0 { + return Err(PyValueError::new_err("k must be at least 1")); + } + if delta.is_nan() || delta < 0.0 { + return Err(PyValueError::new_err( + "delta must be non-negative and not NaN", + )); + } + let score_alpha = non_negative("score_alpha", score_alpha)?; + if int_metric_scale <= 0 { + return Err(PyValueError::new_err("int_metric_scale must be positive")); + } + let metric_mode = match metric_mode.trim() { + "logsumexp_float" | "float" | "exact" => FrontierMetricMode::LogSumExpFloat, + "maxlog_int" | "max_log_int" | "viterbi_int" | "frontierLite" | "frontier_lite" + | "frontier-lite" | "frontierlite" => FrontierMetricMode::MaxLogInt, + value => { + return Err(invalid_choice( + "metric_mode", + value, + "'logsumexp_float', 'maxlog_int'", + )); + } + }; + if metric_mode == FrontierMetricMode::MaxLogInt { + if !delta.is_finite() { + return Err(PyValueError::new_err( + "delta must be finite under maxlog_int", + )); + } + if merge_indistinguishable { + return Err(PyValueError::new_err( + "merge_indistinguishable is incompatible with maxlog_int", + )); + } + } + let column_order = match column_order { + FrontierOrderArgument::Explicit(order) => FrontierColumnOrder::Explicit(order), + FrontierOrderArgument::Name(name) => match name.as_str() { + "deadline_reorder" => FrontierColumnOrder::Deadline, + "time_order" => FrontierColumnOrder::Time, + "backward_deadline_reorder" => FrontierColumnOrder::BackwardDeadline, + value => { + return Err(invalid_choice( + "column_order", + value, + "'deadline_reorder', 'time_order', 'backward_deadline_reorder', or a list of column indices", + )); + } + }, + }; + Ok(PyDecoderSpec::new(pecos_decoders::DecoderSpec::Frontier( + FrontierConfig { + k, + delta, + score_alpha, + bp_score_iterations, + column_order, + merge_indistinguishable, + metric_mode, + int_metric_scale, + }, + ))) +} + +fn frontier_repr(config: &pecos_decoders::spec::FrontierConfig) -> String { + use pecos_decoders::spec::{FrontierColumnOrder, FrontierConfig, FrontierMetricMode}; + let default = FrontierConfig::default(); + let mut args = Vec::new(); + if config.k != default.k { + args.push(format!("k={}", config.k)); + } + if config.delta.to_bits() != default.delta.to_bits() { + args.push(if config.delta.is_infinite() { + "delta=float('inf')".to_owned() + } else { + format!("delta={:?}", config.delta) + }); + } + if config.score_alpha.to_bits() != default.score_alpha.to_bits() { + args.push(format!("score_alpha={:?}", config.score_alpha)); + } + if config.bp_score_iterations != 0 { + args.push(format!( + "bp_score_iterations={}", + config.bp_score_iterations + )); + } + match &config.column_order { + FrontierColumnOrder::Deadline => {} + FrontierColumnOrder::Time => args.push("column_order='time_order'".to_owned()), + FrontierColumnOrder::BackwardDeadline => { + args.push("column_order='backward_deadline_reorder'".to_owned()); + } + FrontierColumnOrder::Explicit(order) => args.push(format!("column_order={order:?}")), + } + if config.merge_indistinguishable { + args.push("merge_indistinguishable=True".to_owned()); + } + if config.metric_mode == FrontierMetricMode::MaxLogInt { + args.push("metric_mode='maxlog_int'".to_owned()); + } + if config.int_metric_scale != default.int_metric_scale { + args.push(format!("int_metric_scale={}", config.int_metric_scale)); + } + finish_repr("frontier", args) +} + +#[derive(FromPyObject)] +enum BpTrellisOrderArgument { + Name(String), + Explicit(Vec), +} + +impl Default for BpTrellisOrderArgument { + fn default() -> Self { + Self::Name("deadline".to_owned()) + } +} + +/// Native Rust BP-guided trellis decoder for raw DEMs, including hyperedges. +/// Batch decoding supports independent Rust workers. Each worker prebuilds the +/// optional escalation ladder, retried only after a no-path result. Use +/// pecos_rslib_exp.BpTrellisDecoder for per-shot confidence and retry telemetry. +#[pyfunction] +#[pyo3(signature = (*, k=8, delta=100.0, score_alpha=0.8, bp_score_iterations=5, merge_indistinguishable=true, ordering=BpTrellisOrderArgument::default(), escalation_ks=None), + text_signature = "(*, k=8, delta=100.0, score_alpha=0.8, bp_score_iterations=5, merge_indistinguishable=True, ordering='deadline', escalation_ks=None)")] +fn bp_trellis( + k: usize, + delta: f64, + score_alpha: f64, + bp_score_iterations: usize, + merge_indistinguishable: bool, + ordering: BpTrellisOrderArgument, + escalation_ks: Option>, +) -> PyResult { + use pecos_decoders::spec::{BpTrellisConfig, BpTrellisOrdering}; + if k == 0 { + return Err(PyValueError::new_err("k must be at least 1")); + } + if delta.is_nan() || delta < 0.0 { + return Err(PyValueError::new_err( + "delta must be non-negative and not NaN", + )); + } + let score_alpha = non_negative("score_alpha", score_alpha)?; + let escalation_ks = escalation_ks.unwrap_or_default(); + if escalation_ks.contains(&0) { + return Err(PyValueError::new_err( + "escalation_ks widths must be at least 1", + )); + } + let ordering = match ordering { + BpTrellisOrderArgument::Explicit(order) => BpTrellisOrdering::Explicit(order), + BpTrellisOrderArgument::Name(name) => match name.as_str() { + "deadline" => BpTrellisOrdering::Deadline, + "backward_deadline" => BpTrellisOrdering::BackwardDeadline, + "time_order" => BpTrellisOrdering::TimeOrder, + value => { + return Err(invalid_choice( + "ordering", + value, + "'deadline', 'backward_deadline', 'time_order', or a list of mechanism indices", + )); + } + }, + }; + Ok(PyDecoderSpec::new(pecos_decoders::DecoderSpec::BpTrellis( + BpTrellisConfig { + k, + delta, + score_alpha, + bp_score_iterations, + merge_indistinguishable, + ordering, + escalation_ks, + }, + ))) +} + +fn bp_trellis_repr(config: &pecos_decoders::spec::BpTrellisConfig) -> String { + use pecos_decoders::spec::{BpTrellisConfig, BpTrellisOrdering}; + let default = BpTrellisConfig::default(); + let mut args = Vec::new(); + if config.k != default.k { + args.push(format!("k={}", config.k)); + } + if config.delta.to_bits() != default.delta.to_bits() { + args.push(if config.delta.is_infinite() { + "delta=float('inf')".to_owned() + } else { + format!("delta={:?}", config.delta) + }); + } + if config.score_alpha.to_bits() != default.score_alpha.to_bits() { + args.push(format!("score_alpha={:?}", config.score_alpha)); + } + if config.bp_score_iterations != default.bp_score_iterations { + args.push(format!( + "bp_score_iterations={}", + config.bp_score_iterations + )); + } + if !config.merge_indistinguishable { + args.push("merge_indistinguishable=False".to_owned()); + } + match &config.ordering { + BpTrellisOrdering::Deadline => {} + BpTrellisOrdering::TimeOrder => args.push("ordering='time_order'".to_owned()), + BpTrellisOrdering::BackwardDeadline => args.push("ordering='backward_deadline'".to_owned()), + BpTrellisOrdering::Explicit(order) => args.push(format!("ordering={order:?}")), + } + if !config.escalation_ks.is_empty() { + args.push(format!("escalation_ks={:?}", config.escalation_ks)); + } + finish_repr("bp_trellis", args) +} diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index ccb99c02d..b75fe73a6 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -6801,7 +6801,8 @@ fn decoder_dem_requirement(decoder_type: &str) -> PyResult { | "belief_matching_hybrid" | "ensemble" => Ok("graphlike".to_string()), "tesseract" | "astar" | "astar_full" | "bp_osd" | "bp_lsd" | "belief_find" - | "union_find" | "min_sum_bp" | "relay_bp" | "mwpf" | "chromobius" => Ok("any".to_string()), + | "union_find" | "min_sum_bp" | "relay_bp" | "mwpf" | "chromobius" | "frontier" + | "bp_trellis" => Ok("any".to_string()), _ => Err(pyo3::exceptions::PyValueError::new_err(format!( "Unknown decoder type: {decoder_type:?}", ))), diff --git a/python/pecos-rslib/tests/test_bp_trellis_batch_decode.py b/python/pecos-rslib/tests/test_bp_trellis_batch_decode.py new file mode 100644 index 000000000..497132b13 --- /dev/null +++ b/python/pecos-rslib/tests/test_bp_trellis_batch_decode.py @@ -0,0 +1,164 @@ +# Copyright 2026 The PECOS Developers + +"""BpTrellis integration with typed specs and native parallel batch execution.""" + +import math +import random + +import pytest +from pecos_rslib.decoders import DecoderSpec, bp_trellis +from pecos_rslib.qec import DemSampler, SampleBatch + +DEM = "error(0.1) D0 D1 D2 L0\nerror(0.03) D0\nerror(0.03) D1\nerror(0.03) D2\n" + + +def test_public_spec_and_configuration(): + from pecos.decoders import bp_trellis as public_bp_trellis + + assert public_bp_trellis() == bp_trellis() == DecoderSpec.parse("bp_trellis") + assert bp_trellis().family == "bp_trellis" + assert not bp_trellis().history_dependent + assert not bp_trellis().wall_clock_dependent + assert repr(bp_trellis()) == "bp_trellis()" + spec = bp_trellis( + k=128, + delta=math.inf, + score_alpha=0.5, + ordering=[3, 2, 1, 0], + bp_score_iterations=2, + merge_indistinguishable=False, + escalation_ks=[256, 512], + ) + assert eval(repr(spec), {"bp_trellis": bp_trellis}) == spec # noqa: S307 - trusted local repr + assert bp_trellis(escalation_ks=None) == bp_trellis(escalation_ks=[]) + + +@pytest.mark.parametrize( + "options", + [ + {"k": 0}, + {"delta": math.nan}, + {"delta": -1}, + {"score_alpha": math.inf}, + {"score_alpha": -1}, + {"ordering": "unknown"}, + {"escalation_ks": [0]}, + ], +) +def test_invalid_options(options): + with pytest.raises(ValueError, match="must|invalid|incompatible"): + bp_trellis(**options) + + +@pytest.mark.parametrize( + "options", + [ + {}, + {"k": 2, "delta": 2.0}, + {"bp_score_iterations": 2}, + {"ordering": "time_order"}, + {"ordering": "backward_deadline"}, + {"ordering": [3, 2, 1, 0]}, + {"merge_indistinguishable": False}, + {"escalation_ks": [16, 32]}, + ], +) +def test_parallel_predictions_match_sequential(options): + # Aperiodic rows expose chunk-order errors; exercise several dynamic chunks. + rng = random.Random(35) # noqa: S311 - deterministic test data + rows = [[rng.randrange(2) for _ in range(3)] for _ in range(1025)] + truth = [rng.randrange(2) for _ in rows] + batch = SampleBatch(rows, truth) + spec = bp_trellis(**options) + sequential = batch.decode(DEM, spec, workers=1, predictions=True) + parallel = batch.decode(DEM, spec, workers=4, predictions=True, timing=True) + assert sequential.execution_path == "sequential" + assert parallel.execution_path == "parallel" + assert parallel.workers_used == 4 + assert parallel.predictions == sequential.predictions + assert parallel.num_errors == sequential.num_errors + assert parallel.num_errors == sum( + a != b for a, b in zip(parallel.predictions, truth, strict=True) + ) + assert parallel.stats.num_timing_samples == len(rows) + assert parallel.reproducibility_warnings == [] + + +def test_auto_execution_wide_observables_and_count_only(): + dem = "error(0.1) D0 D1 D2 L70\n" + rows = [[i % 2] * 3 for i in range(1024)] + expected = [(1 << 70) if i % 2 else 0 for i in range(1024)] + batch = SampleBatch(rows, expected) + auto = batch.decode(dem, bp_trellis(), predictions=True) + assert auto.predictions == expected + assert auto.num_errors == 0 + assert auto.execution_path == "parallel" + count = batch.decode(dem, "bp_trellis", workers=3) + assert count.predictions is None + assert count.num_errors == 0 + empty = ( + DemSampler.from_dem_string(dem) + .sample_batch(0, seed=1) + .decode(dem, bp_trellis(), workers=2) + ) + assert empty.num_shots == 0 + + +@pytest.mark.parametrize("workers", [1, 3]) +def test_invalid_order_and_impossible_syndrome_are_errors(workers): + batch = SampleBatch([[0, 0, 0]], [0]) + with pytest.raises(RuntimeError, match="permutation"): + batch.decode(DEM, bp_trellis(ordering=[0, 0, 1, 2]), workers=workers) + with pytest.raises(RuntimeError, match="(?i)(path|syndrome|shot)"): + SampleBatch([[1]], [0]).decode("detector D0\n", bp_trellis(), workers=workers) + + +def test_fused_sampling_matches_sequential_decoding(): + sampler = DemSampler.from_dem_string(DEM) + expected = sampler.decode( + DEM, 3073, bp_trellis(), seed=17, workers=1, predictions=True + ) + fused = sampler.decode( + DEM, 3073, bp_trellis(), seed=17, workers=3, predictions=True + ) + assert fused.execution_path == "parallel" + assert fused.workers_used == 3 + assert fused.predictions == expected.predictions + assert fused.num_errors == expected.num_errors + + +def test_predictions_match_direct_experimental_binding(): + exp = pytest.importorskip("pecos_rslib_exp") + rows = [[(i >> j) & 1 for j in range(3)] for i in range(8)] + for options in ( + {}, + {"k": 2}, + {"escalation_ks": [16, 32]}, + {"ordering": "time_order"}, + {"bp_score_iterations": 2}, + ): + direct = exp.BpTrellisDecoder.from_dem(DEM, **options) + expected = [direct.decode_syndrome(row).observable_flips.mask for row in rows] + result = SampleBatch(rows, [0] * len(rows)).decode( + DEM, bp_trellis(**options), workers=3, predictions=True + ) + assert result.predictions == expected + + +@pytest.mark.parametrize("workers", [1, 4]) +def test_no_path_escalation_is_used_in_batch_execution(workers): + dem = "error(0.4) D0\nerror(0.4) D1\nerror(0.1) D0 D1 D2 L0\n" + options = dict( + k=2, bp_score_iterations=0, merge_indistinguishable=False, ordering="time_order" + ) + batch = SampleBatch([[0, 0, 1]] * 1025, [1] * 1025) + with pytest.raises(RuntimeError, match="(?i)(path|syndrome|shot)"): + batch.decode(dem, bp_trellis(**options), workers=workers) + result = batch.decode( + dem, + bp_trellis(**options, escalation_ks=[16]), + workers=workers, + predictions=True, + ) + assert result.predictions == [1] * 1025 + assert result.num_errors == 0 diff --git a/python/pecos-rslib/tests/test_frontier_batch_decode.py b/python/pecos-rslib/tests/test_frontier_batch_decode.py new file mode 100644 index 000000000..71c960791 --- /dev/null +++ b/python/pecos-rslib/tests/test_frontier_batch_decode.py @@ -0,0 +1,145 @@ +# Copyright 2026 The PECOS Developers + +"""Frontier integration with typed specs and native parallel batch execution.""" + +import math +import random + +import pytest +from pecos_rslib.decoders import DecoderSpec, frontier +from pecos_rslib.qec import DemSampler, SampleBatch + +DEM = "error(0.1) D0 D1 D2 L0\nerror(0.03) D0\nerror(0.03) D1\nerror(0.03) D2\n" + + +def test_public_spec_and_configuration(): + from pecos.decoders import frontier as public_frontier + + assert public_frontier() == frontier() == DecoderSpec.parse("frontier") + assert frontier().family == "frontier" + assert not frontier().history_dependent + assert not frontier().wall_clock_dependent + assert repr(frontier()) == "frontier()" + spec = frontier( + k=128, + delta=math.inf, + score_alpha=0.5, + column_order=[3, 2, 1, 0], + bp_score_iterations=2, + merge_indistinguishable=True, + ) + assert eval(repr(spec), {"frontier": frontier}) == spec # noqa: S307 - trusted local repr + assert frontier(metric_mode="frontierLite") == frontier(metric_mode="maxlog_int") + + +@pytest.mark.parametrize( + "options", + [ + {"k": 0}, + {"delta": math.nan}, + {"delta": -1}, + {"score_alpha": math.inf}, + {"score_alpha": -1}, + {"column_order": "unknown"}, + {"metric_mode": "unknown"}, + {"int_metric_scale": 0}, + {"metric_mode": "maxlog_int", "delta": math.inf}, + {"metric_mode": "maxlog_int", "merge_indistinguishable": True}, + ], +) +def test_invalid_options(options): + with pytest.raises(ValueError, match="must|invalid|incompatible"): + frontier(**options) + + +@pytest.mark.parametrize( + "options", + [ + {}, + {"k": 2, "delta": 2.0}, + {"bp_score_iterations": 2}, + {"column_order": "time_order"}, + {"column_order": "backward_deadline_reorder"}, + {"column_order": [3, 2, 1, 0]}, + {"merge_indistinguishable": True}, + {"metric_mode": "maxlog_int"}, + ], +) +def test_parallel_predictions_match_sequential(options): + # Aperiodic rows expose chunk-order errors; exercise several dynamic chunks. + rng = random.Random(35) # noqa: S311 - deterministic test data + rows = [[rng.randrange(2) for _ in range(3)] for _ in range(1025)] + truth = [rng.randrange(2) for _ in rows] + batch = SampleBatch(rows, truth) + spec = frontier(**options) + sequential = batch.decode(DEM, spec, workers=1, predictions=True) + parallel = batch.decode(DEM, spec, workers=4, predictions=True, timing=True) + assert sequential.execution_path == "sequential" + assert parallel.execution_path == "parallel" + assert parallel.workers_used == 4 + assert parallel.predictions == sequential.predictions + assert parallel.num_errors == sequential.num_errors + assert parallel.num_errors == sum( + a != b for a, b in zip(parallel.predictions, truth, strict=True) + ) + assert parallel.stats.num_timing_samples == len(rows) + assert parallel.reproducibility_warnings == [] + + +def test_auto_execution_wide_observables_and_count_only(): + dem = "error(0.1) D0 D1 D2 L70\n" + rows = [[i % 2] * 3 for i in range(1024)] + expected = [(1 << 70) if i % 2 else 0 for i in range(1024)] + batch = SampleBatch(rows, expected) + auto = batch.decode(dem, frontier(), predictions=True) + assert auto.predictions == expected + assert auto.num_errors == 0 + assert auto.execution_path == "parallel" + count = batch.decode(dem, "frontier", workers=3) + assert count.predictions is None + assert count.num_errors == 0 + empty = ( + DemSampler.from_dem_string(dem) + .sample_batch(0, seed=1) + .decode(dem, frontier(), workers=2) + ) + assert empty.num_shots == 0 + + +@pytest.mark.parametrize("workers", [1, 3]) +def test_invalid_order_and_impossible_syndrome_are_errors(workers): + batch = SampleBatch([[0, 0, 0]], [0]) + with pytest.raises(RuntimeError, match="permutation"): + batch.decode(DEM, frontier(column_order=[0, 0, 1, 2]), workers=workers) + with pytest.raises(RuntimeError, match="(?i)(path|syndrome|shot)"): + SampleBatch([[1]], [0]).decode("detector D0\n", frontier(), workers=workers) + + +def test_fused_sampling_matches_sequential_decoding(): + sampler = DemSampler.from_dem_string(DEM) + expected = sampler.decode( + DEM, 3073, frontier(), seed=17, workers=1, predictions=True + ) + fused = sampler.decode(DEM, 3073, frontier(), seed=17, workers=3, predictions=True) + assert fused.execution_path == "parallel" + assert fused.workers_used == 3 + assert fused.predictions == expected.predictions + assert fused.num_errors == expected.num_errors + + +def test_predictions_match_direct_experimental_binding(): + exp = pytest.importorskip("pecos_rslib_exp") + rows = [[(i >> j) & 1 for j in range(3)] for i in range(8)] + for options in ( + {}, + {"k": 2}, + {"metric_mode": "maxlog_int"}, + {"column_order": "time_order"}, + {"bp_score_iterations": 2}, + ): + direct = exp.FrontierDecoder.from_dem(DEM, **options) + expected = [direct.decode_syndrome(row).observable_flips.mask for row in rows] + result = SampleBatch(rows, [0] * len(rows)).decode( + DEM, frontier(**options), workers=3, predictions=True + ) + assert result.predictions == expected diff --git a/python/pecos-rslib/tests/test_sample_batch_decode.py b/python/pecos-rslib/tests/test_sample_batch_decode.py index e0f327d0d..cb1197192 100644 --- a/python/pecos-rslib/tests/test_sample_batch_decode.py +++ b/python/pecos-rslib/tests/test_sample_batch_decode.py @@ -12,6 +12,8 @@ from pecos_rslib.decoders import ( bp_osd, + bp_trellis, + frontier, fusion_blossom, mwpf, pecos_uf, @@ -278,7 +280,8 @@ def test_raw_measurement_error_precedes_invalid_decoder() -> None: batch.decode(DEM, "not_a_decoder", allow_dem_mismatch=True) -def test_gil_is_released_during_decode() -> None: +@pytest.mark.parametrize("spec", [pymatching(correlated=True), frontier(), bp_trellis()]) +def test_gil_is_released_during_decode(spec) -> None: batch = _batch(100_000) started = threading.Event() stop = threading.Event() @@ -294,7 +297,7 @@ def worker() -> None: started.wait() before = progress[0] try: - batch.decode(DEM, pymatching(correlated=True)) + batch.decode(DEM, spec) finally: stop.set() thread.join() diff --git a/python/quantum-pecos/src/pecos/decoders/__init__.py b/python/quantum-pecos/src/pecos/decoders/__init__.py index 19ce326c0..f6d702700 100644 --- a/python/quantum-pecos/src/pecos/decoders/__init__.py +++ b/python/quantum-pecos/src/pecos/decoders/__init__.py @@ -46,7 +46,9 @@ belief_matching, bp_lsd, bp_osd, + bp_trellis, ensemble, + frontier, fusion_blossom, k_mwpm, min_sum_bp, @@ -96,7 +98,9 @@ "belief_matching", "bp_lsd", "bp_osd", + "bp_trellis", "ensemble", + "frontier", "fusion_blossom", "k_mwpm", "min_sum_bp", From 61e20fadb224688574e8a4628d3fbb950529f211 Mon Sep 17 00:00:00 2001 From: ciaranra Date: Wed, 16 Sep 2026 17:41:09 -0600 Subject: [PATCH 2/9] Fix trellis integration test formatting and Clippy documentation lint --- .../pecos-decoders/tests/bp_trellis_spec.rs | 2 +- .../tests/test_bp_trellis_batch_decode.py | 26 +++++-------------- .../tests/test_frontier_batch_decode.py | 18 +++---------- 3 files changed, 11 insertions(+), 35 deletions(-) diff --git a/crates/pecos-decoders/tests/bp_trellis_spec.rs b/crates/pecos-decoders/tests/bp_trellis_spec.rs index 49b07fd31..e459b0668 100644 --- a/crates/pecos-decoders/tests/bp_trellis_spec.rs +++ b/crates/pecos-decoders/tests/bp_trellis_spec.rs @@ -1,4 +1,4 @@ -//! Feature-independent specs and native BpTrellis construction. +//! Feature-independent specs and native BP-Trellis construction. use pecos_decoders::spec::{BpTrellisConfig, DecodeModel, DecoderSpec}; #[test] diff --git a/python/pecos-rslib/tests/test_bp_trellis_batch_decode.py b/python/pecos-rslib/tests/test_bp_trellis_batch_decode.py index 497132b13..a339364d6 100644 --- a/python/pecos-rslib/tests/test_bp_trellis_batch_decode.py +++ b/python/pecos-rslib/tests/test_bp_trellis_batch_decode.py @@ -77,9 +77,7 @@ def test_parallel_predictions_match_sequential(options): assert parallel.workers_used == 4 assert parallel.predictions == sequential.predictions assert parallel.num_errors == sequential.num_errors - assert parallel.num_errors == sum( - a != b for a, b in zip(parallel.predictions, truth, strict=True) - ) + assert parallel.num_errors == sum(a != b for a, b in zip(parallel.predictions, truth, strict=True)) assert parallel.stats.num_timing_samples == len(rows) assert parallel.reproducibility_warnings == [] @@ -96,11 +94,7 @@ def test_auto_execution_wide_observables_and_count_only(): count = batch.decode(dem, "bp_trellis", workers=3) assert count.predictions is None assert count.num_errors == 0 - empty = ( - DemSampler.from_dem_string(dem) - .sample_batch(0, seed=1) - .decode(dem, bp_trellis(), workers=2) - ) + empty = DemSampler.from_dem_string(dem).sample_batch(0, seed=1).decode(dem, bp_trellis(), workers=2) assert empty.num_shots == 0 @@ -115,12 +109,8 @@ def test_invalid_order_and_impossible_syndrome_are_errors(workers): def test_fused_sampling_matches_sequential_decoding(): sampler = DemSampler.from_dem_string(DEM) - expected = sampler.decode( - DEM, 3073, bp_trellis(), seed=17, workers=1, predictions=True - ) - fused = sampler.decode( - DEM, 3073, bp_trellis(), seed=17, workers=3, predictions=True - ) + expected = sampler.decode(DEM, 3073, bp_trellis(), seed=17, workers=1, predictions=True) + fused = sampler.decode(DEM, 3073, bp_trellis(), seed=17, workers=3, predictions=True) assert fused.execution_path == "parallel" assert fused.workers_used == 3 assert fused.predictions == expected.predictions @@ -139,18 +129,14 @@ def test_predictions_match_direct_experimental_binding(): ): direct = exp.BpTrellisDecoder.from_dem(DEM, **options) expected = [direct.decode_syndrome(row).observable_flips.mask for row in rows] - result = SampleBatch(rows, [0] * len(rows)).decode( - DEM, bp_trellis(**options), workers=3, predictions=True - ) + result = SampleBatch(rows, [0] * len(rows)).decode(DEM, bp_trellis(**options), workers=3, predictions=True) assert result.predictions == expected @pytest.mark.parametrize("workers", [1, 4]) def test_no_path_escalation_is_used_in_batch_execution(workers): dem = "error(0.4) D0\nerror(0.4) D1\nerror(0.1) D0 D1 D2 L0\n" - options = dict( - k=2, bp_score_iterations=0, merge_indistinguishable=False, ordering="time_order" - ) + options = dict(k=2, bp_score_iterations=0, merge_indistinguishable=False, ordering="time_order") batch = SampleBatch([[0, 0, 1]] * 1025, [1] * 1025) with pytest.raises(RuntimeError, match="(?i)(path|syndrome|shot)"): batch.decode(dem, bp_trellis(**options), workers=workers) diff --git a/python/pecos-rslib/tests/test_frontier_batch_decode.py b/python/pecos-rslib/tests/test_frontier_batch_decode.py index 71c960791..34ea5a728 100644 --- a/python/pecos-rslib/tests/test_frontier_batch_decode.py +++ b/python/pecos-rslib/tests/test_frontier_batch_decode.py @@ -79,9 +79,7 @@ def test_parallel_predictions_match_sequential(options): assert parallel.workers_used == 4 assert parallel.predictions == sequential.predictions assert parallel.num_errors == sequential.num_errors - assert parallel.num_errors == sum( - a != b for a, b in zip(parallel.predictions, truth, strict=True) - ) + assert parallel.num_errors == sum(a != b for a, b in zip(parallel.predictions, truth, strict=True)) assert parallel.stats.num_timing_samples == len(rows) assert parallel.reproducibility_warnings == [] @@ -98,11 +96,7 @@ def test_auto_execution_wide_observables_and_count_only(): count = batch.decode(dem, "frontier", workers=3) assert count.predictions is None assert count.num_errors == 0 - empty = ( - DemSampler.from_dem_string(dem) - .sample_batch(0, seed=1) - .decode(dem, frontier(), workers=2) - ) + empty = DemSampler.from_dem_string(dem).sample_batch(0, seed=1).decode(dem, frontier(), workers=2) assert empty.num_shots == 0 @@ -117,9 +111,7 @@ def test_invalid_order_and_impossible_syndrome_are_errors(workers): def test_fused_sampling_matches_sequential_decoding(): sampler = DemSampler.from_dem_string(DEM) - expected = sampler.decode( - DEM, 3073, frontier(), seed=17, workers=1, predictions=True - ) + expected = sampler.decode(DEM, 3073, frontier(), seed=17, workers=1, predictions=True) fused = sampler.decode(DEM, 3073, frontier(), seed=17, workers=3, predictions=True) assert fused.execution_path == "parallel" assert fused.workers_used == 3 @@ -139,7 +131,5 @@ def test_predictions_match_direct_experimental_binding(): ): direct = exp.FrontierDecoder.from_dem(DEM, **options) expected = [direct.decode_syndrome(row).observable_flips.mask for row in rows] - result = SampleBatch(rows, [0] * len(rows)).decode( - DEM, frontier(**options), workers=3, predictions=True - ) + result = SampleBatch(rows, [0] * len(rows)).decode(DEM, frontier(**options), workers=3, predictions=True) assert result.predictions == expected From 18f4c305f9c3586b9ab4065a22e7a09570478545 Mon Sep 17 00:00:00 2001 From: ciaranra Date: Wed, 16 Sep 2026 20:51:24 -0600 Subject: [PATCH 3/9] Keep trellis decoder providers in the optional experimental extension --- Cargo.lock | 3 +- crates/pecos-decoders/Cargo.toml | 6 +- crates/pecos-decoders/README.md | 16 - crates/pecos-decoders/src/spec.rs | 2 - crates/pecos-decoders/src/spec/build.rs | 82 --- crates/pecos-decoders/src/spec/config.rs | 83 --- crates/pecos-decoders/src/spec/parse.rs | 8 +- .../pecos-decoders/tests/bp_trellis_spec.rs | 41 -- crates/pecos-decoders/tests/frontier_spec.rs | 41 -- docs/experimental/decoders.md | 8 +- docs/user-guide/decoders.md | 20 +- docs/workflows/guppy-dem-decoding.md | 120 ++--- exp/pecos-bp-trellis/README.md | 12 +- exp/pecos-frontier/README.md | 9 +- python/pecos-rslib-exp/Cargo.toml | 1 + python/pecos-rslib-exp/src/decoder_specs.rs | 474 ++++++++++++++++++ python/pecos-rslib-exp/src/lib.rs | 2 + .../tests/test_bp_trellis_batch_decode.py | 42 +- .../tests/test_frontier_batch_decode.py | 38 +- python/pecos-rslib/Cargo.toml | 2 - python/pecos-rslib/src/batch_decoder_spec.rs | 124 +++++ .../pecos-rslib/src/decoder_spec_bindings.rs | 253 ---------- .../src/fault_tolerance_bindings.rs | 29 +- .../fault_tolerance_bindings/batch_decode.rs | 3 +- .../sampler_decode.rs | 3 +- python/pecos-rslib/src/lib.rs | 1 + .../tests/test_decoder_providers.py | 108 ++++ .../tests/test_sample_batch_decode.py | 7 +- .../src/pecos/decoders/__init__.py | 21 +- 29 files changed, 889 insertions(+), 670 deletions(-) delete mode 100644 crates/pecos-decoders/tests/bp_trellis_spec.rs delete mode 100644 crates/pecos-decoders/tests/frontier_spec.rs create mode 100644 python/pecos-rslib-exp/src/decoder_specs.rs rename python/{pecos-rslib => pecos-rslib-exp}/tests/test_bp_trellis_batch_decode.py (82%) rename python/{pecos-rslib => pecos-rslib-exp}/tests/test_frontier_batch_decode.py (84%) create mode 100644 python/pecos-rslib/src/batch_decoder_spec.rs create mode 100644 python/pecos-rslib/tests/test_decoder_providers.py diff --git a/Cargo.lock b/Cargo.lock index 138cbc429..af6653b86 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4214,10 +4214,8 @@ name = "pecos-decoders" version = "0.2.0-dev.0" dependencies = [ "pecos-bp", - "pecos-bp-trellis", "pecos-chromobius", "pecos-decoder-core", - "pecos-frontier", "pecos-fusion-blossom", "pecos-ldpc-decoders", "pecos-mwpf", @@ -4753,6 +4751,7 @@ dependencies = [ "pecos-bp-trellis", "pecos-build", "pecos-core", + "pecos-decoder-core", "pecos-eeg", "pecos-frontier", "pecos-neo", diff --git a/crates/pecos-decoders/Cargo.toml b/crates/pecos-decoders/Cargo.toml index 6199b2fdd..6d2da9b96 100644 --- a/crates/pecos-decoders/Cargo.toml +++ b/crates/pecos-decoders/Cargo.toml @@ -13,8 +13,6 @@ description = "Unified decoder meta-crate for PECOS" [dependencies] pecos-bp.workspace = true -pecos-bp-trellis = { workspace = true, optional = true } -pecos-frontier = { workspace = true, optional = true } pecos-decoder-core.workspace = true pecos-random.workspace = true pecos-ldpc-decoders = { workspace = true, optional = true } @@ -28,8 +26,6 @@ pecos-uf-decoder = { workspace = true, optional = true } [features] default = [] -frontier = ["dep:pecos-frontier"] -bp-trellis = ["dep:pecos-bp-trellis"] ldpc = ["dep:pecos-ldpc-decoders"] fusion-blossom = ["dep:pecos-fusion-blossom"] mwpf = ["dep:pecos-mwpf"] @@ -38,7 +34,7 @@ tesseract = ["dep:pecos-tesseract"] chromobius = ["dep:pecos-chromobius"] relay-bp = ["dep:pecos-relay-bp"] uf = ["dep:pecos-uf-decoder"] -all = ["bp-trellis", "frontier", "ldpc", "fusion-blossom", "mwpf", "pymatching", "tesseract", "chromobius", "relay-bp", "uf"] +all = ["ldpc", "fusion-blossom", "mwpf", "pymatching", "tesseract", "chromobius", "relay-bp", "uf"] [lints] workspace = true diff --git a/crates/pecos-decoders/README.md b/crates/pecos-decoders/README.md index 5fda8d70e..2e2dfabcd 100644 --- a/crates/pecos-decoders/README.md +++ b/crates/pecos-decoders/README.md @@ -25,19 +25,3 @@ Re-exports from `pecos-decoder-core`: - `BatchDecoder` trait - Batch decoding interface - `CssDecoder` trait - CSS code specific decoding - `SoftDecoder` trait - Soft information decoding - -## Frontier - -Enable the `frontier` feature to build the experimental native Rust Frontier -decoder through `DecoderSpec::Frontier(FrontierConfig::default())` or -`DecoderSpec::parse("frontier")`. It accepts raw DEMs with hyperedges and -arbitrary-width observables. Python builds enable this feature and expose -`pecos.decoders.frontier()` for sequential or parallel batch decoding. - -## BP-Trellis - -Enable the `bp-trellis` feature to build PECOS's experimental BP-guided trellis -decoder through `DecoderSpec::BpTrellis(BpTrellisConfig::default())` or -`DecoderSpec::parse("bp_trellis")`. Python exposes `pecos.decoders.bp_trellis()` -with all configuration options, including optional no-path escalation widths, -for sequential or parallel batch decoding. diff --git a/crates/pecos-decoders/src/spec.rs b/crates/pecos-decoders/src/spec.rs index 3623ce2dd..938702505 100644 --- a/crates/pecos-decoders/src/spec.rs +++ b/crates/pecos-decoders/src/spec.rs @@ -53,8 +53,6 @@ pub enum DecoderSpec { UnionFind, RelayBp(RelayBpConfig), MinSumBp(MinSumBpConfig), - Frontier(FrontierConfig), - BpTrellis(BpTrellisConfig), PecosUf(PecosUfPreset), BeliefMatching(BeliefMatchingConfig), Windowed(WindowedConfig), diff --git a/crates/pecos-decoders/src/spec/build.rs b/crates/pecos-decoders/src/spec/build.rs index 3b1257b26..7bee40b77 100644 --- a/crates/pecos-decoders/src/spec/build.rs +++ b/crates/pecos-decoders/src/spec/build.rs @@ -218,8 +218,6 @@ fn build_single(spec: &DecoderSpec, dem: &str) -> Result build_belief_find(dem), DecoderSpec::UnionFind => build_union_find(dem), DecoderSpec::RelayBp(config) => build_relay_bp(dem, config), - DecoderSpec::BpTrellis(config) => build_bp_trellis(dem, config), - DecoderSpec::Frontier(config) => build_frontier(dem, config), DecoderSpec::MinSumBp(config) => build_min_sum_bp(dem, config), DecoderSpec::PecosUf(preset) => build_pecos_uf(dem, *preset), DecoderSpec::BeliefMatching(config) => build_belief_matching(dem, config), @@ -256,8 +254,6 @@ fn family_name(spec: &DecoderSpec) -> &'static str { DecoderSpec::BeliefFind => "belief_find", DecoderSpec::UnionFind => "union_find", DecoderSpec::RelayBp(_) => "relay_bp", - DecoderSpec::BpTrellis(_) => "bp_trellis", - DecoderSpec::Frontier(_) => "frontier", DecoderSpec::MinSumBp(_) => "min_sum_bp", DecoderSpec::PecosUf(_) => "pecos_uf", DecoderSpec::BeliefMatching(_) => "belief_matching", @@ -1388,84 +1384,6 @@ fn build_ensemble( )) } -#[cfg(feature = "frontier")] -fn build_frontier( - dem: &str, - config: &super::config::FrontierConfig, -) -> Result, DecoderError> { - use super::config::{FrontierColumnOrder, FrontierMetricMode}; - use pecos_frontier::{FrontierConfig, FrontierDecoder, MetricMode, SparseDem}; - let dem = SparseDem::from_dem_str(dem)?; - let column_order = match &config.column_order { - FrontierColumnOrder::Deadline => Some(pecos_frontier::deadline_column_order(&dem)?), - FrontierColumnOrder::Time => None, - FrontierColumnOrder::BackwardDeadline => { - Some(pecos_frontier::backward_deadline_column_order(&dem)?) - } - FrontierColumnOrder::Explicit(order) => Some(order.clone()), - }; - let decoder = FrontierDecoder::from_sparse_dem( - &dem, - FrontierConfig { - k: config.k, - delta: config.delta, - score_alpha: config.score_alpha, - column_order, - merge_indistinguishable: config.merge_indistinguishable, - bp_score_iterations: config.bp_score_iterations, - metric_mode: match config.metric_mode { - FrontierMetricMode::LogSumExpFloat => MetricMode::LogSumExpFloat, - FrontierMetricMode::MaxLogInt => MetricMode::MaxLogInt, - }, - int_metric_scale: config.int_metric_scale, - }, - )?; - Ok(Box::new(decoder)) -} - -#[cfg(not(feature = "frontier"))] -fn build_frontier( - _dem: &str, - _config: &super::config::FrontierConfig, -) -> Result, DecoderError> { - unavailable("frontier", "frontier") -} - -#[cfg(feature = "bp-trellis")] -fn build_bp_trellis( - dem: &str, - config: &super::config::BpTrellisConfig, -) -> Result, DecoderError> { - use super::config::BpTrellisOrdering; - use pecos_bp_trellis::{BpTrellisConfig, BpTrellisDecoder, TrellisOrdering}; - let ordering = match &config.ordering { - BpTrellisOrdering::Deadline => TrellisOrdering::Deadline, - BpTrellisOrdering::BackwardDeadline => TrellisOrdering::BackwardDeadline, - BpTrellisOrdering::TimeOrder => TrellisOrdering::TimeOrder, - BpTrellisOrdering::Explicit(order) => TrellisOrdering::Explicit(order.clone()), - }; - Ok(Box::new(BpTrellisDecoder::from_dem_str( - dem, - BpTrellisConfig { - k: config.k, - delta: config.delta, - score_alpha: config.score_alpha, - bp_score_iterations: config.bp_score_iterations, - merge_indistinguishable: config.merge_indistinguishable, - ordering, - escalation_ks: config.escalation_ks.clone(), - }, - )?)) -} - -#[cfg(not(feature = "bp-trellis"))] -fn build_bp_trellis( - _dem: &str, - _config: &super::config::BpTrellisConfig, -) -> Result, DecoderError> { - unavailable("bp_trellis", "bp-trellis") -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/pecos-decoders/src/spec/config.rs b/crates/pecos-decoders/src/spec/config.rs index a3f2cbd3e..fc0040eb8 100644 --- a/crates/pecos-decoders/src/spec/config.rs +++ b/crates/pecos-decoders/src/spec/config.rs @@ -337,86 +337,3 @@ impl Default for BeamSearchConfig { pub struct EnsembleConfig { pub members: Vec, } - -/// Mechanism ordering for the Frontier decoder. -#[derive(Clone, Debug, Default, PartialEq)] -pub enum FrontierColumnOrder { - #[default] - Deadline, - Time, - BackwardDeadline, - Explicit(Vec), -} - -/// Route metric for the Frontier decoder. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub enum FrontierMetricMode { - #[default] - LogSumExpFloat, - MaxLogInt, -} - -/// Feature-independent Frontier options, matching the Python Frontier defaults. -#[derive(Clone, Debug, PartialEq)] -pub struct FrontierConfig { - pub k: usize, - pub delta: f64, - pub score_alpha: f64, - pub column_order: FrontierColumnOrder, - pub merge_indistinguishable: bool, - pub bp_score_iterations: usize, - pub metric_mode: FrontierMetricMode, - pub int_metric_scale: i32, -} - -impl Default for FrontierConfig { - fn default() -> Self { - Self { - k: 64, - delta: 50.0, - score_alpha: 0.8, - column_order: FrontierColumnOrder::Deadline, - merge_indistinguishable: false, - bp_score_iterations: 0, - metric_mode: FrontierMetricMode::LogSumExpFloat, - int_metric_scale: 1024, - } - } -} - -/// Mechanism ordering for the BP-Trellis decoder. -#[derive(Clone, Debug, Default, PartialEq, Eq)] -pub enum BpTrellisOrdering { - #[default] - Deadline, - BackwardDeadline, - TimeOrder, - Explicit(Vec), -} - -/// Feature-independent options matching the native BP-Trellis facade. -#[derive(Clone, Debug, PartialEq)] -pub struct BpTrellisConfig { - pub k: usize, - pub delta: f64, - pub score_alpha: f64, - pub bp_score_iterations: usize, - pub merge_indistinguishable: bool, - pub ordering: BpTrellisOrdering, - /// Additional widths attempted only when the preceding decode has no path. - pub escalation_ks: Vec, -} - -impl Default for BpTrellisConfig { - fn default() -> Self { - Self { - k: 8, - delta: 100.0, - score_alpha: 0.8, - bp_score_iterations: 5, - merge_indistinguishable: true, - ordering: BpTrellisOrdering::Deadline, - escalation_ks: Vec::new(), - } - } -} diff --git a/crates/pecos-decoders/src/spec/parse.rs b/crates/pecos-decoders/src/spec/parse.rs index 5a77de546..0c1d1da7f 100644 --- a/crates/pecos-decoders/src/spec/parse.rs +++ b/crates/pecos-decoders/src/spec/parse.rs @@ -32,12 +32,6 @@ pub(super) fn parse(type_string: &str) -> Result { "belief_find" => Ok(DecoderSpec::BeliefFind), "union_find" => Ok(DecoderSpec::UnionFind), "relay_bp" => Ok(DecoderSpec::RelayBp(RelayBpConfig::default())), - "frontier" => Ok(DecoderSpec::Frontier( - super::config::FrontierConfig::default(), - )), - "bp_trellis" => Ok(DecoderSpec::BpTrellis( - super::config::BpTrellisConfig::default(), - )), "min_sum_bp" => Ok(DecoderSpec::MinSumBp(MinSumBpConfig::default())), "pecos_uf" | "pecos_uf:fast" => Ok(DecoderSpec::PecosUf(PecosUfPreset::Fast)), "pecos_uf:balanced" | "pecos_uf_correlated" => { @@ -80,7 +74,7 @@ pub(super) fn parse(type_string: &str) -> Result { "Unsupported decoder_type: {type_string}. \ Supported: pymatching, tesseract, mwpf, pecos_uf (or \ pecos_uf:fast/balanced/accurate), logical_subgraph, ensemble:d1,d2,..., \ - bp_osd, bp_lsd, union_find, relay_bp, min_sum_bp, frontier, bp_trellis." + bp_osd, bp_lsd, union_find, relay_bp, min_sum_bp." )), } } diff --git a/crates/pecos-decoders/tests/bp_trellis_spec.rs b/crates/pecos-decoders/tests/bp_trellis_spec.rs deleted file mode 100644 index e459b0668..000000000 --- a/crates/pecos-decoders/tests/bp_trellis_spec.rs +++ /dev/null @@ -1,41 +0,0 @@ -//! Feature-independent specs and native BP-Trellis construction. -use pecos_decoders::spec::{BpTrellisConfig, DecodeModel, DecoderSpec}; - -#[test] -fn bp_trellis_spec_is_feature_independent() { - let spec = DecoderSpec::parse("bp_trellis").unwrap(); - assert_eq!(spec, DecoderSpec::BpTrellis(BpTrellisConfig::default())); - assert!(!spec.execution_traits().history_dependent); - assert!(!spec.execution_traits().wall_clock_dependent); - assert!(!spec.requires_graphlike_model()); -} - -#[cfg(not(feature = "bp-trellis"))] -#[test] -fn missing_feature_is_actionable() { - let result = DecoderSpec::parse("bp_trellis") - .unwrap() - .build(&DecodeModel::SingleDem("error(0.1) D0 L0".into())); - assert!(matches!( - result, - Err(pecos_decoders::DecoderError::BackendUnavailable { - family: "bp_trellis", - required_feature: "bp-trellis", - }) - )); -} - -#[cfg(feature = "bp-trellis")] -#[test] -fn raw_hyperedges_and_wide_observables() { - let spec = DecoderSpec::parse("bp_trellis").unwrap(); - let mut decoder = spec - .build(&DecodeModel::SingleDem("error(0.1) D0 D1 D2 L70\n".into())) - .unwrap(); - assert_eq!(decoder.num_detectors(), Some(3)); - let predictions = decoder - .decode_batch_to_observables(&[1, 1, 1, 0, 0, 0], 2, 3) - .unwrap(); - assert_eq!(predictions[0].words(), &[0, 1 << 6]); - assert!(predictions[1].words().iter().all(|&word| word == 0)); -} diff --git a/crates/pecos-decoders/tests/frontier_spec.rs b/crates/pecos-decoders/tests/frontier_spec.rs deleted file mode 100644 index 2448b2844..000000000 --- a/crates/pecos-decoders/tests/frontier_spec.rs +++ /dev/null @@ -1,41 +0,0 @@ -//! Feature-independent specs and native Frontier construction. -use pecos_decoders::spec::{DecodeModel, DecoderSpec, FrontierConfig}; - -#[test] -fn frontier_spec_is_feature_independent() { - let spec = DecoderSpec::parse("frontier").unwrap(); - assert_eq!(spec, DecoderSpec::Frontier(FrontierConfig::default())); - assert!(!spec.execution_traits().history_dependent); - assert!(!spec.execution_traits().wall_clock_dependent); - assert!(!spec.requires_graphlike_model()); -} - -#[cfg(not(feature = "frontier"))] -#[test] -fn missing_feature_is_actionable() { - let result = DecoderSpec::parse("frontier") - .unwrap() - .build(&DecodeModel::SingleDem("error(0.1) D0 L0".into())); - assert!(matches!( - result, - Err(pecos_decoders::DecoderError::BackendUnavailable { - family: "frontier", - required_feature: "frontier", - }) - )); -} - -#[cfg(feature = "frontier")] -#[test] -fn raw_hyperedges_and_wide_observables() { - let spec = DecoderSpec::parse("frontier").unwrap(); - let mut decoder = spec - .build(&DecodeModel::SingleDem("error(0.1) D0 D1 D2 L70\n".into())) - .unwrap(); - assert_eq!(decoder.num_detectors(), Some(3)); - let predictions = decoder - .decode_batch_to_observables(&[1, 1, 1, 0, 0, 0], 2, 3) - .unwrap(); - assert_eq!(predictions[0].words(), &[0, 1 << 6]); - assert!(predictions[1].words().iter().all(|&word| word == 0)); -} diff --git a/docs/experimental/decoders.md b/docs/experimental/decoders.md index e6951d4cd..ddf446728 100644 --- a/docs/experimental/decoders.md +++ b/docs/experimental/decoders.md @@ -1,9 +1,11 @@ # Experimental decoders Experimental decoder engines live in `exp/` and may change without notice. -Frontier and BP-Trellis are available through `pecos.decoders.frontier()` and -`pecos.decoders.bp_trellis()` for unified parallel batch decoding. The detailed-result APIs described here are reached through -`pecos_rslib_exp`. +Frontier and BP-Trellis are available through `pecos_rslib_exp.frontier()` and +`pecos_rslib_exp.bp_trellis()` for unified parallel batch decoding. Install the +optional `pecos-rslib-exp` package to use them. Standard decoder imports work +without this package. The detailed-result APIs described here are also reached +through `pecos_rslib_exp`. Two capabilities live here that the production decoders do not offer: diff --git a/docs/user-guide/decoders.md b/docs/user-guide/decoders.md index e699be53d..3f91cf603 100644 --- a/docs/user-guide/decoders.md +++ b/docs/user-guide/decoders.md @@ -36,8 +36,8 @@ The following decoder APIs and supporting types are publicly re-exported from | API | Primary input | Description | |-----|---------------|-------------| | `MWPM2D` | QECC object | Legacy minimum-weight perfect matching for 2D codes. | -| `bp_trellis()` | Raw DEM text via `SampleBatch.decode` | Experimental native Rust BP-guided trellis, with parallel shots and optional no-path retries. | -| `frontier()` | Raw DEM text via `SampleBatch.decode` | Experimental native Rust Frontier, with parallel shot decoding. | +| `bp_trellis()` (optional `pecos-rslib-exp`) | Raw DEM text via `SampleBatch.decode` | Experimental native Rust BP-guided trellis, with parallel shots and optional no-path retries. | +| `frontier()` (optional `pecos-rslib-exp`) | Raw DEM text via `SampleBatch.decode` | Experimental native Rust Frontier, with parallel shot decoding. | | `DummyDecoder` | None | No-op decoder for tests and interface benchmarks. | | `PyMatchingDecoder` | Graph-like DEM text or `CheckMatrix` | PyMatching minimum-weight perfect matching, with optional correlated decoding. | | `FusionBlossomDecoder` | Check matrix, standard-code parameters, or a manual graph | Pure-Rust minimum-weight perfect matching. | @@ -76,8 +76,6 @@ The Rust API provides access to a broader set of decoders: - Fusion Blossom MWPM (feature: `fusion-blossom`) - PyMatching MWPM (feature: `pymatching`) - Tesseract (feature: `tesseract`) -- Frontier, experimental (feature: `frontier`) -- BP-Trellis, experimental (feature: `bp-trellis`) - Chromobius color code decoder (feature: `chromobius`) ## Installation and Setup @@ -337,8 +335,18 @@ match decoder.decode(&syndrome.view()) { ## Rust-backed Frontier batch decoding +Install the optional `pecos-rslib-exp` package for this section and BP-Trellis +below. Standard `pecos.decoders` imports do not load the experimental extension. +The explicit `from pecos.decoders import frontier, bp_trellis` convenience import +loads it lazily and raises an actionable `ImportError` if it is unavailable. +Experimental factories are excluded from wildcard imports. Their specifications +work with `SampleBatch.decode(...)` and `DemSampler.decode(...)`; the standard +`DecoderSpec.parse` strings and composite-spec factories do not load optional +providers. The experimental calls cross a Python adapter at each shot, with +native model construction and decoding releasing the GIL. + ```python -from pecos.decoders import frontier +from pecos_rslib_exp import frontier from pecos_rslib.qec import SampleBatch dem = "error(0.1) D0 D1 D2 L0\n" @@ -364,7 +372,7 @@ status, and complementary gaps, use the direct experimental binding. ## Rust-backed BP-Trellis batch decoding ```python -from pecos.decoders import bp_trellis +from pecos_rslib_exp import bp_trellis from pecos_rslib.qec import SampleBatch dem = "error(0.1) D0 D1 D2 L0\n" diff --git a/docs/workflows/guppy-dem-decoding.md b/docs/workflows/guppy-dem-decoding.md index 85253bc5d..81eb8967c 100644 --- a/docs/workflows/guppy-dem-decoding.md +++ b/docs/workflows/guppy-dem-decoding.md @@ -300,7 +300,15 @@ returned `DecodeResult` supplies the aggregate count and rate directly. ```python -from pecos.decoders import bp_osd, bp_trellis, frontier, pymatching, tesseract +from pecos.decoders import bp_osd, pymatching, tesseract + +# Optional package: standard decoders work without it. +try: + from pecos_rslib_exp import bp_trellis, frontier +except ModuleNotFoundError as error: + if error.name != "pecos_rslib_exp": + raise + bp_trellis = frontier = None pymatching_result = batch.decode( terminal_graphlike_text, @@ -317,50 +325,36 @@ bp_osd_result = batch.decode( workers=None, ) -frontier_result = batch.decode( - raw_text, - frontier(k=64), - workers=4, - predictions=True, -) - -bp_trellis_result = batch.decode( - raw_text, - bp_trellis(k=8, escalation_ks=[32, 128]), - workers=4, - predictions=True, -) - -pymatching_errors = pymatching_result.num_errors -tesseract_errors = tesseract_result.num_errors -bp_osd_errors = bp_osd_result.num_errors -frontier_errors = frontier_result.num_errors -bp_trellis_errors = bp_trellis_result.num_errors - -shots = batch.num_shots -assert 0 < pymatching_errors < shots -assert 0 < tesseract_errors < shots -assert 0 < bp_osd_errors < shots -assert 0 < frontier_errors < shots -assert len(frontier_result.predictions) == shots -assert 0 < bp_trellis_errors < shots -assert len(bp_trellis_result.predictions) == shots +decoder_results = { + "pymatching": pymatching_result, + "tesseract": tesseract_result, + "bp_osd": bp_osd_result, +} +optional_specs = {} +if frontier is not None: + optional_specs = { + "frontier": frontier(k=64), + "bp_trellis": bp_trellis(k=8, escalation_ks=[32, 128]), + } + for name, spec in optional_specs.items(): + result = batch.decode(raw_text, spec, workers=4, predictions=True) + assert result.execution_path == "parallel" + assert result.workers_used == 4 + assert len(result.predictions) == batch.num_shots + decoder_results[name] = result print("DEM-sampled shots") -print(f"pymatching {pymatching_errors:5} {pymatching_errors / shots:.4%}") -print(f"tesseract {tesseract_errors:5} {tesseract_errors / shots:.4%}") -print(f"bp_osd {bp_osd_errors:5} {bp_osd_errors / shots:.4%}") -print(f"frontier {frontier_errors:5} {frontier_result.logical_error_rate:.4%}") -print(f"bp_trellis {bp_trellis_errors:5} {bp_trellis_result.logical_error_rate:.4%}") -print(f"pymatching execution path: {pymatching_result.execution_path}") -print(f"frontier execution path: {frontier_result.execution_path}") -assert frontier_result.execution_path == "parallel" -assert frontier_result.workers_used == 4 -print(f"bp_trellis execution path: {bp_trellis_result.execution_path}") -assert bp_trellis_result.execution_path == "parallel" -assert bp_trellis_result.workers_used == 4 +for name, result in decoder_results.items(): + assert 0 < result.num_errors < batch.num_shots + print(f"{name:11} {result.num_errors:5} {result.logical_error_rate:.4%}") + print(f"{name} execution path: {result.execution_path}") ``` +Install the optional `pecos-rslib-exp` package to run the Frontier and BP-Trellis +examples. The imports and decoding blocks above are skipped when it is absent. +Explicit imports through `pecos.decoders` are also lazy conveniences, but the +factories and native engines belong to `pecos_rslib_exp`. + `frontier()` uses the native Rust Frontier decoder and accepts the raw DEM, including hyperedges. The example decodes shots across four Rust worker threads; `predictions=True` also returns observable masks in original shot order. Each @@ -402,24 +396,13 @@ sim_errors = sim_batch.decode( pymatching(correlated=True), ).num_errors -sim_frontier_result = sim_batch.decode( - raw_text, - frontier(k=64), - workers=4, -) - -sim_bp_trellis_result = sim_batch.decode( - raw_text, - bp_trellis(escalation_ks=[32, 128]), - workers=4, -) - print(f"simulated shots, pymatching: {sim_errors}/{len(sim_shots)}") -print(f"simulated shots, frontier: {sim_frontier_result.num_errors}/{len(sim_shots)}") -print(f"simulated shots, bp_trellis: {sim_bp_trellis_result.num_errors}/{len(sim_shots)}") +for name, spec in optional_specs.items(): + result = sim_batch.decode(raw_text, spec, workers=4) + print(f"simulated shots, {name}: {result.num_errors}/{len(sim_shots)}") ``` -At this noise level the five decoders land within about a percentage point of +With the optional package installed, at this noise level the five decoders land within about a percentage point of each other on this code; the gaps between decoders widen with code distance and with genuinely hyperedge-like noise. Frontier, BP-Trellis, and BP+OSD consume the raw model in this example; Tesseract uses the source-informed decomposition chosen above. @@ -429,7 +412,7 @@ in this example; Tesseract uses the source-informed decomposition chosen above. !!! warning "Experimental API" The detailed-result APIs below live in `pecos_rslib_exp` and may change - without notice. Use `pecos.decoders.frontier()` or `bp_trellis()` with `batch.decode(...)` + without notice. Use `pecos_rslib_exp.frontier()` or `bp_trellis()` with `batch.decode(...)` for parallel predictions and aggregate error rates. Use these direct APIs when you need per-shot confidence data; that data is not returned by `batch.decode(...)`. @@ -448,17 +431,18 @@ missing gap is not a pruning signal. ```python -from pecos_rslib_exp import FrontierDecoder +if frontier is not None: + from pecos_rslib_exp import FrontierDecoder -frontier_decoder = FrontierDecoder.from_dem(raw_text) -results = [frontier_decoder.decode_syndrome(batch.get_syndrome(shot)) for shot in range(200)] + frontier_decoder = FrontierDecoder.from_dem(raw_text) + results = [frontier_decoder.decode_syndrome(batch.get_syndrome(shot)) for shot in range(200)] -assert all(result.status == "exact" for result in results) -gaps = [result.runner_up_gap for result in results if result.runner_up_gap is not None] + assert all(result.status == "exact" for result in results) + gaps = [result.runner_up_gap for result in results if result.runner_up_gap is not None] -least_confident = min(gaps) -assert least_confident >= 0.0 -print(f"least confident of {len(gaps)} shots: gap={least_confident:.3f}") + least_confident = min(gaps) + assert least_confident >= 0.0 + print(f"least confident of {len(gaps)} shots: gap={least_confident:.3f}") ``` Because the gap is a per-shot quantity, a threshold on it partitions the run @@ -466,9 +450,9 @@ into a confident majority and a tail worth treating differently: ```python -confident = [gap for gap in gaps if gap >= 1.0] - -print(f"{len(confident)}/{len(gaps)} shots decoded with gap >= 1.0") +if frontier is not None: + confident = [gap for gap in gaps if gap >= 1.0] + print(f"{len(confident)}/{len(gaps)} shots decoded with gap >= 1.0") ``` Frontier consumes the raw model directly, so unlike the matching decoders it diff --git a/exp/pecos-bp-trellis/README.md b/exp/pecos-bp-trellis/README.md index 553b87b45..8c98b6cc0 100644 --- a/exp/pecos-bp-trellis/README.md +++ b/exp/pecos-bp-trellis/README.md @@ -13,9 +13,9 @@ remain provisional pending broader validation. The shared trellis engine lives in `pecos-trellis`; this crate contains PECOS's configuration and decoder facade. -The `pecos-decoders` meta-crate exposes this facade through its `bp-trellis` -feature and `DecoderSpec::BpTrellis`. Python builds enable it and provide -`pecos.decoders.bp_trellis(...)` for `SampleBatch.decode` and `DemSampler.decode`, -including parallel Rust workers. All seven configuration options are exposed. -The direct `pecos_rslib_exp.BpTrellisDecoder` API additionally returns detailed -per-shot confidence and retry telemetry. +The optional `pecos-rslib-exp` package provides `pecos_rslib_exp.bp_trellis(...)` +for `SampleBatch.decode` and `DemSampler.decode`, including parallel Rust workers. +All seven configuration options are exposed. Standard `pecos-rslib` and +`pecos-decoders` do not depend on this crate. The direct +`pecos_rslib_exp.BpTrellisDecoder` API additionally returns detailed per-shot +confidence and retry telemetry. diff --git a/exp/pecos-frontier/README.md b/exp/pecos-frontier/README.md index 94f262f18..f6870d7e7 100644 --- a/exp/pecos-frontier/README.md +++ b/exp/pecos-frontier/README.md @@ -6,10 +6,11 @@ decoder (Leverrier & Urbanke, arXiv:2606.20513). Not a wrap of the upstream **Experimental** (`exp/`): the algorithm core is enumeration- and upstream-verified (per-shot parity on matched models), but the crate has not yet accumulated real-user -mileage. It is registered in the `pecos-decoders` meta-crate behind the `frontier` feature -and exposed in Python as `pecos.decoders.frontier()` for parallel -`SampleBatch.decode` execution. The detailed per-shot API remains available in -`pecos_rslib_exp`. Graduation to `crates/` awaits broader real-world use. +mileage. Python exposes `pecos_rslib_exp.frontier()` for parallel `SampleBatch.decode` +and `DemSampler.decode` execution. The experimental extension is optional; +standard `pecos-rslib` and `pecos-decoders` do not depend on this crate. The +detailed per-shot API remains available as `pecos_rslib_exp.FrontierDecoder`. +Graduation to `crates/` awaits broader real-world use. Pruning ranks accumulated prefix log mass plus a `score_alpha`-weighted suffix-compatibility estimate. Unpruned results are exact and upstream-verified. diff --git a/python/pecos-rslib-exp/Cargo.toml b/python/pecos-rslib-exp/Cargo.toml index 97c09755a..780b540bf 100644 --- a/python/pecos-rslib-exp/Cargo.toml +++ b/python/pecos-rslib-exp/Cargo.toml @@ -25,6 +25,7 @@ extension-module = [ ] [dependencies] +pecos-decoder-core.workspace = true pecos-bp-trellis.workspace = true pecos-core.workspace = true pecos-eeg.workspace = true diff --git a/python/pecos-rslib-exp/src/decoder_specs.rs b/python/pecos-rslib-exp/src/decoder_specs.rs new file mode 100644 index 000000000..913dd6462 --- /dev/null +++ b/python/pecos-rslib-exp/src/decoder_specs.rs @@ -0,0 +1,474 @@ +//! Optional experimental decoder factories and native workers for batch decoding. +use pecos_bp_trellis::{BpTrellisConfig, TrellisOrdering as BpTrellisOrdering}; +use pecos_decoder_core::{DecoderError, ObservableDecoder}; +use pyo3::exceptions::{PyRuntimeError, PyValueError}; +use pyo3::prelude::*; +use pyo3::types::PyModule; +use std::sync::Mutex; + +#[derive(Clone, Debug, PartialEq)] +enum ExperimentalSpec { + Frontier(FrontierConfig), + BpTrellis(BpTrellisConfig), +} + +#[pyclass( + name = "ExperimentalDecoderSpec", + module = "pecos_rslib_exp", + frozen, + from_py_object +)] +#[derive(Clone)] +pub struct PyExperimentalDecoderSpec { + inner: ExperimentalSpec, +} +impl PyExperimentalDecoderSpec { + fn new(inner: ExperimentalSpec) -> Self { + Self { inner } + } +} +#[pymethods] +impl PyExperimentalDecoderSpec { + #[getter] + fn family(&self) -> &'static str { + match self.inner { + ExperimentalSpec::Frontier(_) => "frontier", + ExperimentalSpec::BpTrellis(_) => "bp_trellis", + } + } + #[getter] + fn history_dependent(&self) -> bool { + false + } + #[getter] + fn wall_clock_dependent(&self) -> bool { + false + } + #[getter] + fn _pecos_decoder_api_version(&self) -> u32 { + 1 + } + fn __repr__(&self) -> String { + match &self.inner { + ExperimentalSpec::Frontier(c) => frontier_repr(c), + ExperimentalSpec::BpTrellis(c) => bp_trellis_repr(c), + } + } + fn __eq__(&self, other: &Bound<'_, PyAny>, py: Python<'_>) -> PyResult> { + let Ok(other) = other.extract::>() else { + return Ok(py.NotImplemented()); + }; + Ok((self.inner == other.inner) + .into_pyobject(py)? + .to_owned() + .into_any() + .unbind()) + } + fn __hash__(&self) -> u64 { + use std::hash::{Hash, Hasher}; + let mut hasher = std::hash::DefaultHasher::new(); + self.family().hash(&mut hasher); + hasher.finish() + } + /// Internal batch protocol: construct an independent native worker. + fn _pecos_build_decoder(&self, py: Python<'_>, dem: &str) -> PyResult { + let (inner, num_detectors) = py + .detach(|| { + let inner = match &self.inner { + ExperimentalSpec::Frontier(c) => build_frontier(dem, c), + ExperimentalSpec::BpTrellis(c) => build_bp_trellis(dem, c), + }?; + let num_detectors = pecos_decoder_core::dem::utils::parse_dem_metadata(dem)?.0; + Ok::<_, DecoderError>((inner, num_detectors)) + }) + .map_err(|e| PyRuntimeError::new_err(e.to_string()))?; + Ok(PyExperimentalWorker { + inner: Mutex::new(inner), + num_detectors, + }) + } +} + +#[pyclass(module = "pecos_rslib_exp")] +pub struct PyExperimentalWorker { + inner: Mutex>, + #[pyo3(get)] + num_detectors: usize, +} +#[pymethods] +impl PyExperimentalWorker { + /// Decode without the GIL and return little-endian observable words. + fn _pecos_decode_obs(&self, py: Python<'_>, syndrome: Vec) -> PyResult> { + py.detach(|| { + let mut decoder = self + .inner + .lock() + .map_err(|_| PyRuntimeError::new_err("decoder lock poisoned"))?; + decoder + .decode_obs(&syndrome) + .map(|mask| mask.words().to_vec()) + .map_err(|e| PyRuntimeError::new_err(e.to_string())) + }) + } +} + +fn finish_repr(family: &str, args: Vec) -> String { + format!("{family}({})", args.join(", ")) +} +fn invalid_choice(parameter: &str, value: &str, accepted: &str) -> PyErr { + PyValueError::new_err(format!( + "{parameter} has invalid value {value:?}; accepted values: {accepted}" + )) +} +fn non_negative(parameter: &str, value: f64) -> PyResult { + if value.is_finite() && value >= 0.0 { + Ok(value) + } else { + Err(PyValueError::new_err(format!( + "{parameter} must be finite and non-negative" + ))) + } +} +pub fn register(module: &Bound<'_, PyModule>) -> PyResult<()> { + module.add_class::()?; + module.add_function(wrap_pyfunction!(frontier, module)?)?; + module.add_function(wrap_pyfunction!(bp_trellis, module)?)?; + Ok(()) +} + +/// Mechanism ordering for the Frontier decoder. +#[derive(Clone, Debug, Default, PartialEq)] +pub enum FrontierColumnOrder { + #[default] + Deadline, + Time, + BackwardDeadline, + Explicit(Vec), +} + +/// Route metric for the Frontier decoder. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum FrontierMetricMode { + #[default] + LogSumExpFloat, + MaxLogInt, +} + +/// Frontier options, preserving the existing Python ordering defaults. +#[derive(Clone, Debug, PartialEq)] +pub struct FrontierConfig { + pub k: usize, + pub delta: f64, + pub score_alpha: f64, + pub column_order: FrontierColumnOrder, + pub merge_indistinguishable: bool, + pub bp_score_iterations: usize, + pub metric_mode: FrontierMetricMode, + pub int_metric_scale: i32, +} + +impl Default for FrontierConfig { + fn default() -> Self { + Self { + k: 64, + delta: 50.0, + score_alpha: 0.8, + column_order: FrontierColumnOrder::Deadline, + merge_indistinguishable: false, + bp_score_iterations: 0, + metric_mode: FrontierMetricMode::LogSumExpFloat, + int_metric_scale: 1024, + } + } +} + +fn build_frontier( + dem: &str, + config: &self::FrontierConfig, +) -> Result, DecoderError> { + use self::{FrontierColumnOrder, FrontierMetricMode}; + use pecos_frontier::{FrontierConfig, FrontierDecoder, MetricMode, SparseDem}; + let dem = SparseDem::from_dem_str(dem)?; + let column_order = match &config.column_order { + FrontierColumnOrder::Deadline => Some(pecos_frontier::deadline_column_order(&dem)?), + FrontierColumnOrder::Time => None, + FrontierColumnOrder::BackwardDeadline => { + Some(pecos_frontier::backward_deadline_column_order(&dem)?) + } + FrontierColumnOrder::Explicit(order) => Some(order.clone()), + }; + let decoder = FrontierDecoder::from_sparse_dem( + &dem, + FrontierConfig { + k: config.k, + delta: config.delta, + score_alpha: config.score_alpha, + column_order, + merge_indistinguishable: config.merge_indistinguishable, + bp_score_iterations: config.bp_score_iterations, + metric_mode: match config.metric_mode { + FrontierMetricMode::LogSumExpFloat => MetricMode::LogSumExpFloat, + FrontierMetricMode::MaxLogInt => MetricMode::MaxLogInt, + }, + int_metric_scale: config.int_metric_scale, + }, + )?; + Ok(Box::new(decoder)) +} + +fn build_bp_trellis( + dem: &str, + config: &self::BpTrellisConfig, +) -> Result, DecoderError> { + Ok(Box::new(pecos_bp_trellis::BpTrellisDecoder::from_dem_str( + dem, + config.clone(), + )?)) +} + +#[derive(FromPyObject)] +enum FrontierOrderArgument { + Name(String), + Explicit(Vec), +} + +impl Default for FrontierOrderArgument { + fn default() -> Self { + Self::Name("deadline_reorder".to_owned()) + } +} + +/// Native Rust Frontier decoder for raw DEMs, including hyperedges. +/// Batch decoding supports independent Rust workers. Pruning makes predictions +/// approximate; use pecos_rslib_exp.FrontierDecoder for per-shot confidence data. +#[pyfunction] +#[pyo3(signature = (*, k=64, delta=50.0, score_alpha=0.8, bp_score_iterations=0, column_order=FrontierOrderArgument::default(), merge_indistinguishable=false, metric_mode="logsumexp_float", int_metric_scale=1024), + text_signature = "(*, k=64, delta=50.0, score_alpha=0.8, bp_score_iterations=0, column_order='deadline_reorder', merge_indistinguishable=False, metric_mode='logsumexp_float', int_metric_scale=1024)")] +fn frontier( + k: usize, + delta: f64, + score_alpha: f64, + bp_score_iterations: usize, + column_order: FrontierOrderArgument, + merge_indistinguishable: bool, + metric_mode: &str, + int_metric_scale: i32, +) -> PyResult { + use self::{FrontierColumnOrder, FrontierConfig, FrontierMetricMode}; + if k == 0 { + return Err(PyValueError::new_err("k must be at least 1")); + } + if delta.is_nan() || delta < 0.0 { + return Err(PyValueError::new_err( + "delta must be non-negative and not NaN", + )); + } + let score_alpha = non_negative("score_alpha", score_alpha)?; + if int_metric_scale <= 0 { + return Err(PyValueError::new_err("int_metric_scale must be positive")); + } + let metric_mode = match metric_mode.trim() { + "logsumexp_float" | "float" | "exact" => FrontierMetricMode::LogSumExpFloat, + "maxlog_int" | "max_log_int" | "viterbi_int" | "frontierLite" | "frontier_lite" + | "frontier-lite" | "frontierlite" => FrontierMetricMode::MaxLogInt, + value => { + return Err(invalid_choice( + "metric_mode", + value, + "'logsumexp_float', 'maxlog_int'", + )); + } + }; + if metric_mode == FrontierMetricMode::MaxLogInt { + if !delta.is_finite() { + return Err(PyValueError::new_err( + "delta must be finite under maxlog_int", + )); + } + if merge_indistinguishable { + return Err(PyValueError::new_err( + "merge_indistinguishable is incompatible with maxlog_int", + )); + } + } + let column_order = match column_order { + FrontierOrderArgument::Explicit(order) => FrontierColumnOrder::Explicit(order), + FrontierOrderArgument::Name(name) => match name.as_str() { + "deadline_reorder" => FrontierColumnOrder::Deadline, + "time_order" => FrontierColumnOrder::Time, + "backward_deadline_reorder" => FrontierColumnOrder::BackwardDeadline, + value => { + return Err(invalid_choice( + "column_order", + value, + "'deadline_reorder', 'time_order', 'backward_deadline_reorder', or a list of column indices", + )); + } + }, + }; + Ok(PyExperimentalDecoderSpec::new(ExperimentalSpec::Frontier( + FrontierConfig { + k, + delta, + score_alpha, + column_order, + merge_indistinguishable, + bp_score_iterations, + metric_mode, + int_metric_scale, + }, + ))) +} + +fn frontier_repr(config: &self::FrontierConfig) -> String { + use self::{FrontierColumnOrder, FrontierConfig, FrontierMetricMode}; + let default = FrontierConfig::default(); + let mut args = Vec::new(); + if config.k != default.k { + args.push(format!("k={}", config.k)); + } + if config.delta.to_bits() != default.delta.to_bits() { + args.push(if config.delta.is_infinite() { + "delta=float('inf')".to_owned() + } else { + format!("delta={:?}", config.delta) + }); + } + if config.score_alpha.to_bits() != default.score_alpha.to_bits() { + args.push(format!("score_alpha={:?}", config.score_alpha)); + } + if config.bp_score_iterations != 0 { + args.push(format!( + "bp_score_iterations={}", + config.bp_score_iterations + )); + } + match &config.column_order { + FrontierColumnOrder::Deadline => {} + FrontierColumnOrder::Time => args.push("column_order='time_order'".to_owned()), + FrontierColumnOrder::BackwardDeadline => { + args.push("column_order='backward_deadline_reorder'".to_owned()); + } + FrontierColumnOrder::Explicit(order) => args.push(format!("column_order={order:?}")), + } + if config.merge_indistinguishable { + args.push("merge_indistinguishable=True".to_owned()); + } + if config.metric_mode == FrontierMetricMode::MaxLogInt { + args.push("metric_mode='maxlog_int'".to_owned()); + } + if config.int_metric_scale != default.int_metric_scale { + args.push(format!("int_metric_scale={}", config.int_metric_scale)); + } + finish_repr("frontier", args) +} + +#[derive(FromPyObject)] +enum BpTrellisOrderArgument { + Name(String), + Explicit(Vec), +} + +impl Default for BpTrellisOrderArgument { + fn default() -> Self { + Self::Name("deadline".to_owned()) + } +} + +/// Native Rust BP-guided trellis decoder for raw DEMs, including hyperedges. +/// Batch decoding supports independent Rust workers. Each worker prebuilds the +/// optional escalation ladder, retried only after a no-path result. Use +/// pecos_rslib_exp.BpTrellisDecoder for per-shot confidence and retry telemetry. +#[pyfunction] +#[pyo3(signature = (*, k=8, delta=100.0, score_alpha=0.8, bp_score_iterations=5, merge_indistinguishable=true, ordering=BpTrellisOrderArgument::default(), escalation_ks=None), + text_signature = "(*, k=8, delta=100.0, score_alpha=0.8, bp_score_iterations=5, merge_indistinguishable=True, ordering='deadline', escalation_ks=None)")] +fn bp_trellis( + k: usize, + delta: f64, + score_alpha: f64, + bp_score_iterations: usize, + merge_indistinguishable: bool, + ordering: BpTrellisOrderArgument, + escalation_ks: Option>, +) -> PyResult { + use self::{BpTrellisConfig, BpTrellisOrdering}; + if k == 0 { + return Err(PyValueError::new_err("k must be at least 1")); + } + if delta.is_nan() || delta < 0.0 { + return Err(PyValueError::new_err( + "delta must be non-negative and not NaN", + )); + } + let score_alpha = non_negative("score_alpha", score_alpha)?; + let escalation_ks = escalation_ks.unwrap_or_default(); + if escalation_ks.contains(&0) { + return Err(PyValueError::new_err( + "escalation_ks widths must be at least 1", + )); + } + let ordering = match ordering { + BpTrellisOrderArgument::Explicit(order) => BpTrellisOrdering::Explicit(order), + BpTrellisOrderArgument::Name(name) => match name.as_str() { + "deadline" => BpTrellisOrdering::Deadline, + "backward_deadline" => BpTrellisOrdering::BackwardDeadline, + "time_order" => BpTrellisOrdering::TimeOrder, + value => { + return Err(invalid_choice( + "ordering", + value, + "'deadline', 'backward_deadline', 'time_order', or a list of mechanism indices", + )); + } + }, + }; + Ok(PyExperimentalDecoderSpec::new(ExperimentalSpec::BpTrellis( + BpTrellisConfig { + k, + delta, + score_alpha, + bp_score_iterations, + merge_indistinguishable, + ordering, + escalation_ks, + }, + ))) +} + +fn bp_trellis_repr(config: &self::BpTrellisConfig) -> String { + use self::{BpTrellisConfig, BpTrellisOrdering}; + let default = BpTrellisConfig::default(); + let mut args = Vec::new(); + if config.k != default.k { + args.push(format!("k={}", config.k)); + } + if config.delta.to_bits() != default.delta.to_bits() { + args.push(if config.delta.is_infinite() { + "delta=float('inf')".to_owned() + } else { + format!("delta={:?}", config.delta) + }); + } + if config.score_alpha.to_bits() != default.score_alpha.to_bits() { + args.push(format!("score_alpha={:?}", config.score_alpha)); + } + if config.bp_score_iterations != default.bp_score_iterations { + args.push(format!( + "bp_score_iterations={}", + config.bp_score_iterations + )); + } + if !config.merge_indistinguishable { + args.push("merge_indistinguishable=False".to_owned()); + } + match &config.ordering { + BpTrellisOrdering::Deadline => {} + BpTrellisOrdering::TimeOrder => args.push("ordering='time_order'".to_owned()), + BpTrellisOrdering::BackwardDeadline => args.push("ordering='backward_deadline'".to_owned()), + BpTrellisOrdering::Explicit(order) => args.push(format!("ordering={order:?}")), + } + if !config.escalation_ks.is_empty() { + args.push(format!("escalation_ks={:?}", config.escalation_ks)); + } + finish_repr("bp_trellis", args) +} diff --git a/python/pecos-rslib-exp/src/lib.rs b/python/pecos-rslib-exp/src/lib.rs index 06a7ab7b1..35ea4c60f 100644 --- a/python/pecos-rslib-exp/src/lib.rs +++ b/python/pecos-rslib-exp/src/lib.rs @@ -34,6 +34,7 @@ mod bp_trellis_bindings; mod coherent_idle_channel; mod compile_bindings; +mod decoder_specs; mod eeg_bindings; mod frontier_bindings; mod mast_bindings; @@ -108,6 +109,7 @@ fn pecos_rslib_exp(m: &Bound<'_, PyModule>) -> PyResult<()> { // version (CARGO_PKG_VERSION) is a different number -- it rides the Rust workspace train. m.add("__version__", env!("PECOS_PYTHON_VERSION"))?; + decoder_specs::register(m)?; m.add_class::()?; m.add_class::()?; m.add_class::()?; diff --git a/python/pecos-rslib/tests/test_bp_trellis_batch_decode.py b/python/pecos-rslib-exp/tests/test_bp_trellis_batch_decode.py similarity index 82% rename from python/pecos-rslib/tests/test_bp_trellis_batch_decode.py rename to python/pecos-rslib-exp/tests/test_bp_trellis_batch_decode.py index a339364d6..e49bd7287 100644 --- a/python/pecos-rslib/tests/test_bp_trellis_batch_decode.py +++ b/python/pecos-rslib-exp/tests/test_bp_trellis_batch_decode.py @@ -6,16 +6,18 @@ import random import pytest -from pecos_rslib.decoders import DecoderSpec, bp_trellis from pecos_rslib.qec import DemSampler, SampleBatch +exp = pytest.importorskip("pecos_rslib_exp") +bp_trellis = exp.bp_trellis + DEM = "error(0.1) D0 D1 D2 L0\nerror(0.03) D0\nerror(0.03) D1\nerror(0.03) D2\n" def test_public_spec_and_configuration(): from pecos.decoders import bp_trellis as public_bp_trellis - assert public_bp_trellis() == bp_trellis() == DecoderSpec.parse("bp_trellis") + assert public_bp_trellis() == bp_trellis() assert bp_trellis().family == "bp_trellis" assert not bp_trellis().history_dependent assert not bp_trellis().wall_clock_dependent @@ -46,7 +48,7 @@ def test_public_spec_and_configuration(): ], ) def test_invalid_options(options): - with pytest.raises(ValueError, match="must|invalid|incompatible"): + with pytest.raises(ValueError, match=r"must|invalid|incompatible"): bp_trellis(**options) @@ -65,7 +67,7 @@ def test_invalid_options(options): ) def test_parallel_predictions_match_sequential(options): # Aperiodic rows expose chunk-order errors; exercise several dynamic chunks. - rng = random.Random(35) # noqa: S311 - deterministic test data + rng = random.Random(35) rows = [[rng.randrange(2) for _ in range(3)] for _ in range(1025)] truth = [rng.randrange(2) for _ in rows] batch = SampleBatch(rows, truth) @@ -91,7 +93,7 @@ def test_auto_execution_wide_observables_and_count_only(): assert auto.predictions == expected assert auto.num_errors == 0 assert auto.execution_path == "parallel" - count = batch.decode(dem, "bp_trellis", workers=3) + count = batch.decode(dem, bp_trellis(), workers=3) assert count.predictions is None assert count.num_errors == 0 empty = DemSampler.from_dem_string(dem).sample_batch(0, seed=1).decode(dem, bp_trellis(), workers=2) @@ -103,7 +105,7 @@ def test_invalid_order_and_impossible_syndrome_are_errors(workers): batch = SampleBatch([[0, 0, 0]], [0]) with pytest.raises(RuntimeError, match="permutation"): batch.decode(DEM, bp_trellis(ordering=[0, 0, 1, 2]), workers=workers) - with pytest.raises(RuntimeError, match="(?i)(path|syndrome|shot)"): + with pytest.raises(RuntimeError, match=r"(?i)(path|syndrome|shot)"): SampleBatch([[1]], [0]).decode("detector D0\n", bp_trellis(), workers=workers) @@ -136,9 +138,9 @@ def test_predictions_match_direct_experimental_binding(): @pytest.mark.parametrize("workers", [1, 4]) def test_no_path_escalation_is_used_in_batch_execution(workers): dem = "error(0.4) D0\nerror(0.4) D1\nerror(0.1) D0 D1 D2 L0\n" - options = dict(k=2, bp_score_iterations=0, merge_indistinguishable=False, ordering="time_order") + options = {"k": 2, "bp_score_iterations": 0, "merge_indistinguishable": False, "ordering": "time_order"} batch = SampleBatch([[0, 0, 1]] * 1025, [1] * 1025) - with pytest.raises(RuntimeError, match="(?i)(path|syndrome|shot)"): + with pytest.raises(RuntimeError, match=r"(?i)(path|syndrome|shot)"): batch.decode(dem, bp_trellis(**options), workers=workers) result = batch.decode( dem, @@ -148,3 +150,27 @@ def test_no_path_escalation_is_used_in_batch_execution(workers): ) assert result.predictions == [1] * 1025 assert result.num_errors == 0 + + +def test_gil_is_released_during_native_decode(): + import threading + + started = threading.Event() + stop = threading.Event() + progress = [0] + + def worker(): + started.set() + while not stop.is_set(): + progress[0] += 1 + + thread = threading.Thread(target=worker) + thread.start() + started.wait() + before = progress[0] + try: + SampleBatch([[0, 0, 0]] * 2048, [0] * 2048).decode(DEM, bp_trellis(), workers=4) + finally: + stop.set() + thread.join() + assert progress[0] - before > 100 diff --git a/python/pecos-rslib/tests/test_frontier_batch_decode.py b/python/pecos-rslib-exp/tests/test_frontier_batch_decode.py similarity index 84% rename from python/pecos-rslib/tests/test_frontier_batch_decode.py rename to python/pecos-rslib-exp/tests/test_frontier_batch_decode.py index 34ea5a728..9335ee8d9 100644 --- a/python/pecos-rslib/tests/test_frontier_batch_decode.py +++ b/python/pecos-rslib-exp/tests/test_frontier_batch_decode.py @@ -6,16 +6,18 @@ import random import pytest -from pecos_rslib.decoders import DecoderSpec, frontier from pecos_rslib.qec import DemSampler, SampleBatch +exp = pytest.importorskip("pecos_rslib_exp") +frontier = exp.frontier + DEM = "error(0.1) D0 D1 D2 L0\nerror(0.03) D0\nerror(0.03) D1\nerror(0.03) D2\n" def test_public_spec_and_configuration(): from pecos.decoders import frontier as public_frontier - assert public_frontier() == frontier() == DecoderSpec.parse("frontier") + assert public_frontier() == frontier() assert frontier().family == "frontier" assert not frontier().history_dependent assert not frontier().wall_clock_dependent @@ -48,7 +50,7 @@ def test_public_spec_and_configuration(): ], ) def test_invalid_options(options): - with pytest.raises(ValueError, match="must|invalid|incompatible"): + with pytest.raises(ValueError, match=r"must|invalid|incompatible"): frontier(**options) @@ -67,7 +69,7 @@ def test_invalid_options(options): ) def test_parallel_predictions_match_sequential(options): # Aperiodic rows expose chunk-order errors; exercise several dynamic chunks. - rng = random.Random(35) # noqa: S311 - deterministic test data + rng = random.Random(35) rows = [[rng.randrange(2) for _ in range(3)] for _ in range(1025)] truth = [rng.randrange(2) for _ in rows] batch = SampleBatch(rows, truth) @@ -93,7 +95,7 @@ def test_auto_execution_wide_observables_and_count_only(): assert auto.predictions == expected assert auto.num_errors == 0 assert auto.execution_path == "parallel" - count = batch.decode(dem, "frontier", workers=3) + count = batch.decode(dem, frontier(), workers=3) assert count.predictions is None assert count.num_errors == 0 empty = DemSampler.from_dem_string(dem).sample_batch(0, seed=1).decode(dem, frontier(), workers=2) @@ -105,7 +107,7 @@ def test_invalid_order_and_impossible_syndrome_are_errors(workers): batch = SampleBatch([[0, 0, 0]], [0]) with pytest.raises(RuntimeError, match="permutation"): batch.decode(DEM, frontier(column_order=[0, 0, 1, 2]), workers=workers) - with pytest.raises(RuntimeError, match="(?i)(path|syndrome|shot)"): + with pytest.raises(RuntimeError, match=r"(?i)(path|syndrome|shot)"): SampleBatch([[1]], [0]).decode("detector D0\n", frontier(), workers=workers) @@ -133,3 +135,27 @@ def test_predictions_match_direct_experimental_binding(): expected = [direct.decode_syndrome(row).observable_flips.mask for row in rows] result = SampleBatch(rows, [0] * len(rows)).decode(DEM, frontier(**options), workers=3, predictions=True) assert result.predictions == expected + + +def test_gil_is_released_during_native_decode(): + import threading + + started = threading.Event() + stop = threading.Event() + progress = [0] + + def worker(): + started.set() + while not stop.is_set(): + progress[0] += 1 + + thread = threading.Thread(target=worker) + thread.start() + started.wait() + before = progress[0] + try: + SampleBatch([[0, 0, 0]] * 2048, [0] * 2048).decode(DEM, frontier(), workers=4) + finally: + stop.set() + thread.join() + assert progress[0] - before > 100 diff --git a/python/pecos-rslib/Cargo.toml b/python/pecos-rslib/Cargo.toml index 15f08f6bd..09ed34b19 100644 --- a/python/pecos-rslib/Cargo.toml +++ b/python/pecos-rslib/Cargo.toml @@ -77,8 +77,6 @@ pecos-cppsparsestab.workspace = true # Decoders (all backends except mwpf, which remains opt in via the top-level # `mwpf` feature on this crate). pecos-decoders = { workspace = true, features = [ - "frontier", - "bp-trellis", "ldpc", "fusion-blossom", "pymatching", diff --git a/python/pecos-rslib/src/batch_decoder_spec.rs b/python/pecos-rslib/src/batch_decoder_spec.rs new file mode 100644 index 000000000..a82d6d131 --- /dev/null +++ b/python/pecos-rslib/src/batch_decoder_spec.rs @@ -0,0 +1,124 @@ +//! Versioned Python decoder-provider bridge. The standard extension never imports +//! optional providers. Providers own their configuration and native workers; +//! only Python objects and integer word arrays cross extension boundaries. + +use crate::decoder_spec_bindings::PyDecoderSpec; +use pecos_decoder_core::{DecoderError, ObservableDecoder, obs_mask::ObsMask}; +use pecos_decoders::{DecodeModel, DecoderSpec, spec::ExecutionTraits}; +use pyo3::exceptions::PyTypeError; +use pyo3::prelude::*; +use pyo3::types::{PyBytes, PyString}; + +pub(crate) enum BatchDecoderSpec { + Builtin(DecoderSpec), + Provider { + spec: Py, + traits: ExecutionTraits, + }, +} + +impl BatchDecoderSpec { + pub(crate) fn extract(decoder: &Bound<'_, PyAny>) -> PyResult { + if decoder.is_instance_of::() { + return DecoderSpec::parse(decoder.extract::<&str>()?) + .map(Self::Builtin) + .map_err(crate::fault_tolerance_bindings::decoder_parse_error_to_py); + } + if let Ok(spec) = decoder.extract::>() { + return Ok(Self::Builtin(spec.inner.clone())); + } + let version = decoder + .getattr("_pecos_decoder_api_version") + .and_then(|v| v.extract::()); + if !matches!(version, Ok(1)) { + return Err(PyTypeError::new_err( + "decoder must be a DecoderSpec, legacy decoder string, or a version-1 decoder provider", + )); + } + if !decoder.getattr("_pecos_build_decoder")?.is_callable() { + return Err(PyTypeError::new_err( + "decoder provider _pecos_build_decoder must be callable", + )); + } + Ok(Self::Provider { + spec: decoder.clone().unbind(), + traits: ExecutionTraits { + history_dependent: decoder.getattr("history_dependent")?.extract()?, + wall_clock_dependent: decoder.getattr("wall_clock_dependent")?.extract()?, + }, + }) + } + + pub(crate) fn execution_traits(&self) -> ExecutionTraits { + match self { + Self::Builtin(spec) => spec.execution_traits(), + Self::Provider { traits, .. } => *traits, + } + } + pub(crate) fn native_batch_capable(&self) -> bool { + match self { + Self::Builtin(spec) => spec.native_batch_capable(), + Self::Provider { .. } => false, + } + } + pub(crate) fn embedded_hybrid_full_dem(&self) -> Option<&str> { + match self { + Self::Builtin(spec) => spec.embedded_hybrid_full_dem(), + Self::Provider { .. } => None, + } + } + pub(crate) fn build( + &self, + model: &DecodeModel, + ) -> Result, DecoderError> { + match self { + Self::Builtin(spec) => spec.build(model), + Self::Provider { spec, .. } => { + let dem = match model { + DecodeModel::SingleDem(text) => text.clone(), + DecodeModel::StructuredDem(model) => model.to_dem_string(), + DecodeModel::HybridDem { .. } => { + return Err(DecoderError::InvalidConfiguration( + "decoder providers require a single DEM".into(), + )); + } + }; + Python::attach(|py| -> PyResult> { + let worker = spec.bind(py).call_method1("_pecos_build_decoder", (dem,))?; + let num_detectors = worker.getattr("num_detectors")?.extract::()?; + if !worker.getattr("_pecos_decode_obs")?.is_callable() { + return Err(PyTypeError::new_err( + "decoder provider _pecos_decode_obs must be callable", + )); + } + Ok(Box::new(ProviderDecoder { + worker: worker.unbind(), + num_detectors, + })) + }) + .map_err(|e| DecoderError::InvalidConfiguration(e.to_string())) + } + } + } +} + +struct ProviderDecoder { + worker: Py, + num_detectors: usize, +} +impl ObservableDecoder for ProviderDecoder { + fn num_detectors(&self) -> Option { + Some(self.num_detectors) + } + fn decode_obs(&mut self, syndrome: &[u8]) -> Result { + Python::attach(|py| { + let words = self + .worker + .bind(py) + .call_method1("_pecos_decode_obs", (PyBytes::new(py, syndrome),))? + .extract::>()?; + Ok::<_, PyErr>(ObsMask::from_words(&words)) + }) + .map_err(|e| DecoderError::DecodingFailed(e.to_string())) + } +} diff --git a/python/pecos-rslib/src/decoder_spec_bindings.rs b/python/pecos-rslib/src/decoder_spec_bindings.rs index b93aefbc6..dc12b5537 100644 --- a/python/pecos-rslib/src/decoder_spec_bindings.rs +++ b/python/pecos-rslib/src/decoder_spec_bindings.rs @@ -656,8 +656,6 @@ fn spec_family_name(spec: &pecos_decoders::DecoderSpec) -> &'static str { pecos_decoders::DecoderSpec::BeliefFind => "belief_find", pecos_decoders::DecoderSpec::UnionFind => "union_find", pecos_decoders::DecoderSpec::RelayBp(_) => "relay_bp", - pecos_decoders::DecoderSpec::BpTrellis(_) => "bp_trellis", - pecos_decoders::DecoderSpec::Frontier(_) => "frontier", pecos_decoders::DecoderSpec::MinSumBp(_) => "min_sum_bp", pecos_decoders::DecoderSpec::PecosUf(_) => "pecos_uf", pecos_decoders::DecoderSpec::BeliefMatching(_) => "belief_matching", @@ -737,8 +735,6 @@ fn spec_repr(spec: &pecos_decoders::DecoderSpec) -> String { pecos_decoders::DecoderSpec::BeliefFind => finish_repr("belief_find", Vec::new()), pecos_decoders::DecoderSpec::UnionFind => finish_repr("union_find", Vec::new()), pecos_decoders::DecoderSpec::RelayBp(config) => relay_bp_repr(config), - pecos_decoders::DecoderSpec::BpTrellis(config) => bp_trellis_repr(config), - pecos_decoders::DecoderSpec::Frontier(config) => frontier_repr(config), pecos_decoders::DecoderSpec::MinSumBp(config) => { let default = MinSumBpConfig::default(); let mut args = Vec::new(); @@ -1005,8 +1001,6 @@ fn stopping_repr(value: RelayStoppingCriterion) -> String { /// Register typed decoder-spec factories on `pecos_rslib.decoders`. pub fn register_decoder_specs(module: &Bound<'_, PyModule>) -> PyResult<()> { module.add_class::()?; - module.add_function(wrap_pyfunction!(bp_trellis, module)?)?; - module.add_function(wrap_pyfunction!(frontier, module)?)?; module.add_function(wrap_pyfunction!(pymatching, module)?)?; module.add_function(wrap_pyfunction!(tesseract, module)?)?; module.add_function(wrap_pyfunction!(bp_osd, module)?)?; @@ -1029,250 +1023,3 @@ pub fn register_decoder_specs(module: &Bound<'_, PyModule>) -> PyResult<()> { module.add_function(wrap_pyfunction!(perturbed_fb_corr, module)?)?; Ok(()) } - -#[derive(FromPyObject)] -enum FrontierOrderArgument { - Name(String), - Explicit(Vec), -} - -impl Default for FrontierOrderArgument { - fn default() -> Self { - Self::Name("deadline_reorder".to_owned()) - } -} - -/// Native Rust Frontier decoder for raw DEMs, including hyperedges. -/// Batch decoding supports independent Rust workers. Pruning makes predictions -/// approximate; use pecos_rslib_exp.FrontierDecoder for per-shot confidence data. -#[pyfunction] -#[pyo3(signature = (*, k=64, delta=50.0, score_alpha=0.8, bp_score_iterations=0, column_order=FrontierOrderArgument::default(), merge_indistinguishable=false, metric_mode="logsumexp_float", int_metric_scale=1024), - text_signature = "(*, k=64, delta=50.0, score_alpha=0.8, bp_score_iterations=0, column_order='deadline_reorder', merge_indistinguishable=False, metric_mode='logsumexp_float', int_metric_scale=1024)")] -fn frontier( - k: usize, - delta: f64, - score_alpha: f64, - bp_score_iterations: usize, - column_order: FrontierOrderArgument, - merge_indistinguishable: bool, - metric_mode: &str, - int_metric_scale: i32, -) -> PyResult { - use pecos_decoders::spec::{FrontierColumnOrder, FrontierConfig, FrontierMetricMode}; - if k == 0 { - return Err(PyValueError::new_err("k must be at least 1")); - } - if delta.is_nan() || delta < 0.0 { - return Err(PyValueError::new_err( - "delta must be non-negative and not NaN", - )); - } - let score_alpha = non_negative("score_alpha", score_alpha)?; - if int_metric_scale <= 0 { - return Err(PyValueError::new_err("int_metric_scale must be positive")); - } - let metric_mode = match metric_mode.trim() { - "logsumexp_float" | "float" | "exact" => FrontierMetricMode::LogSumExpFloat, - "maxlog_int" | "max_log_int" | "viterbi_int" | "frontierLite" | "frontier_lite" - | "frontier-lite" | "frontierlite" => FrontierMetricMode::MaxLogInt, - value => { - return Err(invalid_choice( - "metric_mode", - value, - "'logsumexp_float', 'maxlog_int'", - )); - } - }; - if metric_mode == FrontierMetricMode::MaxLogInt { - if !delta.is_finite() { - return Err(PyValueError::new_err( - "delta must be finite under maxlog_int", - )); - } - if merge_indistinguishable { - return Err(PyValueError::new_err( - "merge_indistinguishable is incompatible with maxlog_int", - )); - } - } - let column_order = match column_order { - FrontierOrderArgument::Explicit(order) => FrontierColumnOrder::Explicit(order), - FrontierOrderArgument::Name(name) => match name.as_str() { - "deadline_reorder" => FrontierColumnOrder::Deadline, - "time_order" => FrontierColumnOrder::Time, - "backward_deadline_reorder" => FrontierColumnOrder::BackwardDeadline, - value => { - return Err(invalid_choice( - "column_order", - value, - "'deadline_reorder', 'time_order', 'backward_deadline_reorder', or a list of column indices", - )); - } - }, - }; - Ok(PyDecoderSpec::new(pecos_decoders::DecoderSpec::Frontier( - FrontierConfig { - k, - delta, - score_alpha, - bp_score_iterations, - column_order, - merge_indistinguishable, - metric_mode, - int_metric_scale, - }, - ))) -} - -fn frontier_repr(config: &pecos_decoders::spec::FrontierConfig) -> String { - use pecos_decoders::spec::{FrontierColumnOrder, FrontierConfig, FrontierMetricMode}; - let default = FrontierConfig::default(); - let mut args = Vec::new(); - if config.k != default.k { - args.push(format!("k={}", config.k)); - } - if config.delta.to_bits() != default.delta.to_bits() { - args.push(if config.delta.is_infinite() { - "delta=float('inf')".to_owned() - } else { - format!("delta={:?}", config.delta) - }); - } - if config.score_alpha.to_bits() != default.score_alpha.to_bits() { - args.push(format!("score_alpha={:?}", config.score_alpha)); - } - if config.bp_score_iterations != 0 { - args.push(format!( - "bp_score_iterations={}", - config.bp_score_iterations - )); - } - match &config.column_order { - FrontierColumnOrder::Deadline => {} - FrontierColumnOrder::Time => args.push("column_order='time_order'".to_owned()), - FrontierColumnOrder::BackwardDeadline => { - args.push("column_order='backward_deadline_reorder'".to_owned()); - } - FrontierColumnOrder::Explicit(order) => args.push(format!("column_order={order:?}")), - } - if config.merge_indistinguishable { - args.push("merge_indistinguishable=True".to_owned()); - } - if config.metric_mode == FrontierMetricMode::MaxLogInt { - args.push("metric_mode='maxlog_int'".to_owned()); - } - if config.int_metric_scale != default.int_metric_scale { - args.push(format!("int_metric_scale={}", config.int_metric_scale)); - } - finish_repr("frontier", args) -} - -#[derive(FromPyObject)] -enum BpTrellisOrderArgument { - Name(String), - Explicit(Vec), -} - -impl Default for BpTrellisOrderArgument { - fn default() -> Self { - Self::Name("deadline".to_owned()) - } -} - -/// Native Rust BP-guided trellis decoder for raw DEMs, including hyperedges. -/// Batch decoding supports independent Rust workers. Each worker prebuilds the -/// optional escalation ladder, retried only after a no-path result. Use -/// pecos_rslib_exp.BpTrellisDecoder for per-shot confidence and retry telemetry. -#[pyfunction] -#[pyo3(signature = (*, k=8, delta=100.0, score_alpha=0.8, bp_score_iterations=5, merge_indistinguishable=true, ordering=BpTrellisOrderArgument::default(), escalation_ks=None), - text_signature = "(*, k=8, delta=100.0, score_alpha=0.8, bp_score_iterations=5, merge_indistinguishable=True, ordering='deadline', escalation_ks=None)")] -fn bp_trellis( - k: usize, - delta: f64, - score_alpha: f64, - bp_score_iterations: usize, - merge_indistinguishable: bool, - ordering: BpTrellisOrderArgument, - escalation_ks: Option>, -) -> PyResult { - use pecos_decoders::spec::{BpTrellisConfig, BpTrellisOrdering}; - if k == 0 { - return Err(PyValueError::new_err("k must be at least 1")); - } - if delta.is_nan() || delta < 0.0 { - return Err(PyValueError::new_err( - "delta must be non-negative and not NaN", - )); - } - let score_alpha = non_negative("score_alpha", score_alpha)?; - let escalation_ks = escalation_ks.unwrap_or_default(); - if escalation_ks.contains(&0) { - return Err(PyValueError::new_err( - "escalation_ks widths must be at least 1", - )); - } - let ordering = match ordering { - BpTrellisOrderArgument::Explicit(order) => BpTrellisOrdering::Explicit(order), - BpTrellisOrderArgument::Name(name) => match name.as_str() { - "deadline" => BpTrellisOrdering::Deadline, - "backward_deadline" => BpTrellisOrdering::BackwardDeadline, - "time_order" => BpTrellisOrdering::TimeOrder, - value => { - return Err(invalid_choice( - "ordering", - value, - "'deadline', 'backward_deadline', 'time_order', or a list of mechanism indices", - )); - } - }, - }; - Ok(PyDecoderSpec::new(pecos_decoders::DecoderSpec::BpTrellis( - BpTrellisConfig { - k, - delta, - score_alpha, - bp_score_iterations, - merge_indistinguishable, - ordering, - escalation_ks, - }, - ))) -} - -fn bp_trellis_repr(config: &pecos_decoders::spec::BpTrellisConfig) -> String { - use pecos_decoders::spec::{BpTrellisConfig, BpTrellisOrdering}; - let default = BpTrellisConfig::default(); - let mut args = Vec::new(); - if config.k != default.k { - args.push(format!("k={}", config.k)); - } - if config.delta.to_bits() != default.delta.to_bits() { - args.push(if config.delta.is_infinite() { - "delta=float('inf')".to_owned() - } else { - format!("delta={:?}", config.delta) - }); - } - if config.score_alpha.to_bits() != default.score_alpha.to_bits() { - args.push(format!("score_alpha={:?}", config.score_alpha)); - } - if config.bp_score_iterations != default.bp_score_iterations { - args.push(format!( - "bp_score_iterations={}", - config.bp_score_iterations - )); - } - if !config.merge_indistinguishable { - args.push("merge_indistinguishable=False".to_owned()); - } - match &config.ordering { - BpTrellisOrdering::Deadline => {} - BpTrellisOrdering::TimeOrder => args.push("ordering='time_order'".to_owned()), - BpTrellisOrdering::BackwardDeadline => args.push("ordering='backward_deadline'".to_owned()), - BpTrellisOrdering::Explicit(order) => args.push(format!("ordering={order:?}")), - } - if !config.escalation_ks.is_empty() { - args.push(format!("escalation_ks={:?}", config.escalation_ks)); - } - finish_repr("bp_trellis", args) -} diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index b75fe73a6..e8d3dc6f5 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -3717,7 +3717,7 @@ impl PySampleBatch { } /// Decode and score every shot using a typed decoder specification or a - /// legacy decoder string. + /// legacy decoder string, or an optional decoder-provider specification. /// /// `dem=None` uses the exact DEM embedded by `SampleBatch.load`; generated /// batches require an explicit DEM. Automatic execution honors decoder @@ -3749,16 +3749,7 @@ impl PySampleBatch { let decoder = decoder.ok_or_else(|| { pyo3::exceptions::PyTypeError::new_err("decoder is a required argument") })?; - let spec = if decoder.is_instance_of::() { - let decoder_type = decoder.extract::<&str>()?; - pecos_decoders::DecoderSpec::parse(decoder_type).map_err(decoder_parse_error_to_py)? - } else if let Ok(spec) = decoder.extract::>() { - spec.inner.clone() - } else { - return Err(pyo3::exceptions::PyTypeError::new_err( - "decoder must be a pecos.decoders.DecoderSpec or legacy decoder string", - )); - }; + let spec = crate::batch_decoder_spec::BatchDecoderSpec::extract(decoder)?; let explicit_workers = workers .map(|workers| { @@ -4592,7 +4583,7 @@ impl PyDemSampler { /// dem: DEM text used to construct the decoder. It may deliberately be /// a different projection from the sampler's own model. /// `num_shots`: Number of shots to sample and decode. - /// decoder: A typed `DecoderSpec` or legacy decoder string. + /// decoder: A typed `DecoderSpec`, legacy decoder string, or optional decoder-provider specification. /// seed: Optional sampling seed. The resolved seed is returned as /// `sampling_seed_used` and can replay the run. /// workers: Optional exact worker count. @@ -4627,16 +4618,7 @@ impl PyDemSampler { let decoder = decoder.ok_or_else(|| { pyo3::exceptions::PyTypeError::new_err("decoder is a required argument") })?; - let spec = if decoder.is_instance_of::() { - let decoder_type = decoder.extract::<&str>()?; - pecos_decoders::DecoderSpec::parse(decoder_type).map_err(decoder_parse_error_to_py)? - } else if let Ok(spec) = decoder.extract::>() { - spec.inner.clone() - } else { - return Err(pyo3::exceptions::PyTypeError::new_err( - "decoder must be a pecos.decoders.DecoderSpec or legacy decoder string", - )); - }; + let spec = crate::batch_decoder_spec::BatchDecoderSpec::extract(decoder)?; let explicit_workers = workers .map(|workers| { @@ -6801,8 +6783,7 @@ fn decoder_dem_requirement(decoder_type: &str) -> PyResult { | "belief_matching_hybrid" | "ensemble" => Ok("graphlike".to_string()), "tesseract" | "astar" | "astar_full" | "bp_osd" | "bp_lsd" | "belief_find" - | "union_find" | "min_sum_bp" | "relay_bp" | "mwpf" | "chromobius" | "frontier" - | "bp_trellis" => Ok("any".to_string()), + | "union_find" | "min_sum_bp" | "relay_bp" | "mwpf" | "chromobius" => Ok("any".to_string()), _ => Err(pyo3::exceptions::PyValueError::new_err(format!( "Unknown decoder type: {decoder_type:?}", ))), diff --git a/python/pecos-rslib/src/fault_tolerance_bindings/batch_decode.rs b/python/pecos-rslib/src/fault_tolerance_bindings/batch_decode.rs index ec15b8c71..2cd2db8c0 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings/batch_decode.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings/batch_decode.rs @@ -2,10 +2,11 @@ use super::decoder_scoring::{DecodeRangeResult, ShotDecodeError, decode_and_score_range}; use super::{PyDecodeStats, PySampleBatch, decoder_build_error_to_py}; +use crate::batch_decoder_spec::BatchDecoderSpec as DecoderSpec; use pecos_decoder_core::DecoderError; use pecos_decoder_core::obs_mask::ObsMask; +use pecos_decoders::DecodeModel; use pecos_decoders::batch::{ExecutionPath, ExecutionPlan, IndexedChunk, native_sub_batches}; -use pecos_decoders::{DecodeModel, DecoderSpec}; use pyo3::exceptions::{PyRuntimeError, PyValueError}; use pyo3::prelude::*; use rayon::prelude::*; diff --git a/python/pecos-rslib/src/fault_tolerance_bindings/sampler_decode.rs b/python/pecos-rslib/src/fault_tolerance_bindings/sampler_decode.rs index cb13c4879..4cfa05eff 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings/sampler_decode.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings/sampler_decode.rs @@ -2,13 +2,14 @@ use super::batch_decode::{BatchExecutionError, BatchExecutionOutput, decode_model}; use super::decoder_scoring::{DecodeRangeResult, ShotDecodeError}; +use crate::batch_decoder_spec::BatchDecoderSpec as DecoderSpec; use pecos_decoder_core::obs_mask::ObsMask; use pecos_decoder_core::{DecoderError, ObservableDecoder}; +use pecos_decoders::DecodeModel; use pecos_decoders::batch::{ ExecutionPath, ExecutionPlan, IndexedChunk, SAMPLING_CHUNK_SHOTS, assemble_indexed_chunks, for_each_canonical_sample, sampling_chunks, }; -use pecos_decoders::{DecodeModel, DecoderSpec}; use pecos_qec::fault_tolerance::dem_builder::DemSampler; use pecos_random::PecosRng; use rayon::prelude::*; diff --git a/python/pecos-rslib/src/lib.rs b/python/pecos-rslib/src/lib.rs index ca6fd0172..d00d0e8fd 100644 --- a/python/pecos-rslib/src/lib.rs +++ b/python/pecos-rslib/src/lib.rs @@ -32,6 +32,7 @@ mod prelude; mod array_buffer; +mod batch_decoder_spec; mod bit_conversion; mod bit_int_bindings; mod bit_uint_bindings; diff --git a/python/pecos-rslib/tests/test_decoder_providers.py b/python/pecos-rslib/tests/test_decoder_providers.py new file mode 100644 index 000000000..481ded974 --- /dev/null +++ b/python/pecos-rslib/tests/test_decoder_providers.py @@ -0,0 +1,108 @@ +"""Optional decoder providers use the regular batch planner without native dependencies.""" + +import subprocess +import sys + +import pytest +from pecos_rslib.qec import DemSampler, SampleBatch + + +class Provider: + _pecos_decoder_api_version = 1 + history_dependent = False + wall_clock_dependent = False + + def _pecos_build_decoder(self, dem): + assert dem == "error(0.1) D0 L70\n" + return Worker() + + +class Worker: + num_detectors = 1 + + def _pecos_decode_obs(self, syndrome): + return [0, (1 << 6) if syndrome[0] else 0] + + +DEM = "error(0.1) D0 L70\n" + + +@pytest.mark.parametrize("workers", [1, 3]) +def test_provider_batch_and_sampler_paths(workers): + batch = SampleBatch([[0], [1]], [0, 1 << 70]) + result = batch.decode(DEM, Provider(), workers=workers, predictions=True, timing=True) + assert result.predictions == [0, 1 << 70] + assert result.num_errors == 0 + assert result.workers_used == workers + assert result.stats.num_timing_samples == 2 + sampler = DemSampler.from_dem_string(DEM) + result = sampler.decode(DEM, 3073, Provider(), workers=workers, seed=2) + assert result.num_errors == 0 + + +def test_provider_validation_and_execution_traits(): + batch = SampleBatch([[0]], [0]) + with pytest.raises(TypeError, match="decoder must"): + batch.decode(DEM, object()) + provider = Provider() + provider._pecos_decoder_api_version = 2 + with pytest.raises(TypeError, match="version-1"): + batch.decode(DEM, provider) + provider._pecos_decoder_api_version = 1 + provider.history_dependent = True + with pytest.raises(ValueError, match="history|stateful|workers"): + batch.decode(DEM, provider, workers=3) + assert batch.decode(DEM, provider).execution_path == "sequential" + with pytest.raises(ValueError, match="detectors"): + SampleBatch([[0, 0]], [0]).decode(DEM, Provider()) + + +def test_standard_decoders_import_without_experimental_package(): + code = """ +import sys +sys.modules['pecos_rslib_exp'] = None +import pecos.decoders as decoders +from pecos.decoders import * +from pecos_rslib.qec import SampleBatch +assert SampleBatch([[0]], [0]).decode('error(0.1) D0 L0', decoders.pymatching(correlated=False)).num_errors == 0 +for name in ('frontier', 'bp_trellis'): + assert name not in decoders.__all__ + try: + getattr(decoders, name) + except ImportError as error: + assert 'optional pecos-rslib-exp' in str(error) + else: + raise AssertionError('optional dependency was not required') +""" + subprocess.run([sys.executable, "-c", code], check=True) # noqa: S603 - fixed test program + + +def test_published_decoder_manifest_has_no_unpublishable_dependencies(): + """Optional dependencies must also be publishable for crates.io packaging.""" + import tomllib + from pathlib import Path + + root = Path(__file__).resolve().parents[3] + workspace = tomllib.loads((root / "Cargo.toml").read_text())["workspace"]["dependencies"] + visited = set() + + def inspect(manifest_path): + if manifest_path in visited: + return + visited.add(manifest_path) + manifest = tomllib.loads(manifest_path.read_text()) + assert manifest["package"].get("publish") is not False, manifest_path + for section in ("dependencies", "build-dependencies"): + for name, entry in manifest.get(section, {}).items(): + if not isinstance(entry, dict): + continue + if entry.get("workspace"): + dependency = workspace[name] + directory = root + else: + dependency = entry + directory = manifest_path.parent + if isinstance(dependency, dict) and "path" in dependency: + inspect((directory / dependency["path"] / "Cargo.toml").resolve()) + + inspect(root / "crates/pecos-decoders/Cargo.toml") diff --git a/python/pecos-rslib/tests/test_sample_batch_decode.py b/python/pecos-rslib/tests/test_sample_batch_decode.py index cb1197192..e0f327d0d 100644 --- a/python/pecos-rslib/tests/test_sample_batch_decode.py +++ b/python/pecos-rslib/tests/test_sample_batch_decode.py @@ -12,8 +12,6 @@ from pecos_rslib.decoders import ( bp_osd, - bp_trellis, - frontier, fusion_blossom, mwpf, pecos_uf, @@ -280,8 +278,7 @@ def test_raw_measurement_error_precedes_invalid_decoder() -> None: batch.decode(DEM, "not_a_decoder", allow_dem_mismatch=True) -@pytest.mark.parametrize("spec", [pymatching(correlated=True), frontier(), bp_trellis()]) -def test_gil_is_released_during_decode(spec) -> None: +def test_gil_is_released_during_decode() -> None: batch = _batch(100_000) started = threading.Event() stop = threading.Event() @@ -297,7 +294,7 @@ def worker() -> None: started.wait() before = progress[0] try: - batch.decode(DEM, spec) + batch.decode(DEM, pymatching(correlated=True)) finally: stop.set() thread.join() diff --git a/python/quantum-pecos/src/pecos/decoders/__init__.py b/python/quantum-pecos/src/pecos/decoders/__init__.py index f6d702700..c5b1daba4 100644 --- a/python/quantum-pecos/src/pecos/decoders/__init__.py +++ b/python/quantum-pecos/src/pecos/decoders/__init__.py @@ -16,6 +16,8 @@ # specific language governing permissions and limitations under the License. # Rust decoders (from pecos_rslib) +from importlib import import_module + from pecos_rslib.decoders import ( BpLsdBuilder, BpLsdDecoder, @@ -46,9 +48,7 @@ belief_matching, bp_lsd, bp_osd, - bp_trellis, ensemble, - frontier, fusion_blossom, k_mwpm, min_sum_bp, @@ -98,9 +98,7 @@ "belief_matching", "bp_lsd", "bp_osd", - "bp_trellis", "ensemble", - "frontier", "fusion_blossom", "k_mwpm", "min_sum_bp", @@ -114,3 +112,18 @@ "union_find", "windowed", ] + + +def __getattr__(name: str) -> object: + """Load experimental decoder factories only when explicitly requested.""" + if name in {"frontier", "bp_trellis"}: + try: + experimental = import_module("pecos_rslib_exp") + except ModuleNotFoundError as exc: + if exc.name != "pecos_rslib_exp": + raise + message = f"{name} requires the optional pecos-rslib-exp package; install it to use experimental decoders" + raise ImportError(message) from exc + return getattr(experimental, name) + message = f"module {__name__!r} has no attribute {name!r}" + raise AttributeError(message) From ae12e161c476071dfe9f6d5383594880d95f0d5b Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Wed, 16 Sep 2026 22:33:38 -0600 Subject: [PATCH 4/9] Share trellis config validation and ordering across the experimental decoder specs and bindings, propagate provider build exceptions unchanged, and replace the vacuous GIL-release tests --- docs/user-guide/decoders.md | 4 +- docs/workflows/guppy-dem-decoding.md | 56 ++-- exp/pecos-bp-trellis/src/lib.rs | 94 +++--- exp/pecos-frontier/src/lib.rs | 2 +- exp/pecos-trellis/src/lib.rs | 130 +++++--- .../src/bp_trellis_bindings.rs | 50 +-- python/pecos-rslib-exp/src/decoder_specs.rs | 315 ++++-------------- .../pecos-rslib-exp/src/frontier_bindings.rs | 110 +++--- .../tests/test_bp_trellis_batch_decode.py | 34 +- .../tests/test_decoder_spec_gil.py | 84 +++++ .../tests/test_frontier_batch_decode.py | 39 +-- python/pecos-rslib/src/batch_decoder_spec.rs | 51 +-- .../fault_tolerance_bindings/batch_decode.rs | 9 +- .../sampler_decode.rs | 16 +- .../tests/test_decoder_providers.py | 128 ++++++- .../tests/qec/test_frontier_decoder.py | 2 +- ruff.toml | 3 + 17 files changed, 588 insertions(+), 539 deletions(-) create mode 100644 python/pecos-rslib-exp/tests/test_decoder_spec_gil.py diff --git a/docs/user-guide/decoders.md b/docs/user-guide/decoders.md index 3f91cf603..de61b3684 100644 --- a/docs/user-guide/decoders.md +++ b/docs/user-guide/decoders.md @@ -36,8 +36,6 @@ The following decoder APIs and supporting types are publicly re-exported from | API | Primary input | Description | |-----|---------------|-------------| | `MWPM2D` | QECC object | Legacy minimum-weight perfect matching for 2D codes. | -| `bp_trellis()` (optional `pecos-rslib-exp`) | Raw DEM text via `SampleBatch.decode` | Experimental native Rust BP-guided trellis, with parallel shots and optional no-path retries. | -| `frontier()` (optional `pecos-rslib-exp`) | Raw DEM text via `SampleBatch.decode` | Experimental native Rust Frontier, with parallel shot decoding. | | `DummyDecoder` | None | No-op decoder for tests and interface benchmarks. | | `PyMatchingDecoder` | Graph-like DEM text or `CheckMatrix` | PyMatching minimum-weight perfect matching, with optional correlated decoding. | | `FusionBlossomDecoder` | Check matrix, standard-code parameters, or a manual graph | Pure-Rust minimum-weight perfect matching. | @@ -51,6 +49,8 @@ The following decoder APIs and supporting types are publicly re-exported from | `CheckMatrix` / `SparseMatrix` | Dense or coordinate-form matrix data | Matrix containers used by matching and LDPC decoder constructors. | | `MwpmResult` / `BpResult` / `TesseractResult` | Decoder output | Result objects for matching, belief-propagation, and Tesseract decoders. | +The optional factories are described in the [Rust-backed Frontier](#rust-backed-frontier-batch-decoding) and [Rust-backed BP-Trellis](#rust-backed-bp-trellis-batch-decoding) sections below. + Python decoder inputs name their encoding explicitly: use `decode_syndrome(...)` for a dense detector vector and `decode_from_defects(...)` for sparse detector indices. The BP/LDPC classes' diff --git a/docs/workflows/guppy-dem-decoding.md b/docs/workflows/guppy-dem-decoding.md index 81eb8967c..626944c69 100644 --- a/docs/workflows/guppy-dem-decoding.md +++ b/docs/workflows/guppy-dem-decoding.md @@ -302,13 +302,7 @@ returned `DecodeResult` supplies the aggregate count and rate directly. ```python from pecos.decoders import bp_osd, pymatching, tesseract -# Optional package: standard decoders work without it. -try: - from pecos_rslib_exp import bp_trellis, frontier -except ModuleNotFoundError as error: - if error.name != "pecos_rslib_exp": - raise - bp_trellis = frontier = None +from pecos_rslib_exp import bp_trellis, frontier pymatching_result = batch.decode( terminal_graphlike_text, @@ -330,18 +324,16 @@ decoder_results = { "tesseract": tesseract_result, "bp_osd": bp_osd_result, } -optional_specs = {} -if frontier is not None: - optional_specs = { - "frontier": frontier(k=64), - "bp_trellis": bp_trellis(k=8, escalation_ks=[32, 128]), - } - for name, spec in optional_specs.items(): - result = batch.decode(raw_text, spec, workers=4, predictions=True) - assert result.execution_path == "parallel" - assert result.workers_used == 4 - assert len(result.predictions) == batch.num_shots - decoder_results[name] = result +experimental_specs = { + "frontier": frontier(k=64), + "bp_trellis": bp_trellis(k=8, escalation_ks=[32, 128]), +} +for name, spec in experimental_specs.items(): + result = batch.decode(raw_text, spec, workers=4, predictions=True) + assert result.execution_path == "parallel" + assert result.workers_used == 4 + assert len(result.predictions) == batch.num_shots + decoder_results[name] = result print("DEM-sampled shots") for name, result in decoder_results.items(): @@ -351,7 +343,7 @@ for name, result in decoder_results.items(): ``` Install the optional `pecos-rslib-exp` package to run the Frontier and BP-Trellis -examples. The imports and decoding blocks above are skipped when it is absent. +examples. Explicit imports through `pecos.decoders` are also lazy conveniences, but the factories and native engines belong to `pecos_rslib_exp`. @@ -397,7 +389,7 @@ sim_errors = sim_batch.decode( ).num_errors print(f"simulated shots, pymatching: {sim_errors}/{len(sim_shots)}") -for name, spec in optional_specs.items(): +for name, spec in experimental_specs.items(): result = sim_batch.decode(raw_text, spec, workers=4) print(f"simulated shots, {name}: {result.num_errors}/{len(sim_shots)}") ``` @@ -431,18 +423,17 @@ missing gap is not a pruning signal. ```python -if frontier is not None: - from pecos_rslib_exp import FrontierDecoder +from pecos_rslib_exp import FrontierDecoder - frontier_decoder = FrontierDecoder.from_dem(raw_text) - results = [frontier_decoder.decode_syndrome(batch.get_syndrome(shot)) for shot in range(200)] +frontier_decoder = FrontierDecoder.from_dem(raw_text) +results = [frontier_decoder.decode_syndrome(batch.get_syndrome(shot)) for shot in range(200)] - assert all(result.status == "exact" for result in results) - gaps = [result.runner_up_gap for result in results if result.runner_up_gap is not None] +assert all(result.status == "exact" for result in results) +gaps = [result.runner_up_gap for result in results if result.runner_up_gap is not None] - least_confident = min(gaps) - assert least_confident >= 0.0 - print(f"least confident of {len(gaps)} shots: gap={least_confident:.3f}") +least_confident = min(gaps) +assert least_confident >= 0.0 +print(f"least confident of {len(gaps)} shots: gap={least_confident:.3f}") ``` Because the gap is a per-shot quantity, a threshold on it partitions the run @@ -450,9 +441,8 @@ into a confident majority and a tail worth treating differently: ```python -if frontier is not None: - confident = [gap for gap in gaps if gap >= 1.0] - print(f"{len(confident)}/{len(gaps)} shots decoded with gap >= 1.0") +confident = [gap for gap in gaps if gap >= 1.0] +print(f"{len(confident)}/{len(gaps)} shots decoded with gap >= 1.0") ``` Frontier consumes the raw model directly, so unlike the matching decoders it diff --git a/exp/pecos-bp-trellis/src/lib.rs b/exp/pecos-bp-trellis/src/lib.rs index 4313cd7d8..737c59864 100644 --- a/exp/pecos-bp-trellis/src/lib.rs +++ b/exp/pecos-bp-trellis/src/lib.rs @@ -27,28 +27,13 @@ //! trellis engine lives in `pecos-trellis`. use pecos_decoder_core::ObservableDecoder; +pub use pecos_trellis::TrellisOrdering; use pecos_trellis::{ DecoderError, MetricMode, ObsMask, SparseDem, TrellisConfig, TrellisDecodeAttempt, - TrellisDecoder, TrellisResult, backward_deadline_column_order, deadline_column_order, + TrellisDecoder, TrellisResult, }; use std::time::Instant; -/// Processing order used by [`BpTrellisDecoder`]. -#[derive(Clone, Debug, Default, Eq, PartialEq)] -pub enum TrellisOrdering { - /// Compute the deadline-optimized order with [`deadline_column_order`]. - #[default] - Deadline, - /// Compute the backward deadline-optimized order with - /// [`backward_deadline_column_order`]. - BackwardDeadline, - /// Preserve the detector error model's mechanism order. - TimeOrder, - /// Use an explicit permutation mapping target positions to source - /// mechanism indices. - Explicit(Vec), -} - /// Configuration for PECOS's [`BpTrellisDecoder`]. /// /// These defaults are provisional. In particular, `k = 8` was validated as @@ -80,6 +65,42 @@ pub struct BpTrellisConfig { pub escalation_ks: Vec, } +impl BpTrellisConfig { + /// Validate every rung without building a detector error model. + /// + /// # Errors + /// + /// Returns [`DecoderError::InvalidConfiguration`] for an oversized ladder or invalid rung. + pub fn validate(&self) -> Result<(), DecoderError> { + if u32::try_from(self.escalation_ks.len()).is_err() { + return Err(DecoderError::InvalidConfiguration( + "escalation ladder has more rungs than escalation_rungs_used can represent".into(), + )); + } + let mut config = self.trellis_config(); + config.validate()?; + for &k in &self.escalation_ks { + config.k = k; + config.validate()?; + } + Ok(()) + } + + fn trellis_config(&self) -> TrellisConfig { + TrellisConfig { + k: self.k, + delta: self.delta, + score_alpha: self.score_alpha, + column_order: None, + merge_indistinguishable: self.merge_indistinguishable, + bp_score_iterations: self.bp_score_iterations, + // BpTrellis escalation is defined over coset masses, so max-log is deliberately absent from its config. + metric_mode: MetricMode::LogSumExpFloat, + int_metric_scale: 1024, + } + } +} + impl Default for BpTrellisConfig { fn default() -> Self { Self { @@ -117,7 +138,7 @@ impl BpTrellisDecoder { /// Construct a decoder from a sparse detector error model. /// /// Unlike [`TrellisDecoder`], the default ordering is the explicitly - /// computed [`deadline_column_order`], not input order. Every configured + /// computed [`pecos_trellis::deadline_column_order`], not input order. Every configured /// escalation rung is constructed here as an independent /// [`TrellisDecoder`], so construction cost scales with the full ladder /// and decode-time escalation performs no model building. @@ -127,40 +148,13 @@ impl BpTrellisDecoder { /// Returns [`DecoderError`] if ordering generation or the mapped trellis /// configuration fails validation. pub fn from_sparse_dem(dem: &SparseDem, config: BpTrellisConfig) -> Result { + config.validate()?; let build_started = Instant::now(); - let BpTrellisConfig { - k, - delta, - score_alpha, - bp_score_iterations, - merge_indistinguishable, - ordering, - escalation_ks, - } = config; - if u32::try_from(escalation_ks.len()).is_err() { - return Err(DecoderError::InvalidConfiguration( - "escalation ladder has more rungs than escalation_rungs_used can represent".into(), - )); - } - let column_order = match ordering { - TrellisOrdering::Deadline => Some(deadline_column_order(dem)?), - TrellisOrdering::BackwardDeadline => Some(backward_deadline_column_order(dem)?), - TrellisOrdering::TimeOrder => None, - TrellisOrdering::Explicit(order) => Some(order), - }; - let trellis_config = TrellisConfig { - k, - delta, - score_alpha, - column_order, - merge_indistinguishable, - bp_score_iterations, - // BpTrellis escalation is defined over coset masses, so max-log is deliberately absent from its config. - metric_mode: MetricMode::LogSumExpFloat, - int_metric_scale: 1024, - }; + let mut trellis_config = config.trellis_config(); + trellis_config.column_order = config.ordering.resolve(dem)?; let inner = TrellisDecoder::from_sparse_dem(dem, trellis_config.clone())?; - let escalation = escalation_ks + let escalation = config + .escalation_ks .into_iter() .map(|rung_k| { TrellisDecoder::from_sparse_dem( diff --git a/exp/pecos-frontier/src/lib.rs b/exp/pecos-frontier/src/lib.rs index 5b2915df2..25363577e 100644 --- a/exp/pecos-frontier/src/lib.rs +++ b/exp/pecos-frontier/src/lib.rs @@ -24,7 +24,7 @@ use pecos_decoder_core::ObservableDecoder; pub use pecos_trellis::factor::{Factor, FactorModel, Outcome}; pub use pecos_trellis::{ - DecoderError, MetricMode, ObsMask, SparseDem, backward_deadline_column_order, + DecoderError, MetricMode, ObsMask, SparseDem, TrellisOrdering, backward_deadline_column_order, backward_deadline_column_order_for_factors, deadline_column_order, deadline_column_order_for_factors, }; diff --git a/exp/pecos-trellis/src/lib.rs b/exp/pecos-trellis/src/lib.rs index fe6d95909..3fc38603a 100644 --- a/exp/pecos-trellis/src/lib.rs +++ b/exp/pecos-trellis/src/lib.rs @@ -54,6 +54,38 @@ pub enum MetricMode { MaxLogInt, } +/// Processing order used by the trellis decoders. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub enum TrellisOrdering { + /// Compute the deadline-optimized order with [`deadline_column_order`]. + #[default] + Deadline, + /// Compute the backward deadline-optimized order with + /// [`backward_deadline_column_order`]. + BackwardDeadline, + /// Preserve the detector error model's mechanism order. + TimeOrder, + /// Use an explicit permutation mapping target positions to source + /// mechanism indices. + Explicit(Vec), +} + +impl TrellisOrdering { + /// Resolve the processing order for a sparse detector error model. + /// + /// # Errors + /// + /// Returns [`DecoderError`] if ordering generation fails. + pub fn resolve(&self, dem: &SparseDem) -> Result>, DecoderError> { + match self { + Self::Deadline => deadline_column_order(dem).map(Some), + Self::BackwardDeadline => backward_deadline_column_order(dem).map(Some), + Self::TimeOrder => Ok(None), + Self::Explicit(order) => Ok(Some(order.clone())), + } + } +} + /// Pruning and column-order configuration for the trellis engine. /// /// The [`Default`] pruning values are provisional pending benchmarking. @@ -92,6 +124,60 @@ pub struct TrellisConfig { pub int_metric_scale: i32, } +impl TrellisConfig { + /// Validate configuration rules that do not depend on a detector error model. + /// + /// # Errors + /// + /// Returns [`DecoderError::InvalidConfiguration`] for invalid pruning or metric options. + pub fn validate(&self) -> Result<(), DecoderError> { + if self.k == 0 { + return Err(DecoderError::InvalidConfiguration( + "TrellisConfig.k must be at least 1".into(), + )); + } + if self.delta.is_nan() || self.delta < 0.0 { + return Err(DecoderError::InvalidConfiguration(format!( + "TrellisConfig.delta must be non-negative and not NaN, got {}", + self.delta + ))); + } + if self.metric_mode == MetricMode::MaxLogInt && !self.delta.is_finite() { + return Err(DecoderError::InvalidConfiguration( + "delta must be finite under maxlog_int; infinite delta would quantize to zero and prune to score-ties" + .into(), + )); + } + if self.metric_mode == MetricMode::MaxLogInt && self.merge_indistinguishable { + return Err(DecoderError::InvalidConfiguration( + "indistinguishable-mechanism merging sums coset mass and is incompatible with the max-log route metric" + .into(), + )); + } + if self.int_metric_scale <= 0 { + return Err(DecoderError::InvalidConfiguration( + "TrellisConfig.int_metric_scale must be positive".into(), + )); + } + if self.metric_mode == MetricMode::MaxLogInt + && self.score_alpha > 0.0 + && quantize_metric(self.score_alpha, self.int_metric_scale) == 0 + { + return Err(DecoderError::InvalidConfiguration(format!( + "score_alpha {} quantizes to zero at int_metric_scale {} and would silently disable suffix scoring; pass score_alpha 0.0 to disable it explicitly or use a larger scale", + self.score_alpha, self.int_metric_scale + ))); + } + if !self.score_alpha.is_finite() || self.score_alpha < 0.0 { + return Err(DecoderError::InvalidConfiguration(format!( + "TrellisConfig.score_alpha must be finite and non-negative, got {}", + self.score_alpha + ))); + } + Ok(()) + } +} + impl Default for TrellisConfig { fn default() -> Self { // Provisional defaults pending benchmarking. @@ -1556,49 +1642,7 @@ fn factor_supports(model: &FactorModel) -> Vec> { } fn validate_config(config: &TrellisConfig, mechanism_count: usize) -> Result<(), DecoderError> { - if config.k == 0 { - return Err(DecoderError::InvalidConfiguration( - "TrellisConfig.k must be at least 1".into(), - )); - } - if config.delta.is_nan() || config.delta < 0.0 { - return Err(DecoderError::InvalidConfiguration(format!( - "TrellisConfig.delta must be non-negative and not NaN, got {}", - config.delta - ))); - } - if config.metric_mode == MetricMode::MaxLogInt && !config.delta.is_finite() { - return Err(DecoderError::InvalidConfiguration( - "delta must be finite under maxlog_int; infinite delta would quantize to zero and prune to score-ties" - .into(), - )); - } - if config.metric_mode == MetricMode::MaxLogInt && config.merge_indistinguishable { - return Err(DecoderError::InvalidConfiguration( - "indistinguishable-mechanism merging sums coset mass and is incompatible with the max-log route metric" - .into(), - )); - } - if config.int_metric_scale <= 0 { - return Err(DecoderError::InvalidConfiguration( - "TrellisConfig.int_metric_scale must be positive".into(), - )); - } - if config.metric_mode == MetricMode::MaxLogInt - && config.score_alpha > 0.0 - && quantize_metric(config.score_alpha, config.int_metric_scale) == 0 - { - return Err(DecoderError::InvalidConfiguration(format!( - "score_alpha {} quantizes to zero at int_metric_scale {} and would silently disable suffix scoring; pass score_alpha 0.0 to disable it explicitly or use a larger scale", - config.score_alpha, config.int_metric_scale - ))); - } - if !config.score_alpha.is_finite() || config.score_alpha < 0.0 { - return Err(DecoderError::InvalidConfiguration(format!( - "TrellisConfig.score_alpha must be finite and non-negative, got {}", - config.score_alpha - ))); - } + config.validate()?; if let Some(order) = &config.column_order { if order.len() != mechanism_count { return Err(DecoderError::InvalidConfiguration(format!( diff --git a/python/pecos-rslib-exp/src/bp_trellis_bindings.rs b/python/pecos-rslib-exp/src/bp_trellis_bindings.rs index 70682414f..deebca8bd 100644 --- a/python/pecos-rslib-exp/src/bp_trellis_bindings.rs +++ b/python/pecos-rslib-exp/src/bp_trellis_bindings.rs @@ -20,7 +20,7 @@ use pyo3::exceptions::{PyAttributeError, PyRuntimeError, PyValueError}; use pyo3::prelude::*; use pyo3::types::{PyAny, PyBytes, PyInt, PyList}; -enum TrellisOrderArgument { +pub(crate) enum TrellisOrderArgument { Name(String), Explicit(Vec), } @@ -81,33 +81,35 @@ fn parse_dem_and_config( ordering: TrellisOrderArgument, escalation_ks: Option>, ) -> PyResult<(SparseDem, RustBpTrellisConfig)> { + let config = RustBpTrellisConfig { + k, + delta, + score_alpha, + bp_score_iterations, + merge_indistinguishable, + ordering: parse_ordering(ordering)?, + escalation_ks: escalation_ks.unwrap_or_default(), + }; + config + .validate() + .map_err(|error| PyValueError::new_err(error.to_string()))?; let dem = SparseDem::from_dem_str(dem_str).map_err(|error| runtime_error(&error))?; - let ordering = match ordering { + Ok((dem, config)) +} + +pub(crate) fn parse_ordering(ordering: TrellisOrderArgument) -> PyResult { + match ordering { TrellisOrderArgument::Name(name) => match name.as_str() { - "deadline" => RustTrellisOrdering::Deadline, - "backward_deadline" => RustTrellisOrdering::BackwardDeadline, - "time_order" => RustTrellisOrdering::TimeOrder, - _ => { - return Err(PyValueError::new_err(format!( - "invalid ordering {name:?}; expected 'deadline', 'backward_deadline', \ + "deadline" => Ok(RustTrellisOrdering::Deadline), + "backward_deadline" => Ok(RustTrellisOrdering::BackwardDeadline), + "time_order" => Ok(RustTrellisOrdering::TimeOrder), + _ => Err(PyValueError::new_err(format!( + "invalid ordering {name:?}; expected 'deadline', 'backward_deadline', \ 'time_order', or a list of mechanism indices" - ))); - } + ))), }, - TrellisOrderArgument::Explicit(order) => RustTrellisOrdering::Explicit(order), - }; - Ok(( - dem, - RustBpTrellisConfig { - k, - delta, - score_alpha, - bp_score_iterations, - merge_indistinguishable, - ordering, - escalation_ks: escalation_ks.unwrap_or_default(), - }, - )) + TrellisOrderArgument::Explicit(order) => Ok(RustTrellisOrdering::Explicit(order)), + } } fn obs_mask_to_py(py: Python<'_>, mask: &ObsMask) -> PyResult> { diff --git a/python/pecos-rslib-exp/src/decoder_specs.rs b/python/pecos-rslib-exp/src/decoder_specs.rs index 913dd6462..79e03d025 100644 --- a/python/pecos-rslib-exp/src/decoder_specs.rs +++ b/python/pecos-rslib-exp/src/decoder_specs.rs @@ -1,6 +1,9 @@ //! Optional experimental decoder factories and native workers for batch decoding. -use pecos_bp_trellis::{BpTrellisConfig, TrellisOrdering as BpTrellisOrdering}; +use crate::bp_trellis_bindings::{TrellisOrderArgument, parse_ordering}; +use crate::frontier_bindings::{ColumnOrderArgument, parse_column_order, parse_metric_mode}; +use pecos_bp_trellis::BpTrellisConfig; use pecos_decoder_core::{DecoderError, ObservableDecoder}; +use pecos_frontier::{FrontierConfig, TrellisOrdering}; use pyo3::exceptions::{PyRuntimeError, PyValueError}; use pyo3::prelude::*; use pyo3::types::PyModule; @@ -8,7 +11,7 @@ use std::sync::Mutex; #[derive(Clone, Debug, PartialEq)] enum ExperimentalSpec { - Frontier(FrontierConfig), + Frontier(FrontierConfig, TrellisOrdering), BpTrellis(BpTrellisConfig), } @@ -19,7 +22,7 @@ enum ExperimentalSpec { from_py_object )] #[derive(Clone)] -pub struct PyExperimentalDecoderSpec { +struct PyExperimentalDecoderSpec { inner: ExperimentalSpec, } impl PyExperimentalDecoderSpec { @@ -32,7 +35,7 @@ impl PyExperimentalDecoderSpec { #[getter] fn family(&self) -> &'static str { match self.inner { - ExperimentalSpec::Frontier(_) => "frontier", + ExperimentalSpec::Frontier(..) => "frontier", ExperimentalSpec::BpTrellis(_) => "bp_trellis", } } @@ -50,7 +53,7 @@ impl PyExperimentalDecoderSpec { } fn __repr__(&self) -> String { match &self.inner { - ExperimentalSpec::Frontier(c) => frontier_repr(c), + ExperimentalSpec::Frontier(c, ordering) => frontier_repr(c, ordering), ExperimentalSpec::BpTrellis(c) => bp_trellis_repr(c), } } @@ -64,6 +67,9 @@ impl PyExperimentalDecoderSpec { .into_any() .unbind()) } + // Hashing only the family ensures equal specs have equal hashes, including + // float options such as -0.0 and 0.0. Collisions within a family use __eq__, + // which is sufficient for the small collections of decoder specs. fn __hash__(&self) -> u64 { use std::hash::{Hash, Hasher}; let mut hasher = std::hash::DefaultHasher::new(); @@ -75,7 +81,7 @@ impl PyExperimentalDecoderSpec { let (inner, num_detectors) = py .detach(|| { let inner = match &self.inner { - ExperimentalSpec::Frontier(c) => build_frontier(dem, c), + ExperimentalSpec::Frontier(c, ordering) => build_frontier(dem, c, ordering), ExperimentalSpec::BpTrellis(c) => build_bp_trellis(dem, c), }?; let num_detectors = pecos_decoder_core::dem::utils::parse_dem_metadata(dem)?.0; @@ -89,8 +95,8 @@ impl PyExperimentalDecoderSpec { } } -#[pyclass(module = "pecos_rslib_exp")] -pub struct PyExperimentalWorker { +#[pyclass(name = "ExperimentalDecoderWorker", module = "pecos_rslib_exp")] +struct PyExperimentalWorker { inner: Mutex>, #[pyo3(get)] num_detectors: usize, @@ -115,110 +121,29 @@ impl PyExperimentalWorker { fn finish_repr(family: &str, args: Vec) -> String { format!("{family}({})", args.join(", ")) } -fn invalid_choice(parameter: &str, value: &str, accepted: &str) -> PyErr { - PyValueError::new_err(format!( - "{parameter} has invalid value {value:?}; accepted values: {accepted}" - )) -} -fn non_negative(parameter: &str, value: f64) -> PyResult { - if value.is_finite() && value >= 0.0 { - Ok(value) - } else { - Err(PyValueError::new_err(format!( - "{parameter} must be finite and non-negative" - ))) - } -} -pub fn register(module: &Bound<'_, PyModule>) -> PyResult<()> { +pub(crate) fn register(module: &Bound<'_, PyModule>) -> PyResult<()> { module.add_class::()?; module.add_function(wrap_pyfunction!(frontier, module)?)?; module.add_function(wrap_pyfunction!(bp_trellis, module)?)?; Ok(()) } -/// Mechanism ordering for the Frontier decoder. -#[derive(Clone, Debug, Default, PartialEq)] -pub enum FrontierColumnOrder { - #[default] - Deadline, - Time, - BackwardDeadline, - Explicit(Vec), -} - -/// Route metric for the Frontier decoder. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub enum FrontierMetricMode { - #[default] - LogSumExpFloat, - MaxLogInt, -} - -/// Frontier options, preserving the existing Python ordering defaults. -#[derive(Clone, Debug, PartialEq)] -pub struct FrontierConfig { - pub k: usize, - pub delta: f64, - pub score_alpha: f64, - pub column_order: FrontierColumnOrder, - pub merge_indistinguishable: bool, - pub bp_score_iterations: usize, - pub metric_mode: FrontierMetricMode, - pub int_metric_scale: i32, -} - -impl Default for FrontierConfig { - fn default() -> Self { - Self { - k: 64, - delta: 50.0, - score_alpha: 0.8, - column_order: FrontierColumnOrder::Deadline, - merge_indistinguishable: false, - bp_score_iterations: 0, - metric_mode: FrontierMetricMode::LogSumExpFloat, - int_metric_scale: 1024, - } - } -} - fn build_frontier( dem: &str, - config: &self::FrontierConfig, + config: &FrontierConfig, + ordering: &TrellisOrdering, ) -> Result, DecoderError> { - use self::{FrontierColumnOrder, FrontierMetricMode}; - use pecos_frontier::{FrontierConfig, FrontierDecoder, MetricMode, SparseDem}; - let dem = SparseDem::from_dem_str(dem)?; - let column_order = match &config.column_order { - FrontierColumnOrder::Deadline => Some(pecos_frontier::deadline_column_order(&dem)?), - FrontierColumnOrder::Time => None, - FrontierColumnOrder::BackwardDeadline => { - Some(pecos_frontier::backward_deadline_column_order(&dem)?) - } - FrontierColumnOrder::Explicit(order) => Some(order.clone()), - }; - let decoder = FrontierDecoder::from_sparse_dem( - &dem, - FrontierConfig { - k: config.k, - delta: config.delta, - score_alpha: config.score_alpha, - column_order, - merge_indistinguishable: config.merge_indistinguishable, - bp_score_iterations: config.bp_score_iterations, - metric_mode: match config.metric_mode { - FrontierMetricMode::LogSumExpFloat => MetricMode::LogSumExpFloat, - FrontierMetricMode::MaxLogInt => MetricMode::MaxLogInt, - }, - int_metric_scale: config.int_metric_scale, - }, - )?; - Ok(Box::new(decoder)) + let dem = pecos_frontier::SparseDem::from_dem_str(dem)?; + let mut config = config.clone(); + config.column_order = ordering.resolve(&dem)?; + Ok(Box::new(pecos_frontier::FrontierDecoder::from_sparse_dem( + &dem, config, + )?)) } fn build_bp_trellis( dem: &str, - config: &self::BpTrellisConfig, + config: &BpTrellisConfig, ) -> Result, DecoderError> { Ok(Box::new(pecos_bp_trellis::BpTrellisDecoder::from_dem_str( dem, @@ -226,102 +151,42 @@ fn build_bp_trellis( )?)) } -#[derive(FromPyObject)] -enum FrontierOrderArgument { - Name(String), - Explicit(Vec), -} - -impl Default for FrontierOrderArgument { - fn default() -> Self { - Self::Name("deadline_reorder".to_owned()) - } -} - /// Native Rust Frontier decoder for raw DEMs, including hyperedges. /// Batch decoding supports independent Rust workers. Pruning makes predictions /// approximate; use pecos_rslib_exp.FrontierDecoder for per-shot confidence data. #[pyfunction] -#[pyo3(signature = (*, k=64, delta=50.0, score_alpha=0.8, bp_score_iterations=0, column_order=FrontierOrderArgument::default(), merge_indistinguishable=false, metric_mode="logsumexp_float", int_metric_scale=1024), +#[pyo3(signature = (*, k=64, delta=50.0, score_alpha=0.8, bp_score_iterations=0, column_order=ColumnOrderArgument::default(), merge_indistinguishable=false, metric_mode="logsumexp_float", int_metric_scale=1024), text_signature = "(*, k=64, delta=50.0, score_alpha=0.8, bp_score_iterations=0, column_order='deadline_reorder', merge_indistinguishable=False, metric_mode='logsumexp_float', int_metric_scale=1024)")] fn frontier( k: usize, delta: f64, score_alpha: f64, bp_score_iterations: usize, - column_order: FrontierOrderArgument, + column_order: ColumnOrderArgument, merge_indistinguishable: bool, metric_mode: &str, int_metric_scale: i32, ) -> PyResult { - use self::{FrontierColumnOrder, FrontierConfig, FrontierMetricMode}; - if k == 0 { - return Err(PyValueError::new_err("k must be at least 1")); - } - if delta.is_nan() || delta < 0.0 { - return Err(PyValueError::new_err( - "delta must be non-negative and not NaN", - )); - } - let score_alpha = non_negative("score_alpha", score_alpha)?; - if int_metric_scale <= 0 { - return Err(PyValueError::new_err("int_metric_scale must be positive")); - } - let metric_mode = match metric_mode.trim() { - "logsumexp_float" | "float" | "exact" => FrontierMetricMode::LogSumExpFloat, - "maxlog_int" | "max_log_int" | "viterbi_int" | "frontierLite" | "frontier_lite" - | "frontier-lite" | "frontierlite" => FrontierMetricMode::MaxLogInt, - value => { - return Err(invalid_choice( - "metric_mode", - value, - "'logsumexp_float', 'maxlog_int'", - )); - } - }; - if metric_mode == FrontierMetricMode::MaxLogInt { - if !delta.is_finite() { - return Err(PyValueError::new_err( - "delta must be finite under maxlog_int", - )); - } - if merge_indistinguishable { - return Err(PyValueError::new_err( - "merge_indistinguishable is incompatible with maxlog_int", - )); - } - } - let column_order = match column_order { - FrontierOrderArgument::Explicit(order) => FrontierColumnOrder::Explicit(order), - FrontierOrderArgument::Name(name) => match name.as_str() { - "deadline_reorder" => FrontierColumnOrder::Deadline, - "time_order" => FrontierColumnOrder::Time, - "backward_deadline_reorder" => FrontierColumnOrder::BackwardDeadline, - value => { - return Err(invalid_choice( - "column_order", - value, - "'deadline_reorder', 'time_order', 'backward_deadline_reorder', or a list of column indices", - )); - } - }, + let config = FrontierConfig { + k, + delta, + score_alpha, + column_order: None, + merge_indistinguishable, + bp_score_iterations, + metric_mode: parse_metric_mode(metric_mode)?, + int_metric_scale, }; + config + .validate() + .map_err(|error| PyValueError::new_err(error.to_string()))?; Ok(PyExperimentalDecoderSpec::new(ExperimentalSpec::Frontier( - FrontierConfig { - k, - delta, - score_alpha, - column_order, - merge_indistinguishable, - bp_score_iterations, - metric_mode, - int_metric_scale, - }, + config, + parse_column_order(column_order)?, ))) } -fn frontier_repr(config: &self::FrontierConfig) -> String { - use self::{FrontierColumnOrder, FrontierConfig, FrontierMetricMode}; +fn frontier_repr(config: &FrontierConfig, ordering: &TrellisOrdering) -> String { let default = FrontierConfig::default(); let mut args = Vec::new(); if config.k != default.k { @@ -337,24 +202,24 @@ fn frontier_repr(config: &self::FrontierConfig) -> String { if config.score_alpha.to_bits() != default.score_alpha.to_bits() { args.push(format!("score_alpha={:?}", config.score_alpha)); } - if config.bp_score_iterations != 0 { + if config.bp_score_iterations != default.bp_score_iterations { args.push(format!( "bp_score_iterations={}", config.bp_score_iterations )); } - match &config.column_order { - FrontierColumnOrder::Deadline => {} - FrontierColumnOrder::Time => args.push("column_order='time_order'".to_owned()), - FrontierColumnOrder::BackwardDeadline => { + match ordering { + TrellisOrdering::Deadline => {} + TrellisOrdering::TimeOrder => args.push("column_order='time_order'".to_owned()), + TrellisOrdering::BackwardDeadline => { args.push("column_order='backward_deadline_reorder'".to_owned()); } - FrontierColumnOrder::Explicit(order) => args.push(format!("column_order={order:?}")), + TrellisOrdering::Explicit(order) => args.push(format!("column_order={order:?}")), } - if config.merge_indistinguishable { + if config.merge_indistinguishable != default.merge_indistinguishable { args.push("merge_indistinguishable=True".to_owned()); } - if config.metric_mode == FrontierMetricMode::MaxLogInt { + if config.metric_mode != default.metric_mode { args.push("metric_mode='maxlog_int'".to_owned()); } if config.int_metric_scale != default.int_metric_scale { @@ -363,24 +228,12 @@ fn frontier_repr(config: &self::FrontierConfig) -> String { finish_repr("frontier", args) } -#[derive(FromPyObject)] -enum BpTrellisOrderArgument { - Name(String), - Explicit(Vec), -} - -impl Default for BpTrellisOrderArgument { - fn default() -> Self { - Self::Name("deadline".to_owned()) - } -} - /// Native Rust BP-guided trellis decoder for raw DEMs, including hyperedges. /// Batch decoding supports independent Rust workers. Each worker prebuilds the /// optional escalation ladder, retried only after a no-path result. Use /// pecos_rslib_exp.BpTrellisDecoder for per-shot confidence and retry telemetry. #[pyfunction] -#[pyo3(signature = (*, k=8, delta=100.0, score_alpha=0.8, bp_score_iterations=5, merge_indistinguishable=true, ordering=BpTrellisOrderArgument::default(), escalation_ks=None), +#[pyo3(signature = (*, k=8, delta=100.0, score_alpha=0.8, bp_score_iterations=5, merge_indistinguishable=true, ordering=TrellisOrderArgument::default(), escalation_ks=None), text_signature = "(*, k=8, delta=100.0, score_alpha=0.8, bp_score_iterations=5, merge_indistinguishable=True, ordering='deadline', escalation_ks=None)")] fn bp_trellis( k: usize, @@ -388,55 +241,27 @@ fn bp_trellis( score_alpha: f64, bp_score_iterations: usize, merge_indistinguishable: bool, - ordering: BpTrellisOrderArgument, + ordering: TrellisOrderArgument, escalation_ks: Option>, ) -> PyResult { - use self::{BpTrellisConfig, BpTrellisOrdering}; - if k == 0 { - return Err(PyValueError::new_err("k must be at least 1")); - } - if delta.is_nan() || delta < 0.0 { - return Err(PyValueError::new_err( - "delta must be non-negative and not NaN", - )); - } - let score_alpha = non_negative("score_alpha", score_alpha)?; - let escalation_ks = escalation_ks.unwrap_or_default(); - if escalation_ks.contains(&0) { - return Err(PyValueError::new_err( - "escalation_ks widths must be at least 1", - )); - } - let ordering = match ordering { - BpTrellisOrderArgument::Explicit(order) => BpTrellisOrdering::Explicit(order), - BpTrellisOrderArgument::Name(name) => match name.as_str() { - "deadline" => BpTrellisOrdering::Deadline, - "backward_deadline" => BpTrellisOrdering::BackwardDeadline, - "time_order" => BpTrellisOrdering::TimeOrder, - value => { - return Err(invalid_choice( - "ordering", - value, - "'deadline', 'backward_deadline', 'time_order', or a list of mechanism indices", - )); - } - }, + let config = BpTrellisConfig { + k, + delta, + score_alpha, + bp_score_iterations, + merge_indistinguishable, + ordering: parse_ordering(ordering)?, + escalation_ks: escalation_ks.unwrap_or_default(), }; + config + .validate() + .map_err(|error| PyValueError::new_err(error.to_string()))?; Ok(PyExperimentalDecoderSpec::new(ExperimentalSpec::BpTrellis( - BpTrellisConfig { - k, - delta, - score_alpha, - bp_score_iterations, - merge_indistinguishable, - ordering, - escalation_ks, - }, + config, ))) } -fn bp_trellis_repr(config: &self::BpTrellisConfig) -> String { - use self::{BpTrellisConfig, BpTrellisOrdering}; +fn bp_trellis_repr(config: &BpTrellisConfig) -> String { let default = BpTrellisConfig::default(); let mut args = Vec::new(); if config.k != default.k { @@ -458,16 +283,16 @@ fn bp_trellis_repr(config: &self::BpTrellisConfig) -> String { config.bp_score_iterations )); } - if !config.merge_indistinguishable { + if config.merge_indistinguishable != default.merge_indistinguishable { args.push("merge_indistinguishable=False".to_owned()); } match &config.ordering { - BpTrellisOrdering::Deadline => {} - BpTrellisOrdering::TimeOrder => args.push("ordering='time_order'".to_owned()), - BpTrellisOrdering::BackwardDeadline => args.push("ordering='backward_deadline'".to_owned()), - BpTrellisOrdering::Explicit(order) => args.push(format!("ordering={order:?}")), + TrellisOrdering::Deadline => {} + TrellisOrdering::TimeOrder => args.push("ordering='time_order'".to_owned()), + TrellisOrdering::BackwardDeadline => args.push("ordering='backward_deadline'".to_owned()), + TrellisOrdering::Explicit(order) => args.push(format!("ordering={order:?}")), } - if !config.escalation_ks.is_empty() { + if config.escalation_ks != default.escalation_ks { args.push(format!("escalation_ks={:?}", config.escalation_ks)); } finish_repr("bp_trellis", args) diff --git a/python/pecos-rslib-exp/src/frontier_bindings.rs b/python/pecos-rslib-exp/src/frontier_bindings.rs index e97017866..79b599435 100644 --- a/python/pecos-rslib-exp/src/frontier_bindings.rs +++ b/python/pecos-rslib-exp/src/frontier_bindings.rs @@ -15,16 +15,15 @@ use pecos_frontier::{ FrontierCommittee as RustFrontierCommittee, FrontierCommitteeResult as RustFrontierCommitteeResult, FrontierConfig as RustFrontierConfig, FrontierDecoder as RustFrontierDecoder, FrontierResult as RustFrontierResult, FrontierStatus, - MetricMode, ObsMask, Outcome, SparseDem, backward_deadline_column_order, - backward_deadline_column_order_for_factors, deadline_column_order, - deadline_column_order_for_factors, + MetricMode, ObsMask, Outcome, SparseDem, TrellisOrdering, + backward_deadline_column_order_for_factors, deadline_column_order_for_factors, }; use pyo3::Borrowed; use pyo3::exceptions::{PyAttributeError, PyRuntimeError, PyValueError}; use pyo3::prelude::*; use pyo3::types::{PyAny, PyBytes, PyInt, PyList}; -enum ColumnOrderArgument { +pub(crate) enum ColumnOrderArgument { Name(String), Explicit(Vec), } @@ -59,7 +58,7 @@ fn runtime_error(error: &DecoderError) -> PyErr { PyRuntimeError::new_err(error.to_string()) } -fn parse_metric_mode(metric_mode: &str) -> PyResult { +pub(crate) fn parse_metric_mode(metric_mode: &str) -> PyResult { match metric_mode.trim() { "logsumexp_float" | "float" | "exact" => Ok(MetricMode::LogSumExpFloat), "frontierLite" | "frontier_lite" | "frontier-lite" | "frontierlite" | "maxlog_int" @@ -89,48 +88,34 @@ fn sparse_to_dense(indices: &[u64], num_detectors: usize) -> PyResult> { Ok(syndrome) } -fn resolve_column_order( - dem: &SparseDem, - column_order: ColumnOrderArgument, -) -> PyResult>> { +pub(crate) fn parse_column_order(column_order: ColumnOrderArgument) -> PyResult { match column_order { ColumnOrderArgument::Name(name) => match name.as_str() { - "deadline_reorder" => deadline_column_order(dem) - .map(Some) - .map_err(|e| runtime_error(&e)), - "time_order" => Ok(None), - "backward_deadline_reorder" => backward_deadline_column_order(dem) - .map(Some) - .map_err(|e| runtime_error(&e)), + "deadline_reorder" => Ok(TrellisOrdering::Deadline), + "time_order" => Ok(TrellisOrdering::TimeOrder), + "backward_deadline_reorder" => Ok(TrellisOrdering::BackwardDeadline), _ => Err(PyValueError::new_err(format!( "invalid column_order {name:?}; expected 'deadline_reorder', 'time_order', \ 'backward_deadline_reorder', or a list of column indices" ))), }, - ColumnOrderArgument::Explicit(order) => Ok(Some(order)), + ColumnOrderArgument::Explicit(order) => Ok(TrellisOrdering::Explicit(order)), } } fn resolve_factor_column_order( model: &FactorModel, - column_order: ColumnOrderArgument, + ordering: TrellisOrdering, ) -> PyResult>> { - match column_order { - ColumnOrderArgument::Name(name) => match name.as_str() { - "deadline_reorder" => deadline_column_order_for_factors(model) - .map(Some) - .map_err(|error| runtime_error(&error)), - "time_order" => Ok(None), - "backward_deadline_reorder" => backward_deadline_column_order_for_factors(model) - .map(Some) - .map_err(|error| runtime_error(&error)), - _ => Err(PyValueError::new_err(format!( - "invalid column_order {name:?}; expected 'deadline_reorder', 'time_order', \ - 'backward_deadline_reorder', or a list of column indices" - ))), - }, - ColumnOrderArgument::Explicit(order) => Ok(Some(order)), + match ordering { + TrellisOrdering::Deadline => deadline_column_order_for_factors(model).map(Some), + TrellisOrdering::BackwardDeadline => { + backward_deadline_column_order_for_factors(model).map(Some) + } + TrellisOrdering::TimeOrder => Ok(None), + TrellisOrdering::Explicit(order) => Ok(Some(order)), } + .map_err(|error| runtime_error(&error)) } fn parse_dem_and_config( @@ -144,22 +129,25 @@ fn parse_dem_and_config( metric_mode: &str, int_metric_scale: i32, ) -> PyResult<(SparseDem, RustFrontierConfig)> { + let mut config = RustFrontierConfig { + k, + delta, + score_alpha, + column_order: None, + merge_indistinguishable, + bp_score_iterations, + metric_mode: parse_metric_mode(metric_mode)?, + int_metric_scale, + }; + config + .validate() + .map_err(|error| PyValueError::new_err(error.to_string()))?; + let ordering = parse_column_order(column_order)?; let dem = SparseDem::from_dem_str(dem_str).map_err(|e| runtime_error(&e))?; - let column_order = resolve_column_order(&dem, column_order)?; - let metric_mode = parse_metric_mode(metric_mode)?; - Ok(( - dem, - RustFrontierConfig { - k, - delta, - score_alpha, - column_order, - merge_indistinguishable, - bp_score_iterations, - metric_mode, - int_metric_scale, - }, - )) + config.column_order = ordering + .resolve(&dem) + .map_err(|error| runtime_error(&error))?; + Ok((dem, config)) } fn obs_mask_to_py(py: Python<'_>, mask: &ObsMask) -> PyResult> { @@ -537,6 +525,20 @@ impl PyFrontierDecoder { int_metric_scale: i32, ) -> PyResult { let metric_mode = parse_metric_mode(metric_mode)?; + let mut config = RustFrontierConfig { + k, + delta, + score_alpha, + column_order: None, + merge_indistinguishable, + bp_score_iterations, + metric_mode, + int_metric_scale, + }; + config + .validate() + .map_err(|error| PyValueError::new_err(error.to_string()))?; + let ordering = parse_column_order(column_order)?; let factors = factors .into_iter() .map(|outcomes| Factor { @@ -552,17 +554,7 @@ impl PyFrontierDecoder { .collect(); let model = FactorModel::new(factors, num_detectors, num_observables) .map_err(|error| runtime_error(&error))?; - let column_order = resolve_factor_column_order(&model, column_order)?; - let config = RustFrontierConfig { - k, - delta, - score_alpha, - column_order, - merge_indistinguishable, - bp_score_iterations, - metric_mode, - int_metric_scale, - }; + config.column_order = resolve_factor_column_order(&model, ordering)?; let inner = RustFrontierDecoder::from_factor_model(&model, config) .map_err(|error| runtime_error(&error))?; Ok(Self { diff --git a/python/pecos-rslib-exp/tests/test_bp_trellis_batch_decode.py b/python/pecos-rslib-exp/tests/test_bp_trellis_batch_decode.py index e49bd7287..57c514bdb 100644 --- a/python/pecos-rslib-exp/tests/test_bp_trellis_batch_decode.py +++ b/python/pecos-rslib-exp/tests/test_bp_trellis_batch_decode.py @@ -44,12 +44,16 @@ def test_public_spec_and_configuration(): {"score_alpha": math.inf}, {"score_alpha": -1}, {"ordering": "unknown"}, + {"ordering": None}, {"escalation_ks": [0]}, ], ) def test_invalid_options(options): - with pytest.raises(ValueError, match=r"must|invalid|incompatible"): + with pytest.raises(ValueError, match=r"must|invalid|incompatible") as factory_error: bp_trellis(**options) + with pytest.raises(ValueError, match=r"must|invalid|incompatible") as direct_error: + exp.BpTrellisDecoder.from_dem(DEM, **options) + assert str(factory_error.value) == str(direct_error.value) @pytest.mark.parametrize( @@ -105,7 +109,7 @@ def test_invalid_order_and_impossible_syndrome_are_errors(workers): batch = SampleBatch([[0, 0, 0]], [0]) with pytest.raises(RuntimeError, match="permutation"): batch.decode(DEM, bp_trellis(ordering=[0, 0, 1, 2]), workers=workers) - with pytest.raises(RuntimeError, match=r"(?i)(path|syndrome|shot)"): + with pytest.raises(RuntimeError, match="unexplainable"): SampleBatch([[1]], [0]).decode("detector D0\n", bp_trellis(), workers=workers) @@ -140,7 +144,7 @@ def test_no_path_escalation_is_used_in_batch_execution(workers): dem = "error(0.4) D0\nerror(0.4) D1\nerror(0.1) D0 D1 D2 L0\n" options = {"k": 2, "bp_score_iterations": 0, "merge_indistinguishable": False, "ordering": "time_order"} batch = SampleBatch([[0, 0, 1]] * 1025, [1] * 1025) - with pytest.raises(RuntimeError, match=r"(?i)(path|syndrome|shot)"): + with pytest.raises(RuntimeError, match="unexplainable"): batch.decode(dem, bp_trellis(**options), workers=workers) result = batch.decode( dem, @@ -150,27 +154,3 @@ def test_no_path_escalation_is_used_in_batch_execution(workers): ) assert result.predictions == [1] * 1025 assert result.num_errors == 0 - - -def test_gil_is_released_during_native_decode(): - import threading - - started = threading.Event() - stop = threading.Event() - progress = [0] - - def worker(): - started.set() - while not stop.is_set(): - progress[0] += 1 - - thread = threading.Thread(target=worker) - thread.start() - started.wait() - before = progress[0] - try: - SampleBatch([[0, 0, 0]] * 2048, [0] * 2048).decode(DEM, bp_trellis(), workers=4) - finally: - stop.set() - thread.join() - assert progress[0] - before > 100 diff --git a/python/pecos-rslib-exp/tests/test_decoder_spec_gil.py b/python/pecos-rslib-exp/tests/test_decoder_spec_gil.py new file mode 100644 index 000000000..61d671a77 --- /dev/null +++ b/python/pecos-rslib-exp/tests/test_decoder_spec_gil.py @@ -0,0 +1,84 @@ +# Copyright 2026 The PECOS Developers + +"""A single native provider call lets another Python thread keep running.""" + +import threading +import time + +import pytest +from pecos_rslib_exp import bp_trellis, frontier + +# A call this long spans many interpreter switch intervals, so a thread that +# stalls for half of it can only mean the call held the GIL. +MIN_CALL_SECONDS = 0.2 + + +@pytest.fixture(params=[frontier(), bp_trellis()], ids=["frontier", "bp_trellis"]) +def spec(request): + return request.param + + +def synthetic_dem(length, stride): + return "".join(f"error(0.3) D{i} D{i + stride} L0\nerror(0.2) D{i}\n" for i in range(length)) + + +def largest_python_stall(native_call): + """Return the call's duration and the longest pause of a concurrent Python thread.""" + ready = threading.Event() + finished = threading.Event() + largest_gap = [0.0] + + def observe(): + previous = time.perf_counter() + ready.set() + while True: + now = time.perf_counter() + largest_gap[0] = max(largest_gap[0], now - previous) + previous = now + # Record the interval crossing the call's return before stopping. + if finished.is_set(): + return + + observer = threading.Thread(target=observe) + observer.start() + ready.wait() + try: + started = time.perf_counter() + native_call() + duration = time.perf_counter() - started + finally: + finished.set() + observer.join(timeout=10) + assert not observer.is_alive() + return duration, largest_gap[0] + + +def assert_releases_gil(native_call_for_length): + # Build profile and machine speed change the cost of a call by orders of + # magnitude, so grow the model until one call is long enough to judge. + for length in (500 * 2**doubling for doubling in range(8)): + duration, stall = largest_python_stall(native_call_for_length(length)) + if duration > MIN_CALL_SECONDS: + assert stall < duration / 2, f"Python thread stalled for {stall:.3f}s of a {duration:.3f}s native call" + return + pytest.fail(f"no native call exceeded {MIN_CALL_SECONDS}s; the largest took {duration:.3f}s") + + +def test_build_releases_gil(spec): + def build_call(length): + dem = synthetic_dem(length, 1) + return lambda: spec._pecos_build_decoder(dem) + + assert_releases_gil(build_call) + + +def test_decode_releases_gil(spec): + # A wide detector stride keeps many boundary states alive, making one decode substantial. + stride = 64 + + def decode_call(length): + worker = spec._pecos_build_decoder(synthetic_dem(length, stride)) + syndrome = bytes(length + stride) + return lambda: worker._pecos_decode_obs(syndrome) + + assert_releases_gil(decode_call) diff --git a/python/pecos-rslib-exp/tests/test_frontier_batch_decode.py b/python/pecos-rslib-exp/tests/test_frontier_batch_decode.py index 9335ee8d9..3c81e9a49 100644 --- a/python/pecos-rslib-exp/tests/test_frontier_batch_decode.py +++ b/python/pecos-rslib-exp/tests/test_frontier_batch_decode.py @@ -43,15 +43,26 @@ def test_public_spec_and_configuration(): {"score_alpha": math.inf}, {"score_alpha": -1}, {"column_order": "unknown"}, + {"column_order": 3}, {"metric_mode": "unknown"}, {"int_metric_scale": 0}, {"metric_mode": "maxlog_int", "delta": math.inf}, + {"metric_mode": "maxlog_int", "score_alpha": 1e-9}, {"metric_mode": "maxlog_int", "merge_indistinguishable": True}, ], ) def test_invalid_options(options): - with pytest.raises(ValueError, match=r"must|invalid|incompatible"): + with pytest.raises(ValueError, match=r"must|invalid|incompatible|quantizes") as factory_error: frontier(**options) + with pytest.raises(ValueError, match=r"must|invalid|incompatible|quantizes") as direct_error: + exp.FrontierDecoder.from_dem(DEM, **options) + assert str(factory_error.value) == str(direct_error.value) + with pytest.raises(ValueError, match=r"must|invalid|incompatible|quantizes") as committee_error: + exp.FrontierCommitteeDecoder.from_dem(DEM, **options) + assert str(factory_error.value) == str(committee_error.value) + with pytest.raises(ValueError, match=r"must|invalid|incompatible|quantizes") as factor_error: + exp.FrontierDecoder.from_factors([[(0.9, [], []), (0.1, [0], [0])]], 1, 1, **options) + assert str(factory_error.value) == str(factor_error.value) @pytest.mark.parametrize( @@ -107,7 +118,7 @@ def test_invalid_order_and_impossible_syndrome_are_errors(workers): batch = SampleBatch([[0, 0, 0]], [0]) with pytest.raises(RuntimeError, match="permutation"): batch.decode(DEM, frontier(column_order=[0, 0, 1, 2]), workers=workers) - with pytest.raises(RuntimeError, match=r"(?i)(path|syndrome|shot)"): + with pytest.raises(RuntimeError, match="unexplainable"): SampleBatch([[1]], [0]).decode("detector D0\n", frontier(), workers=workers) @@ -135,27 +146,3 @@ def test_predictions_match_direct_experimental_binding(): expected = [direct.decode_syndrome(row).observable_flips.mask for row in rows] result = SampleBatch(rows, [0] * len(rows)).decode(DEM, frontier(**options), workers=3, predictions=True) assert result.predictions == expected - - -def test_gil_is_released_during_native_decode(): - import threading - - started = threading.Event() - stop = threading.Event() - progress = [0] - - def worker(): - started.set() - while not stop.is_set(): - progress[0] += 1 - - thread = threading.Thread(target=worker) - thread.start() - started.wait() - before = progress[0] - try: - SampleBatch([[0, 0, 0]] * 2048, [0] * 2048).decode(DEM, frontier(), workers=4) - finally: - stop.set() - thread.join() - assert progress[0] - before > 100 diff --git a/python/pecos-rslib/src/batch_decoder_spec.rs b/python/pecos-rslib/src/batch_decoder_spec.rs index a82d6d131..528674f0f 100644 --- a/python/pecos-rslib/src/batch_decoder_spec.rs +++ b/python/pecos-rslib/src/batch_decoder_spec.rs @@ -17,6 +17,11 @@ pub(crate) enum BatchDecoderSpec { }, } +pub(crate) enum DecoderBuildError { + Builtin(DecoderError), + Provider(PyErr), +} + impl BatchDecoderSpec { pub(crate) fn extract(decoder: &Bound<'_, PyAny>) -> PyResult { if decoder.is_instance_of::() { @@ -35,17 +40,27 @@ impl BatchDecoderSpec { "decoder must be a DecoderSpec, legacy decoder string, or a version-1 decoder provider", )); } - if !decoder.getattr("_pecos_build_decoder")?.is_callable() { - return Err(PyTypeError::new_err( - "decoder provider _pecos_build_decoder must be callable", - )); - } - Ok(Self::Provider { - spec: decoder.clone().unbind(), - traits: ExecutionTraits { + let members = || -> PyResult { + if !decoder.getattr("_pecos_build_decoder")?.is_callable() { + return Err(PyTypeError::new_err( + "_pecos_build_decoder must be callable", + )); + } + Ok(ExecutionTraits { history_dependent: decoder.getattr("history_dependent")?.extract()?, wall_clock_dependent: decoder.getattr("wall_clock_dependent")?.extract()?, - }, + }) + }; + let traits = members().map_err(|cause| { + let error = PyTypeError::new_err( + "version-1 decoder providers require callable _pecos_build_decoder and boolean history_dependent and wall_clock_dependent members", + ); + error.set_cause(decoder.py(), Some(cause)); + error + })?; + Ok(Self::Provider { + spec: decoder.clone().unbind(), + traits, }) } @@ -70,18 +85,16 @@ impl BatchDecoderSpec { pub(crate) fn build( &self, model: &DecodeModel, - ) -> Result, DecoderError> { + ) -> Result, DecoderBuildError> { match self { - Self::Builtin(spec) => spec.build(model), + Self::Builtin(spec) => spec.build(model).map_err(DecoderBuildError::Builtin), Self::Provider { spec, .. } => { - let dem = match model { - DecodeModel::SingleDem(text) => text.clone(), - DecodeModel::StructuredDem(model) => model.to_dem_string(), - DecodeModel::HybridDem { .. } => { - return Err(DecoderError::InvalidConfiguration( + let DecodeModel::SingleDem(dem) = model else { + return Err(DecoderBuildError::Builtin( + DecoderError::InvalidConfiguration( "decoder providers require a single DEM".into(), - )); - } + ), + )); }; Python::attach(|py| -> PyResult> { let worker = spec.bind(py).call_method1("_pecos_build_decoder", (dem,))?; @@ -96,7 +109,7 @@ impl BatchDecoderSpec { num_detectors, })) }) - .map_err(|e| DecoderError::InvalidConfiguration(e.to_string())) + .map_err(DecoderBuildError::Provider) } } } diff --git a/python/pecos-rslib/src/fault_tolerance_bindings/batch_decode.rs b/python/pecos-rslib/src/fault_tolerance_bindings/batch_decode.rs index 2cd2db8c0..02290b68a 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings/batch_decode.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings/batch_decode.rs @@ -3,8 +3,8 @@ use super::decoder_scoring::{DecodeRangeResult, ShotDecodeError, decode_and_score_range}; use super::{PyDecodeStats, PySampleBatch, decoder_build_error_to_py}; use crate::batch_decoder_spec::BatchDecoderSpec as DecoderSpec; -use pecos_decoder_core::DecoderError; -use pecos_decoder_core::obs_mask::ObsMask; +use crate::batch_decoder_spec::DecoderBuildError; +use pecos_decoder_core::{DecoderError, obs_mask::ObsMask}; use pecos_decoders::DecodeModel; use pecos_decoders::batch::{ExecutionPath, ExecutionPlan, IndexedChunk, native_sub_batches}; use pyo3::exceptions::{PyRuntimeError, PyValueError}; @@ -19,7 +19,7 @@ pub(super) struct BatchExecutionOutput { } pub(super) enum BatchExecutionError { - Build(DecoderError), + Build(DecoderBuildError), Dimension { batch_detectors: usize, decoder_detectors: usize, @@ -35,7 +35,8 @@ pub(super) enum BatchExecutionError { impl BatchExecutionError { pub(super) fn into_pyerr(self) -> PyErr { match self { - Self::Build(error) => decoder_build_error_to_py(error), + Self::Build(DecoderBuildError::Builtin(error)) => decoder_build_error_to_py(error), + Self::Build(DecoderBuildError::Provider(error)) => error, Self::Dimension { batch_detectors, decoder_detectors, diff --git a/python/pecos-rslib/src/fault_tolerance_bindings/sampler_decode.rs b/python/pecos-rslib/src/fault_tolerance_bindings/sampler_decode.rs index 4cfa05eff..84817e452 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings/sampler_decode.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings/sampler_decode.rs @@ -3,6 +3,7 @@ use super::batch_decode::{BatchExecutionError, BatchExecutionOutput, decode_model}; use super::decoder_scoring::{DecodeRangeResult, ShotDecodeError}; use crate::batch_decoder_spec::BatchDecoderSpec as DecoderSpec; +use crate::batch_decoder_spec::DecoderBuildError; use pecos_decoder_core::obs_mask::ObsMask; use pecos_decoder_core::{DecoderError, ObservableDecoder}; use pecos_decoders::DecodeModel; @@ -298,7 +299,7 @@ fn parallel( chunks .into_par_iter() .map_init( - || spec.build(model).map_err(|error| error.to_string()), + || spec.build(model), |decoder, (chunk_index, range)| { // `map_init` state belongs to one Rayon job, not one OS // worker. That is sufficient: stateless chunk results depend @@ -313,9 +314,16 @@ fn parallel( seed, options, ), - Err(message) => Err(BatchExecutionError::Runtime(format!( - "parallel decoder construction failed: {message}" - ))), + Err(DecoderBuildError::Builtin(error)) => { + Err(BatchExecutionError::Runtime(format!( + "parallel decoder construction failed: {error}" + ))) + } + Err(DecoderBuildError::Provider(error)) => { + Err(BatchExecutionError::Build(DecoderBuildError::Provider( + pyo3::Python::attach(|py| error.clone_ref(py)), + ))) + } }; IndexedChunk { chunk_index, value } }, diff --git a/python/pecos-rslib/tests/test_decoder_providers.py b/python/pecos-rslib/tests/test_decoder_providers.py index 481ded974..21901a1fe 100644 --- a/python/pecos-rslib/tests/test_decoder_providers.py +++ b/python/pecos-rslib/tests/test_decoder_providers.py @@ -78,7 +78,7 @@ def test_standard_decoders_import_without_experimental_package(): def test_published_decoder_manifest_has_no_unpublishable_dependencies(): - """Optional dependencies must also be publishable for crates.io packaging.""" + """crates/pecos-decoders must not transitively path-depend on a publish = false crate.""" import tomllib from pathlib import Path @@ -106,3 +106,129 @@ def inspect(manifest_path): inspect((directory / dependency["path"] / "Cargo.toml").resolve()) inspect(root / "crates/pecos-decoders/Cargo.toml") + + +def test_provider_workers_decode_concurrently(): + import threading + + workers = 3 + barrier = threading.Barrier(workers, timeout=10) + + class ConcurrentWorker(Worker): + first = True + + def _pecos_decode_obs(self, syndrome): + if self.first: + self.first = False + barrier.wait() + return super()._pecos_decode_obs(syndrome) + + class ConcurrentProvider(Provider): + def _pecos_build_decoder(self, dem): + assert dem == DEM + return ConcurrentWorker() + + batch = SampleBatch([[0]] * 3073, [0] * 3073) + result = batch.decode(DEM, ConcurrentProvider(), workers=workers) + assert result.num_errors == 0 + assert result.workers_used == workers + assert not barrier.broken + + +@pytest.mark.parametrize("workers", [1, 3]) +@pytest.mark.parametrize("sampler", [False, True]) +@pytest.mark.parametrize("failure", ["build", "num_detectors", "_pecos_decode_obs"]) +def test_provider_build_exceptions_propagate(workers, sampler, failure): + import traceback + + error = KeyError("boom") + + def raise_error(): + raise error + + class InvalidWorker: + @property + def num_detectors(self): + if failure == "num_detectors": + raise_error() + return 1 + + @property + def _pecos_decode_obs(self): + raise_error() + + class InvalidProvider(Provider): + def _pecos_build_decoder(self, dem): + if failure == "build": + raise_error() + return InvalidWorker() + + if sampler: + source = DemSampler.from_dem_string(DEM) + args = (DEM, 3073, InvalidProvider()) + else: + source = SampleBatch([[0]] * 3073, [0] * 3073) + args = (DEM, InvalidProvider()) + with pytest.raises(KeyError, match="boom") as caught: + source.decode(*args, workers=workers) + assert caught.value is error + assert str(caught.value) == "'boom'" + assert traceback.extract_tb(caught.value.__traceback__)[-1].name == "raise_error" + + +def test_fused_provider_build_exception_after_preflight(): + import traceback + + class InvalidProvider(Provider): + builds = 0 + + def _pecos_build_decoder(self, dem): + self.builds += 1 + if self.builds > 1: + raise KeyError("boom") + return super()._pecos_build_decoder(dem) + + provider = InvalidProvider() + with pytest.raises(KeyError, match="boom") as caught: + DemSampler.from_dem_string(DEM).decode(DEM, 3073, provider, workers=3) + assert provider.builds > 1 + assert traceback.extract_tb(caught.value.__traceback__)[-1].name == "_pecos_build_decoder" + + +@pytest.mark.parametrize("member", ["_pecos_build_decoder", "history_dependent", "wall_clock_dependent"]) +@pytest.mark.parametrize("missing", [False, True]) +def test_required_version_one_members(member, missing): + members = { + "_pecos_decoder_api_version": 1, + "_pecos_build_decoder": lambda self, dem: Worker(), + "history_dependent": False, + "wall_clock_dependent": False, + } + if missing: + del members[member] + else: + members[member] = "wrong type" + provider = type("InvalidProvider", (), members)() + with pytest.raises(TypeError) as caught: + SampleBatch([[0]], [0]).decode(DEM, provider) + message = str(caught.value) + assert "version-1" in message + for required in ( + "_pecos_build_decoder", + "history_dependent", + "wall_clock_dependent", + ): + assert required in message + + +def test_provider_per_shot_exception_keeps_shot_context(): + class FailingWorker(Worker): + def _pecos_decode_obs(self, syndrome): + raise KeyError("boom") + + class FailingProvider(Provider): + def _pecos_build_decoder(self, dem): + return FailingWorker() + + with pytest.raises(RuntimeError, match="decoder failed on shot 0:.*KeyError.*boom"): + SampleBatch([[0]], [0]).decode(DEM, FailingProvider()) diff --git a/python/quantum-pecos/tests/qec/test_frontier_decoder.py b/python/quantum-pecos/tests/qec/test_frontier_decoder.py index dc634cb67..ad38bc842 100644 --- a/python/quantum-pecos/tests/qec/test_frontier_decoder.py +++ b/python/quantum-pecos/tests/qec/test_frontier_decoder.py @@ -183,7 +183,7 @@ def test_metric_and_factor_model_error_paths() -> None: ) with pytest.raises(RuntimeError, match="integer max-log metric is not supported"): FrontierCommitteeDecoder.from_dem(SMALL_DEM, metric_mode="maxlog_int") - with pytest.raises(RuntimeError, match="delta must be finite under maxlog_int"): + with pytest.raises(ValueError, match="delta must be finite under maxlog_int"): FrontierDecoder.from_dem( SMALL_DEM, delta=float("inf"), diff --git a/ruff.toml b/ruff.toml index 2a3fac953..6839a0dfd 100644 --- a/ruff.toml +++ b/ruff.toml @@ -243,6 +243,9 @@ ignore = [ "python/quantum-pecos/tests/qec/surface/test_logical_subgraph_region_comparison.py" = [ "SLF001", # Private member access - unit-testing the internal graphlike-decomposition recommendation ] +"python/pecos-rslib-exp/tests/test_decoder_spec_gil.py" = [ + "SLF001", # Private member access - timing single calls of the internal decoder-provider protocol +] # SLR qeclib visualization - matplotlib lazy imports "python/quantum-pecos/src/pecos/slr/qeclib/color488/plot_layout.py" = [ "PLC0415", # Import inside function - matplotlib optional From 11e6649ec475133a4a6d4e6d26be7ede172a755f Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Thu, 17 Sep 2026 14:50:54 -0600 Subject: [PATCH 5/9] Validate provider predictions against the model, keep non-protocol provider errors intact, name the failing escalation rung, and show every decoder explicitly in the Guppy DEM workflow --- docs/user-guide/decoders.md | 10 ++- docs/workflows/guppy-dem-decoding.md | 60 +++++++++------- exp/pecos-bp-trellis/src/lib.rs | 9 ++- .../tests/test_bp_trellis_batch_decode.py | 2 + .../tests/test_decoder_spec_gil.py | 4 +- python/pecos-rslib/src/batch_decoder_spec.rs | 70 ++++++++++++++++--- .../fault_tolerance_bindings/batch_decode.rs | 4 +- .../sampler_decode.rs | 6 +- .../tests/test_decoder_providers.py | 43 +++++++++++- 9 files changed, 160 insertions(+), 48 deletions(-) diff --git a/docs/user-guide/decoders.md b/docs/user-guide/decoders.md index de61b3684..a238f1f6d 100644 --- a/docs/user-guide/decoders.md +++ b/docs/user-guide/decoders.md @@ -49,7 +49,7 @@ The following decoder APIs and supporting types are publicly re-exported from | `CheckMatrix` / `SparseMatrix` | Dense or coordinate-form matrix data | Matrix containers used by matching and LDPC decoder constructors. | | `MwpmResult` / `BpResult` / `TesseractResult` | Decoder output | Result objects for matching, belief-propagation, and Tesseract decoders. | -The optional factories are described in the [Rust-backed Frontier](#rust-backed-frontier-batch-decoding) and [Rust-backed BP-Trellis](#rust-backed-bp-trellis-batch-decoding) sections below. +The experimental `frontier()` and `bp_trellis()` factories are not part of this table: they import from `pecos.decoders` only when the optional `pecos-rslib-exp` package is installed, and are described in the [Rust-backed Frontier](#rust-backed-frontier-batch-decoding) and [Rust-backed BP-Trellis](#rust-backed-bp-trellis-batch-decoding) sections below. Python decoder inputs name their encoding explicitly: use `decode_syndrome(...)` for a dense detector vector and @@ -358,8 +358,12 @@ assert result.num_errors == 0 Frontier accepts raw DEMs, including hyperedges. `workers=None` selects the worker count automatically; `workers=1` runs sequentially. Parallel execution -releases the Python GIL and uses one Rust decoder per worker, preserving shot -order. More workers and larger `k` increase memory use. +releases the Python GIL and preserves shot order. At most one Rust decoder per +worker is alive at a time, so more workers and larger `k` increase memory use. +`SampleBatch.decode(...)` builds exactly one decoder per worker; +`DemSampler.decode(...)` builds one per scheduled group of sampling chunks, which +can be several times the worker count on a long run, so a model that is slow to +construct pays that cost more than once per worker there. Options match `pecos_rslib_exp.FrontierDecoder.from_dem`: `k`, `delta`, `score_alpha`, `bp_score_iterations`, `column_order`, `merge_indistinguishable`, diff --git a/docs/workflows/guppy-dem-decoding.md b/docs/workflows/guppy-dem-decoding.md index 626944c69..14c08e20e 100644 --- a/docs/workflows/guppy-dem-decoding.md +++ b/docs/workflows/guppy-dem-decoding.md @@ -300,9 +300,7 @@ returned `DecodeResult` supplies the aggregate count and rate directly. ```python -from pecos.decoders import bp_osd, pymatching, tesseract - -from pecos_rslib_exp import bp_trellis, frontier +from pecos.decoders import bp_osd, bp_trellis, frontier, pymatching, tesseract pymatching_result = batch.decode( terminal_graphlike_text, @@ -318,22 +316,30 @@ bp_osd_result = batch.decode( bp_osd(max_iter=10, osd_order=1), workers=None, ) +frontier_result = batch.decode( + raw_text, + frontier(k=64), + workers=4, + predictions=True, +) +bp_trellis_result = batch.decode( + raw_text, + bp_trellis(k=8, escalation_ks=[32, 128]), + workers=4, + predictions=True, +) + +assert frontier_result.execution_path == bp_trellis_result.execution_path == "parallel" +assert frontier_result.workers_used == bp_trellis_result.workers_used == 4 +assert len(frontier_result.predictions) == len(bp_trellis_result.predictions) == batch.num_shots decoder_results = { "pymatching": pymatching_result, "tesseract": tesseract_result, "bp_osd": bp_osd_result, + "frontier": frontier_result, + "bp_trellis": bp_trellis_result, } -experimental_specs = { - "frontier": frontier(k=64), - "bp_trellis": bp_trellis(k=8, escalation_ks=[32, 128]), -} -for name, spec in experimental_specs.items(): - result = batch.decode(raw_text, spec, workers=4, predictions=True) - assert result.execution_path == "parallel" - assert result.workers_used == 4 - assert len(result.predictions) == batch.num_shots - decoder_results[name] = result print("DEM-sampled shots") for name, result in decoder_results.items(): @@ -342,10 +348,10 @@ for name, result in decoder_results.items(): print(f"{name} execution path: {result.execution_path}") ``` -Install the optional `pecos-rslib-exp` package to run the Frontier and BP-Trellis -examples. -Explicit imports through `pecos.decoders` are also lazy conveniences, but the -factories and native engines belong to `pecos_rslib_exp`. +`frontier` and `bp_trellis` are experimental: importing them from +`pecos.decoders` loads the optional `pecos-rslib-exp` package, which must be +installed, and raises an `ImportError` naming it otherwise. The other three +factories need nothing beyond the standard install. `frontier()` uses the native Rust Frontier decoder and accepts the raw DEM, including hyperedges. The example decodes shots across four Rust worker threads; @@ -383,18 +389,20 @@ sim_batch = SampleBatch( [syndrome for syndrome, _ in sim_shots], [observable_mask for _, observable_mask in sim_shots], ) -sim_errors = sim_batch.decode( - terminal_graphlike_text, - pymatching(correlated=True), -).num_errors +sim_results = { + "pymatching": sim_batch.decode(terminal_graphlike_text, pymatching(correlated=True)), + "tesseract": sim_batch.decode(source_graphlike_text, tesseract(preset="fast", pqlimit=50_000)), + "bp_osd": sim_batch.decode(raw_text, bp_osd(max_iter=10, osd_order=1)), + "frontier": sim_batch.decode(raw_text, frontier(k=64), workers=4), + "bp_trellis": sim_batch.decode(raw_text, bp_trellis(k=8, escalation_ks=[32, 128]), workers=4), +} -print(f"simulated shots, pymatching: {sim_errors}/{len(sim_shots)}") -for name, spec in experimental_specs.items(): - result = sim_batch.decode(raw_text, spec, workers=4) - print(f"simulated shots, {name}: {result.num_errors}/{len(sim_shots)}") +print("simulated shots") +for name, result in sim_results.items(): + print(f"{name:11} {result.num_errors:5}/{len(sim_shots)}") ``` -With the optional package installed, at this noise level the five decoders land within about a percentage point of +At this noise level the five decoders land within about a percentage point of each other on this code; the gaps between decoders widen with code distance and with genuinely hyperedge-like noise. Frontier, BP-Trellis, and BP+OSD consume the raw model in this example; Tesseract uses the source-informed decomposition chosen above. diff --git a/exp/pecos-bp-trellis/src/lib.rs b/exp/pecos-bp-trellis/src/lib.rs index 737c59864..ef3e04b7d 100644 --- a/exp/pecos-bp-trellis/src/lib.rs +++ b/exp/pecos-bp-trellis/src/lib.rs @@ -79,9 +79,14 @@ impl BpTrellisConfig { } let mut config = self.trellis_config(); config.validate()?; - for &k in &self.escalation_ks { + for (rung, &k) in self.escalation_ks.iter().enumerate() { config.k = k; - config.validate()?; + config.validate().map_err(|error| match error { + DecoderError::InvalidConfiguration(message) => { + DecoderError::InvalidConfiguration(format!("escalation_ks[{rung}]: {message}")) + } + other => other, + })?; } Ok(()) } diff --git a/python/pecos-rslib-exp/tests/test_bp_trellis_batch_decode.py b/python/pecos-rslib-exp/tests/test_bp_trellis_batch_decode.py index 57c514bdb..16e75b15e 100644 --- a/python/pecos-rslib-exp/tests/test_bp_trellis_batch_decode.py +++ b/python/pecos-rslib-exp/tests/test_bp_trellis_batch_decode.py @@ -54,6 +54,8 @@ def test_invalid_options(options): with pytest.raises(ValueError, match=r"must|invalid|incompatible") as direct_error: exp.BpTrellisDecoder.from_dem(DEM, **options) assert str(factory_error.value) == str(direct_error.value) + if "escalation_ks" in options: + assert "escalation_ks[0]" in str(factory_error.value) @pytest.mark.parametrize( diff --git a/python/pecos-rslib-exp/tests/test_decoder_spec_gil.py b/python/pecos-rslib-exp/tests/test_decoder_spec_gil.py index 61d671a77..d531bbbac 100644 --- a/python/pecos-rslib-exp/tests/test_decoder_spec_gil.py +++ b/python/pecos-rslib-exp/tests/test_decoder_spec_gil.py @@ -9,7 +9,7 @@ from pecos_rslib_exp import bp_trellis, frontier # A call this long spans many interpreter switch intervals, so a thread that -# stalls for half of it can only mean the call held the GIL. +# stalls for a quarter of it can only mean the call held the GIL. MIN_CALL_SECONDS = 0.2 @@ -59,7 +59,7 @@ def assert_releases_gil(native_call_for_length): for length in (500 * 2**doubling for doubling in range(8)): duration, stall = largest_python_stall(native_call_for_length(length)) if duration > MIN_CALL_SECONDS: - assert stall < duration / 2, f"Python thread stalled for {stall:.3f}s of a {duration:.3f}s native call" + assert stall < duration / 4, f"Python thread stalled for {stall:.3f}s of a {duration:.3f}s native call" return pytest.fail(f"no native call exceeded {MIN_CALL_SECONDS}s; the largest took {duration:.3f}s") diff --git a/python/pecos-rslib/src/batch_decoder_spec.rs b/python/pecos-rslib/src/batch_decoder_spec.rs index 528674f0f..56520e372 100644 --- a/python/pecos-rslib/src/batch_decoder_spec.rs +++ b/python/pecos-rslib/src/batch_decoder_spec.rs @@ -1,11 +1,38 @@ //! Versioned Python decoder-provider bridge. The standard extension never imports //! optional providers. Providers own their configuration and native workers; //! only Python objects and integer word arrays cross extension boundaries. +//! +//! This is an internal protocol between PECOS packages, not a public API. A +//! version-1 provider is any Python object with: +//! +//! - `_pecos_decoder_api_version == 1`. Any other value is rejected, so a change +//! to this contract takes a new version number. +//! - `history_dependent: bool` and `wall_clock_dependent: bool`, read once when the +//! provider is passed to `decode`. Either one keeps automatic planning +//! sequential. An explicit parallel worker count is rejected for a +//! history-dependent provider and runs with a reproducibility warning for a +//! wall-clock-dependent one, exactly as for built-in specifications. +//! - `_pecos_build_decoder(dem: str)`, called once per decoder the batch planner +//! needs, possibly from several threads at once, and returning a worker object. +//! An exception it raises reaches the caller of `decode` unchanged. +//! +//! A worker object has: +//! +//! - `num_detectors: int`, checked against the batch before any shot is decoded. +//! - `_pecos_decode_obs(syndrome: bytes) -> Sequence[int]`. `syndrome` holds one +//! byte per detector, each 0 or 1. The result is the predicted observable mask as +//! little-endian 64-bit words, lowest observables first, with no observable at or +//! above the count the DEM declares. A worker is only ever called from the one +//! thread that built it, one shot at a time. An exception it raises is reported as +//! a decode failure naming the shot. +//! +//! The bridge holds the GIL only around those calls. A provider that does native +//! work should release it inside them, or its workers run one at a time. use crate::decoder_spec_bindings::PyDecoderSpec; use pecos_decoder_core::{DecoderError, ObservableDecoder, obs_mask::ObsMask}; use pecos_decoders::{DecodeModel, DecoderSpec, spec::ExecutionTraits}; -use pyo3::exceptions::PyTypeError; +use pyo3::exceptions::{PyAttributeError, PyTypeError}; use pyo3::prelude::*; use pyo3::types::{PyBytes, PyString}; @@ -18,8 +45,8 @@ pub(crate) enum BatchDecoderSpec { } pub(crate) enum DecoderBuildError { - Builtin(DecoderError), - Provider(PyErr), + Decoder(DecoderError), + Python(PyErr), } impl BatchDecoderSpec { @@ -52,10 +79,18 @@ impl BatchDecoderSpec { }) }; let traits = members().map_err(|cause| { + let py = decoder.py(); + // Only a missing or wrong-typed member is a protocol error; anything + // else a provider's own attribute access raises belongs to the caller. + if !(cause.is_instance_of::(py) + || cause.is_instance_of::(py)) + { + return cause; + } let error = PyTypeError::new_err( "version-1 decoder providers require callable _pecos_build_decoder and boolean history_dependent and wall_clock_dependent members", ); - error.set_cause(decoder.py(), Some(cause)); + error.set_cause(py, Some(cause)); error })?; Ok(Self::Provider { @@ -87,15 +122,18 @@ impl BatchDecoderSpec { model: &DecodeModel, ) -> Result, DecoderBuildError> { match self { - Self::Builtin(spec) => spec.build(model).map_err(DecoderBuildError::Builtin), + Self::Builtin(spec) => spec.build(model).map_err(DecoderBuildError::Decoder), Self::Provider { spec, .. } => { let DecodeModel::SingleDem(dem) = model else { - return Err(DecoderBuildError::Builtin( + return Err(DecoderBuildError::Decoder( DecoderError::InvalidConfiguration( "decoder providers require a single DEM".into(), ), )); }; + let num_observables = pecos_decoder_core::dem::utils::parse_dem_metadata(dem) + .map_err(DecoderBuildError::Decoder)? + .1; Python::attach(|py| -> PyResult> { let worker = spec.bind(py).call_method1("_pecos_build_decoder", (dem,))?; let num_detectors = worker.getattr("num_detectors")?.extract::()?; @@ -107,9 +145,10 @@ impl BatchDecoderSpec { Ok(Box::new(ProviderDecoder { worker: worker.unbind(), num_detectors, + num_observables, })) }) - .map_err(DecoderBuildError::Provider) + .map_err(DecoderBuildError::Python) } } } @@ -118,13 +157,14 @@ impl BatchDecoderSpec { struct ProviderDecoder { worker: Py, num_detectors: usize, + num_observables: usize, } impl ObservableDecoder for ProviderDecoder { fn num_detectors(&self) -> Option { Some(self.num_detectors) } fn decode_obs(&mut self, syndrome: &[u8]) -> Result { - Python::attach(|py| { + let mask = Python::attach(|py| { let words = self .worker .bind(py) @@ -132,6 +172,18 @@ impl ObservableDecoder for ProviderDecoder { .extract::>()?; Ok::<_, PyErr>(ObsMask::from_words(&words)) }) - .map_err(|e| DecoderError::DecodingFailed(e.to_string())) + .map_err(|e| DecoderError::DecodingFailed(e.to_string()))?; + // The provider is outside this extension, so its answer is checked + // against the model it was built from rather than trusted. + if let Some(observable) = mask + .iter_set_bits() + .find(|&bit| bit >= self.num_observables) + { + return Err(DecoderError::DecodingFailed(format!( + "decoder provider predicted observable {observable}, but the DEM declares {} observables", + self.num_observables + ))); + } + Ok(mask) } } diff --git a/python/pecos-rslib/src/fault_tolerance_bindings/batch_decode.rs b/python/pecos-rslib/src/fault_tolerance_bindings/batch_decode.rs index 02290b68a..5e7c5989a 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings/batch_decode.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings/batch_decode.rs @@ -35,8 +35,8 @@ pub(super) enum BatchExecutionError { impl BatchExecutionError { pub(super) fn into_pyerr(self) -> PyErr { match self { - Self::Build(DecoderBuildError::Builtin(error)) => decoder_build_error_to_py(error), - Self::Build(DecoderBuildError::Provider(error)) => error, + Self::Build(DecoderBuildError::Decoder(error)) => decoder_build_error_to_py(error), + Self::Build(DecoderBuildError::Python(error)) => error, Self::Dimension { batch_detectors, decoder_detectors, diff --git a/python/pecos-rslib/src/fault_tolerance_bindings/sampler_decode.rs b/python/pecos-rslib/src/fault_tolerance_bindings/sampler_decode.rs index 84817e452..ca82ed813 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings/sampler_decode.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings/sampler_decode.rs @@ -314,13 +314,13 @@ fn parallel( seed, options, ), - Err(DecoderBuildError::Builtin(error)) => { + Err(DecoderBuildError::Decoder(error)) => { Err(BatchExecutionError::Runtime(format!( "parallel decoder construction failed: {error}" ))) } - Err(DecoderBuildError::Provider(error)) => { - Err(BatchExecutionError::Build(DecoderBuildError::Provider( + Err(DecoderBuildError::Python(error)) => { + Err(BatchExecutionError::Build(DecoderBuildError::Python( pyo3::Python::attach(|py| error.clone_ref(py)), ))) } diff --git a/python/pecos-rslib/tests/test_decoder_providers.py b/python/pecos-rslib/tests/test_decoder_providers.py index 21901a1fe..61a1040a5 100644 --- a/python/pecos-rslib/tests/test_decoder_providers.py +++ b/python/pecos-rslib/tests/test_decoder_providers.py @@ -52,11 +52,51 @@ def test_provider_validation_and_execution_traits(): provider.history_dependent = True with pytest.raises(ValueError, match="history|stateful|workers"): batch.decode(DEM, provider, workers=3) - assert batch.decode(DEM, provider).execution_path == "sequential" with pytest.raises(ValueError, match="detectors"): SampleBatch([[0, 0]], [0]).decode(DEM, Provider()) +@pytest.mark.parametrize("trait", [None, "history_dependent", "wall_clock_dependent"]) +def test_execution_traits_steer_automatic_planning(trait): + # Large enough that automatic planning parallelizes a provider with neither trait. + batch = SampleBatch([[0]] * 3073, [0] * 3073) + provider = Provider() + if trait is not None: + setattr(provider, trait, True) + expected = "parallel" if trait is None else "sequential" + assert batch.decode(DEM, provider).execution_path == expected + + +def test_member_access_errors_other_than_the_protocol_propagate(): + class BrokenProvider(Provider): + @property + def history_dependent(self): + return 1 // 0 + + with pytest.raises(ZeroDivisionError): + SampleBatch([[0]], [0]).decode(DEM, BrokenProvider()) + + +@pytest.mark.parametrize("sampler", [False, True]) +def test_prediction_outside_the_model_observables_is_an_error(sampler): + class WideWorker(Worker): + def _pecos_decode_obs(self, syndrome): + return [0, 1 << 7] + + class WideProvider(Provider): + def _pecos_build_decoder(self, dem): + return WideWorker() + + if sampler: + source = DemSampler.from_dem_string(DEM) + args = (DEM, 8, WideProvider()) + else: + source = SampleBatch([[0]], [0]) + args = (DEM, WideProvider()) + with pytest.raises(RuntimeError, match=r"predicted observable 71, but the DEM declares 71 observables"): + source.decode(*args, workers=1) + + def test_standard_decoders_import_without_experimental_package(): code = """ import sys @@ -211,6 +251,7 @@ def test_required_version_one_members(member, missing): provider = type("InvalidProvider", (), members)() with pytest.raises(TypeError) as caught: SampleBatch([[0]], [0]).decode(DEM, provider) + assert isinstance(caught.value.__cause__, AttributeError if missing else TypeError) message = str(caught.value) assert "version-1" in message for required in ( From 6b56441a21572ed6c9d9a4d26dc0f0f715a01628 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Thu, 17 Sep 2026 15:33:19 -0600 Subject: [PATCH 6/9] Count DEM targets joined by an unspaced separator in parse_dem_metadata so provider predictions are checked against the model the engines see --- crates/pecos-decoder-core/src/dem.rs | 13 ++++- docs/user-guide/decoders.md | 7 +-- .../tests/test_bp_trellis_batch_decode.py | 3 +- python/pecos-rslib/src/batch_decoder_spec.rs | 5 +- .../tests/test_decoder_providers.py | 52 ++++++++++++++++--- 5 files changed, 68 insertions(+), 12 deletions(-) diff --git a/crates/pecos-decoder-core/src/dem.rs b/crates/pecos-decoder-core/src/dem.rs index 5eaa59004..6d09ebd06 100644 --- a/crates/pecos-decoder-core/src/dem.rs +++ b/crates/pecos-decoder-core/src/dem.rs @@ -158,7 +158,9 @@ pub mod utils { // ids; `logical_observable` declares deterministic logicals that // Stim emits with no flipping mechanism but still count. "error" | "logical_observable" => { - for part in &parts[1..] { + // A decomposed mechanism may join components with `^` and no + // surrounding spaces, which the other parsers accept. + for part in parts[1..].iter().flat_map(|part| part.split('^')) { if let Some(d_str) = part.strip_prefix('D') { if let Ok(d) = d_str.parse::() { max_detector = Some(max_detector.map_or(d, |m: usize| m.max(d))); @@ -1394,6 +1396,15 @@ mod tests { ); } + #[test] + fn test_metadata_counts_targets_joined_by_an_unspaced_separator() { + // The highest detector and observable sit only behind an unspaced `^`. + let dem = "error(0.1) D0 L0^D3 L5\nerror(0.2) D1 L0\n"; + let sparse = SparseDem::from_dem_str(dem).unwrap(); + assert_eq!((sparse.num_detectors, sparse.num_observables), (4, 6)); + assert_eq!(utils::parse_dem_metadata(dem).unwrap(), (4, 6)); + } + #[test] fn test_non_flattened_dem_rejected() { // repeat blocks and shift_detectors would corrupt detector ids if parsed diff --git a/docs/user-guide/decoders.md b/docs/user-guide/decoders.md index a238f1f6d..1b0e29a1b 100644 --- a/docs/user-guide/decoders.md +++ b/docs/user-guide/decoders.md @@ -361,9 +361,10 @@ worker count automatically; `workers=1` runs sequentially. Parallel execution releases the Python GIL and preserves shot order. At most one Rust decoder per worker is alive at a time, so more workers and larger `k` increase memory use. `SampleBatch.decode(...)` builds exactly one decoder per worker; -`DemSampler.decode(...)` builds one per scheduled group of sampling chunks, which -can be several times the worker count on a long run, so a model that is slow to -construct pays that cost more than once per worker there. +`DemSampler.decode(...)` builds one up front to check dimensions and then one per +scheduled group of sampling chunks, which can be several times the worker count +on a long run, so a model that is slow to construct pays that cost more than +once per worker there. Options match `pecos_rslib_exp.FrontierDecoder.from_dem`: `k`, `delta`, `score_alpha`, `bp_score_iterations`, `column_order`, `merge_indistinguishable`, diff --git a/python/pecos-rslib-exp/tests/test_bp_trellis_batch_decode.py b/python/pecos-rslib-exp/tests/test_bp_trellis_batch_decode.py index 16e75b15e..7a6d9d0bb 100644 --- a/python/pecos-rslib-exp/tests/test_bp_trellis_batch_decode.py +++ b/python/pecos-rslib-exp/tests/test_bp_trellis_batch_decode.py @@ -46,6 +46,7 @@ def test_public_spec_and_configuration(): {"ordering": "unknown"}, {"ordering": None}, {"escalation_ks": [0]}, + {"escalation_ks": [16, 0]}, ], ) def test_invalid_options(options): @@ -55,7 +56,7 @@ def test_invalid_options(options): exp.BpTrellisDecoder.from_dem(DEM, **options) assert str(factory_error.value) == str(direct_error.value) if "escalation_ks" in options: - assert "escalation_ks[0]" in str(factory_error.value) + assert f"escalation_ks[{options['escalation_ks'].index(0)}]" in str(factory_error.value) @pytest.mark.parametrize( diff --git a/python/pecos-rslib/src/batch_decoder_spec.rs b/python/pecos-rslib/src/batch_decoder_spec.rs index 56520e372..6e1f9758c 100644 --- a/python/pecos-rslib/src/batch_decoder_spec.rs +++ b/python/pecos-rslib/src/batch_decoder_spec.rs @@ -14,7 +14,10 @@ //! wall-clock-dependent one, exactly as for built-in specifications. //! - `_pecos_build_decoder(dem: str)`, called once per decoder the batch planner //! needs, possibly from several threads at once, and returning a worker object. -//! An exception it raises reaches the caller of `decode` unchanged. +//! The DEM must be flat (no `repeat` or `shift_detectors`), as for built-in +//! specifications; the bridge reads its observable count before calling the +//! provider. An exception the provider raises reaches the caller of `decode` +//! unchanged. //! //! A worker object has: //! diff --git a/python/pecos-rslib/tests/test_decoder_providers.py b/python/pecos-rslib/tests/test_decoder_providers.py index 61a1040a5..a845dd9f0 100644 --- a/python/pecos-rslib/tests/test_decoder_providers.py +++ b/python/pecos-rslib/tests/test_decoder_providers.py @@ -78,25 +78,50 @@ def history_dependent(self): @pytest.mark.parametrize("sampler", [False, True]) -def test_prediction_outside_the_model_observables_is_an_error(sampler): +@pytest.mark.parametrize( + ("dem", "words", "observable", "declared"), + [ + (DEM, [0, 1 << 7], 71, 71), + ("error(0.1) D0 L0\n", [1 << 5], 5, 1), + ("error(0.1) D0\n", [1], 0, 0), + ], +) +def test_prediction_outside_the_model_observables_is_an_error(sampler, dem, words, observable, declared): class WideWorker(Worker): def _pecos_decode_obs(self, syndrome): - return [0, 1 << 7] + return words class WideProvider(Provider): def _pecos_build_decoder(self, dem): return WideWorker() if sampler: - source = DemSampler.from_dem_string(DEM) - args = (DEM, 8, WideProvider()) + source = DemSampler.from_dem_string(dem) + args = (dem, 8, WideProvider()) else: source = SampleBatch([[0]], [0]) - args = (DEM, WideProvider()) - with pytest.raises(RuntimeError, match=r"predicted observable 71, but the DEM declares 71 observables"): + args = (dem, WideProvider()) + message = f"predicted observable {observable}, but the DEM declares {declared} observables" + with pytest.raises(RuntimeError, match=message): source.decode(*args, workers=1) +def test_prediction_behind_an_unspaced_separator_is_inside_the_model(): + class SeparatorWorker(Worker): + num_detectors = 2 + + def _pecos_decode_obs(self, syndrome): + return [1 << 5] + + class SeparatorProvider(Provider): + def _pecos_build_decoder(self, dem): + return SeparatorWorker() + + dem = "error(0.1) D0 L0^D1 L5\n" + result = SampleBatch([[0, 0]], [1 << 5]).decode(dem, SeparatorProvider(), workers=1, predictions=True) + assert result.predictions == [1 << 5] + + def test_standard_decoders_import_without_experimental_package(): code = """ import sys @@ -252,6 +277,21 @@ def test_required_version_one_members(member, missing): with pytest.raises(TypeError) as caught: SampleBatch([[0]], [0]).decode(DEM, provider) assert isinstance(caught.value.__cause__, AttributeError if missing else TypeError) + + +def test_member_access_error_subclasses_are_protocol_errors(): + class MissingMember(AttributeError): + pass + + class DynamicProvider: + _pecos_decoder_api_version = 1 + + def __getattr__(self, name): + raise MissingMember(name) + + with pytest.raises(TypeError, match="version-1") as caught: + SampleBatch([[0]], [0]).decode(DEM, DynamicProvider()) + assert isinstance(caught.value.__cause__, MissingMember) message = str(caught.value) assert "version-1" in message for required in ( From 13f9aa11a91c4b40ad2d826748f329f2cff91062 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Thu, 17 Sep 2026 18:21:48 -0600 Subject: [PATCH 7/9] Bound an explicit SampleBatch.decode worker count by the shot count so an oversized request no longer stalls spawning idle threads --- crates/pecos-decoders/src/batch.rs | 19 +++++++++++++++++++ docs/user-guide/dem-from-guppy.md | 5 +++-- docs/workflows/guppy-dem-decoding.md | 5 ++++- .../src/fault_tolerance_bindings.rs | 16 +++++++++++++--- .../fault_tolerance_bindings/batch_decode.rs | 2 +- .../tests/test_decoder_providers.py | 15 ++++++++++++++- .../tests/test_sample_batch_decode.py | 11 +++++++++++ 7 files changed, 65 insertions(+), 8 deletions(-) diff --git a/crates/pecos-decoders/src/batch.rs b/crates/pecos-decoders/src/batch.rs index 6d4d3505b..a7d752510 100644 --- a/crates/pecos-decoders/src/batch.rs +++ b/crates/pecos-decoders/src/batch.rs @@ -321,6 +321,17 @@ pub fn fused_worker_cap(num_shots: usize) -> usize { num_shots.div_ceil(SAMPLING_CHUNK_SHOTS).max(1) } +/// Upper bound on useful workers for decoding a sampled batch. +/// +/// Generic parallel workers pull chunks that shrink down to a single shot, so a +/// worker beyond one per shot can never be given anything to do. Bounding the +/// pool here keeps an oversized explicit request from spending its time +/// spawning idle threads, and `workers_used` reports the bounded count. +#[must_use] +pub fn batch_worker_cap(num_shots: usize) -> usize { + num_shots.max(1) +} + /// The single seam constructing the canonical per-chunk RNG for sampling ABI v1. /// /// Every execution path — sequential, parallel, native — must obtain its chunk @@ -646,6 +657,14 @@ mod tests { assert_eq!(parallel_chunk_shots(5, 0), 5); } + #[test] + fn batch_worker_cap_is_one_worker_per_shot() { + assert_eq!(batch_worker_cap(0), 1); + assert_eq!(batch_worker_cap(1), 1); + assert_eq!(batch_worker_cap(2), 2); + assert_eq!(batch_worker_cap(100_000), 100_000); + } + #[test] fn parallel_fake_decoder_reports_lowest_absolute_failure() { use pecos_decoder_core::obs_mask::ObsMask; diff --git a/docs/user-guide/dem-from-guppy.md b/docs/user-guide/dem-from-guppy.md index 842811220..5c1019554 100644 --- a/docs/user-guide/dem-from-guppy.md +++ b/docs/user-guide/dem-from-guppy.md @@ -242,8 +242,9 @@ assert stats.num_shots == batch.num_shots `.logical_error_rate` hold aggregates, optional `.predictions` preserves shot order, and optional `.stats` holds timing statistics. With the default `workers=None`, the execution planner selects the native-batch, sequential, or -parallel path. Pass `workers=N` to request a specific worker count and inspect -`.execution_path` to see which path ran. A former raw-list call such as +parallel path. Pass `workers=N` to request up to that many workers, never more +than one per shot, and inspect `.execution_path` and `.workers_used` to see +which path ran and how many workers it used. A former raw-list call such as `detectors, observables = sampler.sample_batch(...)` becomes a batch call followed by the two bulk accessors shown above. diff --git a/docs/workflows/guppy-dem-decoding.md b/docs/workflows/guppy-dem-decoding.md index 14c08e20e..f77745ccb 100644 --- a/docs/workflows/guppy-dem-decoding.md +++ b/docs/workflows/guppy-dem-decoding.md @@ -369,7 +369,10 @@ BP-Trellis also remains experimental; pruning can make predictions approximate. With `workers=None`, PECOS automatically selects a native-batch, sequential, or parallel path based on the decoder and batch size. Pass `workers=N` to request -an exact worker count. `result.execution_path` reports which path ran. Request +that many workers; a batch with fewer shots than `N` gets one worker per shot +(at least one), and `result.workers_used` reports the size of the pool that ran. +`result.execution_path` +reports which path ran. Request `predictions=True` when you also need each shot's arbitrary-precision observable mask; the default avoids materializing them in Python. diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index e8d3dc6f5..c3f155232 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -3723,8 +3723,9 @@ impl PySampleBatch { /// batches require an explicit DEM. Automatic execution honors decoder /// statefulness, uses native batching where available, and otherwise chooses /// sequential or bounded parallel per-shot execution. Set `workers` to opt - /// into an exact worker count, `predictions` to retain wide per-shot masks, - /// and `timing` to retain per-shot elapsed-time statistics. + /// into that many workers, bounded by one per shot (and never below one) + /// and reported as `workers_used`; `predictions` to retain wide per-shot + /// masks; and `timing` to retain per-shot elapsed-time statistics. #[pyo3(signature = (dem=None, decoder=None, *, workers=None, predictions=false, timing=false, allow_dem_mismatch=false))] fn decode( &self, @@ -3766,7 +3767,7 @@ impl PySampleBatch { }) .transpose()?; let traits = spec.execution_traits(); - let plan = + let mut plan = pecos_decoders::batch::plan_execution(pecos_decoders::batch::ExecutionPlanInputs { traits, num_shots: self.num_shots, @@ -3777,6 +3778,15 @@ impl PySampleBatch { }) .map_err(|error| pyo3::exceptions::PyValueError::new_err(error.to_string()))?; + // Report the workers that can actually run: a worker needs at least one + // shot, so anything beyond one worker per shot would only idle. An empty + // batch keeps one worker, which builds its decoder and decodes nothing. + if plan.path == pecos_decoders::batch::ExecutionPath::Parallel { + plan.workers_used = plan + .workers_used + .min(pecos_decoders::batch::batch_worker_cap(self.num_shots)); + } + let output = py .detach(|| batch_decode::execute(self, resolved_dem, &spec, &plan, predictions, timing)) .map_err(batch_decode::BatchExecutionError::into_pyerr)?; diff --git a/python/pecos-rslib/src/fault_tolerance_bindings/batch_decode.rs b/python/pecos-rslib/src/fault_tolerance_bindings/batch_decode.rs index 5e7c5989a..8c50255d7 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings/batch_decode.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings/batch_decode.rs @@ -135,7 +135,7 @@ fn parallel( (0..workers) .into_par_iter() .map(|_| { - // Build even when this worker wins no chunk: an explicit + // Build even when this worker wins no chunk: the planned // worker count means exactly that many decoder instances. let mut decoder = spec.build(model).map_err(BatchExecutionError::Build)?; preflight_dimensions(batch, decoder.as_ref())?; diff --git a/python/pecos-rslib/tests/test_decoder_providers.py b/python/pecos-rslib/tests/test_decoder_providers.py index a845dd9f0..647c9b066 100644 --- a/python/pecos-rslib/tests/test_decoder_providers.py +++ b/python/pecos-rslib/tests/test_decoder_providers.py @@ -33,7 +33,7 @@ def test_provider_batch_and_sampler_paths(workers): result = batch.decode(DEM, Provider(), workers=workers, predictions=True, timing=True) assert result.predictions == [0, 1 << 70] assert result.num_errors == 0 - assert result.workers_used == workers + assert result.workers_used == min(workers, batch.num_shots) assert result.stats.num_timing_samples == 2 sampler = DemSampler.from_dem_string(DEM) result = sampler.decode(DEM, 3073, Provider(), workers=workers, seed=2) @@ -173,6 +173,19 @@ def inspect(manifest_path): inspect(root / "crates/pecos-decoders/Cargo.toml") +def test_oversized_worker_request_builds_one_decoder_per_shot(): + builds = [0] + + class CountingProvider(Provider): + def _pecos_build_decoder(self, dem): + builds[0] += 1 + return Worker() + + result = SampleBatch([[0], [1]], [0, 1 << 70]).decode(DEM, CountingProvider(), workers=64) + assert result.workers_used == 2 + assert builds[0] == 2 + + def test_provider_workers_decode_concurrently(): import threading diff --git a/python/pecos-rslib/tests/test_sample_batch_decode.py b/python/pecos-rslib/tests/test_sample_batch_decode.py index e0f327d0d..23f260098 100644 --- a/python/pecos-rslib/tests/test_sample_batch_decode.py +++ b/python/pecos-rslib/tests/test_sample_batch_decode.py @@ -188,6 +188,17 @@ def test_explicit_and_auto_worker_contracts() -> None: assert _batch().decode(DEM, spec).execution_path == "sequential" +def test_explicit_workers_are_bounded_by_the_shot_count() -> None: + # A worker beyond one per shot could only idle, so the pool never grows past the batch. + batch = _batch(2) + result = batch.decode(DEM, tesseract(preset="fast"), workers=64) + assert result.execution_path == "parallel" + assert result.workers_used == 2 + assert result.num_errors == batch.decode(DEM, tesseract(preset="fast"), workers=1).num_errors + empty = DemSampler.from_dem_string(DEM).sample_batch(0, seed=1).decode(DEM, tesseract(preset="fast"), workers=64) + assert (empty.execution_path, empty.workers_used, empty.num_shots) == ("parallel", 1, 0) + + def test_wall_clock_limited_mwpf_requires_explicit_parallel_opt_in() -> None: spec = mwpf(timeout=0.5) batch = _batch() From 2663d865eea22263f8cb575720b080d6c0bfc3a5 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Fri, 18 Sep 2026 00:16:16 -0600 Subject: [PATCH 8/9] Take provider model dimensions from the worker instead of a second DEM parse, propagate non-protocol errors from the version probe, and bring the experimental decoder docs in line with the batch API --- crates/pecos-decoder-core/src/dem.rs | 7 ++- docs/experimental/decoders.md | 10 +-- docs/workflows/guppy-dem-decoding.md | 5 +- exp/pecos-bp-trellis/src/lib.rs | 2 +- python/pecos-rslib-exp/src/decoder_specs.rs | 62 ++++++++----------- .../tests/test_frontier_batch_decode.py | 8 +++ python/pecos-rslib/src/batch_decoder_spec.rs | 59 +++++++++++------- .../src/fault_tolerance_bindings.rs | 3 +- .../tests/test_decoder_providers.py | 44 +++++++++---- .../src/pecos/decoders/__init__.py | 8 ++- 10 files changed, 125 insertions(+), 83 deletions(-) diff --git a/crates/pecos-decoder-core/src/dem.rs b/crates/pecos-decoder-core/src/dem.rs index 6d09ebd06..b647690db 100644 --- a/crates/pecos-decoder-core/src/dem.rs +++ b/crates/pecos-decoder-core/src/dem.rs @@ -158,8 +158,8 @@ pub mod utils { // ids; `logical_observable` declares deterministic logicals that // Stim emits with no flipping mechanism but still count. "error" | "logical_observable" => { - // A decomposed mechanism may join components with `^` and no - // surrounding spaces, which the other parsers accept. + // Split on `^` so an unspaced separator counts the same targets + // here as in the other parsers, which tokenize that way. for part in parts[1..].iter().flat_map(|part| part.split('^')) { if let Some(d_str) = part.strip_prefix('D') { if let Ok(d) = d_str.parse::() { @@ -1398,7 +1398,8 @@ mod tests { #[test] fn test_metadata_counts_targets_joined_by_an_unspaced_separator() { - // The highest detector and observable sit only behind an unspaced `^`. + // Parity with `SparseDem`, whose tokenizer splits on `^`: the highest + // detector and observable sit only behind an unspaced separator. let dem = "error(0.1) D0 L0^D3 L5\nerror(0.2) D1 L0\n"; let sparse = SparseDem::from_dem_str(dem).unwrap(); assert_eq!((sparse.num_detectors, sparse.num_observables), (4, 6)); diff --git a/docs/experimental/decoders.md b/docs/experimental/decoders.md index ddf446728..c9f6c32ef 100644 --- a/docs/experimental/decoders.md +++ b/docs/experimental/decoders.md @@ -178,7 +178,9 @@ rest — it has no circuit to re-derive provenance from. ## What is not here yet -These decoders are not reachable through `DecoderSpec` / `pecos.decoders`, so -they cannot be passed to `SampleBatch.decode(...)` or `DemSampler.decode(...)` -and do not participate in unified execution planning, batching, or timing. Use -their own `decode_syndrome` / `decode_batch` methods directly. +`frontier()` and `bp_trellis()` are not `DecoderSpec` values: `DecoderSpec.parse` +does not know their names and the composite factories such as `windowed(...)` +cannot wrap them. They run through `SampleBatch.decode(...)` and +`DemSampler.decode(...)` only, and those return predictions and aggregate +scores; per-shot confidence, pruning status and retry telemetry still come from +the direct `decode_syndrome` / `decode_batch` methods. diff --git a/docs/workflows/guppy-dem-decoding.md b/docs/workflows/guppy-dem-decoding.md index f77745ccb..70333907a 100644 --- a/docs/workflows/guppy-dem-decoding.md +++ b/docs/workflows/guppy-dem-decoding.md @@ -359,7 +359,7 @@ including hyperedges. The example decodes shots across four Rust worker threads; worker owns a decoder, so memory use grows with the worker count and frontier width `k`. Frontier remains experimental; pruning can make its answers approximate. -`bp_trellis()` uses PECOS’s native BP-guided trellis decoder on the same raw +`bp_trellis()` uses PECOS's native BP-guided trellis decoder on the same raw DEM. Its defaults are `k=8`, `delta=100.0`, `score_alpha=0.8`, `bp_score_iterations=5`, `merge_indistinguishable=True`, `ordering="deadline"`, and `escalation_ks=None`. The example opts into retry widths `[32, 128]`: these @@ -371,8 +371,7 @@ With `workers=None`, PECOS automatically selects a native-batch, sequential, or parallel path based on the decoder and batch size. Pass `workers=N` to request that many workers; a batch with fewer shots than `N` gets one worker per shot (at least one), and `result.workers_used` reports the size of the pool that ran. -`result.execution_path` -reports which path ran. Request +`result.execution_path` reports which path ran. Request `predictions=True` when you also need each shot's arbitrary-precision observable mask; the default avoids materializing them in Python. diff --git a/exp/pecos-bp-trellis/src/lib.rs b/exp/pecos-bp-trellis/src/lib.rs index ef3e04b7d..2d9aec1b0 100644 --- a/exp/pecos-bp-trellis/src/lib.rs +++ b/exp/pecos-bp-trellis/src/lib.rs @@ -66,7 +66,7 @@ pub struct BpTrellisConfig { } impl BpTrellisConfig { - /// Validate every rung without building a detector error model. + /// Validate the base configuration and every rung without a detector error model. /// /// # Errors /// diff --git a/python/pecos-rslib-exp/src/decoder_specs.rs b/python/pecos-rslib-exp/src/decoder_specs.rs index 79e03d025..0b425e429 100644 --- a/python/pecos-rslib-exp/src/decoder_specs.rs +++ b/python/pecos-rslib-exp/src/decoder_specs.rs @@ -3,7 +3,7 @@ use crate::bp_trellis_bindings::{TrellisOrderArgument, parse_ordering}; use crate::frontier_bindings::{ColumnOrderArgument, parse_column_order, parse_metric_mode}; use pecos_bp_trellis::BpTrellisConfig; use pecos_decoder_core::{DecoderError, ObservableDecoder}; -use pecos_frontier::{FrontierConfig, TrellisOrdering}; +use pecos_frontier::{FrontierConfig, SparseDem, TrellisOrdering}; use pyo3::exceptions::{PyRuntimeError, PyValueError}; use pyo3::prelude::*; use pyo3::types::PyModule; @@ -78,20 +78,28 @@ impl PyExperimentalDecoderSpec { } /// Internal batch protocol: construct an independent native worker. fn _pecos_build_decoder(&self, py: Python<'_>, dem: &str) -> PyResult { - let (inner, num_detectors) = py - .detach(|| { - let inner = match &self.inner { - ExperimentalSpec::Frontier(c, ordering) => build_frontier(dem, c, ordering), - ExperimentalSpec::BpTrellis(c) => build_bp_trellis(dem, c), - }?; - let num_detectors = pecos_decoder_core::dem::utils::parse_dem_metadata(dem)?.0; - Ok::<_, DecoderError>((inner, num_detectors)) + py.detach(|| { + // The engine and the reported dimensions come from the same parse. + let dem = SparseDem::from_dem_str(dem)?; + let inner: Box = match &self.inner { + ExperimentalSpec::Frontier(c, ordering) => { + let mut config = c.clone(); + config.column_order = ordering.resolve(&dem)?; + Box::new(pecos_frontier::FrontierDecoder::from_sparse_dem( + &dem, config, + )?) + } + ExperimentalSpec::BpTrellis(c) => Box::new( + pecos_bp_trellis::BpTrellisDecoder::from_sparse_dem(&dem, c.clone())?, + ), + }; + Ok::<_, DecoderError>(PyExperimentalWorker { + inner: Mutex::new(inner), + num_detectors: dem.num_detectors, + num_observables: dem.num_observables, }) - .map_err(|e| PyRuntimeError::new_err(e.to_string()))?; - Ok(PyExperimentalWorker { - inner: Mutex::new(inner), - num_detectors, }) + .map_err(|e| PyRuntimeError::new_err(e.to_string())) } } @@ -100,6 +108,8 @@ struct PyExperimentalWorker { inner: Mutex>, #[pyo3(get)] num_detectors: usize, + #[pyo3(get)] + num_observables: usize, } #[pymethods] impl PyExperimentalWorker { @@ -123,36 +133,14 @@ fn finish_repr(family: &str, args: Vec) -> String { } pub(crate) fn register(module: &Bound<'_, PyModule>) -> PyResult<()> { module.add_class::()?; + module.add_class::()?; module.add_function(wrap_pyfunction!(frontier, module)?)?; module.add_function(wrap_pyfunction!(bp_trellis, module)?)?; Ok(()) } -fn build_frontier( - dem: &str, - config: &FrontierConfig, - ordering: &TrellisOrdering, -) -> Result, DecoderError> { - let dem = pecos_frontier::SparseDem::from_dem_str(dem)?; - let mut config = config.clone(); - config.column_order = ordering.resolve(&dem)?; - Ok(Box::new(pecos_frontier::FrontierDecoder::from_sparse_dem( - &dem, config, - )?)) -} - -fn build_bp_trellis( - dem: &str, - config: &BpTrellisConfig, -) -> Result, DecoderError> { - Ok(Box::new(pecos_bp_trellis::BpTrellisDecoder::from_dem_str( - dem, - config.clone(), - )?)) -} - /// Native Rust Frontier decoder for raw DEMs, including hyperedges. -/// Batch decoding supports independent Rust workers. Pruning makes predictions +/// Batch decoding supports independent Rust workers. Pruning can make predictions /// approximate; use pecos_rslib_exp.FrontierDecoder for per-shot confidence data. #[pyfunction] #[pyo3(signature = (*, k=64, delta=50.0, score_alpha=0.8, bp_score_iterations=0, column_order=ColumnOrderArgument::default(), merge_indistinguishable=false, metric_mode="logsumexp_float", int_metric_scale=1024), diff --git a/python/pecos-rslib-exp/tests/test_frontier_batch_decode.py b/python/pecos-rslib-exp/tests/test_frontier_batch_decode.py index 3c81e9a49..fe911c4a8 100644 --- a/python/pecos-rslib-exp/tests/test_frontier_batch_decode.py +++ b/python/pecos-rslib-exp/tests/test_frontier_batch_decode.py @@ -132,6 +132,14 @@ def test_fused_sampling_matches_sequential_decoding(): assert fused.num_errors == expected.num_errors +def test_batch_dimensions_follow_the_model_the_engine_parses(): + # Both the highest detector and the highest observable sit behind an unspaced separator. + dem = "error(0.1) D0 L0^D3 L5\n" + result = SampleBatch([[1, 0, 0, 1]], [33]).decode(dem, frontier(), workers=1, predictions=True) + assert result.predictions == [33] + assert result.num_errors == 0 + + def test_predictions_match_direct_experimental_binding(): exp = pytest.importorskip("pecos_rslib_exp") rows = [[(i >> j) & 1 for j in range(3)] for i in range(8)] diff --git a/python/pecos-rslib/src/batch_decoder_spec.rs b/python/pecos-rslib/src/batch_decoder_spec.rs index 6e1f9758c..3d6c2dbfd 100644 --- a/python/pecos-rslib/src/batch_decoder_spec.rs +++ b/python/pecos-rslib/src/batch_decoder_spec.rs @@ -14,20 +14,19 @@ //! wall-clock-dependent one, exactly as for built-in specifications. //! - `_pecos_build_decoder(dem: str)`, called once per decoder the batch planner //! needs, possibly from several threads at once, and returning a worker object. -//! The DEM must be flat (no `repeat` or `shift_detectors`), as for built-in -//! specifications; the bridge reads its observable count before calling the -//! provider. An exception the provider raises reaches the caller of `decode` -//! unchanged. +//! An exception the provider raises reaches the caller of `decode` unchanged. //! //! A worker object has: //! -//! - `num_detectors: int`, checked against the batch before any shot is decoded. +//! - `num_detectors: int` and `num_observables: int`, the dimensions of the model +//! the worker was built from. Detectors are checked against the batch before any +//! shot is decoded. //! - `_pecos_decode_obs(syndrome: bytes) -> Sequence[int]`. `syndrome` holds one //! byte per detector, each 0 or 1. The result is the predicted observable mask as -//! little-endian 64-bit words, lowest observables first, with no observable at or -//! above the count the DEM declares. A worker is only ever called from the one -//! thread that built it, one shot at a time. An exception it raises is reported as -//! a decode failure naming the shot. +//! little-endian 64-bit words, lowest observables first, no more words than +//! `num_observables` needs and no observable at or above it. A worker is only +//! ever called from the one thread that built it, one shot at a time. An +//! exception it raises is reported as a decode failure naming the shot. //! //! The bridge holds the GIL only around those calls. A provider that does native //! work should release it inside them, or its workers run one at a time. @@ -62,13 +61,26 @@ impl BatchDecoderSpec { if let Ok(spec) = decoder.extract::>() { return Ok(Self::Builtin(spec.inner.clone())); } - let version = decoder - .getattr("_pecos_decoder_api_version") - .and_then(|v| v.extract::()); - if !matches!(version, Ok(1)) { - return Err(PyTypeError::new_err( + let py = decoder.py(); + let not_a_provider = || { + PyTypeError::new_err( "decoder must be a DecoderSpec, legacy decoder string, or a version-1 decoder provider", - )); + ) + }; + // An object without the version member is simply not a provider; any + // other failure while reading it belongs to the caller. + let version = match decoder.getattr("_pecos_decoder_api_version") { + Ok(version) => version.extract::().ok(), + Err(error) + if error.is_instance_of::(py) + || error.is_instance_of::(py) => + { + None + } + Err(error) => return Err(error), + }; + if version != Some(1) { + return Err(not_a_provider()); } let members = || -> PyResult { if !decoder.getattr("_pecos_build_decoder")?.is_callable() { @@ -82,7 +94,6 @@ impl BatchDecoderSpec { }) }; let traits = members().map_err(|cause| { - let py = decoder.py(); // Only a missing or wrong-typed member is a protocol error; anything // else a provider's own attribute access raises belongs to the caller. if !(cause.is_instance_of::(py) @@ -134,12 +145,10 @@ impl BatchDecoderSpec { ), )); }; - let num_observables = pecos_decoder_core::dem::utils::parse_dem_metadata(dem) - .map_err(DecoderBuildError::Decoder)? - .1; Python::attach(|py| -> PyResult> { let worker = spec.bind(py).call_method1("_pecos_build_decoder", (dem,))?; let num_detectors = worker.getattr("num_detectors")?.extract::()?; + let num_observables = worker.getattr("num_observables")?.extract::()?; if !worker.getattr("_pecos_decode_obs")?.is_callable() { return Err(PyTypeError::new_err( "decoder provider _pecos_decode_obs must be callable", @@ -177,13 +186,21 @@ impl ObservableDecoder for ProviderDecoder { }) .map_err(|e| DecoderError::DecodingFailed(e.to_string()))?; // The provider is outside this extension, so its answer is checked - // against the model it was built from rather than trusted. + // against the model it reports rather than trusted. + if mask.words().len() > self.num_observables.div_ceil(64) { + return Err(DecoderError::DecodingFailed(format!( + "decoder provider returned {} observable words, but {} observables need at most {}", + mask.words().len(), + self.num_observables, + self.num_observables.div_ceil(64) + ))); + } if let Some(observable) = mask .iter_set_bits() .find(|&bit| bit >= self.num_observables) { return Err(DecoderError::DecodingFailed(format!( - "decoder provider predicted observable {observable}, but the DEM declares {} observables", + "decoder provider predicted observable {observable}, but its model has {} observables", self.num_observables ))); } diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index c3f155232..f1f520fc2 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -4596,7 +4596,8 @@ impl PyDemSampler { /// decoder: A typed `DecoderSpec`, legacy decoder string, or optional decoder-provider specification. /// seed: Optional sampling seed. The resolved seed is returned as /// `sampling_seed_used` and can replay the run. - /// workers: Optional exact worker count. + /// workers: Optional worker count, bounded by one per 1024-shot + /// sampling chunk and reported as `workers_used`. /// predictions: Retain predictions in absolute shot order. /// timing: Retain decode-call timings. Sampling time is excluded from /// individual samples but included in `wall_elapsed`. diff --git a/python/pecos-rslib/tests/test_decoder_providers.py b/python/pecos-rslib/tests/test_decoder_providers.py index 647c9b066..ad927057d 100644 --- a/python/pecos-rslib/tests/test_decoder_providers.py +++ b/python/pecos-rslib/tests/test_decoder_providers.py @@ -19,6 +19,7 @@ def _pecos_build_decoder(self, dem): class Worker: num_detectors = 1 + num_observables = 71 def _pecos_decode_obs(self, syndrome): return [0, (1 << 6) if syndrome[0] else 0] @@ -83,11 +84,12 @@ def history_dependent(self): [ (DEM, [0, 1 << 7], 71, 71), ("error(0.1) D0 L0\n", [1 << 5], 5, 1), - ("error(0.1) D0\n", [1], 0, 0), ], ) def test_prediction_outside_the_model_observables_is_an_error(sampler, dem, words, observable, declared): class WideWorker(Worker): + num_observables = declared + def _pecos_decode_obs(self, syndrome): return words @@ -101,25 +103,41 @@ def _pecos_build_decoder(self, dem): else: source = SampleBatch([[0]], [0]) args = (dem, WideProvider()) - message = f"predicted observable {observable}, but the DEM declares {declared} observables" + message = f"predicted observable {observable}, but its model has {declared} observables" with pytest.raises(RuntimeError, match=message): source.decode(*args, workers=1) -def test_prediction_behind_an_unspaced_separator_is_inside_the_model(): - class SeparatorWorker(Worker): - num_detectors = 2 - +@pytest.mark.parametrize( + ("num_observables", "words", "message"), + [ + (71, [0, 0, 0], "returned 3 observable words, but 71 observables need at most 2"), + (0, [1], "returned 1 observable words, but 0 observables need at most 0"), + ], +) +def test_too_many_prediction_words_is_an_error(num_observables, words, message): + class LongWorker(Worker): def _pecos_decode_obs(self, syndrome): - return [1 << 5] + return words - class SeparatorProvider(Provider): + class LongProvider(Provider): def _pecos_build_decoder(self, dem): - return SeparatorWorker() + worker = LongWorker() + worker.num_observables = num_observables + return worker - dem = "error(0.1) D0 L0^D1 L5\n" - result = SampleBatch([[0, 0]], [1 << 5]).decode(dem, SeparatorProvider(), workers=1, predictions=True) - assert result.predictions == [1 << 5] + with pytest.raises(RuntimeError, match=message): + SampleBatch([[0]], [0]).decode(DEM, LongProvider(), workers=1) + + +def test_version_probe_errors_other_than_the_protocol_propagate(): + class LazyProvider: + @property + def _pecos_decoder_api_version(self): + raise ImportError("optional extension missing") + + with pytest.raises(ImportError, match="optional extension missing"): + SampleBatch([[0]], [0]).decode(DEM, LazyProvider()) def test_standard_decoders_import_without_experimental_package(): @@ -225,6 +243,8 @@ def raise_error(): raise error class InvalidWorker: + num_observables = 71 + @property def num_detectors(self): if failure == "num_detectors": diff --git a/python/quantum-pecos/src/pecos/decoders/__init__.py b/python/quantum-pecos/src/pecos/decoders/__init__.py index c5b1daba4..0d52d2f00 100644 --- a/python/quantum-pecos/src/pecos/decoders/__init__.py +++ b/python/quantum-pecos/src/pecos/decoders/__init__.py @@ -124,6 +124,12 @@ def __getattr__(name: str) -> object: raise message = f"{name} requires the optional pecos-rslib-exp package; install it to use experimental decoders" raise ImportError(message) from exc - return getattr(experimental, name) + try: + return getattr(experimental, name) + except AttributeError as exc: + message = ( + f"the installed pecos-rslib-exp package does not provide {name}; upgrade it to match quantum-pecos" + ) + raise ImportError(message) from exc message = f"module {__name__!r} has no attribute {name!r}" raise AttributeError(message) From 83b982c7b8fa613d9b58cb42db68f2066225c0fa Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Fri, 18 Sep 2026 10:47:25 -0600 Subject: [PATCH 9/9] Seed the two statistical random-pattern tests so a legitimate 3-sigma draw cannot fail CI --- python/pecos-rslib/tests/test_random_edge_cases.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/python/pecos-rslib/tests/test_random_edge_cases.py b/python/pecos-rslib/tests/test_random_edge_cases.py index cd9790533..f036b2c8b 100644 --- a/python/pecos-rslib/tests/test_random_edge_cases.py +++ b/python/pecos-rslib/tests/test_random_edge_cases.py @@ -121,6 +121,9 @@ def test_error_generation_pattern(self) -> None: n_qubits = 1000 error_rate = 0.01 + # A fixed seed keeps the 3-sigma bound below from failing on the + # roughly one draw in 370 that legitimately falls outside it. + pc.random.seed(7) random_vals = pc.random.random(n_qubits) errors = random_vals < error_rate @@ -148,6 +151,8 @@ def test_measurement_outcome_pattern(self) -> None: # Simulate: outcomes = np.random.randint(0, 2, n_measurements) n_measurements = 1000 + # Use a fixed seed for deterministic test behavior + pc.random.seed(11) outcomes = pc.random.randint(0, 2, n_measurements) assert len(outcomes) == n_measurements