Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 81 additions & 33 deletions crates/pecos-qec/src/distance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -344,19 +344,33 @@ pub fn find_min_weight_logicals(
pub fn find_min_weight_logicals_with_info(
code: &StabilizerCodeSpec,
config: &DistanceSearchConfig,
) -> Vec<LogicalOperatorInfo> {
find_shortest_logicals(code, config, 0)
}

/// Find all logical operators from the minimum weight through `delta` weights above it.
///
/// The search always starts at weight 1. Once the minimum logical weight is found,
/// collection continues through `minimum_weight + delta`, subject to
/// `config.max_weight`.
#[must_use]
pub fn find_shortest_logicals(
code: &StabilizerCodeSpec,
config: &DistanceSearchConfig,
delta: usize,
) -> Vec<LogicalOperatorInfo> {
let max_weight = config.max_weight.unwrap_or(code.num_qubits());
let mut results = Vec::new();
let mut found_distance = None;
let mut found_distance: Option<usize> = None;

// Build indices once for O(weight) lookups instead of O(num_stabilizers * weight)
let stab_index = code.build_stabilizer_index();
let log_index = code.build_logical_index();

for weight in 1..=max_weight {
// If we've found logical operators and this weight is larger, stop
// If we've searched through the requested range above the minimum, stop.
if let Some(d) = found_distance
&& weight > d
&& weight > d.saturating_add(delta)
{
break;
}
Expand Down Expand Up @@ -437,6 +451,39 @@ mod tests {
)
}

fn five_qubit_code() -> StabilizerCodeSpec {
// The [[5,1,3]] perfect code
// Stabilizers: XZZXI, IXZZX, XIXZZ, ZXIXZ
let stab1 = pauli_string(&[(Pauli::X, 0), (Pauli::Z, 1), (Pauli::Z, 2), (Pauli::X, 3)]);
let stab2 = pauli_string(&[(Pauli::X, 1), (Pauli::Z, 2), (Pauli::Z, 3), (Pauli::X, 4)]);
let stab3 = pauli_string(&[(Pauli::X, 0), (Pauli::X, 2), (Pauli::Z, 3), (Pauli::Z, 4)]);
let stab4 = pauli_string(&[(Pauli::Z, 0), (Pauli::X, 1), (Pauli::X, 3), (Pauli::Z, 4)]);

// Logical operators for [[5,1,3]]: Z = ZZZZZ, X = XXXXX
let logical_z = pauli_string(&[
(Pauli::Z, 0),
(Pauli::Z, 1),
(Pauli::Z, 2),
(Pauli::Z, 3),
(Pauli::Z, 4),
]);
let logical_x = pauli_string(&[
(Pauli::X, 0),
(Pauli::X, 1),
(Pauli::X, 2),
(Pauli::X, 3),
(Pauli::X, 4),
]);

StabilizerCodeSpec::new(
5,
vec![stab1, stab2, stab3, stab4],
vec![logical_z],
vec![logical_x],
)
.unwrap()
}

#[test]
fn test_weighted_pauli_iterator_weight_1() {
let iter = WeightedPauliIterator::new(3, 1, false);
Expand Down Expand Up @@ -533,36 +580,7 @@ mod tests {

#[test]
fn test_five_qubit_code_distance() {
// The [[5,1,3]] perfect code
// Stabilizers: XZZXI, IXZZX, XIXZZ, ZXIXZ
let stab1 = pauli_string(&[(Pauli::X, 0), (Pauli::Z, 1), (Pauli::Z, 2), (Pauli::X, 3)]);
let stab2 = pauli_string(&[(Pauli::X, 1), (Pauli::Z, 2), (Pauli::Z, 3), (Pauli::X, 4)]);
let stab3 = pauli_string(&[(Pauli::X, 0), (Pauli::X, 2), (Pauli::Z, 3), (Pauli::Z, 4)]);
let stab4 = pauli_string(&[(Pauli::Z, 0), (Pauli::X, 1), (Pauli::X, 3), (Pauli::Z, 4)]);

// Logical operators for [[5,1,3]]: Z = ZZZZZ, X = XXXXX
let logical_z = pauli_string(&[
(Pauli::Z, 0),
(Pauli::Z, 1),
(Pauli::Z, 2),
(Pauli::Z, 3),
(Pauli::Z, 4),
]);
let logical_x = pauli_string(&[
(Pauli::X, 0),
(Pauli::X, 1),
(Pauli::X, 2),
(Pauli::X, 3),
(Pauli::X, 4),
]);

let code = StabilizerCodeSpec::new(
5,
vec![stab1, stab2, stab3, stab4],
vec![logical_z],
vec![logical_x],
)
.unwrap();
let code = five_qubit_code();

// Verify the code is valid
assert!(code.verify().is_ok());
Expand All @@ -576,6 +594,36 @@ mod tests {
assert_eq!(result.distance, 3);
}

#[test]
fn test_five_qubit_shortest_logicals_respect_logical_weight_spectrum() {
let code = five_qubit_code();
let config = DistanceSearchConfig::default();
let minimum = find_min_weight_logicals_with_info(&code, &config);
let delta_one = find_shortest_logicals(&code, &config, 1);
let delta_two = find_shortest_logicals(&code, &config, 2);

assert_eq!(minimum.len(), 30);
assert_eq!(delta_one.len(), 30);
assert!(
delta_one
.iter()
.map(|info| &info.operator)
.eq(minimum.iter().map(|info| &info.operator))
);

assert_eq!(delta_two.len(), 48);
assert_eq!(delta_two.iter().filter(|info| info.weight == 3).count(), 30);
assert_eq!(delta_two.iter().filter(|info| info.weight == 5).count(), 18);
assert!(delta_two.iter().all(|info| matches!(info.weight, 3 | 5)));
assert!(
delta_two
.iter()
.take(minimum.len())
.map(|info| &info.operator)
.eq(minimum.iter().map(|info| &info.operator))
);
}

#[test]
fn test_logical_equivalence_tracking() {
// 3-qubit bit flip code
Expand Down
3 changes: 3 additions & 0 deletions crates/pecos-qec/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,16 +69,19 @@ pub mod fault_tolerance;
pub mod geometry;
pub mod logical_discovery;
pub mod mem_stab;
pub mod parity_check_matrix;
pub mod stabilizer_code;
pub mod stabilizer_code_spec;
pub mod surface;

pub use dem_stab::{DemStabError, DemStabShotBatch, DemStabSim, DemStabSimBuilder};
pub use mem_stab::{MemStabError, MemStabSim, MemStabSimBuilder};
pub use parity_check_matrix::{ParityCheckMatrix, ParityCheckMatrixError};

pub use distance::{
DistanceResult, DistanceSearchConfig, LogicalOperatorInfo, WeightedPauliIterator,
calculate_distance, find_min_weight_logicals, find_min_weight_logicals_with_info,
find_shortest_logicals,
};
pub use fault_tolerance::dem_builder::{
DecomposedFault, DemBuilder, DemBuilderError, DemOutput, DetectorDef, DetectorErrorModel,
Expand Down
188 changes: 188 additions & 0 deletions crates/pecos-qec/src/parity_check_matrix.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
// Copyright 2026 The PECOS Developers
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

//! Role-neutral binary parity-check matrices for QEC codes.

use pecos_core::{Pauli, PauliString, QuarterPhase, QubitId};
use pecos_quantum::F2Matrix;
use thiserror::Error;

/// Errors that can occur when constructing a [`ParityCheckMatrix`].
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum ParityCheckMatrixError {
/// No rows were supplied, so the matrix width cannot be inferred.
#[error(
"cannot infer parity-check matrix width from empty input; use ParityCheckMatrix::zeros"
)]
EmptyRows,
/// A row has a different width from the first row.
#[error("parity-check matrix row {row} has {actual} columns, expected {expected}")]
RaggedRows {
/// Index of the mismatched row.
row: usize,
/// Width inferred from the first row.
expected: usize,
/// Actual width of the mismatched row.
actual: usize,
},
/// A dense entry was not binary.
#[error("parity-check matrix entry at row {row}, column {column} is {value}, expected 0 or 1")]
InvalidEntry {
/// Row containing the invalid entry.
row: usize,
/// Column containing the invalid entry.
column: usize,
/// Invalid value.
value: u8,
},
}

/// A role-neutral binary matrix whose rows are checks and columns are qubits.
///
/// Whether rows become X-type or Z-type stabilizers is chosen only when
/// converting the matrix; that role is not stored in this type.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParityCheckMatrix {
matrix: F2Matrix,
}

impl ParityCheckMatrix {
/// Constructs a validated parity-check matrix from dense binary rows.
///
/// # Errors
///
/// Returns an error for empty input, ragged rows, or non-binary entries.
pub fn from_dense(rows: Vec<Vec<u8>>) -> Result<Self, ParityCheckMatrixError> {
let Some(first) = rows.first() else {
return Err(ParityCheckMatrixError::EmptyRows);
};
let num_qubits = first.len();
for (row_index, row) in rows.iter().enumerate() {
if row.len() != num_qubits {
return Err(ParityCheckMatrixError::RaggedRows {
row: row_index,
expected: num_qubits,
actual: row.len(),
});
}
for (column, &value) in row.iter().enumerate() {
if value > 1 {
return Err(ParityCheckMatrixError::InvalidEntry {
row: row_index,
column,
value,
});
}
}
}
Ok(Self {
matrix: F2Matrix::from_rows(rows),
})
}

/// Constructs an all-zero matrix with an explicit number of qubits.
#[must_use]
pub fn zeros(num_checks: usize, num_qubits: usize) -> Self {
Self {
matrix: F2Matrix::zeros(num_checks, num_qubits),
}
}

/// Returns the number of checks (rows).
#[must_use]
pub fn num_checks(&self) -> usize {
self.matrix.num_rows()
}

/// Returns the number of qubits (columns).
#[must_use]
pub fn num_qubits(&self) -> usize {
self.matrix.num_cols()
}

/// Returns the rank over GF(2).
#[must_use]
pub fn rank(&self) -> usize {
self.matrix.row_reduce().1.len()
}

/// Returns dense copies of all rows.
#[must_use]
pub fn rows(&self) -> Vec<Vec<u8>> {
self.matrix.rows()
}

/// Returns a dense copy of one row, or `None` if the index is out of range.
#[must_use]
pub fn row(&self, index: usize) -> Option<Vec<u8>> {
(index < self.num_checks()).then(|| self.matrix.row(index))
}

/// Converts rows to stabilizers made of Pauli X operators, with phase `+1`.
///
/// “X stabilizers” means stabilizers made of X, not stabilizers that detect
/// X errors.
#[must_use]
pub fn to_x_stabilizers(&self) -> Vec<PauliString> {
self.to_stabilizers(Pauli::X)
}

/// Converts rows to stabilizers made of Pauli Z operators, with phase `+1`.
///
/// “Z stabilizers” means stabilizers made of Z, not stabilizers that detect
/// Z errors.
#[must_use]
pub fn to_z_stabilizers(&self) -> Vec<PauliString> {
self.to_stabilizers(Pauli::Z)
}

pub(crate) fn matrix(&self) -> &F2Matrix {
&self.matrix
}

fn to_stabilizers(&self, pauli: Pauli) -> Vec<PauliString> {
(0..self.num_checks())
.map(|row| {
let paulis = (0..self.num_qubits())
.filter(|&qubit| self.matrix.get(row, qubit) == 1)
.map(|qubit| (pauli, QubitId::new(qubit)))
.collect();
PauliString::with_phase_and_paulis(QuarterPhase::PlusOne, paulis)
})
.collect()
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn rank_detects_dependent_rows() {
let matrix =
ParityCheckMatrix::from_dense(vec![vec![1, 1, 0], vec![0, 1, 1], vec![1, 0, 1]])
.unwrap();

assert_eq!(matrix.num_checks(), 3);
assert_eq!(matrix.rank(), 2);
}

#[test]
fn zero_row_matrix_preserves_width() {
let matrix = ParityCheckMatrix::zeros(0, 9);
assert_eq!(matrix.num_checks(), 0);
assert_eq!(matrix.num_qubits(), 9);
assert!(matrix.rows().is_empty());
}
}
Loading
Loading