Skip to main content

oxicuda_quantum/algorithms/
quantum_walk.rs

1//! Discrete-time coined quantum walk on a cycle.
2//!
3//! References: Aharonov, Davidovich, Zagury, *"Quantum random walks"*,
4//! Phys. Rev. A 48, 1687 (1993); Kempe, *"Quantum random walks – an introductory
5//! overview"*, Contemp. Phys. 44, 307 (2003); Aharonov, Ambainis, Kempe,
6//! Vazirani, *"Quantum walks on graphs"*, STOC 2001.
7//!
8//! # Model
9//!
10//! The walker lives on a cycle of `N` positions (with `N = 2^p` so positions fit
11//! in `p` qubits) tensored with a two-level *coin*. One step of the walk is
12//!
13//! ```text
14//! U = S · (I ⊗ C),
15//! ```
16//!
17//! where `C` is the Hadamard coin acting on the coin qubit and `S` is the
18//! coin-conditioned cyclic shift
19//!
20//! ```text
21//! S |x, 0⟩ = |(x + 1) mod N, 0⟩,
22//! S |x, 1⟩ = |(x − 1) mod N, 1⟩.
23//! ```
24//!
25//! # State encoding (little-endian)
26//!
27//! Consistent with this crate's LSB-ordered [`StateVector`] (qubit `q` ↦ mask
28//! `1 << q`), the coin occupies qubit `0` and the position occupies qubits
29//! `1 ..= p`. A basis index `i` therefore decomposes as
30//!
31//! ```text
32//! coin     = i & 1,
33//! position = i >> 1,
34//! ```
35//!
36//! so the global amplitude index is `i = coin + 2·position`.
37//!
38//! The Hadamard coin is applied with the shared [`apply_1q_inplace`] gate
39//! machinery; the shift is the exact coin-conditioned cyclic permutation of the
40//! amplitude vector (a genuine unitary, simulated without approximation).
41//!
42//! # Physics demonstrated by the tests
43//!
44//! The Hadamard walk spreads **ballistically**: the position standard deviation
45//! grows *linearly* in the number of steps `T` (`σ ≈ 0.54·T` for the symmetric
46//! coin), in stark contrast to the classical random walk's diffusive `σ = √T`.
47//! The symmetric initial coin `(|0⟩ + i|1⟩)/√2` yields a left–right symmetric
48//! distribution, and on the smallest cycles the walk is exactly periodic in time.
49
50use crate::error::{QuantumError, QuantumResult};
51use crate::gates::hadamard::gate_h;
52use crate::statevec::apply_1q::apply_1q_inplace;
53use crate::statevec::state::StateVector;
54use num_complex::Complex;
55
56type Complex32 = Complex<f32>;
57
58/// A discrete-time coined quantum walk on a cycle of `n_positions = 2^p` sites.
59#[derive(Debug, Clone)]
60pub struct CoinedWalk {
61    /// Number of positions on the cycle; must be a power of two.
62    n_positions: usize,
63    /// Number of position qubits `p` (so `n_positions == 1 << p`).
64    position_qubits: usize,
65}
66
67/// The two coin basis states used to seed a localized walker.
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69pub enum CoinInit {
70    /// Coin `|0⟩` — the "rightward-biased" Hadamard walk seed.
71    Zero,
72    /// Coin `|1⟩` — the "leftward-biased" Hadamard walk seed.
73    One,
74    /// Symmetric coin `(|0⟩ + i|1⟩)/√2`, which yields a left–right symmetric
75    /// position distribution under the Hadamard walk.
76    SymmetricI,
77}
78
79impl CoinedWalk {
80    /// Create a walk on a cycle of `n_positions` sites.
81    ///
82    /// # Errors
83    /// * [`QuantumError::InvalidParameter`] when `n_positions` is not a power of
84    ///   two, is `< 2`, or would require more than 29 position qubits (leaving
85    ///   room for the coin qubit within the simulator's 30-qubit limit).
86    pub fn new(n_positions: usize) -> QuantumResult<Self> {
87        if n_positions < 2 || !n_positions.is_power_of_two() {
88            return Err(QuantumError::InvalidParameter {
89                name: format!("n_positions={n_positions} must be a power of two ≥ 2"),
90            });
91        }
92        let position_qubits = n_positions.trailing_zeros() as usize;
93        if position_qubits + 1 > 29 {
94            return Err(QuantumError::InvalidParameter {
95                name: format!("cycle of {n_positions} sites needs too many qubits"),
96            });
97        }
98        Ok(Self {
99            n_positions,
100            position_qubits,
101        })
102    }
103
104    /// The number of cycle positions.
105    #[must_use]
106    pub fn n_positions(&self) -> usize {
107        self.n_positions
108    }
109
110    /// Total qubit count: `position_qubits + 1` (coin).
111    #[must_use]
112    pub fn n_qubits(&self) -> usize {
113        self.position_qubits + 1
114    }
115
116    /// Build the localized initial state `|x0⟩ ⊗ |coin⟩` over `n_qubits()` qubits.
117    ///
118    /// # Errors
119    /// [`QuantumError::QubitIndexOutOfRange`] (reused as a range error) when
120    /// `x0 >= n_positions`.
121    pub fn initial_state(&self, x0: usize, coin: CoinInit) -> QuantumResult<StateVector> {
122        if x0 >= self.n_positions {
123            return Err(QuantumError::QubitIndexOutOfRange {
124                index: x0,
125                n_qubits: self.n_positions,
126            });
127        }
128        let n_qubits = self.n_qubits();
129        let dim = 1usize << n_qubits;
130        let mut amps = vec![Complex32::new(0.0, 0.0); dim];
131        let base = x0 << 1; // position in qubits 1.. , coin in qubit 0
132        let inv_sqrt2 = std::f32::consts::FRAC_1_SQRT_2;
133        match coin {
134            CoinInit::Zero => amps[base] = Complex32::new(1.0, 0.0),
135            CoinInit::One => amps[base | 1] = Complex32::new(1.0, 0.0),
136            CoinInit::SymmetricI => {
137                amps[base] = Complex32::new(inv_sqrt2, 0.0);
138                amps[base | 1] = Complex32::new(0.0, inv_sqrt2);
139            }
140        }
141        Ok(StateVector { amps, n_qubits })
142    }
143
144    /// Apply the coin-conditioned cyclic shift `S` in place.
145    ///
146    /// `S |x, 0⟩ = |(x+1) mod N, 0⟩` and `S |x, 1⟩ = |(x−1) mod N, 1⟩`. Implemented
147    /// as the exact permutation of the amplitude vector that this unitary induces.
148    fn apply_shift(&self, sv: &mut StateVector) -> QuantumResult<()> {
149        let expected = self.n_qubits();
150        if sv.n_qubits != expected {
151            return Err(QuantumError::DimensionMismatch {
152                expected: 1usize << expected,
153                got: sv.amps.len(),
154            });
155        }
156        let n = self.n_positions;
157        let dim = sv.amps.len();
158        let mut out = vec![Complex32::new(0.0, 0.0); dim];
159        for (i, &a) in sv.amps.iter().enumerate() {
160            let coin = i & 1;
161            let pos = i >> 1;
162            let new_pos = if coin == 0 {
163                (pos + 1) % n
164            } else {
165                (pos + n - 1) % n
166            };
167            out[(new_pos << 1) | coin] = a;
168        }
169        sv.amps = out;
170        Ok(())
171    }
172
173    /// Apply one full step `U = S · (I ⊗ C)` in place (coin first, then shift).
174    ///
175    /// # Errors
176    /// [`QuantumError::DimensionMismatch`] if `sv` does not have `n_qubits()`
177    /// qubits; propagates gate-application errors.
178    pub fn step(&self, sv: &mut StateVector) -> QuantumResult<()> {
179        apply_1q_inplace(sv, 0, &gate_h())?;
180        self.apply_shift(sv)
181    }
182
183    /// Marginal position distribution of `sv`, summed over the coin.
184    ///
185    /// Returns a length-`n_positions` vector `p[x] = |⟨x,0|ψ⟩|² + |⟨x,1|ψ⟩|²`.
186    ///
187    /// # Errors
188    /// [`QuantumError::DimensionMismatch`] if `sv` does not have `n_qubits()`
189    /// qubits.
190    pub fn position_distribution(&self, sv: &StateVector) -> QuantumResult<Vec<f32>> {
191        let expected = self.n_qubits();
192        if sv.n_qubits != expected {
193            return Err(QuantumError::DimensionMismatch {
194                expected: 1usize << expected,
195                got: sv.amps.len(),
196            });
197        }
198        let mut dist = vec![0.0_f32; self.n_positions];
199        for (i, a) in sv.amps.iter().enumerate() {
200            dist[i >> 1] += a.norm_sqr();
201        }
202        Ok(dist)
203    }
204
205    /// Evolve `t_steps` steps from a localized state `|x0, coin⟩` and return the
206    /// final position distribution (summed over the coin).
207    ///
208    /// # Errors
209    /// Propagates from [`Self::initial_state`] and [`Self::step`].
210    pub fn evolve(&self, x0: usize, coin: CoinInit, t_steps: usize) -> QuantumResult<Vec<f32>> {
211        let mut sv = self.initial_state(x0, coin)?;
212        for _ in 0..t_steps {
213            self.step(&mut sv)?;
214        }
215        self.position_distribution(&sv)
216    }
217}
218
219/// Position mean and standard deviation of a distribution centered (for variance
220/// purposes) on `x0`, with positions interpreted as signed offsets `x − x0` to
221/// avoid the cycle's wrap-around discontinuity. The caller must ensure the
222/// distribution has not wrapped around the cycle (`n_positions` large enough for
223/// the number of steps), so that signed offsets are unambiguous in
224/// `[-N/2, N/2)`.
225#[must_use]
226pub fn position_std_about(dist: &[f32], x0: usize) -> f64 {
227    let n = dist.len() as i64;
228    let mut mean = 0.0_f64;
229    let mut total = 0.0_f64;
230    for (x, &p) in dist.iter().enumerate() {
231        let mut off = x as i64 - x0 as i64;
232        // Wrap signed offset into [-N/2, N/2).
233        if off >= n / 2 {
234            off -= n;
235        } else if off < -n / 2 {
236            off += n;
237        }
238        mean += off as f64 * p as f64;
239        total += p as f64;
240    }
241    if total > 0.0 {
242        mean /= total;
243    }
244    let mut var = 0.0_f64;
245    for (x, &p) in dist.iter().enumerate() {
246        let mut off = x as i64 - x0 as i64;
247        if off >= n / 2 {
248            off -= n;
249        } else if off < -n / 2 {
250            off += n;
251        }
252        let d = off as f64 - mean;
253        var += d * d * p as f64;
254    }
255    if total > 0.0 {
256        var /= total;
257    }
258    var.max(0.0).sqrt()
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264
265    const TOL: f32 = 1e-5;
266    const INV_SQRT2: f32 = std::f32::consts::FRAC_1_SQRT_2;
267
268    // (a) One Hadamard-walk step from |x0, 0⟩ produces the known two-amplitude
269    //     split: amplitude 1/√2 at (x0+1, coin 0) and 1/√2 at (x0−1, coin 1).
270    #[test]
271    fn single_step_two_amplitude_split() {
272        let walk = CoinedWalk::new(8).expect("valid CoinedWalk with 8 positions");
273        let x0 = 3usize;
274        let mut sv = walk
275            .initial_state(x0, CoinInit::Zero)
276            .expect("valid initial state at x0");
277        walk.step(&mut sv)
278            .expect("step should succeed on valid state");
279
280        // H|0⟩ = (|0⟩+|1⟩)/√2; shift sends coin0 → x0+1, coin1 → x0−1.
281        let n = 8usize;
282        let idx_right = ((x0 + 1) % n) << 1; // coin 0
283        let idx_left = (((x0 + n - 1) % n) << 1) | 1; // coin 1
284        for (i, a) in sv.amps.iter().enumerate() {
285            if i == idx_right {
286                assert!((a.re - INV_SQRT2).abs() < TOL, "right re={}", a.re);
287                assert!(a.im.abs() < TOL, "right im={}", a.im);
288            } else if i == idx_left {
289                assert!((a.re - INV_SQRT2).abs() < TOL, "left re={}", a.re);
290                assert!(a.im.abs() < TOL, "left im={}", a.im);
291            } else {
292                assert!(a.norm() < TOL, "spurious amp[{i}]={a:?}");
293            }
294        }
295    }
296
297    // (b) Ballistic spreading: σ(T) is linear in T (slope > 0, R² ≈ 1) and σ is
298    //     much larger than the classical √T at large T.
299    #[test]
300    fn ballistic_spreading_linear_std() {
301        let walk = CoinedWalk::new(128).expect("valid CoinedWalk with 128 positions");
302        let x0 = 64usize;
303        let t_values: Vec<usize> = (4..=24).collect();
304        let sigmas: Vec<f64> = t_values
305            .iter()
306            .map(|&t| {
307                let dist = walk
308                    .evolve(x0, CoinInit::SymmetricI, t)
309                    .expect("evolve should succeed");
310                position_std_about(&dist, x0)
311            })
312            .collect();
313
314        // Least-squares linear fit σ = a·T + b.
315        let n = t_values.len() as f64;
316        let sx: f64 = t_values.iter().map(|&t| t as f64).sum();
317        let sy: f64 = sigmas.iter().sum();
318        let sxx: f64 = t_values.iter().map(|&t| (t as f64) * (t as f64)).sum();
319        let sxy: f64 = t_values
320            .iter()
321            .zip(&sigmas)
322            .map(|(&t, &s)| (t as f64) * s)
323            .sum();
324        let slope = (n * sxy - sx * sy) / (n * sxx - sx * sx);
325        let intercept = (sy - slope * sx) / n;
326
327        // Coefficient of determination R².
328        let mean_y = sy / n;
329        let ss_tot: f64 = sigmas.iter().map(|&s| (s - mean_y).powi(2)).sum();
330        let ss_res: f64 = t_values
331            .iter()
332            .zip(&sigmas)
333            .map(|(&t, &s)| {
334                let pred = slope * t as f64 + intercept;
335                (s - pred).powi(2)
336            })
337            .sum();
338        let r2 = 1.0 - ss_res / ss_tot;
339
340        assert!(slope > 0.3, "slope={slope} should be positive & ~0.54");
341        assert!(r2 > 0.97, "R²={r2} should be ≈ 1 (linear growth)");
342
343        // Quantum σ ≫ classical √T at the largest T.
344        let t_max = *t_values.last().expect("t_values is non-empty");
345        let sigma_max = *sigmas.last().expect("sigmas is non-empty");
346        let classical = (t_max as f64).sqrt();
347        assert!(
348            sigma_max > 1.8 * classical,
349            "quantum σ={sigma_max} not ≫ classical √T={classical}"
350        );
351    }
352
353    // (c) Probability is conserved (Σ = 1) at every step.
354    #[test]
355    fn probability_conserved_each_step() {
356        let walk = CoinedWalk::new(32).expect("valid CoinedWalk with 32 positions");
357        let mut sv = walk
358            .initial_state(10, CoinInit::Zero)
359            .expect("valid initial state at position 10");
360        for t in 0..30 {
361            let dist = walk
362                .position_distribution(&sv)
363                .expect("position distribution should succeed on valid state");
364            let total: f32 = dist.iter().sum();
365            assert!((total - 1.0).abs() < 1e-4, "step {t}: Σp={total}");
366            assert!((sv.norm_sq() - 1.0).abs() < 1e-4, "step {t}: norm");
367            walk.step(&mut sv)
368                .expect("step should succeed on valid state");
369        }
370    }
371
372    // (d) The step operator U is unitary: it preserves inner products. Apply U to
373    //     two distinct localized states and check ⟨Uψ|Uφ⟩ = ⟨ψ|φ⟩ (here both
374    //     orthogonal → 0) and that each remains normalized; additionally verify on
375    //     a generic superposition that the norm is preserved exactly.
376    #[test]
377    fn step_operator_is_unitary() {
378        let walk = CoinedWalk::new(16).expect("valid CoinedWalk with 16 positions");
379        // Orthogonal basis states stay orthonormal under U.
380        let mut a = walk
381            .initial_state(2, CoinInit::Zero)
382            .expect("valid initial state at position 2");
383        let mut b = walk
384            .initial_state(5, CoinInit::One)
385            .expect("valid initial state at position 5");
386        let before = a
387            .inner_product(&b)
388            .expect("inner product between same-dimension states");
389        walk.step(&mut a)
390            .expect("step should succeed on valid state a");
391        walk.step(&mut b)
392            .expect("step should succeed on valid state b");
393        let after = a
394            .inner_product(&b)
395            .expect("inner product between same-dimension states after step");
396        assert!((before.norm()) < 1e-6, "states should start orthogonal");
397        assert!(after.norm() < 1e-5, "U broke orthogonality: {after:?}");
398        assert!((a.norm_sq() - 1.0).abs() < 1e-5);
399        assert!((b.norm_sq() - 1.0).abs() < 1e-5);
400
401        // Generic superposition: build a random normalized state on the walk's
402        // register and confirm U preserves the norm over many steps.
403        let mut rng = crate::handle::LcgRng::new(2026);
404        let dim = 1usize << walk.n_qubits();
405        let mut amps = Vec::with_capacity(dim);
406        for _ in 0..dim {
407            amps.push(Complex32::new(rng.next_normal(), rng.next_normal()));
408        }
409        let mut sv = StateVector {
410            amps,
411            n_qubits: walk.n_qubits(),
412        };
413        sv.normalize_inplace();
414        for _ in 0..20 {
415            walk.step(&mut sv)
416                .expect("step should succeed on valid state");
417            assert!((sv.norm_sq() - 1.0).abs() < 1e-4, "norm drift");
418        }
419    }
420
421    // (e) The symmetric initial coin (|0⟩ + i|1⟩)/√2 yields a left–right symmetric
422    //     distribution about x0.
423    #[test]
424    fn symmetric_coin_symmetric_distribution() {
425        let walk = CoinedWalk::new(64).expect("valid CoinedWalk with 64 positions");
426        let x0 = 32usize;
427        let dist = walk
428            .evolve(x0, CoinInit::SymmetricI, 18)
429            .expect("evolve should succeed");
430        let n = 64usize;
431        for j in 1..20 {
432            let right = dist[(x0 + j) % n];
433            let left = dist[(x0 + n - j) % n];
434            assert!(
435                (right - left).abs() < 1e-5,
436                "asymmetry at j={j}: right={right}, left={left}"
437            );
438        }
439    }
440
441    // (e-bis) The asymmetric coin |0⟩ produces a *biased* (non-symmetric)
442    //         distribution — the contrast that makes (e) meaningful.
443    #[test]
444    fn zero_coin_is_biased() {
445        let walk = CoinedWalk::new(64).expect("valid CoinedWalk with 64 positions");
446        let x0 = 32usize;
447        let dist = walk
448            .evolve(x0, CoinInit::Zero, 18)
449            .expect("evolve should succeed");
450        let n = 64usize;
451        // Mass to the right vs left of x0 should differ noticeably.
452        let mut right_mass = 0.0_f32;
453        let mut left_mass = 0.0_f32;
454        for j in 1..30 {
455            right_mass += dist[(x0 + j) % n];
456            left_mass += dist[(x0 + n - j) % n];
457        }
458        assert!(
459            (right_mass - left_mass).abs() > 0.1,
460            "expected bias: right={right_mass}, left={left_mass}"
461        );
462    }
463
464    // (f) On the smallest cycle the walk is periodic in time. For N = 2 both shift
465    //     directions coincide (±1 mod 2 = NOT), so U = X_pos · H_coin and U² = I:
466    //     the state — and hence the distribution — recurs exactly after 2 steps.
467    #[test]
468    fn small_cycle_periodic() {
469        let walk = CoinedWalk::new(2).expect("valid CoinedWalk with 2 positions");
470        let initial = walk
471            .initial_state(0, CoinInit::Zero)
472            .expect("valid initial state at position 0");
473        let mut sv = initial.clone();
474        walk.step(&mut sv).expect("first step should succeed");
475        walk.step(&mut sv).expect("second step should succeed");
476        // After 2 steps the full state returns to the initial state.
477        for (a, b) in sv.amps.iter().zip(initial.amps.iter()) {
478            assert!((a.re - b.re).abs() < 1e-5, "re {a:?} vs {b:?}");
479            assert!((a.im - b.im).abs() < 1e-5, "im {a:?} vs {b:?}");
480        }
481
482        // The distribution is likewise periodic with period 2.
483        let d0 = walk
484            .position_distribution(&initial)
485            .expect("position distribution of initial state should succeed");
486        let d2 = walk
487            .position_distribution(&sv)
488            .expect("position distribution after 2 steps should succeed");
489        for (p0, p2) in d0.iter().zip(d2.iter()) {
490            assert!((p0 - p2).abs() < 1e-5, "dist not periodic: {p0} vs {p2}");
491        }
492    }
493
494    // (f-bis) Cyclic translational covariance: walking from x0 equals walking from
495    //         0 then cyclically shifting by x0 — the periodic/ring structure of the
496    //         shift made explicit.
497    #[test]
498    fn cyclic_translation_covariance() {
499        let walk = CoinedWalk::new(32).expect("valid CoinedWalk with 32 positions");
500        let t = 10usize;
501        let from_zero = walk
502            .evolve(0, CoinInit::SymmetricI, t)
503            .expect("evolve from position 0 should succeed");
504        let x0 = 7usize;
505        let from_x0 = walk
506            .evolve(x0, CoinInit::SymmetricI, t)
507            .expect("evolve from position x0 should succeed");
508        let n = 32usize;
509        for x in 0..n {
510            let shifted = from_zero[(x + n - x0) % n];
511            assert!(
512                (from_x0[x] - shifted).abs() < 1e-5,
513                "covariance broken at x={x}: {} vs {}",
514                from_x0[x],
515                shifted
516            );
517        }
518    }
519
520    // Construction validation.
521    #[test]
522    fn rejects_non_power_of_two() {
523        assert!(CoinedWalk::new(6).is_err());
524        assert!(CoinedWalk::new(0).is_err());
525        assert!(CoinedWalk::new(1).is_err());
526        assert!(CoinedWalk::new(8).is_ok());
527    }
528
529    #[test]
530    fn rejects_out_of_range_start() {
531        let walk = CoinedWalk::new(8).expect("valid CoinedWalk with 8 positions");
532        assert!(walk.initial_state(8, CoinInit::Zero).is_err());
533        assert!(walk.initial_state(0, CoinInit::Zero).is_ok());
534    }
535}