From 566974a95c4d719220559a39769a42dd0221812a Mon Sep 17 00:00:00 2001 From: Mathias Soeken Date: Tue, 7 Jul 2026 09:20:21 +0000 Subject: [PATCH 1/2] Add ErrorComposition class and integrate into estimation processes - Introduced ErrorComposition to control error contribution methods (UnionBound and Product). - Updated estimation functions to accept ErrorComposition as a parameter. - Enhanced EstimationResult to manage error composition mode. - Added tests to validate behavior of error composition modes. --- source/qdk_package/qdk/qre/__init__.py | 2 + source/qdk_package/qdk/qre/_estimation.py | 27 ++++- source/qdk_package/qdk/qre/_qre.py | 1 + source/qdk_package/qdk/qre/_qre.pyi | 88 ++++++++++++++- source/qdk_package/src/qre.rs | 103 ++++++++++++++++-- .../qdk_package/tests/qre/test_application.py | 39 +++++++ source/qre/src/lib.rs | 4 +- source/qre/src/result.rs | 48 +++++++- source/qre/src/trace.rs | 24 ++-- source/qre/src/trace/estimation.rs | 11 +- source/qre/src/trace/tests.rs | 42 ++++++- 11 files changed, 353 insertions(+), 36 deletions(-) diff --git a/source/qdk_package/qdk/qre/__init__.py b/source/qdk_package/qdk/qre/__init__.py index 74de35bf191..8270c88a5dc 100644 --- a/source/qdk_package/qdk/qre/__init__.py +++ b/source/qdk_package/qdk/qre/__init__.py @@ -26,6 +26,7 @@ Constraint, ConstraintBound, EstimationResult, + ErrorComposition, FactoryResult, ISARequirements, Block, @@ -76,6 +77,7 @@ "EvictionStrategy", "DynamicMemoryCompute", "Encoding", + "ErrorComposition", "EstimationResult", "EstimationTable", "EstimationTableColumn", diff --git a/source/qdk_package/qdk/qre/_estimation.py b/source/qdk_package/qdk/qre/_estimation.py index 228e139ede6..6191ac4b455 100644 --- a/source/qdk_package/qdk/qre/_estimation.py +++ b/source/qdk_package/qdk/qre/_estimation.py @@ -12,6 +12,7 @@ _estimate_parallel, _estimate_with_graph, _EstimationCollection, + ErrorComposition, Trace, ) from ._trace import TraceQuery, PSSPC, LatticeSurgery @@ -28,6 +29,7 @@ def estimate( max_error: float = 1.0, post_process: bool = False, use_graph: bool = True, + composition: ErrorComposition = ErrorComposition.UnionBound, name: Optional[str] = None, ) -> EstimationTable: """ @@ -73,6 +75,11 @@ def estimate( builds a graph of ISAs and prunes suboptimal ISAs during estimation. If False, use the Rust estimation path that does not perform any pruning and simply enumerates all ISAs for each trace. + composition (ErrorComposition): Controls how per-item error + contributions are composed into the total error. + ``ErrorComposition.UnionBound`` (default) sums the contributions, + while ``ErrorComposition.Product`` composes them as + ``1 - prod(1 - p_i)``. name (Optional[str]): An optional name for the estimation. If given, this will be added as a first column to the results table for all entries. @@ -106,7 +113,11 @@ def estimate( num_isas = arch_ctx._provenance.total_isa_count() collection = _estimate_with_graph( - cast(list[Trace], traces_only), arch_ctx._provenance, max_error, True + cast(list[Trace], traces_only), + arch_ctx._provenance, + max_error, + True, + composition, ) isas = collection.isas else: @@ -115,7 +126,7 @@ def estimate( num_isas = len(isas) collection = _estimate_parallel( - cast(list[Trace], traces_only), isas, max_error, True + cast(list[Trace], traces_only), isas, max_error, True, composition ) total_jobs = collection.total_jobs @@ -133,7 +144,7 @@ def estimate( trace_sample_isa[t_idx] = isa_idx for t_idx, isa_idx in trace_sample_isa.items(): params, trace = params_and_traces[t_idx] - sample = trace.estimate(isas[isa_idx], max_error) + sample = trace.estimate(isas[isa_idx], max_error, composition) if sample is not None: pre_q = sample.qubits pre_r = sample.runtime @@ -164,7 +175,7 @@ def estimate( pp_collection = _EstimationCollection() for t_idx, isa_idx, _q, _r in approx_pareto: params, trace = params_and_traces[t_idx] - result = trace.estimate(isas[isa_idx], max_error) + result = trace.estimate(isas[isa_idx], max_error, composition) if result is not None: pp_result = app_ctx.application.post_process(params, result) if pp_result is not None: @@ -181,7 +192,11 @@ def estimate( num_isas = arch_ctx._provenance.total_isa_count() collection = _estimate_with_graph( - cast(list[Trace], traces), arch_ctx._provenance, max_error, False + cast(list[Trace], traces), + arch_ctx._provenance, + max_error, + False, + composition, ) else: isas = list(isa_query.enumerate(arch_ctx)) @@ -190,7 +205,7 @@ def estimate( # Use the Rust parallel estimation path collection = _estimate_parallel( - cast(list[Trace], traces), isas, max_error, False + cast(list[Trace], traces), isas, max_error, False, composition ) total_jobs = collection.total_jobs diff --git a/source/qdk_package/qdk/qre/_qre.py b/source/qdk_package/qdk/qre/_qre.py index ed197dc9ca0..677590f3ab4 100644 --- a/source/qdk_package/qdk/qre/_qre.py +++ b/source/qdk_package/qdk/qre/_qre.py @@ -14,6 +14,7 @@ _estimate_parallel, _estimate_with_graph, _EstimationCollection, + ErrorComposition, EstimationResult, FactoryResult, _FloatFunction, diff --git a/source/qdk_package/qdk/qre/_qre.pyi b/source/qdk_package/qdk/qre/_qre.pyi index 31e144176c7..79956375039 100644 --- a/source/qdk_package/qdk/qre/_qre.pyi +++ b/source/qdk_package/qdk/qre/_qre.pyi @@ -688,10 +688,7 @@ def generic_function( func: Callable[[int, list[float]], float], ) -> _FloatFunction: ... def generic_function( - func: ( - Callable[[int], int | float] - | Callable[[int, list[float]], int | float] - ), + func: Callable[[int], int | float] | Callable[[int, list[float]], int | float], ) -> _IntFunction | _FloatFunction: """ Create a generic function from a Python callable. @@ -935,6 +932,49 @@ class _ProvenanceGraph: """ ... +class ErrorComposition: + """ + Controls how per-item error contributions are composed into the total + error reported by an estimation. + + Both modes estimate the probability that at least one error occurs across + many fault-prone operations, each with failure probability ``p_i``. They + differ in the assumptions they make: + + - ``UnionBound`` computes ``sum(p_i)`` (Boole's inequality). It is a strict + upper bound that holds for any events, whether or not they are + independent. + + Advantages: always conservative (never underestimates); requires no + independence assumption; cheap; additive, so contributions from + subsystems simply add together. + + Disadvantages: can exceed 1.0, which is meaningless as a probability; it + grows increasingly loose as the individual errors grow, because it + overcounts the overlap between simultaneous failures. + + - ``Product`` computes ``1 - prod(1 - p_i)``, i.e. one minus the + probability that no operation fails, assuming the failures are + independent. + + Advantages: always stays in ``[0, 1)``; it is tight (in fact exact) when + the errors are independent. + + Disadvantages: it relies on independence, so it can underestimate when + errors are positively correlated; it is slightly more work to compute; + and for small ``p_i`` it barely differs from the union-bound sum, so the + two only diverge noticeably in the high-error regime. + + Attributes: + UnionBound (ErrorComposition): Union bound; contributions are summed + (``sum(p_i)``). This is the default and can exceed 1.0. + Product (ErrorComposition): Product composition; contributions combine + as ``1 - prod(1 - p_i)``. + """ + + UnionBound: ErrorComposition + Product: ErrorComposition + class EstimationResult: """ Represents the result of a resource estimation. @@ -1016,6 +1056,26 @@ class EstimationResult: """ ... + @property + def error_composition(self) -> ErrorComposition: + """ + The composition mode used when accumulating error contributions. + + Returns: + ErrorComposition: The current composition mode. + """ + ... + + @error_composition.setter + def error_composition(self, composition: ErrorComposition) -> None: + """ + Set the composition mode used when accumulating error contributions. + + Args: + composition (ErrorComposition): The composition mode to set. + """ + ... + @property def factories(self) -> dict[int, FactoryResult]: """ @@ -1425,7 +1485,10 @@ class Trace: ... def estimate( - self, isa: ISA, max_error: Optional[float] = None + self, + isa: ISA, + max_error: Optional[float] = None, + composition: ErrorComposition = ErrorComposition.UnionBound, ) -> Optional[EstimationResult]: """ Estimate resources for the trace given a logical ISA. @@ -1434,6 +1497,11 @@ class Trace: isa (ISA): The logical ISA. max_error (Optional[float]): The maximum allowed error. If None, Pareto points are computed. + composition (ErrorComposition): Controls how per-item error + contributions are composed. ``ErrorComposition.UnionBound`` + (default) sums the contributions, while + ``ErrorComposition.Product`` composes them as + ``1 - prod(1 - p_i)``. Returns: Optional[EstimationResult]: The estimation result if max_error is @@ -1720,6 +1788,7 @@ def _estimate_parallel( isas: list[ISA], max_error: float = 1.0, post_process: bool = False, + composition: ErrorComposition = ErrorComposition.UnionBound, ) -> _EstimationCollection: """ Estimate resources for multiple traces and ISAs in parallel. @@ -1730,6 +1799,10 @@ def _estimate_parallel( max_error (float): The maximum allowed error. The default is 1.0. post_process (bool): If True, computes auxiliary data such as result summaries needed for post-processing after estimation. + composition (ErrorComposition): Controls how per-item error + contributions are composed. ``ErrorComposition.UnionBound`` + (default) sums the contributions, while + ``ErrorComposition.Product`` composes them as ``1 - prod(1 - p_i)``. Returns: _EstimationCollection: The estimation collection. @@ -1741,6 +1814,7 @@ def _estimate_with_graph( graph: _ProvenanceGraph, max_error: float = 1.0, post_process: bool = False, + composition: ErrorComposition = ErrorComposition.UnionBound, ) -> _EstimationCollection: """ Estimate resources using a Pareto-filtered provenance graph. @@ -1755,6 +1829,10 @@ def _estimate_with_graph( max_error (float): The maximum allowed error. The default is 1.0. post_process (bool): If True, computes auxiliary data such as result summaries and ISAs needed for post-processing after estimation. + composition (ErrorComposition): Controls how per-item error + contributions are composed. ``ErrorComposition.UnionBound`` + (default) sums the contributions, while + ``ErrorComposition.Product`` composes them as ``1 - prod(1 - p_i)``. Returns: _EstimationCollection: The estimation collection. diff --git a/source/qdk_package/src/qre.rs b/source/qdk_package/src/qre.rs index 0ef260e92f6..ab3f4c56895 100644 --- a/source/qdk_package/src/qre.rs +++ b/source/qdk_package/src/qre.rs @@ -32,6 +32,7 @@ pub(crate) fn register_qre_submodule(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_class::()?; + m.add_class::()?; m.add_class::()?; m.add_class::()?; m.add_class::()?; @@ -1023,6 +1024,63 @@ impl EstimationCollectionIterator { #[pyclass] pub struct EstimationResult(qre::EstimationResult); +/// Controls how per-item error contributions are composed into the total error +/// reported by an estimation. +/// +/// Both modes estimate the probability that at least one error occurs across +/// many fault-prone operations, each with failure probability ``p_i``. They +/// differ in the assumptions they make: +/// +/// - ``UnionBound`` computes ``sum(p_i)`` (Boole's inequality). It is a strict +/// upper bound that holds for any events, whether or not they are +/// independent. +/// +/// Advantages: always conservative (never underestimates); requires no +/// independence assumption; cheap; additive, so contributions from +/// subsystems simply add together. +/// +/// Disadvantages: can exceed 1.0, which is meaningless as a probability; it +/// grows increasingly loose as the individual errors grow, because it +/// overcounts the overlap between simultaneous failures. +/// +/// - ``Product`` computes ``1 - prod(1 - p_i)``, i.e. one minus the probability +/// that no operation fails, assuming the failures are independent. +/// +/// Advantages: always stays in ``[0, 1)``; it is tight (in fact exact) when +/// the errors are independent. +/// +/// Disadvantages: it relies on independence, so it can underestimate when +/// errors are positively correlated; it is slightly more work to compute; and +/// for small ``p_i`` it barely differs from the union-bound sum, so the two +/// only diverge noticeably in the high-error regime. +#[pyclass(name = "ErrorComposition", eq, eq_int, from_py_object)] +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum ErrorComposition { + /// Union bound: contributions are summed (`sum(p_i)`). This is the default + /// and can exceed 1.0. + UnionBound, + /// Product composition: contributions combine as `1 - prod(1 - p_i)`. + Product, +} + +impl From for qre::ErrorComposition { + fn from(value: ErrorComposition) -> Self { + match value { + ErrorComposition::UnionBound => qre::ErrorComposition::UnionBound, + ErrorComposition::Product => qre::ErrorComposition::Product, + } + } +} + +impl From for ErrorComposition { + fn from(value: qre::ErrorComposition) -> Self { + match value { + qre::ErrorComposition::UnionBound => ErrorComposition::UnionBound, + qre::ErrorComposition::Product => ErrorComposition::Product, + } + } +} + #[pymethods] impl EstimationResult { #[new] @@ -1031,7 +1089,7 @@ impl EstimationResult { let mut result = qre::EstimationResult::new(); result.add_qubits(qubits); result.add_runtime(runtime); - result.add_error(error); + result.add_error(error, 1.0); EstimationResult(result) } @@ -1066,6 +1124,16 @@ impl EstimationResult { self.0.set_error(error); } + #[getter] + pub fn error_composition(&self) -> ErrorComposition { + self.0.error_composition().into() + } + + #[setter] + pub fn set_error_composition(&mut self, composition: ErrorComposition) { + self.0.set_error_composition(composition.into()); + } + #[allow(clippy::needless_pass_by_value)] #[getter] pub fn factories(self_: PyRef<'_, Self>) -> PyResult> { @@ -1282,10 +1350,15 @@ impl Trace { Ok(dict) } - #[pyo3(signature = (isa, max_error = None))] - pub fn estimate(&self, isa: &ISA, max_error: Option) -> Option { + #[pyo3(signature = (isa, max_error = None, composition = ErrorComposition::UnionBound))] + pub fn estimate( + &self, + isa: &ISA, + max_error: Option, + composition: ErrorComposition, + ) -> Option { self.0 - .estimate(&isa.0, max_error) + .estimate(&isa.0, max_error, composition.into()) .map(|mut r| { r.set_isa(isa.0.clone()); EstimationResult(r) @@ -1639,13 +1712,14 @@ impl InstructionFrontierIterator { } #[allow(clippy::needless_pass_by_value)] -#[pyfunction(name = "_estimate_parallel", signature = (traces, isas, max_error = 1.0, post_process = false))] +#[pyfunction(name = "_estimate_parallel", signature = (traces, isas, max_error = 1.0, post_process = false, composition = ErrorComposition::UnionBound))] pub fn estimate_parallel( py: Python<'_>, traces: Vec>, isas: Vec>, max_error: f64, post_process: bool, + composition: ErrorComposition, ) -> EstimationCollection { let traces: Vec<_> = traces.iter().map(|t| &t.0).collect(); let isas: Vec<_> = isas.iter().map(|i| &i.0).collect(); @@ -1656,24 +1730,37 @@ pub fn estimate_parallel( // If the calling thread holds the GIL while blocked in // std::thread::scope, the worker threads deadlock. let collection = release_gil(py, || { - qre::estimate_parallel(&traces, &isas, Some(max_error), post_process) + qre::estimate_parallel( + &traces, + &isas, + Some(max_error), + post_process, + composition.into(), + ) }); EstimationCollection(collection) } #[allow(clippy::needless_pass_by_value)] -#[pyfunction(name = "_estimate_with_graph", signature = (traces, graph, max_error = 1.0, post_process = false))] +#[pyfunction(name = "_estimate_with_graph", signature = (traces, graph, max_error = 1.0, post_process = false, composition = ErrorComposition::UnionBound))] pub fn estimate_with_graph( py: Python<'_>, traces: Vec>, graph: &ProvenanceGraph, max_error: f64, post_process: bool, + composition: ErrorComposition, ) -> PyResult { let traces: Vec<_> = traces.iter().map(|t| &t.0).collect(); let collection = release_gil(py, || { - qre::estimate_with_graph(&traces, &graph.0, Some(max_error), post_process) + qre::estimate_with_graph( + &traces, + &graph.0, + Some(max_error), + post_process, + composition.into(), + ) }); Ok(EstimationCollection(collection)) } diff --git a/source/qdk_package/tests/qre/test_application.py b/source/qdk_package/tests/qre/test_application.py index 4ec8d912c4d..1503f98b4ca 100644 --- a/source/qdk_package/tests/qre/test_application.py +++ b/source/qdk_package/tests/qre/test_application.py @@ -11,6 +11,7 @@ ISA, LOGICAL, PSSPC, + ErrorComposition, EstimationResult, LatticeSurgery, Trace, @@ -207,6 +208,44 @@ def test_trace_enumeration(): assert sum(1 for _ in q.enumerate(ctx)) == 32 +def test_error_composition_modes(): + """Test that union-bound and product error composition differ as expected.""" + # Ten T gates, each with error rate 0.1. Under the union bound the total + # error is the sum (1.0), while under product composition it is + # 1 - (1 - 0.1)^10. + trace = Trace(1) + for _ in range(10): + trace.add_operation(T, [0]) + + graph = _ProvenanceGraph() + isa = graph.make_isa( + [ + graph.add_instruction( + T, encoding=LOGICAL, time=1000, space=400, error_rate=0.1 + ), + ] + ) + + union = trace.estimate( + isa, max_error=float("inf"), composition=ErrorComposition.UnionBound + ) + assert union is not None + assert abs(union.error - 1.0) <= 1e-9 + assert union.error_composition == ErrorComposition.UnionBound + + product = trace.estimate( + isa, max_error=float("inf"), composition=ErrorComposition.Product + ) + assert product is not None + assert abs(product.error - (1.0 - 0.9**10)) <= 1e-9 + assert product.error_composition == ErrorComposition.Product + + # The default composition is the union bound. + default = trace.estimate(isa, max_error=float("inf")) + assert default is not None + assert abs(default.error - union.error) <= 1e-9 + + def test_rotation_error_psspc(): """Test that PSSPC base error stays below 1.0 for a single rotation gate.""" # This test helps to bound the variables for the number of rotations in PSSPC diff --git a/source/qre/src/lib.rs b/source/qre/src/lib.rs index 8fdab74a642..73beb1158f2 100644 --- a/source/qre/src/lib.rs +++ b/source/qre/src/lib.rs @@ -16,7 +16,9 @@ pub use isa::{ ConstraintBound, Encoding, ISA, ISARequirements, Instruction, InstructionConstraint, LockedISA, ProvenanceGraph, VariableArityFunction, }; -pub use result::{EstimationCollection, EstimationResult, FactoryResult, ResultSummary}; +pub use result::{ + ErrorComposition, EstimationCollection, EstimationResult, FactoryResult, ResultSummary, +}; mod trace; pub use trace::instruction_ids; pub use trace::instruction_ids::instruction_name; diff --git a/source/qre/src/result.rs b/source/qre/src/result.rs index 208195531ba..2956bcf1426 100644 --- a/source/qre/src/result.rs +++ b/source/qre/src/result.rs @@ -10,11 +10,26 @@ use rustc_hash::FxHashMap; use crate::{ISA, ParetoFrontier2D, ParetoItem2D, Property}; +/// Controls how individual error contributions passed to +/// [`EstimationResult::add_error`] are composed into the total error. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum ErrorComposition { + /// Union bound: contributions are summed (`Σ pᵢ`). This is the default and + /// preserves the historical behavior. The resulting error can exceed 1.0. + #[default] + UnionBound, + /// Product composition: contributions are combined as `1 − ∏(1 − pᵢ)`, + /// treating each contribution as an independent error event. The resulting + /// error is always in `[0, 1)`. + Product, +} + #[derive(Clone, Default)] pub struct EstimationResult { qubits: u64, runtime: u64, error: f64, + error_composition: ErrorComposition, factories: FxHashMap, isa: ISA, isa_index: Option, @@ -60,6 +75,17 @@ impl EstimationResult { self.error = error; } + /// Returns the composition mode used by [`Self::add_error`]. + #[must_use] + pub fn error_composition(&self) -> ErrorComposition { + self.error_composition + } + + /// Sets the composition mode used by [`Self::add_error`]. + pub fn set_error_composition(&mut self, composition: ErrorComposition) { + self.error_composition = composition; + } + /// Adds to the current qubit count and returns the new value. pub fn add_qubits(&mut self, qubits: u64) -> u64 { self.qubits += qubits; @@ -72,9 +98,25 @@ impl EstimationResult { self.runtime } - /// Adds to the current error and returns the new value. - pub fn add_error(&mut self, error: f64) -> f64 { - self.error += error; + /// Composes `count` copies of an error of rate `rate` into the current total + /// error according to the configured [`ErrorComposition`] and returns the + /// new total error. + /// + /// For [`ErrorComposition::UnionBound`] the contribution `rate * count` is + /// summed. For [`ErrorComposition::Product`] the survival probability + /// `1 − error` is multiplied by `(1 − rate)^count`, i.e. + /// `error ← 1 − (1 − error) · (1 − rate)^count`. + /// + /// In both modes the total error is non-decreasing in `rate` and `count`. + pub fn add_error(&mut self, rate: f64, count: f64) -> f64 { + match self.error_composition { + ErrorComposition::UnionBound => { + self.error += rate * count; + } + ErrorComposition::Product => { + self.error = 1.0 - (1.0 - self.error) * (1.0 - rate).powf(count); + } + } self.error } diff --git a/source/qre/src/trace.rs b/source/qre/src/trace.rs index 709559668c8..b2a26d10909 100644 --- a/source/qre/src/trace.rs +++ b/source/qre/src/trace.rs @@ -10,8 +10,8 @@ use rustc_hash::{FxHashMap, FxHashSet}; use serde::{Deserialize, Serialize}; use crate::{ - ConstraintBound, Encoding, Error, EstimationResult, FactoryResult, ISA, ISARequirements, - Instruction, InstructionConstraint, LockedISA, + ConstraintBound, Encoding, Error, ErrorComposition, EstimationResult, FactoryResult, ISA, + ISARequirements, Instruction, InstructionConstraint, LockedISA, property_keys::{ LOGICAL_COMPUTE_QUBITS, LOGICAL_MEMORY_QUBITS, PHYSICAL_COMPUTE_QUBITS, PHYSICAL_FACTORY_QUBITS, PHYSICAL_MEMORY_QUBITS, @@ -260,7 +260,12 @@ impl Trace { clippy::cast_sign_loss, clippy::too_many_lines )] - pub fn estimate(&self, isa: &ISA, max_error: Option) -> Result { + pub fn estimate( + &self, + isa: &ISA, + max_error: Option, + composition: ErrorComposition, + ) -> Result { let locked = isa.lock(); let max_error = max_error.unwrap_or(1.0); @@ -272,9 +277,10 @@ impl Trace { } let mut result = EstimationResult::new(); + result.set_error_composition(composition); // base error starts with the error already present in the trace - result.add_error(self.base_error); + result.add_error(self.base_error, 1.0); // Counts how many magic state factories are needed per resource state ID let mut factories: FxHashMap = FxHashMap::default(); @@ -290,7 +296,7 @@ impl Trace { if let Some(resource_states) = &self.resource_states { for (state_id, count) in resource_states { let rate = get_error_rate_by_id(&locked, *state_id, &[])?; - let actual_error = result.add_error(rate * (*count as f64)); + let actual_error = result.add_error(rate, *count as f64); if actual_error > max_error { return Err(Error::MaximumErrorExceeded { actual_error, @@ -319,7 +325,7 @@ impl Trace { qubit_counts.insert(i, qubit_count); } - let actual_error = result.add_error(rate * (mult as f64)); + let actual_error = result.add_error(rate, mult as f64); if actual_error > max_error { return Err(Error::MaximumErrorExceeded { actual_error, @@ -393,8 +399,10 @@ impl Trace { .runtime() .div_ceil(memory.expect_time(Some(memory_qubits), &[])); - let actual_error = result - .add_error(rounds as f64 * memory.expect_error_rate(Some(memory_qubits), &[])); + let actual_error = result.add_error( + memory.expect_error_rate(Some(memory_qubits), &[]), + rounds as f64, + ); if actual_error > max_error { return Err(Error::MaximumErrorExceeded { actual_error, diff --git a/source/qre/src/trace/estimation.rs b/source/qre/src/trace/estimation.rs index 050ae80928c..d36f163a34b 100644 --- a/source/qre/src/trace/estimation.rs +++ b/source/qre/src/trace/estimation.rs @@ -10,7 +10,7 @@ use std::{ use rustc_hash::{FxHashMap, FxHashSet}; -use crate::{EstimationCollection, ISA, ProvenanceGraph, ResultSummary, Trace}; +use crate::{ErrorComposition, EstimationCollection, ISA, ProvenanceGraph, ResultSummary, Trace}; /// Estimates all (trace, ISA) combinations in parallel, returning only the /// successful results collected into an [`EstimationCollection`]. @@ -35,6 +35,7 @@ pub fn estimate_parallel<'a>( isas: &[&'a ISA], max_error: Option, post_process: bool, + composition: ErrorComposition, ) -> EstimationCollection { let total_jobs = traces.len() * isas.len(); let num_isas = isas.len(); @@ -71,7 +72,8 @@ pub fn estimate_parallel<'a>( let trace_idx = job / num_isas; let isa_idx = job % num_isas; - if let Ok(mut estimation) = traces[trace_idx].estimate(isas[isa_idx], max_error) + if let Ok(mut estimation) = + traces[trace_idx].estimate(isas[isa_idx], max_error, composition) { estimation.set_isa_index(isa_idx); estimation.set_trace_index(trace_idx); @@ -286,6 +288,7 @@ pub fn estimate_with_graph( graph: &Arc>, max_error: Option, post_process: bool, + composition: ErrorComposition, ) -> EstimationCollection { let max_error = max_error.unwrap_or(1.0); @@ -444,7 +447,9 @@ pub fn estimate_with_graph( isa.add_node(entry.instruction_id, entry.node.node_index); } - if let Ok(mut result) = traces[*trace_idx].estimate(&isa, Some(max_error)) { + if let Ok(mut result) = + traces[*trace_idx].estimate(&isa, Some(max_error), composition) + { let isa_idx = isa_index .write() .expect("RwLock should not be poisoned") diff --git a/source/qre/src/trace/tests.rs b/source/qre/src/trace/tests.rs index 9c9e6cc536d..d66ed304b92 100644 --- a/source/qre/src/trace/tests.rs +++ b/source/qre/src/trace/tests.rs @@ -2,6 +2,7 @@ // Licensed under the MIT License. use crate::isa::{Encoding, ISA, Instruction}; +use crate::result::ErrorComposition; use crate::trace::{Trace, instruction_ids::*}; #[test] @@ -225,13 +226,48 @@ fn test_estimate_simple() { 0.001, // error_rate )); - let result = trace.estimate(&isa, None).expect("Estimation failed"); + let result = trace + .estimate(&isa, None, ErrorComposition::UnionBound) + .expect("Estimation failed"); assert!((result.error() - 0.001).abs() <= f64::EPSILON); assert_eq!(result.runtime(), 100); assert_eq!(result.qubits(), 50); } +#[test] +fn test_estimate_product_composition() { + // Ten T gates, each with error rate 0.1. Under the union bound the total + // error is the sum (1.0), while under product composition it is + // 1 - (1 - 0.1)^10. + let mut trace = Trace::new(1); + for _ in 0..10 { + trace.add_operation(T, vec![0], vec![]); + } + + let mut isa = ISA::new(); + isa.add_instruction(Instruction::fixed_arity( + T, + Encoding::Logical, + 1, // arity + 100, // time + Some(50), // space + None, // length (defaults to arity) + 0.1, // error_rate + )); + + let union = trace + .estimate(&isa, None, ErrorComposition::UnionBound) + .expect("Estimation failed"); + assert!((union.error() - 1.0).abs() <= 1e-9); + + let product = trace + .estimate(&isa, None, ErrorComposition::Product) + .expect("Estimation failed"); + let expected = 1.0 - 0.9_f64.powi(10); + assert!((product.error() - expected).abs() <= 1e-9); +} + #[test] fn test_estimate_with_factory() { let mut trace = Trace::new(1); @@ -265,7 +301,9 @@ fn test_estimate_with_factory() { 0.0, )); - let result = trace.estimate(&isa, None).expect("Estimation failed"); + let result = trace + .estimate(&isa, None, ErrorComposition::UnionBound) + .expect("Estimation failed"); assert_eq!(result.runtime(), 1000); assert_eq!(result.qubits(), 700); From 40ba11d683264d36441185d3622e3d722f75d760 Mon Sep 17 00:00:00 2001 From: Mathias Soeken Date: Wed, 8 Jul 2026 10:25:15 +0000 Subject: [PATCH 2/2] Address review comments. --- source/qdk_package/qdk/qre/_qre.pyi | 7 +++++-- source/qdk_package/src/qre.rs | 9 ++++++--- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/source/qdk_package/qdk/qre/_qre.pyi b/source/qdk_package/qdk/qre/_qre.pyi index 79956375039..77e5922f842 100644 --- a/source/qdk_package/qdk/qre/_qre.pyi +++ b/source/qdk_package/qdk/qre/_qre.pyi @@ -962,8 +962,11 @@ class ErrorComposition: Disadvantages: it relies on independence, so it can underestimate when errors are positively correlated; it is slightly more work to compute; - and for small ``p_i`` it barely differs from the union-bound sum, so the - two only diverge noticeably in the high-error regime. + for small ``p_i`` it barely differs from the union-bound sum, so the + two only diverge noticeably in the high-error regime; and it is prone to + finite-precision loss when composing many small probabilities, because + each ``1 - p_i`` factor rounds toward 1 and the final ``1 - prod(...)`` + subtraction cancels most significant digits. Attributes: UnionBound (ErrorComposition): Union bound; contributions are summed diff --git a/source/qdk_package/src/qre.rs b/source/qdk_package/src/qre.rs index ab3f4c56895..f841ca9acda 100644 --- a/source/qdk_package/src/qre.rs +++ b/source/qdk_package/src/qre.rs @@ -1050,9 +1050,12 @@ pub struct EstimationResult(qre::EstimationResult); /// the errors are independent. /// /// Disadvantages: it relies on independence, so it can underestimate when -/// errors are positively correlated; it is slightly more work to compute; and -/// for small ``p_i`` it barely differs from the union-bound sum, so the two -/// only diverge noticeably in the high-error regime. +/// errors are positively correlated; it is slightly more work to compute; for +/// small ``p_i`` it barely differs from the union-bound sum, so the two only +/// diverge noticeably in the high-error regime; and it is prone to +/// finite-precision loss when composing many small probabilities, because +/// each ``1 - p_i`` factor rounds toward 1 and the final ``1 - prod(...)`` +/// subtraction cancels most significant digits. #[pyclass(name = "ErrorComposition", eq, eq_int, from_py_object)] #[derive(Clone, Copy, PartialEq, Eq)] pub enum ErrorComposition {