Skip to main content

wai_quantum/
quantum_info.rs

1//! Quantum information: mixed states, subsystems, and the measurements that
2//! separate quantum theory from any classical account of it.
3//!
4//! Everything else in this crate carries a **pure** state — a single vector of
5//! amplitudes. That is enough to run circuits, but it is not enough to do quantum
6//! information science, because the interesting objects there are not pure. Throw
7//! away half of an entangled pair and what remains is not a state vector at all;
8//! it is a mixture, and its entropy is the whole point.
9//!
10//! So this module adds the density matrix, the partial trace that produces one,
11//! and the quantities you ask of it. It stays in the same fixed point as the
12//! simulator, so a reduced state has a portable hash like everything else: two
13//! machines agree on the mixture, not merely on a rendering of it.
14
15use crate::quantum::{fxmul, Amp, StateVector, FRAC, ONE};
16
17/// A density matrix, row-major `d × d` over the same fixed point as the simulator.
18#[derive(Clone, Debug, PartialEq, Eq)]
19pub struct DensityMatrix {
20    pub n_qubits: u8,
21    /// `d = 2^n_qubits`.
22    pub d: usize,
23    m: Vec<Amp>,
24}
25
26impl DensityMatrix {
27
28    /// Build a density matrix from raw row-major entries. `m.len()` must be `d²`
29    /// for a power-of-two `d`; nothing here checks that the result is a physical
30    /// state, so callers that construct one by hand own that.
31    pub fn from_entries(m: Vec<Amp>) -> Option<DensityMatrix> {
32        let d = (m.len() as f64).sqrt() as usize;
33        if d * d != m.len() || !d.is_power_of_two() {
34            return None;
35        }
36        Some(DensityMatrix { n_qubits: d.trailing_zeros() as u8, d, m })
37    }
38
39    /// `Tr(ρ P)` for a Pauli string — the mixed-state counterpart of
40    /// [`expect_pauli`], and what a tomographer actually measures.
41    pub fn expect_pauli(&self, ops: &[(u8, Pauli)]) -> i64 {
42        let flip = pauli_flip(ops);
43        let mut acc: i128 = 0;
44        for j in 0..self.d {
45            acc += pauli_coeff(j, ops).mul(self.get(j, j ^ flip)).re as i128;
46        }
47        acc as i64
48    }
49
50    pub fn get(&self, r: usize, c: usize) -> Amp {
51        self.m[r * self.d + c]
52    }
53
54    /// `ρ = |ψ⟩⟨ψ|` for a pure state.
55    pub fn from_pure(sv: &StateVector) -> DensityMatrix {
56        let d = sv.amps.len();
57        let mut m = vec![Amp::ZERO; d * d];
58        for r in 0..d {
59            for c in 0..d {
60                m[r * d + c] = sv.amps[r].mul(sv.amps[c].conj());
61            }
62        }
63        DensityMatrix { n_qubits: sv.n_qubits, d, m }
64    }
65
66    /// A classical mixture `Σ wᵢ ρᵢ`, weights in fixed point (they should sum to
67    /// `ONE`; nothing here silently renormalises them).
68    pub fn mixture(parts: &[(i64, DensityMatrix)]) -> Option<DensityMatrix> {
69        let first = parts.first()?;
70        let (n, d) = (first.1.n_qubits, first.1.d);
71        if parts.iter().any(|(_, p)| p.d != d) {
72            return None;
73        }
74        let mut m = vec![Amp::ZERO; d * d];
75        for (w, p) in parts {
76            for k in 0..d * d {
77                m[k] = m[k].add(Amp { re: fxmul(*w, p.m[k].re), im: fxmul(*w, p.m[k].im) });
78            }
79        }
80        Some(DensityMatrix { n_qubits: n, d, m })
81    }
82
83    /// `Tr ρ`, which is `ONE` for any state.
84    pub fn trace(&self) -> Amp {
85        (0..self.d).fold(Amp::ZERO, |acc, i| acc.add(self.get(i, i)))
86    }
87
88    /// `Tr ρ²` — one for a pure state, `1/d` for the maximally mixed one. The
89    /// cheapest honest answer to "how mixed is this?", and unlike the entropy it
90    /// needs no eigenvalues, so it stays exactly in the fixed point.
91    pub fn purity(&self) -> i64 {
92        let mut acc: i128 = 0;
93        for r in 0..self.d {
94            for c in 0..self.d {
95                let (a, b) = (self.get(r, c), self.get(c, r));
96                acc += (a.re as i128 * b.re as i128 - a.im as i128 * b.im as i128) >> FRAC;
97            }
98        }
99        acc as i64
100    }
101
102    /// Trace out every qubit not in `keep`, leaving the reduced state of the rest.
103    ///
104    /// This is the operation that makes mixed states unavoidable: the reduced state
105    /// of half a Bell pair is maximally mixed, and no state vector can say that.
106    pub fn partial_trace(&self, keep: &[u8]) -> DensityMatrix {
107        let mut keep: Vec<u8> = keep.to_vec();
108        keep.sort_unstable();
109        keep.dedup();
110        let traced: Vec<u8> = (0..self.n_qubits).filter(|q| !keep.contains(q)).collect();
111        let dk = 1usize << keep.len();
112        let dt = 1usize << traced.len();
113        let widen = |bits: usize, qs: &[u8]| -> usize {
114            qs.iter().enumerate().fold(0usize, |acc, (i, &q)| {
115                acc | (((bits >> i) & 1) << q)
116            })
117        };
118        let mut m = vec![Amp::ZERO; dk * dk];
119        for a in 0..dk {
120            for b in 0..dk {
121                let (ka, kb) = (widen(a, &keep), widen(b, &keep));
122                let mut sum = Amp::ZERO;
123                for t in 0..dt {
124                    let tt = widen(t, &traced);
125                    sum = sum.add(self.get(ka | tt, kb | tt));
126                }
127                m[a * dk + b] = sum;
128            }
129        }
130        DensityMatrix { n_qubits: keep.len() as u8, d: dk, m }
131    }
132
133    /// Portable identity of the mixture itself.
134    pub fn hash(&self) -> [u8; 32] {
135        let mut h = blake3::Hasher::new();
136        h.update(b"wai:density-matrix\x01");
137        h.update(&(self.n_qubits as u64).to_le_bytes());
138        for a in &self.m {
139            h.update(&a.re.to_le_bytes());
140            h.update(&a.im.to_le_bytes());
141        }
142        *h.finalize().as_bytes()
143    }
144}
145
146/// A single-qubit Pauli factor of an observable.
147#[derive(Clone, Copy, Debug, PartialEq, Eq)]
148pub enum Pauli {
149    I,
150    X,
151    Y,
152    Z,
153}
154
155/// Which basis-state bits a Pauli string moves: `X` and `Y` flip theirs.
156pub(crate) fn pauli_flip(ops: &[(u8, Pauli)]) -> usize {
157    let mut flip = 0usize;
158    for (q, p) in ops {
159        if matches!(p, Pauli::X | Pauli::Y) {
160            flip |= 1 << q;
161        }
162    }
163    flip
164}
165
166/// The coefficient `c` in `P|i⟩ = c |i ⊕ flip⟩`: `Z` and `Y` contribute signs,
167/// `Y` also an `i`.
168pub(crate) fn pauli_coeff(i: usize, ops: &[(u8, Pauli)]) -> Amp {
169    let mut sign = 1i64;
170    let mut i_pow = 0u32;
171    for (q, p) in ops {
172        let bit = (i >> q) & 1;
173        match p {
174            Pauli::I | Pauli::X => {}
175            Pauli::Z => {
176                if bit == 1 {
177                    sign = -sign;
178                }
179            }
180            Pauli::Y => {
181                i_pow += 1;
182                if bit == 1 {
183                    sign = -sign;
184                }
185            }
186        }
187    }
188    match i_pow % 4 {
189        0 => Amp { re: sign * ONE, im: 0 },
190        1 => Amp { re: 0, im: sign * ONE },
191        2 => Amp { re: -sign * ONE, im: 0 },
192        _ => Amp { re: 0, im: -sign * ONE },
193    }
194}
195
196/// `⟨ψ| P |ψ⟩` for a Pauli string, exactly, in fixed point.
197///
198/// Pauli strings are Hermitian, so the answer is real and lies in `[-ONE, ONE]`.
199/// Every observable this module reports is built from these, which is what keeps
200/// the reported values exact rather than sampled.
201pub fn expect_pauli(sv: &StateVector, ops: &[(u8, Pauli)]) -> i64 {
202    let flip = pauli_flip(ops);
203    let mut acc: i128 = 0;
204    for i in 0..sv.amps.len() {
205        let c = pauli_coeff(i, ops);
206        acc += sv.amps[i ^ flip].conj().mul(c.mul(sv.amps[i])).re as i128;
207    }
208    acc as i64
209}
210
211/// The CHSH correlator for the Bell state `(|00⟩+|11⟩)/√2` at the standard optimal
212/// settings, and the two bounds that make it famous.
213///
214/// A local hidden-variable account of the world cannot exceed `2`. Quantum theory
215/// cannot exceed `2√2` — Tsirelson's bound — and at these settings it reaches it.
216/// The value here is computed from exact Pauli expectations rather than sampled,
217/// so the violation is arithmetic, not statistics: `S = √2 (⟨ZZ⟩ + ⟨XX⟩)`.
218pub struct Chsh {
219    pub s: f64,
220    pub classical_bound: f64,
221    pub tsirelson_bound: f64,
222    /// `⟨Z⊗Z⟩` and `⟨X⊗X⟩`, exact in fixed point.
223    pub zz_fx: i64,
224    pub xx_fx: i64,
225}
226
227/// Evaluate CHSH on a two-qubit state.
228pub fn chsh(sv: &StateVector) -> Option<Chsh> {
229    if sv.n_qubits != 2 {
230        return None;
231    }
232    let zz = expect_pauli(sv, &[(0, Pauli::Z), (1, Pauli::Z)]);
233    let xx = expect_pauli(sv, &[(0, Pauli::X), (1, Pauli::X)]);
234    let s = std::f64::consts::SQRT_2 * ((zz + xx) as f64 / ONE as f64);
235    Some(Chsh {
236        s,
237        classical_bound: 2.0,
238        tsirelson_bound: 2.0 * std::f64::consts::SQRT_2,
239        zz_fx: zz,
240        xx_fx: xx,
241    })
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247    use crate::quantum::Circuit;
248
249    fn bell() -> StateVector {
250        let mut c = Circuit::new(2);
251        c.h(0).cx(0, 1);
252        c.simulate().unwrap()
253    }
254
255    #[test]
256    fn a_pure_state_is_pure_and_normalised() {
257        let dm = DensityMatrix::from_pure(&bell());
258        let tol = ONE / 10_000;
259        assert!((dm.trace().re - ONE).abs() < tol, "trace {}", dm.trace().re);
260        assert!(dm.trace().im.abs() < tol);
261        assert!((dm.purity() - ONE).abs() < tol, "purity {}", dm.purity());
262    }
263
264    /// Half of a Bell pair is maximally mixed. This is the fact a state vector
265    /// cannot express, and the reason this module exists.
266    #[test]
267    fn half_of_a_bell_pair_is_maximally_mixed() {
268        let dm = DensityMatrix::from_pure(&bell());
269        let red = dm.partial_trace(&[0]);
270        let tol = ONE / 10_000;
271        assert_eq!(red.d, 2);
272        assert!((red.trace().re - ONE).abs() < tol);
273        // purity 1/2 for one maximally mixed qubit, and no coherence off-diagonal
274        assert!((red.purity() - ONE / 2).abs() < tol, "purity {}", red.purity());
275        assert!(red.get(0, 1).re.abs() < tol && red.get(0, 1).im.abs() < tol);
276        assert!((red.get(0, 0).re - ONE / 2).abs() < tol);
277        assert!((red.get(1, 1).re - ONE / 2).abs() < tol);
278    }
279
280    #[test]
281    fn a_product_state_leaves_a_pure_subsystem() {
282        let mut c = Circuit::new(2);
283        c.h(0); // |+>|0>, no entanglement
284        let dm = DensityMatrix::from_pure(&c.simulate().unwrap());
285        let red = dm.partial_trace(&[0]);
286        let tol = ONE / 10_000;
287        assert!((red.purity() - ONE).abs() < tol, "a product state's part is pure: {}", red.purity());
288    }
289
290    #[test]
291    fn pauli_expectations_of_a_bell_state() {
292        let sv = bell();
293        let tol = ONE / 10_000;
294        // perfectly correlated in Z and in X, and locally random in each
295        assert!((expect_pauli(&sv, &[(0, Pauli::Z), (1, Pauli::Z)]) - ONE).abs() < tol);
296        assert!((expect_pauli(&sv, &[(0, Pauli::X), (1, Pauli::X)]) - ONE).abs() < tol);
297        assert!((expect_pauli(&sv, &[(0, Pauli::Y), (1, Pauli::Y)]) + ONE).abs() < tol);
298        assert!(expect_pauli(&sv, &[(0, Pauli::Z)]).abs() < tol);
299        assert!(expect_pauli(&sv, &[(1, Pauli::X)]).abs() < tol);
300        // and the identity is one
301        assert!((expect_pauli(&sv, &[(0, Pauli::I)]) - ONE).abs() < tol);
302    }
303
304    /// The point of the whole module in one assertion: no local hidden-variable
305    /// theory can exceed 2, this state reaches 2√2, and quantum theory itself
306    /// cannot do better.
307    #[test]
308    fn chsh_violates_the_classical_bound_and_saturates_tsirelson() {
309        let r = chsh(&bell()).expect("two qubits");
310        assert!(r.s > r.classical_bound + 0.5, "S = {} must beat the classical 2", r.s);
311        assert!(r.s <= r.tsirelson_bound + 1e-6, "S = {} may not beat Tsirelson", r.s);
312        assert!((r.s - 2.0 * std::f64::consts::SQRT_2).abs() < 1e-3, "S = {} should reach 2√2", r.s);
313    }
314
315    /// A separable state cannot violate anything.
316    #[test]
317    fn a_product_state_obeys_the_classical_bound() {
318        let mut c = Circuit::new(2);
319        c.h(0).h(1);
320        let r = chsh(&c.simulate().unwrap()).unwrap();
321        assert!(r.s <= r.classical_bound + 1e-6, "S = {} from a product state", r.s);
322    }
323
324    /// Mixing a state with itself gives that state back — numerically. Not
325    /// bit-for-bit: halving twice rounds twice, so demanding an identical hash
326    /// would be demanding that fixed-point division be lossless, which it is not.
327    /// The hash is for *identity* of a computed mixture, and that is asserted
328    /// separately.
329    #[test]
330    fn mixing_a_state_with_itself_returns_it() {
331        let pure = DensityMatrix::from_pure(&bell());
332        let mixed = DensityMatrix::mixture(&[(ONE / 2, pure.clone()), (ONE / 2, pure.clone())]).unwrap();
333        let tol = ONE / 100_000;
334        for r in 0..pure.d {
335            for c in 0..pure.d {
336                let (a, b) = (pure.get(r, c), mixed.get(r, c));
337                assert!(
338                    (a.re - b.re).abs() <= tol && (a.im - b.im).abs() <= tol,
339                    "entry ({r},{c}) drifted: {a:?} vs {b:?}"
340                );
341            }
342        }
343        assert!((mixed.trace().re - ONE).abs() < ONE / 10_000, "a mixture is still a state");
344    }
345
346    #[test]
347    fn the_hash_identifies_the_mixture() {
348        let a = DensityMatrix::from_pure(&bell());
349        let b = DensityMatrix::from_pure(&bell());
350        assert_eq!(a.hash(), b.hash(), "same computation, same identity");
351        let mut c = Circuit::new(2);
352        c.h(0); // a different state
353        assert_ne!(DensityMatrix::from_pure(&c.simulate().unwrap()).hash(), a.hash());
354    }
355}