diff --git a/crates/aleph-py/python/aleph/cudaq.py b/crates/aleph-py/python/aleph/cudaq.py index 0cffde07..e3bd8831 100644 --- a/crates/aleph-py/python/aleph/cudaq.py +++ b/crates/aleph-py/python/aleph/cudaq.py @@ -15,9 +15,10 @@ ones): cudaq keeps ``^``-decomposed hyperedges of a Stim DEM as one column, so pass ``dem_to_matrices(dem)`` instead of the DEM string for ``aleph-mwpm`` / ``aleph-union-find*``. Matching decoders (``aleph-mwpm``, ``aleph-union-find``, ``aleph-union-find-weighted``) also -require every prior (``error_rate_vec`` entry, or the scalar ``error_rate``) to be <= 0.5 -(matching on a negative-weight edge is undefined here); BP-family decoders (``aleph-bp``, -``aleph-bp-osd``, ``aleph-relay-bp``, ``aleph-relay-bp-osd``) have no such restriction. +require every prior (``error_rate_vec`` entry, or the scalar ``error_rate``) to be finite +and in [0, 0.5]. Unsupported priors raise at construction with the offending column; +inputs are never clamped. BP-family decoders (``aleph-bp``, ``aleph-bp-osd``, +``aleph-relay-bp``, ``aleph-relay-bp-osd``) accept priors in [0, 1]. """ import cudaq_qec as _qec # the only cudaq import in aleph; ImportError means "install the extra" import numpy as np @@ -27,8 +28,6 @@ NAMES = tuple(_aq.decoder_names()) -_MATCHING = {"mwpm", "union-find", "union-find-weighted"} - __all__ = ["NAMES", "DECODERS", "dem_to_matrices"] @@ -71,12 +70,8 @@ def __init__(self, H, O=None, error_rate_vec=None, error_rate=None, **params): _qec.Decoder.__init__(self, H) n = H.shape[1] rates = _rates(n, error_rate_vec, error_rate) - if name in _MATCHING and rates.size: - j = int(np.argmax(rates)) - if rates[j] > 0.5: - raise ValueError( - f"aleph-{name}: priors must be <= 0.5 for a matching decoder " - f"(max {rates[j]:.3g} at column {j}); clamp or use a BP-family decoder") + # Rust's MatchingGraph validates matching priors in the common + # construction path; keep the same policy and errors as aleph.qec. try: dem = _aq.dem_from_matrices(H, O, rates) self._inner = _aq.Decoder(dem, name, **params) diff --git a/crates/aleph-py/python/aleph/qec.py b/crates/aleph-py/python/aleph/qec.py index ae748abc..2556fc52 100644 --- a/crates/aleph-py/python/aleph/qec.py +++ b/crates/aleph-py/python/aleph/qec.py @@ -11,6 +11,11 @@ Returned error columns index ``split``, in mechanism/component order. Keep the original DEM for sampling: splitting makes formerly correlated parts independent. BP-family decoders continue to use the original, parity-reduced mechanism columns. + +Matching decoders require finite priors in [0, 0.5] and raise ValueError at construction +for unsupported priors (with the offending column). Zero is allowed and omitted from +the graph; 0.5 is allowed with zero edge weight. Priors are never clamped. BP-family +models retain their [0, 1] prior range. """ import numpy as np diff --git a/crates/aleph-qec/src/error.rs b/crates/aleph-qec/src/error.rs index 7852a960..9e6e9d1c 100644 --- a/crates/aleph-qec/src/error.rs +++ b/crates/aleph-qec/src/error.rs @@ -4,6 +4,15 @@ /// Errors produced by `aleph-qec`. #[derive(Debug, thiserror::Error)] pub enum Error { + /// Matching decoders require finite per-mechanism priors in [0, 0.5]. + #[error("matching prior at column {column} must be finite and in [0, 0.5], got {prob}")] + InvalidMatchingPrior { + /// Zero-based column in the DEM used to build the graph (split columns if split first). + column: usize, + /// Rejected input probability, retained without clamping. + prob: f64, + }, + /// The experiment's sampling/decode batch limit must be positive and fit in u64. #[error("DEM experiment batch size must be positive and fit in u64")] InvalidExperimentBatchSize, diff --git a/crates/aleph-qec/src/matching.rs b/crates/aleph-qec/src/matching.rs index 9a1234b3..78c5e3d2 100644 --- a/crates/aleph-qec/src/matching.rs +++ b/crates/aleph-qec/src/matching.rs @@ -53,7 +53,7 @@ pub struct MatchingEdge { pub a: NodeId, /// Higher-indexed endpoint (possibly the boundary node). pub b: NodeId, - /// Combined firing probability of this edge, in `(0, 1)`. + /// Combined firing probability of this edge, in `(0, 0.5]`. pub prob: f64, /// Edge weight `ln((1 - prob) / prob)`. Non-negative when `prob ≤ 0.5`. pub weight: f64, @@ -101,7 +101,11 @@ impl MatchingGraph { /// one edge per component. /// /// # Errors - /// Returns [`Error::NonGraphlike`] if any mechanism flips three or more detectors. + /// Returns [`Error::NonGraphlike`] if any active part flips three or more detectors. + /// Returns [`Error::InvalidMatchingPrior`] for any non-finite prior or prior outside + /// `[0, 0.5]`, including empty/purely logical columns and priors that would otherwise + /// disappear after merging. Priors are never clamped. Zero-probability mechanisms + /// are omitted; `0.5` is supported and produces zero-weight edges. pub fn from_dem(dem: &DetectorErrorModel) -> Result { let boundary = dem.detectors; @@ -113,11 +117,16 @@ impl MatchingGraph { let mut order: Vec<(EdgeKey, u32)> = Vec::new(); for (j, e) in dem.errors.iter().enumerate() { - // A non-positive (or NaN) probability never fires; `!(p > 0.0)` is true for NaN, so - // this also rejects NaN per the IEEE-754 discipline in CLAUDE.md / ADR 0006. A - // probability of 1.0 (or more) is a deterministic fault with `ln(0) = -inf` weight, - // which has no place in a matching; skip it too. - if !(e.prob > 0.0 && e.prob < 1.0) { + // Validate source columns before skipping/merging: an inadmissible prior + // must not disappear in an inactive edge or XOR-combine into an allowed one. + // Check finiteness explicitly before comparisons (ADR 0006). + if !e.prob.is_finite() || !(0.0..=0.5).contains(&e.prob) { + return Err(Error::InvalidMatchingPrior { + column: j, + prob: e.prob, + }); + } + if e.prob == 0.0 { continue; } @@ -229,9 +238,17 @@ impl MatchingGraph { /// Edge weight `ln((1 - p) / p)`. /// /// For `p ∈ (0, 0.5]` this is `≥ 0`; smaller `p` ⇒ larger weight ⇒ a less-likely (more costly) -/// edge for the matching to use. Caller guarantees `p ∈ (0, 1)`. +/// edge for the matching to use. Caller guarantees finite `p ∈ (0, 0.5]`. fn edge_weight(p: f64) -> f64 { - ((1.0 - p) / p).ln() + let odds = (1.0 - p) / p; + if odds.is_finite() { + // Preserve existing weights/quantization for ordinary supported priors. + odds.ln() + } else { + // A positive subnormal prior can overflow the ratio although its log is + // finite. An infinite weight would overflow sparse matching's integer scale. + (1.0 - p).ln() - p.ln() + } } /// Combine two independent firing probabilities under XOR: `p₁ + p₂ − 2p₁p₂` — the probability @@ -385,7 +402,7 @@ mod tests { } #[test] - fn zero_and_one_probabilities_are_skipped() { + fn zero_probability_is_skipped_without_renumbering_columns() { let dem = DetectorErrorModel { detectors: 2, observables: 0, @@ -396,12 +413,6 @@ mod tests { obs: vec![], components: vec![], }, - DemError { - prob: 1.0, - dets: vec![1], - obs: vec![], - components: vec![], - }, DemError { prob: 0.1, dets: vec![0, 1], @@ -413,6 +424,8 @@ mod tests { let g = MatchingGraph::from_dem(&dem).unwrap(); assert_eq!(g.edges().len(), 1); assert_eq!((g.edges()[0].a, g.edges()[0].b), (0, 1)); + assert_eq!(g.num_columns(), 2); + assert_eq!(g.edges()[0].column, 1); } #[test] diff --git a/crates/aleph-qec/src/mwpm.rs b/crates/aleph-qec/src/mwpm.rs index 65ef9d2e..7d5d5068 100644 --- a/crates/aleph-qec/src/mwpm.rs +++ b/crates/aleph-qec/src/mwpm.rs @@ -128,6 +128,7 @@ impl MwpmDecoder { /// # Errors /// Propagates [`crate::Error::NonGraphlike`] if the DEM has a hyperedge (matching needs a /// graph-like DEM). + /// Returns [`crate::Error::InvalidMatchingPrior`] for any prior outside finite `[0, 0.5]`. pub fn new(dem: &DetectorErrorModel) -> Result { let graph = MatchingGraph::from_dem(dem)?; Ok(Self::from_graph(&graph)) @@ -142,6 +143,8 @@ impl MwpmDecoder { /// /// # Errors /// Returns [`crate::Error::NonGraphlike`] if an individual part is a hyperedge. + /// Returns [`crate::Error::InvalidMatchingPrior`] for an unsupported prior; its column + /// index refers to the split model. pub fn new_with_split_components(dem: &DetectorErrorModel) -> Result { Self::new(&dem.split_components()) } @@ -216,9 +219,6 @@ impl MwpmDecoder { /// Exceptions: /// - an odd component with no boundary has no perfect matching; like `decode`, one defect /// there is left unmatched, so `H ê` misses that detector; - /// - mechanisms with p > 0.5 have negative weight `ln((1-p)/p)` and are never retraced - /// through (a negative edge makes the shortest-path search ill-posed): a pair whose path - /// needs one is left unmarked unless a non-negative path within the same bound exists; /// - on a DEM with `^`-decomposed mechanisms the estimate is per mechanism, not per part, and /// `H ê = s` does not hold — use [`Self::new_with_split_components`] or construct /// the decoder against [`DetectorErrorModel::split_components`] first. diff --git a/crates/aleph-qec/src/sparse_blossom/retrace.rs b/crates/aleph-qec/src/sparse_blossom/retrace.rs index c4735198..83008688 100644 --- a/crates/aleph-qec/src/sparse_blossom/retrace.rs +++ b/crates/aleph-qec/src/sparse_blossom/retrace.rs @@ -8,7 +8,9 @@ //! //! Negative edges (mechanisms with p > 0.5) are not relaxed: an undirected negative edge is a //! negative 2-cycle, on which a Dijkstra never terminates. A pair whose path needs one is left -//! unmarked (or retraced along a non-negative path, if one fits the bound). +//! unmarked (or retraced along a non-negative path, if one fits the bound). Public matching +//! graph construction rejects these priors; this guard remains defensive for raw internal +//! integer graphs and their regression tests. use std::cmp::Reverse; diff --git a/crates/aleph-qec/src/union_find.rs b/crates/aleph-qec/src/union_find.rs index fdb3e3d9..241a63a5 100644 --- a/crates/aleph-qec/src/union_find.rs +++ b/crates/aleph-qec/src/union_find.rs @@ -126,6 +126,7 @@ impl UnionFindDecoder { /// # Errors /// Propagates [`crate::Error::NonGraphlike`] if the DEM has a hyperedge (Union-Find, like /// MWPM, needs a graph-like DEM). + /// Returns [`crate::Error::InvalidMatchingPrior`] for any prior outside finite `[0, 0.5]`. pub fn new(dem: &DetectorErrorModel) -> Result { let graph = MatchingGraph::from_dem(dem)?; Ok(Self::from_graph(&graph)) @@ -141,6 +142,8 @@ impl UnionFindDecoder { /// /// # Errors /// Returns [`crate::Error::NonGraphlike`] if an individual part is a hyperedge. + /// Returns [`crate::Error::InvalidMatchingPrior`] for an unsupported prior; its column + /// index refers to the split model. pub fn new_with_split_components(dem: &DetectorErrorModel) -> Result { Self::new(&dem.split_components()) } @@ -149,7 +152,8 @@ impl UnionFindDecoder { /// `UnionFindDecoder::new(dem)?.weighted(true)`. /// /// # Errors - /// Propagates [`crate::Error::NonGraphlike`] if the DEM has a hyperedge. + /// Propagates [`crate::Error::NonGraphlike`] for hyperedges and + /// [`crate::Error::InvalidMatchingPrior`] for priors outside finite `[0, 0.5]`. pub fn new_weighted(dem: &DetectorErrorModel) -> Result { Ok(Self::new(dem)?.weighted(true)) } diff --git a/crates/aleph-qec/tests/matching_priors.rs b/crates/aleph-qec/tests/matching_priors.rs new file mode 100644 index 00000000..7f4dd029 --- /dev/null +++ b/crates/aleph-qec/tests/matching_priors.rs @@ -0,0 +1,186 @@ +//! Matching priors are rejected before building a decoder, consistently in debug/release. +use aleph_qec::{ + BpDecoder, Decoder, DemError, DetectorErrorModel, Error, MatchingGraph, MwpmDecoder, Syndrome, + UnionFindDecoder, +}; + +fn model(prob: f64) -> DetectorErrorModel { + DetectorErrorModel { + detectors: 2, + observables: 1, + errors: vec![ + DemError::new(0.1, vec![0], vec![]), + DemError::new(prob, vec![1], vec![0]), + ], + } +} + +fn assert_prior_error(result: aleph_qec::Result, column: usize, prob: f64) { + match result { + Err(Error::InvalidMatchingPrior { + column: actual, + prob: rejected, + }) => { + assert_eq!(actual, column); + assert_eq!( + rejected.to_bits(), + prob.to_bits(), + "input must not be clamped" + ); + } + Err(other) => panic!("wrong error: {other}"), + Ok(_) => panic!("unsupported prior accepted: {prob}"), + } +} + +#[test] +fn all_matching_constructors_reject_unsupported_priors() { + for p in [ + 0.5f64.next_up(), + 0.6, + 1.0, + 1.01, + -0.1, + f64::NAN, + f64::INFINITY, + f64::NEG_INFINITY, + ] { + let dem = model(p); + assert_prior_error(MatchingGraph::from_dem(&dem), 1, p); + assert_prior_error(MwpmDecoder::new(&dem), 1, p); + assert_prior_error(UnionFindDecoder::new(&dem), 1, p); + assert_prior_error(UnionFindDecoder::new_weighted(&dem), 1, p); + assert_prior_error(MwpmDecoder::new_with_split_components(&dem), 1, p); + assert_prior_error(UnionFindDecoder::new_with_split_components(&dem), 1, p); + } +} + +#[test] +fn source_priors_are_checked_before_dropping_or_merging_edges() { + // Two inadmissible 0.6 priors would XOR-merge to 0.48. Validate each source + // column, not just the resulting graph, so invalid inputs cannot disappear. + let dem = DetectorErrorModel { + detectors: 1, + observables: 0, + errors: vec![ + DemError::new(0.6, vec![0], vec![]), + DemError::new(0.6, vec![0], vec![]), + ], + }; + assert_prior_error(MatchingGraph::from_dem(&dem), 0, 0.6); + for error in [ + DemError::new(0.6, vec![], vec![]), + DemError::new(1.0, vec![], vec![0]), + DemError::new(f64::NAN, vec![], vec![]), + DemError::with_components(0.6, vec![(vec![0], vec![]), (vec![1], vec![0])]), + ] { + let p = error.prob; + let dem = DetectorErrorModel { + detectors: 2, + observables: 1, + errors: vec![error], + }; + assert_prior_error(MatchingGraph::from_dem(&dem), 0, p); + assert_prior_error(MwpmDecoder::new_with_split_components(&dem), 0, p); + } +} + +#[test] +fn split_constructor_errors_use_split_column_indices() { + let dem = DetectorErrorModel { + detectors: 2, + observables: 0, + errors: vec![ + DemError::with_components(0.1, vec![(vec![0], vec![]), (vec![1], vec![])]), + DemError::new(0.7, vec![], vec![]), + ], + }; + assert_prior_error(MwpmDecoder::new(&dem), 1, 0.7); + assert_prior_error(MwpmDecoder::new_with_split_components(&dem), 2, 0.7); +} + +#[test] +fn supported_boundary_priors_decode_and_project_in_debug_and_release() { + for p in [ + f64::from_bits(1), + 1e-320, + f64::MIN_POSITIVE, + 0.1, + 0.5f64.next_down(), + 0.5, + ] { + // Distinct boundary edges give unique supports even when their weight is zero. + let dem = DetectorErrorModel { + detectors: 2, + observables: 2, + errors: vec![ + DemError::new(0.0, vec![0, 1], vec![]), + DemError::new(p, vec![0], vec![0]), + DemError::new(p, vec![1], vec![1]), + ], + }; + let graph = MatchingGraph::from_dem(&dem).unwrap(); + assert_eq!(graph.num_columns(), 3); + assert_eq!(graph.edges().len(), 2); + assert!(graph + .edges() + .iter() + .all(|e| e.weight.is_finite() && e.weight >= 0.0)); + let mwpm = MwpmDecoder::new(&dem).unwrap(); + let uf = UnionFindDecoder::new(&dem).unwrap(); + let weighted = UnionFindDecoder::new_weighted(&dem).unwrap(); + for mask in 0..4 { + let bits = vec![mask & 1 != 0, mask & 2 != 0]; + let s = Syndrome::from_bits(&bits); + for (decoder, estimate) in [ + (&mwpm as &dyn Decoder, mwpm.decode_errors(&s)), + (&uf as &dyn Decoder, uf.decode_errors(&s)), + (&weighted as &dyn Decoder, weighted.decode_errors(&s)), + ] { + assert_eq!(estimate, vec![0, bits[0] as u8, bits[1] as u8]); + assert_eq!(decoder.decode(&s).observable_flips, bits); + } + } + } +} + +#[test] +fn zero_negative_zero_and_empty_models_are_supported() { + let empty = DetectorErrorModel::default(); + assert!(MatchingGraph::from_dem(&empty).unwrap().edges().is_empty()); + MwpmDecoder::new(&empty).unwrap(); + UnionFindDecoder::new_weighted(&empty).unwrap(); + for p in [0.0, -0.0] { + let dem = model(p); + let graph = MatchingGraph::from_dem(&dem).unwrap(); + assert_eq!(graph.num_columns(), 2); + assert_eq!(graph.edges().len(), 1); + assert_eq!(graph.edges()[0].column, 0); + } +} + +#[test] +fn bp_and_dem_construction_keep_the_full_probability_range() { + for p in [0.6, 1.0] { + let dem = + DetectorErrorModel::from_check_matrices(1, 1, &[vec![0]], &[vec![0]], &[p]).unwrap(); + let _bp = BpDecoder::new(&dem); + assert_eq!(dem.errors[0].prob.to_bits(), p.to_bits()); + } + let dem = DetectorErrorModel::parse("error(0.6) D0 L0").unwrap(); + assert_prior_error(MwpmDecoder::new(&dem), 0, 0.6); +} + +#[test] +fn ordinary_supported_weights_remain_bit_identical() { + for p in [1e-300, 0.0001, 0.01, 0.2, 0.49, 0.5f64.next_down(), 0.5] { + let dem = DetectorErrorModel { + detectors: 1, + observables: 0, + errors: vec![DemError::new(p, vec![0], vec![])], + }; + let graph = MatchingGraph::from_dem(&dem).unwrap(); + let legacy = ((1.0 - p) / p).ln(); + assert_eq!(graph.edges()[0].weight.to_bits(), legacy.to_bits()); + } +} diff --git a/docs/perf/data/q1-515-projection-audit.csv b/docs/perf/data/q1-515-projection-audit.csv new file mode 100644 index 00000000..9af11c69 --- /dev/null +++ b/docs/perf/data/q1-515-projection-audit.csv @@ -0,0 +1,7 @@ +distance,decoder,shots,probability,original_columns,split_columns,native_reference_estimate_disagreement,syndrome_projection_failures,observable_vs_decode_disagreement,reference_observable_vs_decode_disagreement,original_decode_disagreement +3,mwpm,2000,0.003,291,568,0,0,0,0,0 +3,union-find,2000,0.003,291,568,0,0,0,0,0 +3,union-find-weighted,2000,0.003,291,568,0,0,0,0,0 +5,mwpm,2000,0.003,1958,3718,0,0,0,0,0 +5,union-find,2000,0.003,1958,3718,0,0,0,0,0 +5,union-find-weighted,2000,0.003,1958,3718,0,0,0,0,0 diff --git a/docs/perf/data/q1-515-source-sha256.txt b/docs/perf/data/q1-515-source-sha256.txt new file mode 100644 index 00000000..43f433f2 --- /dev/null +++ b/docs/perf/data/q1-515-source-sha256.txt @@ -0,0 +1,10 @@ +9d4aa8860dcb86df3bd697d3bf20cbb3f8404020196af48b7eb5d0b6d3a3f5e6 crates/aleph-qec/src/matching.rs +07de4fb5cc065b7b418e05d53bd89839fe1e317f9c7f11378a1a73820def8ad9 crates/aleph-qec/src/error.rs +d2b96b195c2d29d089f91cf3c22bc571cf816618d12c82f6c977e778a16036ec crates/aleph-qec/src/mwpm.rs +7b8eb297d7570a398d0bd1a36bd9f7002063552fa67ae9124f5e46873ad97659 crates/aleph-qec/src/union_find.rs +84acce32c329cc174226d140b06c4a68f7e6dc6b6285d0294ffe0a2c7ab4afc8 crates/aleph-qec/src/sparse_blossom/retrace.rs +ce761e559acf94c28b1bac22ba98a6fcf7ebfb04ac81f12ff939eb9cbcd11a51 crates/aleph-py/python/aleph/cudaq.py +e4f77487cf7b2d16219e161ce335dbfc1a6009dfba61bc493fab9e44db0eebc6 crates/aleph-py/python/aleph/qec.py +14f7dec0a0757d3553ef30ad2b43f394ca439e6c1a123392b3e6169594fd099c crates/aleph-qec/tests/matching_priors.rs +f19a674ab522b11dbe042eb4f94711fe75e6cdb8b584af97b13c13b3ea2175a1 scripts/python/test_qec.py +359ebb4f7501467d9f8bbe036a7e51c122f029a0295cb14d537007e23c5a1463 scripts/python/test_cudaq.py diff --git a/docs/perf/data/q1-515-validation.json b/docs/perf/data/q1-515-validation.json new file mode 100644 index 00000000..74a29eed --- /dev/null +++ b/docs/perf/data/q1-515-validation.json @@ -0,0 +1,61 @@ +{ + "issue": 515, + "date": "2026-10-16", + "baseline_commit": "5a671ae26b2eb4235d024403952c2f27988bddfb", + "scope": "Targeted correctness, no throughput claim; full workspace tests delegated to PR CI", + "rust_tests_per_environment": { + "prior-debug": 7, + "prior-release": 7, + "dem-tests": 21, + "matching-tests": 19, + "split-tests": 7, + "doc-tests": 4 + }, + "local": { + "platform": "macOS", + "python": "3.12.14", + "numpy": "2.5.4", + "stim": "1.16.0", + "python_tests_passed": 15, + "checks": [ + "workspace all-target clippy", + "Python-feature all-target clippy", + "rustfmt" + ] + }, + "remote": { + "host": "openwebgui.splynx.com", + "temporary_root": "/tmp/aleph-515-LAf0e0", + "python": "3.12.3", + "numpy": "2.5.3", + "stim": "1.16.0", + "rustc": "1.96.0 (ac68faa20 2026-05-25)", + "real_cudaq_qec": true, + "python_tests_passed": 33, + "python_tests_skipped": 0, + "cudaq_registration_tests": 18, + "source_hashes_matched": 10, + "checks": [ + "QEC and Python-feature all-target clippy" + ], + "installed_environment_modified": false + }, + "stim_projection_audit": { + "seed": 518, + "distances": [ + 3, + 5 + ], + "probability": 0.003, + "shots_per_decoder_distance": 2000, + "decoders": [ + "mwpm", + "union-find", + "union-find-weighted" + ], + "total_cases": 12000, + "all_disagreement_columns_zero": true + }, + "review": "Manual self-review; no blocking findings", + "validated_code_commit": "ef9c93b723c9942c7434b0aa9001f06fcfadfe9c" +} diff --git a/docs/perf/data/q1-515-validation.txt b/docs/perf/data/q1-515-validation.txt new file mode 100644 index 00000000..c2b72b33 --- /dev/null +++ b/docs/perf/data/q1-515-validation.txt @@ -0,0 +1,335 @@ +prior-debug + Finished `test` profile [unoptimized + debuginfo] target(s) in 9.08s +test source_priors_are_checked_before_dropping_or_merging_edges ... ok +test ordinary_supported_weights_remain_bit_identical ... ok +test bp_and_dem_construction_keep_the_full_probability_range ... ok +test all_matching_constructors_reject_unsupported_priors ... ok +test split_constructor_errors_use_split_column_indices ... ok +test zero_negative_zero_and_empty_models_are_supported ... ok +test supported_boundary_priors_decode_and_project_in_debug_and_release ... ok +test result: ok. 7 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s + +prior-release + Finished `release` profile [optimized] target(s) in 17.38s +test ordinary_supported_weights_remain_bit_identical ... ok +test bp_and_dem_construction_keep_the_full_probability_range ... ok +test all_matching_constructors_reject_unsupported_priors ... ok +test split_constructor_errors_use_split_column_indices ... ok +test source_priors_are_checked_before_dropping_or_merging_edges ... ok +test zero_negative_zero_and_empty_models_are_supported ... ok +test supported_boundary_priors_decode_and_project_in_debug_and_release ... ok +test result: ok. 7 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s + +dem-tests + Finished `release` profile [optimized] target(s) in 13.93s +test dem::tests::detector_offset_u64_overflow_is_an_error ... ok +test dem::tests::from_check_matrices_builds_one_mechanism_per_column ... ok +test dem::tests::detector_declaration_is_shifted ... ok +test dem::tests::boundary_count_round_trips_via_trailing_declaration ... ok +test dem::tests::merged_view_is_parity_reduced ... ok +test dem::tests::from_check_matrices_rejects_length_mismatch ... ok +test dem::tests::nested_repeat_and_zero_count ... ok +test dem::tests::parses_known_stim_snippet ... ok +test dem::tests::rejects_duplicate_index ... ok +test dem::tests::probability_must_be_finite_and_in_unit_interval ... ok +test dem::tests::rejects_garbage ... ok +test dem::tests::rejects_more_than_64_observables ... ok +test dem::tests::rejects_non_finite_probability ... ok +test dem::tests::rejects_out_of_range_index ... ok +test dem::tests::repeat_errors_are_reported ... ok +test dem::tests::repeat_unrolls_with_shift ... ok +test dem::tests::separable_components_are_preserved ... ok +test dem::tests::single_part_has_no_components ... ok +test dem::tests::target_order_is_normalised ... ok +test dem::tests::shifted_index_overflow_is_an_error ... ok +test dem::tests::emit_parse_round_trip ... ok +test result: ok. 21 passed; 0 failed; 0 ignored; 0 measured; 169 filtered out; finished in 0.00s + +matching-tests + Finished `release` profile [optimized] target(s) in 0.04s +test matching::tests::component_with_three_detectors_is_still_rejected ... ok +test matching::tests::duplicate_targets_cancel_by_parity ... ok +test matching::tests::parallel_edges_merge_by_xor ... ok +test matching::tests::single_detector_error_connects_to_boundary ... ok +test matching::tests::hyperedge_is_rejected ... ok +test matching::tests::different_observables_are_distinct_edges ... ok +test matching::tests::edge_column_is_the_first_mechanism_with_that_key ... ok +test matching::tests::repetition_code_hand_drawn_adjacency ... ok +test matching::tests::decomposed_hyperedge_becomes_component_edges ... ok +test matching::tests::parallel_edges_stay_below_half_and_keep_weight_nonnegative ... ok +test matching::tests::two_detector_error_connects_detectors ... ok +test matching::tests::zero_detector_error_is_dropped ... ok +test matching::tests::zero_probability_is_skipped_without_renumbering_columns ... ok +test pymatching::tests::empty_batch_needs_no_subprocess ... ok +test pymatching::tests::caches_dem_and_counts ... ok +test matching::tests::endpoints_ordered_and_in_range ... ok +test matching::tests::adjacency_consistent_with_edges ... ok +test matching::tests::all_weights_nonnegative_for_p_le_half ... ok +test matching::tests::surface_code_d3_d5_node_counts_and_graphlike ... ok +test result: ok. 19 passed; 0 failed; 0 ignored; 0 measured; 171 filtered out; finished in 0.01s + +split-tests + Finished `release` profile [optimized] target(s) in 2.96s +test graph_edges_are_identical_except_for_column_indices ... ok +test hyperedge_parts_still_fail_explicitly ... ok +test split_retains_order_priors_counts_empty_parts_and_independent_parity ... ok +test split_constructors_fix_shared_columns_for_all_syndromes_without_ties ... ok +test undecomposed_and_empty_models_are_unchanged ... ok +test splitting_is_not_an_equivalent_noise_sampling_model ... ok +test component_parity_is_preserved_without_cross_contamination ... ok +test result: ok. 7 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s + +doc-tests + Finished `release` profile [optimized] target(s) in 0.04s +test crates/aleph-qec/src/experiment.rs - experiment::run_memory_experiment (line 75) - compile ... ok +test crates/aleph-qec/src/decoder.rs - decoder::LogicalErrorResult::interval95 (line 103) ... ok +test crates/aleph-qec/src/dem.rs - dem::DetectorErrorModel::split_components (line 161) ... ok +test crates/aleph-qec/src/experiment.rs - experiment::run_dem_experiment_with_options (line 153) ... ok +test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.49s + +environment.txt +/root/cqvenv/lib/python3.12/site-packages/cudaq_qec/__init__.py:13: FutureWarning: The CUDA-Q `sample` and `observe` algorithmic primitives will change in a future release. Existing code may require updates. See https://nvidia.github.io/cuda-quantum/latest/using/migration/upcoming_changes.html for details. + import cudaq +3.12.3 (main, Aug 31 2026, 10:18:26) [GCC 13.3.0] +Linux-6.8.0-146-generic-x86_64-with-glibc2.39 +numpy 2.5.3 stim 1.16.0 +cudaq_qec /root/cqvenv/lib/python3.12/site-packages/cudaq_qec/__init__.py +rustc 1.96.0 (ac68faa20 2026-05-25) + + +python-tests.log +/root/cqvenv/lib/python3.12/site-packages/cudaq_qec/__init__.py:13: FutureWarning: The CUDA-Q `sample` and `observe` algorithmic primitives will change in a future release. Existing code may require updates. See https://nvidia.github.io/cuda-quantum/latest/using/migration/upcoming_changes.html for details. + import cudaq +test_bad_inputs_raise_value_error (test_qec.TestApi.test_bad_inputs_raise_value_error) ... ok +test_decode_batch_errors (test_qec.TestApi.test_decode_batch_errors) ... ok +test_decode_batch_errors_empty_batch (test_qec.TestApi.test_decode_batch_errors_empty_batch) ... ok +test_decode_shapes_and_dtypes (test_qec.TestApi.test_decode_shapes_and_dtypes) ... ok +test_dem_from_matrices_accepts_dense_int64_and_sparse (test_qec.TestApi.test_dem_from_matrices_accepts_dense_int64_and_sparse) ... ok +test_dem_from_matrices_rejects_bad_input (test_qec.TestApi.test_dem_from_matrices_rejects_bad_input) ... ok +test_dem_properties (test_qec.TestApi.test_dem_properties) ... ok +test_gross_code_dem (test_qec.TestApi.test_gross_code_dem) ... ok +test_matching_invalid_prior_cannot_hide_in_empty_or_merged_columns (test_qec.TestApi.test_matching_invalid_prior_cannot_hide_in_empty_or_merged_columns) ... ok +test_matching_prior_policy_at_construction (test_qec.TestApi.test_matching_prior_policy_at_construction) ... ok +test_names (test_qec.TestApi.test_names) ... ok +test_split_components_matches_matrices_without_mutation (test_qec.TestApi.test_split_components_matches_matrices_without_mutation) ... ok +test_split_matching_projects_each_part_exactly_without_ties (test_qec.TestApi.test_split_matching_projects_each_part_exactly_without_ties) ... ok +test_native_split_matches_matrix_roundtrip_on_real_stim_shots (test_qec.TestStimDems.test_native_split_matches_matrix_roundtrip_on_real_stim_shots) ... ok +test_matching_decode_batch_errors_rejects_decomposed_dem (test_qec.TestStimDems.test_matching_decode_batch_errors_rejects_decomposed_dem) ... ok +test_all_seven_resolve (test_cudaq.TestRegistration.test_all_seven_resolve) ... ok +test_both_error_rate_forms_rejected (test_cudaq.TestRegistration.test_both_error_rate_forms_rejected) ... ok +test_decode_accepts_single_row_2d (test_cudaq.TestRegistration.test_decode_accepts_single_row_2d) ... ok +test_decode_batch_contract (test_cudaq.TestRegistration.test_decode_batch_contract) ... ok +test_decode_batch_empty (test_cudaq.TestRegistration.test_decode_batch_empty) ... ok +test_decode_contract (test_cudaq.TestRegistration.test_decode_contract) ... ok +test_decode_empty_syndrome_raises (test_cudaq.TestRegistration.test_decode_empty_syndrome_raises) ... ok +test_dem_to_matrices_cancels_duplicate_targets (test_cudaq.TestRegistration.test_dem_to_matrices_cancels_duplicate_targets) ... ok +test_dem_to_matrices_splits_hat (test_cudaq.TestRegistration.test_dem_to_matrices_splits_hat) ... ok +test_matching_boundary_priors_are_accepted (test_cudaq.TestRegistration.test_matching_boundary_priors_are_accepted) ... ok +test_matching_decoder_rejects_hyperedge_column (test_cudaq.TestRegistration.test_matching_decoder_rejects_hyperedge_column) ... ok +test_matching_decoder_rejects_prior_above_half (test_cudaq.TestRegistration.test_matching_decoder_rejects_prior_above_half) ... ok +test_matching_prior_message_matches_native_python (test_cudaq.TestRegistration.test_matching_prior_message_matches_native_python) ... ok +test_nan_priors_rejected (test_cudaq.TestRegistration.test_nan_priors_rejected) ... ok +test_scalar_error_rate_and_missing_rates (test_cudaq.TestRegistration.test_scalar_error_rate_and_missing_rates) ... ok +test_scipy_sparse_h (test_cudaq.TestRegistration.test_scipy_sparse_h) ... ok +test_soft_syndrome_thresholds (test_cudaq.TestRegistration.test_soft_syndrome_thresholds) ... ok +test_unknown_param_and_wrong_width (test_cudaq.TestRegistration.test_unknown_param_and_wrong_width) ... ok + +---------------------------------------------------------------------- +Ran 33 tests in 3.267s + +OK +PRIVATE_ALEPH_MODULE /tmp/aleph-515-LAf0e0/candidate/crates/aleph-py/python/aleph/__init__.py +PRIVATE_NATIVE_MODULE /tmp/aleph-515-LAf0e0/candidate/crates/aleph-py/python/aleph/_native.abi3.so + + +remote clippy + Compiling syn v2.0.117 + Checking cfg-if v1.0.4 + Checking bytemuck v1.25.0 + Checking num-traits v0.2.19 + Checking libc v0.2.186 + Checking crossbeam-utils v0.8.21 + Checking bitflags v2.11.1 + Checking num-complex v0.4.6 + Checking crossbeam-epoch v0.9.18 + Checking either v1.16.0 + Checking crossbeam-deque v0.8.6 + Checking once_cell v1.21.4 + Checking smallvec v1.15.1 + Checking rayon-core v1.13.0 + Checking getrandom v0.2.17 + Checking memchr v2.8.0 + Checking rand_core v0.6.4 + Checking rayon v1.12.0 + Checking approx v0.5.1 + Checking serde_core v1.0.228 + Checking getrandom v0.3.4 + Checking rand_core v0.9.5 + Checking regex-syntax v0.8.10 + Checking linux-raw-sys v0.12.1 + Checking zmij v1.0.21 + Checking getrandom v0.4.2 + Checking statrs v0.18.0 + Checking rustix v1.1.4 + Checking plotters-backend v0.3.7 + Checking itoa v1.0.18 + Checking anstyle v1.0.14 + Checking fastrand v2.4.1 + Checking clap_lex v1.1.0 + Checking ciborium-io v0.2.2 + Checking serde_json v1.0.150 + Checking clap_builder v4.6.0 + Checking tempfile v3.27.0 + Checking plotters-svg v0.3.7 + Compiling zerocopy-derive v0.8.48 + Compiling thiserror-impl v1.0.69 + Compiling serde_derive v1.0.228 + Checking regex-automata v0.4.14 + Checking thiserror v1.0.69 + Checking aleph-core v0.3.0 (/tmp/aleph-515-LAf0e0/candidate/crates/aleph-core) + Checking zerocopy v0.8.48 + Checking aleph-ir v0.3.0 (/tmp/aleph-515-LAf0e0/candidate/crates/aleph-ir) + Checking itertools v0.10.5 + Checking aleph-backend v0.3.0 (/tmp/aleph-515-LAf0e0/candidate/crates/aleph-backend) + Checking wait-timeout v0.2.1 + Checking bit-vec v0.8.0 + Checking fnv v1.0.7 + Checking same-file v1.0.6 + Checking quick-error v1.2.3 + Checking cast v0.3.0 + Checking rusty-fork v0.3.1 + Checking criterion-plot v0.5.0 + Checking bit-set v0.8.0 + Checking walkdir v2.5.0 + Checking regex v1.12.3 + Checking clap v4.6.1 + Checking serde v1.0.228 + Checking plotters v0.3.7 + Checking rand v0.9.4 + Checking tinytemplate v1.2.1 + Checking rand_xorshift v0.4.0 + Checking is-terminal v0.4.17 + Checking anes v0.1.6 + Checking oorandom v11.1.5 + Checking unarray v0.1.4 + Compiling target-lexicon v0.12.16 + Compiling nano-gemm-codegen v0.2.1 + Compiling paste v1.0.15 + Compiling version_check v0.9.5 + Compiling pyo3-build-config v0.22.6 + Compiling libm v0.2.16 + Checking ppv-lite86 v0.2.21 + Checking half v2.7.1 + Compiling pulp v0.22.2 + Checking raw-cpuid v11.6.0 + Checking rand_chacha v0.3.1 + Checking ciborium-ll v0.2.2 + Checking ciborium v0.2.2 + Checking rand v0.8.6 + Checking rand_chacha v0.9.0 + Checking proptest v1.11.0 + Checking criterion v0.5.1 + Checking aleph-stab v0.3.0 (/tmp/aleph-515-LAf0e0/candidate/crates/aleph-stab) + Checking aleph-qec v0.3.0 (/tmp/aleph-515-LAf0e0/candidate/crates/aleph-qec) + Checking pulp-wasm-simd-flag v0.1.0 + Checking reborrow v0.5.5 + Compiling dyn-stack-macros v0.1.3 + Compiling syn v1.0.109 + Checking dyn-stack v0.13.2 + Compiling seq-macro v0.3.6 + Checking nano-gemm-core v0.2.1 + Compiling matrixmultiply v0.3.10 + Compiling pyo3-macros-backend v0.22.6 + Compiling pyo3-ffi v0.22.6 + Compiling nano-gemm-c32 v0.2.1 + Compiling nano-gemm-f32 v0.2.1 + Compiling nano-gemm-f64 v0.2.1 + Compiling nano-gemm-c64 v0.2.1 + Compiling equator-macro v0.4.2 + Checking num-integer v0.1.46 + Checking crossbeam-channel v0.5.15 + Checking crossbeam-queue v0.3.12 + Checking rawpointer v0.2.1 + Compiling defer v0.2.1 + Checking crossbeam v0.8.4 + Checking equator v0.4.2 + Compiling equator-macro v0.2.1 + Checking atomic-wait v1.1.0 + Checking safe_arch v0.7.4 + Compiling memoffset v0.9.1 + Compiling heck v0.5.0 + Checking equator v0.2.2 + Checking wide v0.7.33 + Checking gemm-common v0.19.0 + Checking gemm-c32 v0.19.0 + Checking gemm-c64 v0.19.0 + Checking gemm-f64 v0.19.0 + Compiling interpol v0.2.1 + Checking gemm-f32 v0.19.0 + Checking qd v0.8.0 + Compiling private-gemm-x86 v0.1.20 + Checking spindle v0.2.6 + Compiling pyo3 v0.22.6 + Checking num_cpus v1.17.0 + Checking minimal-lexical v0.2.1 + Compiling equator-macro v0.6.0 + Checking generativity v1.2.1 + Checking faer-traits v0.24.0 + Checking equator v0.6.0 + Checking nom v7.1.3 + Checking nano-gemm v0.2.2 + Compiling pyo3-macros v0.22.6 + Checking gemm v0.19.0 + Checking simba v0.9.1 + Checking num-rational v0.4.2 + Compiling nalgebra-macros v0.2.2 + Compiling indoc v2.0.7 + Checking unindent v0.2.4 + Checking bytecount v0.6.9 + Checking typenum v1.20.1 + Checking nom_locate v4.2.0 + Checking faer v0.24.0 + Checking ndarray v0.16.1 + Checking rustc-hash v1.1.0 + Checking aleph-parser v0.3.0 (/tmp/aleph-515-LAf0e0/candidate/crates/aleph-parser) + Checking aleph-sv v0.3.0 (/tmp/aleph-515-LAf0e0/candidate/crates/aleph-sv) + Checking nalgebra v0.33.3 + Checking numpy v0.22.1 + Checking aleph-mps v0.3.0 (/tmp/aleph-515-LAf0e0/candidate/crates/aleph-mps) + Checking aleph-py v0.3.0 (/tmp/aleph-515-LAf0e0/candidate/crates/aleph-py) + Finished `dev` profile [unoptimized + debuginfo] target(s) in 20.00s + + +local python-tests +Ran 15 tests in 1.605s +OK + +local clippy + Finished `dev` profile [unoptimized + debuginfo] target(s) in 36.82s + +local python-clippy + Finished `dev` profile [unoptimized + debuginfo] target(s) in 5.67s + +before tiny-prior fix + Compiling aleph-qec v0.3.0 (/Users/ex/GitHub/aleph/crates/aleph-qec) + Finished `test` profile [unoptimized + debuginfo] target(s) in 0.49s + Running tests/matching_priors.rs (target/debug/deps/matching_priors-517c15419d395118) + +running 1 test +test supported_boundary_priors_decode_and_project_in_debug_and_release ... FAILED + +failures: + +---- supported_boundary_priors_decode_and_project_in_debug_and_release stdout ---- + +thread 'supported_boundary_priors_decode_and_project_in_debug_and_release' (9290746) panicked at crates/aleph-qec/tests/matching_priors.rs:125:9: +assertion failed: graph.edges().iter().all(|e| e.weight.is_finite() && e.weight >= 0.0) +note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace + + +failures: + supported_boundary_priors_decode_and_project_in_debug_and_release + +test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 5 filtered out; finished in 0.00s + +error: test failed, to rerun pass `-p aleph-qec --test matching_priors` diff --git a/docs/perf/f6-cudaq-plugin.md b/docs/perf/f6-cudaq-plugin.md index 1c99fb38..0ff327cd 100644 --- a/docs/perf/f6-cudaq-plugin.md +++ b/docs/perf/f6-cudaq-plugin.md @@ -407,7 +407,8 @@ unchanged in cost; the new `decode_errors`/`from_check_matrices` surface is addi > 0.5 (negative edge weights) is inconsistent across the stack (`aleph.cudaq` rejects them for matching decoders, Rust `MatchingGraph::from_dem` accepts them, the Sparse Blossom retrace skips negative-weight edges, and the flooder trips a `debug_assert` in debug builds); decide - reject-vs-clamp at construction, consistently. + reject-vs-clamp at construction, consistently. **Resolution:** matching constructors now reject + unsupported priors consistently; see the [policy and validation](../qec/matching-priors.md). - [#516](https://github.com/aleph-sim/aleph/issues/516) — `decode_batch_errors`'s per-shot path retrace measured ≈1.84x slower than `decode_batch`'s direct observable decode on MWPM at d=5 (522k vs 284k shots/s on the split model, single-threaded, see "Where the plugin path's time goes"); audit whether diff --git a/docs/qec/matching-priors.md b/docs/qec/matching-priors.md new file mode 100644 index 00000000..59f2a7b1 --- /dev/null +++ b/docs/qec/matching-priors.md @@ -0,0 +1,107 @@ +# Matching prior policy (#515) + +MWPM, Union-Find and weighted Union-Find require each source mechanism's prior to +be finite and in **[0, 0.5]**. Rust, native Python and the CUDA-Q adapter reject +unsupported priors at construction. Inputs are never clamped: doing so silently +changes the noise model. DEM parsing and matrix construction retain their [0, 1] +domain, as do the four BP-family decoder constructors. + +| Prior | Matching construction | +| --- | --- | +| 0 or negative zero | Accepted; no edge, original column numbering retained | +| Positive, including the smallest positive `f64` | Accepted | +| 0.5 | Accepted; zero-weight edge | +| Greater than 0.5, including 1 | Rejected | +| Negative, NaN or infinity | Rejected | + +Validation runs before dropping empty/purely logical columns or merging parallel +edges. For example, two 0.6 source priors are rejected even though their XOR +probability would be 0.48. It also applies to `^` components. Split constructors +report columns of the **split** input model; ordinary constructors report original +DEM columns. Indices are zero-based. + +Rust returns `Error::InvalidMatchingPrior { column, prob }`, retaining the rejected +value. Python raises `ValueError`; for example: + +```python +from aleph import qec + +dem = qec.DetectorErrorModel("error(0.1) D0\nerror(0.6) D1 L0") +qec.Decoder(dem, "mwpm") +# ValueError: matching prior at column 1 must be finite and in [0, 0.5], got 0.6 +``` + +`aleph.cudaq` delegates matching range validation to this same native constructor. +Its existing finite-input and matrix validation remain in place. Tests compare +the adapter's range-error message directly with the native Python message. + +## Why reject, and what changed for tiny priors? + +Previously Rust accepted priors above 0.5, creating negative log-odds weights. +Sparse Blossom's retrace cannot use these edges and its flooder assumes +nonnegative weights. Rust also silently discarded probability 1 and non-finite +mechanisms while the CUDA-Q adapter rejected high priors. Explicit rejection +avoids a partially decoded model and makes the supported domain consistent. +Supporting high matching priors through a transformed model would require a +separate design; clamping is not a substitute. + +Boundary testing also reproduced an infinite weight for the smallest positive +`f64`: `(1-p)/p` overflows before taking its logarithm. Such a weight can overflow +the sparse graph's integer scaling. When the ratio overflows, weight calculation +now uses `ln(1-p) - ln(p)`, which stays finite. Ordinary finite ratios retain the +original expression; regression tests check bit-identical weights at representative +priors. Decoder algorithms, integer scale and tie-breaking are unchanged. + +This does not resolve infeasible syndromes or genuine MWPM retrace ties described +in [component splitting](split-components.md). No throughput improvement is claimed. + +## Validation (2026-10-16) + +Baseline: `5a671ae26b2eb4235d024403952c2f27988bddfb`; validated code: +`ef9c93b723c9942c7434b0aa9001f06fcfadfe9c`. Manual self-review found no +blocking issues. These are targeted correctness checks, not a full workspace test +run; the PR's CI provides the full platform matrix. + +| Environment | Rust tests | Python tests | Additional checks | +| --- | ---: | ---: | --- | +| Local macOS, Python 3.12 | 65 | 15 | Workspace/all-target Clippy, Python-feature Clippy, rustfmt | +| openwebgui Linux, Python 3.12.3, real CUDA-Q QEC SDK | 65 | 33 | QEC/Python all-target Clippy; no Python skips | + +Each Rust total includes seven prior regressions in **both debug and release**, +21 DEM tests, 19 matching tests, seven split-component tests and four doctests. +Cases cover invalid ranges/NaN/infinities, all matching constructors, unchanged BP +construction, zero and half priors, subnormal weights, split indices and exact +syndrome/observable projection on independently constrained columns. + +The Linux Python run includes 18 actual CUDA-Q registration/adapter tests, using +an isolated source/native-module overlay over the existing `cqvenv`; the installed +environment and shared services were left intact. Ten source hashes were verified +against the local patch. An additional Stim audit decoded 2,000 shots for each of +three matching decoders at distances 3 and 5 (12,000 cases): all native/reference, +syndrome and observable comparisons agree. This audit uses the existing matrix +roundtrip reference, not an independent optimal-matching oracle. + +Evidence: [validation metadata](../perf/data/q1-515-validation.json), +[Stim audit](../perf/data/q1-515-projection-audit.csv), +[source hashes](../perf/data/q1-515-source-sha256.txt), and +[targeted validation output](../perf/data/q1-515-validation.txt). + +Reproduce after building the Python extension with `maturin develop --release +--features python` from `crates/aleph-py`: + +```bash +cargo test -p aleph-qec --test matching_priors +cargo test --release -p aleph-qec --test matching_priors +cargo test -p aleph-qec --lib dem::tests +cargo test -p aleph-qec --lib matching::tests +cargo test -p aleph-qec --test split_components +cargo test -p aleph-qec --doc +PYTHONPATH=scripts/python python -m unittest test_qec.TestApi \ + test_qec.TestStimDems.test_native_split_matches_matrix_roundtrip_on_real_stim_shots \ + test_qec.TestStimDems.test_matching_decode_batch_errors_rejects_decomposed_dem \ + test_cudaq.TestRegistration +python scripts/python/audit_qec_split_components.py +``` + +The CUDA-Q class requires its SDK and SciPy; without them unittest may skip it. +The recorded Linux run explicitly required the SDK and rejected any skipped test. diff --git a/docs/qec/split-components.md b/docs/qec/split-components.md index e63db19f..36276707 100644 --- a/docs/qec/split-components.md +++ b/docs/qec/split-components.md @@ -67,8 +67,9 @@ equivalent noise model. The Rust regression tests include a deterministic exampl D0 alone is possible in the split model but impossible under `error(p) D0 ^ D1`. Per-part column identity fixes this representation problem. Existing matching -limitations still apply: infeasible syndromes, negative edge weights (#515), and -MWPM equal-weight path retrace ties. In particular, this API does not promise that +limitations still apply: infeasible syndromes and MWPM equal-weight path retrace +ties. Unsupported matching priors are now [rejected at construction](matching-priors.md) +(#515). In particular, this API does not promise that MWPM `O·ehat` always equals its separate `decode()` observable prediction on genuine ties. Union-Find uses the same peeled edges for both outputs. No hot decoder or sampling algorithm, precision policy or tie-breaking is changed here; no speedup diff --git a/docs/superpowers/specs/2026-09-24-cudaq-plugin-design.md b/docs/superpowers/specs/2026-09-24-cudaq-plugin-design.md index 6ad700d6..c8230fbb 100644 --- a/docs/superpowers/specs/2026-09-24-cudaq-plugin-design.md +++ b/docs/superpowers/specs/2026-09-24-cudaq-plugin-design.md @@ -163,8 +163,9 @@ reference for this representation. Splitting is for decoder construction, not equivalent noise generation: parts of one original error fire together; sampling split columns loses that correlation. Use original DEMs/circuit samples for experiments. For split H/O columns the usual -matching limitations still apply: infeasible syndromes, negative weights (#515), -and genuine equal-weight MWPM retrace ties (§4.3). Splitting does not promise +matching limitations still apply: infeasible syndromes and genuine equal-weight +MWPM retrace ties (§4.3). Unsupported matching priors are rejected at construction +([#515 policy](../../qec/matching-priors.md)). Splitting does not promise unconditional agreement with `decode()` on those ties. ### 4.3 Sparse Blossom: matched pairs + path retrace diff --git a/scripts/python/test_cudaq.py b/scripts/python/test_cudaq.py index f7fc5989..d7bb4551 100644 --- a/scripts/python/test_cudaq.py +++ b/scripts/python/test_cudaq.py @@ -96,9 +96,37 @@ def test_matching_decoder_rejects_hyperedge_column(self): def test_matching_decoder_rejects_prior_above_half(self): for name in ("mwpm", "union-find", "union-find-weighted"): - with self.assertRaisesRegex(ValueError, "0.5"): - cq.get_decoder(f"aleph-{name}", H, O=O, error_rate_vec=[0.1, 0.6, 0.1]) - cq.get_decoder("aleph-bp", H, O=O, error_rate_vec=[0.1, 0.6, 0.1]) # BP unaffected + for probability in (np.nextafter(0.5, 1.0), 0.6, 1.0): + for kwargs in ({"error_rate_vec": [0.1, probability, 0.1]}, + {"error_rate": probability}): + with self.subTest(name=name, kwargs=kwargs): + with self.assertRaisesRegex(ValueError, r"matching prior at column [01].*0.5"): + cq.get_decoder(f"aleph-{name}", H, O=O, **kwargs) + for name in ("bp", "bp-osd", "relay-bp", "relay-bp-osd"): + cq.get_decoder(f"aleph-{name}", H, O=O, error_rate_vec=[0.1, 0.6, 0.1]) + + def test_matching_prior_message_matches_native_python(self): + rates = [0.1, 0.7, 0.1] + dem = aq.dem_from_matrices(H, O, rates) + for name in ("mwpm", "union-find", "union-find-weighted"): + with self.subTest(name=name): + with self.assertRaises(ValueError) as native: + aq.Decoder(dem, name) + with self.assertRaises(ValueError) as adapter: + cq.get_decoder(f"aleph-{name}", H, O=O, error_rate_vec=rates) + self.assertEqual(str(adapter.exception), str(native.exception)) + + def test_matching_boundary_priors_are_accepted(self): + for name in ("mwpm", "union-find", "union-find-weighted"): + for probability in (0.0, 0.5, np.nextafter(0.0, 1.0)): + cq.get_decoder(f"aleph-{name}", H, O=O, error_rate=probability) + # Independently constrained columns exercise zero and very large finite weights. + for probability in (0.5, np.nextafter(0.0, 1.0)): + dec = cq.get_decoder(f"aleph-{name}", np.ones((1, 1), dtype=np.uint8), + O=np.ones((1, 1), dtype=np.uint8), error_rate=probability) + result = dec.decode_batch(np.array([[0], [1]], dtype=float)) + self.assertTrue(np.asarray(result.converged).all()) + np.testing.assert_array_equal(np.asarray(result.result) > 0.5, [[0], [1]]) def test_scipy_sparse_h(self): import scipy.sparse as sp diff --git a/scripts/python/test_qec.py b/scripts/python/test_qec.py index c9f47108..c6e5c1a8 100644 --- a/scripts/python/test_qec.py +++ b/scripts/python/test_qec.py @@ -105,6 +105,38 @@ def test_split_matching_projects_each_part_exactly_without_ties(self): empty, converged = dec.decode_batch_errors(np.zeros((0, 2), dtype=np.uint8)) self.assertEqual((empty.shape, converged.shape), ((0, 2), (0,))) + def test_matching_prior_policy_at_construction(self): + for probability in (np.nextafter(0.5, 1.0), 0.6, 1.0): + dem = qec.DetectorErrorModel(f"error(0.1) D0\nerror({probability}) D1 L0") + for name in sorted(MATCHING): + with self.subTest(name=name, probability=probability): + with self.assertRaisesRegex(ValueError, r"column 1.*\[0, 0.5\]"): + qec.Decoder(dem, name) + # Parsing and BP-family construction still accept priors in [0, 1]. + for name in sorted(set(ALL) - MATCHING): + qec.Decoder(dem, name) + for probability in (0.0, 0.5, np.nextafter(0.0, 1.0)): + dem = qec.DetectorErrorModel(f"error({probability}) D0 L0") + for name in sorted(MATCHING): + dec = qec.Decoder(dem, name) + if probability > 0: + dets = np.array([[0], [1]], dtype=np.uint8) + estimate, conv = dec.decode_batch_errors(dets) + self.assertTrue(conv.all()) + np.testing.assert_array_equal(estimate, dets) + np.testing.assert_array_equal(dec.decode_batch(dets), dets.astype(bool)) + + def test_matching_invalid_prior_cannot_hide_in_empty_or_merged_columns(self): + for text in ("error(0.6) L0", "error(0.6)", + "error(0.6) D0 ^ D1", "error(0.6) D0\nerror(0.6) D0"): + dem = qec.DetectorErrorModel(text) + for name in sorted(MATCHING): + with self.subTest(name=name, text=text): + with self.assertRaisesRegex(ValueError, r"matching prior at column 0"): + qec.Decoder(dem, name) + with self.assertRaisesRegex(ValueError, r"matching prior at column 0"): + qec.Decoder(dem.split_components(), name) + def test_names(self): self.assertEqual(sorted(qec.decoder_names()), sorted(ALL))