Skip to content

Commit dba30fd

Browse files
david-plclaude
andcommitted
fix: address Copilot review on symmetry and Lindblad entry points
- symmetry: try_from_generators reports order overflow as GroupError (PermutationOrderOverflow, GroupOrderOverflow) instead of panicking - symmetry: CharacterTable records its group's generator orders, so a table from a different group of equal order is rejected - lindblad: cap_basis and cap_map_to_room keep exactly the requested count under magnitude ties (also fixes a room+1 off-by-one) - python: LindbladSpec.action validates the input width - python: array entry points reject non-integer or out-of-range Pauli codes instead of letting the uint8 cast wrap them - python: add matplotlib to the demo dependency group Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 1cf9db2 commit dba30fd

13 files changed

Lines changed: 250 additions & 48 deletions

File tree

‎crates/ppvm-lindblad/src/truncate.rs‎

Lines changed: 85 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,8 @@ pub(crate) fn cap_map_to_room<const C: usize, T: Coeff>(
3636
return;
3737
}
3838
let mut mags: Vec<f64> = merged.values().map(|v| v.mag()).collect();
39-
let k = room.min(mags.len() - 1);
40-
let cutoff = nth_largest(&mut mags, k);
41-
merged.retain(|_, v| v.mag() >= cutoff);
39+
let mut keep = top_k_keeper(&mut mags, room);
40+
merged.retain(|_, v| keep(v.mag()));
4241
}
4342

4443
/// Compact `basis` / `coeffs` in place: drop entries whose coefficient
@@ -82,15 +81,12 @@ pub(crate) fn cap_basis<const C: usize, T: Coeff>(
8281
.filter(|(w, _)| !protected_set.contains(w))
8382
.map(|(_, c)| c.mag())
8483
.collect();
85-
let cutoff = if slots == 0 {
86-
f64::INFINITY
87-
} else if slots >= mags.len() {
84+
if slots >= mags.len() {
8885
return;
89-
} else {
90-
nth_largest(&mut mags, slots - 1)
91-
};
86+
}
87+
let mut keep = top_k_keeper(&mut mags, slots);
9288
retain_in_place(basis, coeffs, |w, c| {
93-
protected_set.contains(w) || c.mag() >= cutoff
89+
protected_set.contains(w) || keep(c.mag())
9490
});
9591
}
9692

@@ -139,6 +135,27 @@ fn retain_in_place<const C: usize, T>(
139135
coeffs.truncate(write);
140136
}
141137

138+
/// Predicate that, fed every magnitude of `mags` once, accepts exactly the
139+
/// `k` largest (`k < mags.len()`): all above the cutoff, then ties in visit
140+
/// order. Reorders `mags`.
141+
fn top_k_keeper(mags: &mut [f64], k: usize) -> impl FnMut(f64) -> bool {
142+
let (cutoff, mut ties_left) = if k == 0 {
143+
(f64::INFINITY, 0)
144+
} else {
145+
let cutoff = nth_largest(mags, k - 1);
146+
let n_above = mags.iter().filter(|&&m| m > cutoff).count();
147+
(cutoff, k.saturating_sub(n_above))
148+
};
149+
move |m| {
150+
if m > cutoff {
151+
return true;
152+
}
153+
let tie = m == cutoff && ties_left > 0;
154+
ties_left -= tie as usize;
155+
tie
156+
}
157+
}
158+
142159
/// The `k`-th largest element of `mags` (0-indexed), via a partial sort.
143160
/// Reorders `mags`. Panics if `k >= mags.len()`.
144161
fn nth_largest(mags: &mut [f64], k: usize) -> f64 {
@@ -154,3 +171,61 @@ fn desc_by_mag<T: Coeff>(a: T, b: T) -> std::cmp::Ordering {
154171
.partial_cmp(&a.mag())
155172
.unwrap_or(std::cmp::Ordering::Equal)
156173
}
174+
175+
#[cfg(test)]
176+
mod tests {
177+
use super::*;
178+
use crate::word::word_from_codes;
179+
180+
/// `n` distinct 4-qubit words.
181+
fn words(n: usize) -> Vec<Word> {
182+
(0..n)
183+
.map(|i| {
184+
let codes: Vec<u8> = (0..4).map(|q| ((i >> (2 * q)) & 3) as u8).collect();
185+
word_from_codes(&codes).unwrap()
186+
})
187+
.collect()
188+
}
189+
190+
#[test]
191+
fn cap_basis_is_a_hard_cap_under_ties() {
192+
let mut basis = words(10);
193+
// One large entry, nine tied zeros (fresh leakage admissions).
194+
let mut coeffs = vec![0.0; 10];
195+
coeffs[3] = 1.0;
196+
cap_basis(&mut basis, &mut coeffs, 4, &[]);
197+
assert_eq!(basis.len(), 4);
198+
assert_eq!(coeffs, vec![0.0, 0.0, 0.0, 1.0]);
199+
}
200+
201+
#[test]
202+
fn cap_basis_keeps_protected_beyond_slots() {
203+
let all = words(6);
204+
let mut basis = all.clone();
205+
let mut coeffs = vec![0.5; 6];
206+
let protected = [all[5]];
207+
cap_basis(&mut basis, &mut coeffs, 3, &protected);
208+
assert_eq!(basis, vec![all[0], all[1], all[5]]);
209+
}
210+
211+
#[test]
212+
fn cap_map_to_room_keeps_exactly_room_under_ties() {
213+
let mut merged: FxHashMap<Word, f64> = words(10).into_iter().map(|w| (w, 0.25)).collect();
214+
cap_map_to_room(&mut merged, 3);
215+
assert_eq!(merged.len(), 3);
216+
}
217+
218+
#[test]
219+
fn cap_map_to_room_keeps_the_largest() {
220+
let ws = words(5);
221+
let mut merged: FxHashMap<Word, f64> = ws
222+
.iter()
223+
.zip([1.0, -5.0, 3.0, 0.5, -4.0])
224+
.map(|(w, c)| (*w, c))
225+
.collect();
226+
cap_map_to_room(&mut merged, 2);
227+
let mut kept: Vec<f64> = merged.values().copied().collect();
228+
kept.sort_by(f64::total_cmp);
229+
assert_eq!(kept, vec![-5.0, -4.0]);
230+
}
231+
}

‎crates/ppvm-pauli-sum/src/symmetry/group.rs‎

Lines changed: 28 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,12 @@ fn gcd(mut a: usize, mut b: usize) -> usize {
1212
a
1313
}
1414

15-
fn checked_lcm(a: usize, b: usize, context: &str) -> usize {
16-
a.checked_div(gcd(a, b))
17-
.and_then(|q| q.checked_mul(b))
18-
.unwrap_or_else(|| panic!("{context} overflow"))
15+
fn checked_lcm(a: usize, b: usize) -> Option<usize> {
16+
a.checked_div(gcd(a, b)).and_then(|q| q.checked_mul(b))
1917
}
2018

21-
fn permutation_order(perm: &[u32], generator: usize) -> u32 {
19+
/// Exact cyclic order of `perm`, or `None` if it does not fit in `u32`.
20+
fn permutation_order(perm: &[u32], generator: usize) -> Option<u32> {
2221
let mut seen = vec![false; perm.len()];
2322
let mut order = 1usize;
2423
for start in 0..perm.len() {
@@ -36,22 +35,19 @@ fn permutation_order(perm: &[u32], generator: usize) -> u32 {
3635
break;
3736
}
3837
}
39-
order = checked_lcm(order, length, "permutation order");
38+
order = checked_lcm(order, length)?;
4039
}
41-
u32::try_from(order).unwrap_or_else(|_| {
42-
panic!("generator {generator} exact permutation order does not fit in u32")
43-
})
40+
u32::try_from(order).ok()
4441
}
4542

4643
fn permutations_commute(left: &[u32], right: &[u32]) -> bool {
4744
(0..left.len()).all(|q| left[right[q] as usize] == right[left[q] as usize])
4845
}
4946

50-
pub(super) fn checked_group_order(orders: &[u32]) -> usize {
51-
orders.iter().enumerate().fold(1usize, |acc, (g, &value)| {
52-
acc.checked_mul(value as usize)
53-
.unwrap_or_else(|| panic!("group order overflows usize at generator {g}"))
54-
})
47+
pub(super) fn checked_group_order(orders: &[u32]) -> Option<usize> {
48+
orders
49+
.iter()
50+
.try_fold(1usize, |acc, &value| acc.checked_mul(value as usize))
5551
}
5652

5753
pub(super) fn validate_site_count(n: usize, context: &str) {
@@ -66,9 +62,7 @@ pub(super) fn validate_site_count(n: usize, context: &str) {
6662
///
6763
/// Every variant is caller-supplied-input error, and its [`Display`]
6864
/// text is exactly what [`TranslationGroup::from_generators`] panics
69-
/// with. Arithmetic overflow in the group order or character phase
70-
/// modulus is NOT covered — that needs generator orders in the billions
71-
/// and still panics.
65+
/// with.
7266
///
7367
/// [`Display`]: std::fmt::Display
7468
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -99,6 +93,10 @@ pub enum GroupError {
9993
},
10094
/// Two generators do not commute, so they generate no abelian group.
10195
NonCommuting { left: usize, right: usize },
96+
/// A generator's exact cyclic order does not fit in `u32`.
97+
PermutationOrderOverflow { generator: usize },
98+
/// The group order `Π orders[g]` does not fit in `usize`.
99+
GroupOrderOverflow,
102100
}
103101

104102
impl std::fmt::Display for GroupError {
@@ -142,6 +140,11 @@ impl std::fmt::Display for GroupError {
142140
Self::NonCommuting { left, right } => {
143141
write!(f, "generators {left} and {right} do not commute")
144142
}
143+
Self::PermutationOrderOverflow { generator } => write!(
144+
f,
145+
"generator {generator} exact permutation order does not fit in u32"
146+
),
147+
Self::GroupOrderOverflow => write!(f, "group order overflows usize"),
145148
}
146149
}
147150
}
@@ -438,7 +441,8 @@ impl TranslationGroup {
438441
if declared == 0 {
439442
return Err(GroupError::ZeroOrder { generator });
440443
}
441-
let exact = permutation_order(&perms[generator], generator);
444+
let exact = permutation_order(&perms[generator], generator)
445+
.ok_or(GroupError::PermutationOrderOverflow { generator })?;
442446
if declared != exact {
443447
return Err(GroupError::OrderMismatch {
444448
generator,
@@ -454,10 +458,12 @@ impl TranslationGroup {
454458
}
455459
}
456460
}
457-
let order = checked_group_order(&orders);
458-
let phase_modulus = orders.iter().fold(1usize, |acc, &value| {
459-
checked_lcm(acc, value as usize, "character phase modulus")
460-
});
461+
let order = checked_group_order(&orders).ok_or(GroupError::GroupOrderOverflow)?;
462+
// lcm divides the product, so this cannot overflow once `order` fits.
463+
let phase_modulus = orders
464+
.iter()
465+
.try_fold(1usize, |acc, &value| checked_lcm(acc, value as usize))
466+
.ok_or(GroupError::GroupOrderOverflow)?;
461467
let block_cyclic = detect_block_cyclic(n_qubits, &perms, &orders);
462468
let rotations = perms
463469
.iter()

‎crates/ppvm-pauli-sum/src/symmetry/momentum.rs‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,11 @@ impl TranslationGroup {
8888
*c = 0;
8989
}
9090
}
91-
CharacterTable { numerators, values }
91+
CharacterTable {
92+
orders: self.orders.clone(),
93+
numerators,
94+
values,
95+
}
9296
}
9397

9498
/// Everything the phase-aware routines need about `w`'s orbit in
@@ -148,8 +152,7 @@ impl TranslationGroup {
148152
S: BuildHasher + Clone + Default + HashFinalize,
149153
{
150154
assert_eq!(
151-
table.len(),
152-
self.order(),
155+
table.orders, self.orders,
153156
"character table does not belong to this group"
154157
);
155158
let (rep, idx, stabilizer) =
@@ -163,6 +166,9 @@ impl TranslationGroup {
163166
/// [`TranslationGroup::character_table`].
164167
#[derive(Debug, Clone)]
165168
pub struct CharacterTable {
169+
/// Generator orders of the originating group; element indices (and so
170+
/// the table) are only meaningful for a group with the same orders.
171+
orders: Vec<u32>,
166172
/// Exact phase numerators (see `character_numerator`); `0` ⇔ `χ = 1`.
167173
numerators: Vec<usize>,
168174
values: Vec<Complex<f64>>,

‎crates/ppvm-pauli-sum/src/symmetry/tests.rs‎

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -824,7 +824,27 @@ fn rejects_group_order_overflow() {
824824
} else {
825825
vec![u32::MAX, u32::MAX]
826826
};
827-
assert!(std::panic::catch_unwind(|| { super::group::checked_group_order(&orders) }).is_err());
827+
assert_eq!(super::group::checked_group_order(&orders), None);
828+
}
829+
830+
#[test]
831+
fn try_from_generators_reports_overflow() {
832+
use super::GroupError;
833+
// 64 copies of one swap: a valid direct product of order 2^64.
834+
let err = TranslationGroup::try_from_generators(2, vec![vec![1, 0]; 64], vec![2; 64])
835+
.expect_err("group order must overflow");
836+
assert_eq!(err, GroupError::GroupOrderOverflow);
837+
838+
// Disjoint prime cycles 2..29 on 129 qubits: order 6469693230 > u32::MAX.
839+
let mut perm = Vec::new();
840+
for len in [2u32, 3, 5, 7, 11, 13, 17, 19, 23, 29] {
841+
let start = perm.len() as u32;
842+
perm.extend((0..len).map(|j| start + (j + 1) % len));
843+
}
844+
let n = perm.len();
845+
let err = TranslationGroup::try_from_generators(n, vec![perm], vec![1])
846+
.expect_err("permutation order must overflow u32");
847+
assert_eq!(err, GroupError::PermutationOrderOverflow { generator: 0 });
828848
}
829849

830850
// ---------------------------------------------------------------------------
@@ -1100,3 +1120,12 @@ fn orbit_yields_hashed_words() {
11001120
assert_eq!(fxhash::hash64(&member), fxhash::hash64(&fresh));
11011121
}
11021122
}
1123+
1124+
#[test]
1125+
#[should_panic(expected = "character table does not belong to this group")]
1126+
fn character_table_rejects_a_group_of_equal_order() {
1127+
// Both groups have order 4, but their element indices mean different things.
1128+
let table = TranslationGroup::chain_1d(4).character_table(&[1]);
1129+
let torus = TranslationGroup::torus_2d(2, 2);
1130+
torus.canonicalize_in_sector_indexed(&word("XIII"), &table);
1131+
}

‎crates/ppvm-python-native/src/lindblad.rs‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,13 @@ impl LindbladSpec {
228228
) -> PyResult<PyPauliMap<'py>> {
229229
with_spec!(&self.inner, inner, C => {
230230
let p_slice = p.as_slice()?;
231+
if p_slice.len() != inner.n_qubits() {
232+
return Err(PyValueError::new_err(format!(
233+
"p has {} entries but spec.n_qubits = {}",
234+
p_slice.len(),
235+
inner.n_qubits()
236+
)));
237+
}
231238
let p_word = word_from_codes::<C>(p_slice).map_err(map_err)?;
232239
let pairs = inner.action(&p_word);
233240
pack_pauli_map(py, pairs, inner.n_qubits())

‎ppvm-python/pyproject.toml‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,8 +58,10 @@ dev = [
5858
"pytest>=9.0.2",
5959
"pytest-benchmark>=5.2.3",
6060
]
61-
# Optional: only the `demo/` scripts use it (`expm_multiply` for the reference
62-
# matrix exponential). Runtime ppvm and the test suite have no scipy dep.
61+
# Optional: only the `demo/` scripts use these (`expm_multiply` for the
62+
# reference matrix exponential, plus plotting). Runtime ppvm and the test
63+
# suite depend on neither.
6364
demo = [
65+
"matplotlib>=3.8",
6466
"scipy>=1.13",
6567
]

‎ppvm-python/src/ppvm/_codes.py‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# SPDX-FileCopyrightText: 2026 The PPVM Authors
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
"""Coercion of array-like Pauli codes to the uint8 layout the core expects."""
5+
6+
from __future__ import annotations
7+
8+
import numpy as np
9+
import numpy.typing as npt
10+
11+
12+
def as_pauli_codes(codes: npt.ArrayLike) -> np.ndarray:
13+
"""Return `codes` as a C-contiguous uint8 array of Pauli codes
14+
(``0=I, 1=X, 2=Z, 3=Y``).
15+
16+
uint8 input passes through unchanged (the core range-checks it). Any
17+
other input must be integer-typed with every value in ``0..=3``, so an
18+
out-of-range code raises ``ValueError`` instead of wrapping on the cast
19+
(e.g. ``256`` silently becoming ``0 = I``).
20+
"""
21+
arr = np.asarray(codes)
22+
if arr.dtype != np.uint8 and arr.size:
23+
if not np.issubdtype(arr.dtype, np.integer):
24+
raise ValueError(f"Pauli codes must be integers, got dtype {arr.dtype}")
25+
lo, hi = arr.min(), arr.max()
26+
if lo < 0 or hi > 3:
27+
bad = lo if lo < 0 else hi
28+
raise ValueError(f"Pauli code must be 0 (I), 1 (X), 2 (Z), or 3 (Y); got {bad}")
29+
return np.ascontiguousarray(arr, dtype=np.uint8)

0 commit comments

Comments
 (0)