diff --git a/Cargo.lock b/Cargo.lock index ac79667da..af6653b86 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4751,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-decoder-core/src/dem.rs b/crates/pecos-decoder-core/src/dem.rs index 5eaa59004..b647690db 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..] { + // 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::() { max_detector = Some(max_detector.map_or(d, |m: usize| m.max(d))); @@ -1394,6 +1396,16 @@ mod tests { ); } + #[test] + fn test_metadata_counts_targets_joined_by_an_unspaced_separator() { + // 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)); + 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/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/experimental/decoders.md b/docs/experimental/decoders.md index d1bfb1eb6..c9f6c32ef 100644 --- a/docs/experimental/decoders.md +++ b/docs/experimental/decoders.md @@ -1,7 +1,11 @@ # 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_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: @@ -174,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/user-guide/decoders.md b/docs/user-guide/decoders.md index d40bc0b4e..1b0e29a1b 100644 --- a/docs/user-guide/decoders.md +++ b/docs/user-guide/decoders.md @@ -49,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 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 `decode_from_defects(...)` for sparse detector indices. The BP/LDPC classes' @@ -331,6 +333,83 @@ match decoder.decode(&syndrome.view()) { - Use multiple threads for batch decoding - Consider memory layout for cache efficiency +## 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_rslib_exp 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 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 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`, +`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_rslib_exp 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 +429,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/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 19b929228..70333907a 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, @@ -315,26 +316,62 @@ 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, +) -pymatching_errors = pymatching_result.num_errors -tesseract_errors = tesseract_result.num_errors -bp_osd_errors = bp_osd_result.num_errors +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 -shots = batch.num_shots -assert 0 < pymatching_errors < shots -assert 0 < tesseract_errors < shots -assert 0 < bp_osd_errors < shots +decoder_results = { + "pymatching": pymatching_result, + "tesseract": tesseract_result, + "bp_osd": bp_osd_result, + "frontier": frontier_result, + "bp_trellis": bp_trellis_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"pymatching execution path: {pymatching_result.execution_path}") +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}") ``` +`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; +`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 +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. @@ -354,28 +391,33 @@ 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 - -print(f"simulated shots, pymatching: {sim_errors}/{len(sim_shots)}") +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("simulated shots") +for name, result in sim_results.items(): + print(f"{name:11} {result.num_errors:5}/{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_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(...)`. 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 +435,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] @@ -410,7 +452,6 @@ 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") ``` diff --git a/exp/pecos-bp-trellis/README.md b/exp/pecos-bp-trellis/README.md index d079c41d4..8c98b6cc0 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 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-bp-trellis/src/lib.rs b/exp/pecos-bp-trellis/src/lib.rs index 4313cd7d8..2d9aec1b0 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,47 @@ pub struct BpTrellisConfig { pub escalation_ks: Vec, } +impl BpTrellisConfig { + /// Validate the base configuration and every rung without 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 (rung, &k) in self.escalation_ks.iter().enumerate() { + config.k = k; + config.validate().map_err(|error| match error { + DecoderError::InvalidConfiguration(message) => { + DecoderError::InvalidConfiguration(format!("escalation_ks[{rung}]: {message}")) + } + other => other, + })?; + } + 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 +143,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 +153,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/README.md b/exp/pecos-frontier/README.md index 398bf41fc..f6870d7e7 100644 --- a/exp/pecos-frontier/README.md +++ b/exp/pecos-frontier/README.md @@ -6,9 +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. 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. 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/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/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/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 new file mode 100644 index 000000000..0b425e429 --- /dev/null +++ b/python/pecos-rslib-exp/src/decoder_specs.rs @@ -0,0 +1,287 @@ +//! Optional experimental decoder factories and native workers for batch decoding. +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, SparseDem, TrellisOrdering}; +use pyo3::exceptions::{PyRuntimeError, PyValueError}; +use pyo3::prelude::*; +use pyo3::types::PyModule; +use std::sync::Mutex; + +#[derive(Clone, Debug, PartialEq)] +enum ExperimentalSpec { + Frontier(FrontierConfig, TrellisOrdering), + BpTrellis(BpTrellisConfig), +} + +#[pyclass( + name = "ExperimentalDecoderSpec", + module = "pecos_rslib_exp", + frozen, + from_py_object +)] +#[derive(Clone)] +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, ordering) => frontier_repr(c, ordering), + 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()) + } + // 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(); + 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 { + 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())) + } +} + +#[pyclass(name = "ExperimentalDecoderWorker", module = "pecos_rslib_exp")] +struct PyExperimentalWorker { + inner: Mutex>, + #[pyo3(get)] + num_detectors: usize, + #[pyo3(get)] + num_observables: 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(", ")) +} +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(()) +} + +/// Native Rust Frontier decoder for raw DEMs, including hyperedges. +/// 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), + 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: ColumnOrderArgument, + merge_indistinguishable: bool, + metric_mode: &str, + int_metric_scale: i32, +) -> PyResult { + 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( + config, + parse_column_order(column_order)?, + ))) +} + +fn frontier_repr(config: &FrontierConfig, ordering: &TrellisOrdering) -> String { + 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 != default.bp_score_iterations { + args.push(format!( + "bp_score_iterations={}", + config.bp_score_iterations + )); + } + 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()); + } + TrellisOrdering::Explicit(order) => args.push(format!("column_order={order:?}")), + } + if config.merge_indistinguishable != default.merge_indistinguishable { + args.push("merge_indistinguishable=True".to_owned()); + } + if config.metric_mode != default.metric_mode { + 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) +} + +/// 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=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, + delta: f64, + score_alpha: f64, + bp_score_iterations: usize, + merge_indistinguishable: bool, + ordering: TrellisOrderArgument, + escalation_ks: Option>, +) -> PyResult { + 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( + config, + ))) +} + +fn bp_trellis_repr(config: &BpTrellisConfig) -> String { + 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 != default.merge_indistinguishable { + args.push("merge_indistinguishable=False".to_owned()); + } + match &config.ordering { + 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 != 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/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-exp/tests/test_bp_trellis_batch_decode.py b/python/pecos-rslib-exp/tests/test_bp_trellis_batch_decode.py new file mode 100644 index 000000000..7a6d9d0bb --- /dev/null +++ b/python/pecos-rslib-exp/tests/test_bp_trellis_batch_decode.py @@ -0,0 +1,159 @@ +# Copyright 2026 The PECOS Developers + +"""BpTrellis integration with typed specs and native parallel batch execution.""" + +import math +import random + +import pytest +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() + 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"}, + {"ordering": None}, + {"escalation_ks": [0]}, + {"escalation_ks": [16, 0]}, + ], +) +def test_invalid_options(options): + 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) + if "escalation_ks" in options: + assert f"escalation_ks[{options['escalation_ks'].index(0)}]" in str(factory_error.value) + + +@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) + 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="unexplainable"): + 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 = {"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="unexplainable"): + 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-exp/tests/test_decoder_spec_gil.py b/python/pecos-rslib-exp/tests/test_decoder_spec_gil.py new file mode 100644 index 000000000..d531bbbac --- /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 a quarter 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 / 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") + + +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 new file mode 100644 index 000000000..fe911c4a8 --- /dev/null +++ b/python/pecos-rslib-exp/tests/test_frontier_batch_decode.py @@ -0,0 +1,156 @@ +# Copyright 2026 The PECOS Developers + +"""Frontier integration with typed specs and native parallel batch execution.""" + +import math +import random + +import pytest +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() + 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"}, + {"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|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( + "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) + 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="unexplainable"): + 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_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)] + 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/src/batch_decoder_spec.rs b/python/pecos-rslib/src/batch_decoder_spec.rs new file mode 100644 index 000000000..3d6c2dbfd --- /dev/null +++ b/python/pecos-rslib/src/batch_decoder_spec.rs @@ -0,0 +1,209 @@ +//! 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 the provider raises reaches the caller of `decode` unchanged. +//! +//! A worker object has: +//! +//! - `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, 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. + +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::{PyAttributeError, PyTypeError}; +use pyo3::prelude::*; +use pyo3::types::{PyBytes, PyString}; + +pub(crate) enum BatchDecoderSpec { + Builtin(DecoderSpec), + Provider { + spec: Py, + traits: ExecutionTraits, + }, +} + +pub(crate) enum DecoderBuildError { + Decoder(DecoderError), + Python(PyErr), +} + +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 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() { + 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| { + // 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(py, Some(cause)); + error + })?; + Ok(Self::Provider { + spec: decoder.clone().unbind(), + traits, + }) + } + + 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, DecoderBuildError> { + match self { + Self::Builtin(spec) => spec.build(model).map_err(DecoderBuildError::Decoder), + Self::Provider { spec, .. } => { + let DecodeModel::SingleDem(dem) = model else { + return Err(DecoderBuildError::Decoder( + 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::()?; + 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", + )); + } + Ok(Box::new(ProviderDecoder { + worker: worker.unbind(), + num_detectors, + num_observables, + })) + }) + .map_err(DecoderBuildError::Python) + } + } + } +} + +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 { + let mask = 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()))?; + // The provider is outside this extension, so its answer is checked + // 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 its model has {} observables", + self.num_observables + ))); + } + Ok(mask) + } +} diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index ccb99c02d..f1f520fc2 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -3717,14 +3717,15 @@ 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 /// 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, @@ -3749,16 +3750,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| { @@ -3775,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, @@ -3786,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)?; @@ -4592,10 +4593,11 @@ 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. + /// 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`. @@ -4627,16 +4629,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| { 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..8c50255d7 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 pecos_decoder_core::DecoderError; -use pecos_decoder_core::obs_mask::ObsMask; +use crate::batch_decoder_spec::BatchDecoderSpec as DecoderSpec; +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 pecos_decoders::{DecodeModel, DecoderSpec}; use pyo3::exceptions::{PyRuntimeError, PyValueError}; use pyo3::prelude::*; use rayon::prelude::*; @@ -18,7 +19,7 @@ pub(super) struct BatchExecutionOutput { } pub(super) enum BatchExecutionError { - Build(DecoderError), + Build(DecoderBuildError), Dimension { batch_detectors: usize, decoder_detectors: usize, @@ -34,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::Decoder(error)) => decoder_build_error_to_py(error), + Self::Build(DecoderBuildError::Python(error)) => error, Self::Dimension { batch_detectors, decoder_detectors, @@ -133,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/src/fault_tolerance_bindings/sampler_decode.rs b/python/pecos-rslib/src/fault_tolerance_bindings/sampler_decode.rs index cb13c4879..ca82ed813 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,15 @@ 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; 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::*; @@ -297,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 @@ -312,9 +314,16 @@ fn parallel( seed, options, ), - Err(message) => Err(BatchExecutionError::Runtime(format!( - "parallel decoder construction failed: {message}" - ))), + Err(DecoderBuildError::Decoder(error)) => { + Err(BatchExecutionError::Runtime(format!( + "parallel decoder construction failed: {error}" + ))) + } + Err(DecoderBuildError::Python(error)) => { + Err(BatchExecutionError::Build(DecoderBuildError::Python( + pyo3::Python::attach(|py| error.clone_ref(py)), + ))) + } }; IndexedChunk { chunk_index, value } }, 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..ad927057d --- /dev/null +++ b/python/pecos-rslib/tests/test_decoder_providers.py @@ -0,0 +1,348 @@ +"""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 + num_observables = 71 + + 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 == 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) + 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) + 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]) +@pytest.mark.parametrize( + ("dem", "words", "observable", "declared"), + [ + (DEM, [0, 1 << 7], 71, 71), + ("error(0.1) D0 L0\n", [1 << 5], 5, 1), + ], +) +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 + + 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()) + message = f"predicted observable {observable}, but its model has {declared} observables" + with pytest.raises(RuntimeError, match=message): + source.decode(*args, workers=1) + + +@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 words + + class LongProvider(Provider): + def _pecos_build_decoder(self, dem): + worker = LongWorker() + worker.num_observables = num_observables + return worker + + 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(): + 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(): + """crates/pecos-decoders must not transitively path-depend on a publish = false crate.""" + 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") + + +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 + + 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: + num_observables = 71 + + @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) + 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 ( + "_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/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 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() diff --git a/python/quantum-pecos/src/pecos/decoders/__init__.py b/python/quantum-pecos/src/pecos/decoders/__init__.py index 19ce326c0..0d52d2f00 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, @@ -110,3 +112,24 @@ "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 + 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) 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