Skip to main content

quantrs2_tytan/sampler/
simulated_bifurcation.rs

1//! # Simulated Bifurcation (SB) Sampler
2//!
3//! The Simulated Bifurcation (SB) algorithm solves QUBO/Ising problems by simulating
4//! the adiabatic bifurcation of a network of nonlinear oscillators. As a pump
5//! parameter is slowly increased, oscillators bifurcate from a zero-amplitude
6//! equilibrium into one of two stable oscillation phases, which correspond to
7//! binary spin values ±1.
8//!
9//! ## Algorithm Variants
10//!
11//! - **Ballistic SB (bSB)**: uses the continuous oscillator amplitude `x_i` in
12//!   the coupling term. Converges faster but may clip more frequently.
13//! - **Discrete SB (dSB)**: uses `sign(x_i)` in the coupling term, snapping
14//!   spins to their binary values at every step.
15//!
16//! ## QUBO to Ising Conversion
17//!
18//! Given QUBO matrix Q (upper-triangular or symmetric), the Ising parameters are:
19//!
20//! ```text
21//! Q_sym[i,j] = (Q[i,j] + Q[j,i]) / 2     (for i != j, symmetrised off-diagonal)
22//! J[i,j]     = -Q_sym[i,j] / 4            (off-diagonal Ising coupling)
23//! h[i]       = Q[i,i]/2 + sum_{j!=i} Q_sym[i,j]/2   (local field)
24//! ```
25//!
26//! ## SB Dynamics (Symplectic Euler Integration)
27//!
28//! For each time step t, with pump parameter a(t) linearly ramped from `a_init` to
29//! `a_final`:
30//!
31//! ```text
32//! a(t) = a_init + (a_final - a_init) * t / T
33//!
34//! bSB update:
35//!   y_i <- y_i + (-a(t)*x_i - c0 * sum_j J[i,j]*x_j - h_i) * dt
36//!
37//! dSB update:
38//!   y_i <- y_i + (-a(t)*x_i - c0 * sum_j J[i,j]*sign(x_j) - h_i) * dt
39//!
40//! x_i <- x_i + y_i * dt
41//! Clip: if |x_i| > 1: x_i = sign(x_i), y_i = 0
42//! ```
43//!
44//! Final spin assignment: `s_i = sign(x_i)` mapped to binary {0, 1}.
45//!
46//! ## Citation
47//!
48//! Goto, H., Tatsumura, K., & Dixon, A. R. (2019).
49//! Combinatorial optimization by simulating adiabatic bifurcations in nonlinear
50//! Hamiltonian systems.
51//! *Science Advances*, 5(4), eaav2372.
52//! <https://doi.org/10.1126/sciadv.aav2372>
53//!
54//! ## Parameters
55//!
56//! See [`SBParams`] for all tunable parameters (`dt`, `c0`, `a_init`, `a_final`,
57//! `time_steps`, `variant`).
58//!
59//! ## When to Use
60//!
61//! - **Best for**: large, dense QUBO matrices (n ≥ 200) where SA is too slow.
62//! - **Strengths**: highly parallelisable; well-suited for GPU acceleration.
63//! - **Limitations**: requires tuning of `dt` and `c0` for best results; less
64//!   effective on sparse, structured problems than Tabu Search.
65//!
66//! ## Usage
67//!
68//! ```
69//! use quantrs2_tytan::sampler::{SBSampler, SBVariant, Sampler};
70//! use scirs2_core::ndarray::Array;
71//! use std::collections::HashMap;
72//!
73//! // K3 Max-Cut QUBO
74//! let mut q = Array::<f64, _>::zeros((3, 3));
75//! q[[0, 0]] = -1.0;
76//! q[[1, 1]] = -1.0;
77//! q[[2, 2]] = -1.0;
78//! q[[0, 1]] = 2.0;
79//! q[[0, 2]] = 2.0;
80//! q[[1, 2]] = 2.0;
81//!
82//! let mut var_map = HashMap::new();
83//! var_map.insert("x0".to_string(), 0);
84//! var_map.insert("x1".to_string(), 1);
85//! var_map.insert("x2".to_string(), 2);
86//!
87//! let sampler = SBSampler::new()
88//!     .with_seed(42)
89//!     .with_variant(SBVariant::Discrete)
90//!     .with_time_steps(200);
91//!
92//! let results = sampler.run_qubo(&(q, var_map), 5).expect("SB sampler failed");
93//! assert!(!results.is_empty());
94//! println!("Best energy: {}", results[0].energy);
95//! ```
96
97use scirs2_core::ndarray::{Array, ArrayD, Ix2};
98use scirs2_core::random::prelude::*;
99use scirs2_core::random::rngs::StdRng;
100use std::collections::HashMap;
101
102use super::energy::energy_full_simd;
103use super::{SampleResult, Sampler, SamplerError, SamplerResult};
104
105/// Variant of Simulated Bifurcation algorithm
106#[derive(Debug, Clone, PartialEq)]
107pub enum SBVariant {
108    /// Ballistic SB (bSB): uses x_i in the coupling term (continuous)
109    Ballistic,
110    /// Discrete SB (dSB): uses sign(x_i) in the coupling term
111    Discrete,
112}
113
114/// Parameters for the Simulated Bifurcation algorithm
115#[derive(Debug, Clone)]
116pub struct SBParams {
117    /// Algorithm variant (Ballistic or Discrete)
118    pub variant: SBVariant,
119    /// Symplectic Euler time step size
120    pub dt: f64,
121    /// Coupling scale factor (default: 0.5, adjusted internally if 0.0)
122    pub c0: f64,
123    /// Initial pump parameter
124    pub a_init: f64,
125    /// Final pump parameter
126    pub a_final: f64,
127    /// Number of integration time steps
128    pub time_steps: usize,
129}
130
131impl Default for SBParams {
132    fn default() -> Self {
133        Self {
134            variant: SBVariant::Discrete,
135            dt: 0.5,
136            c0: 0.5,
137            a_init: 0.0,
138            a_final: 1.0,
139            time_steps: 1000,
140        }
141    }
142}
143
144/// Simulated Bifurcation Sampler
145///
146/// Solves QUBO/Ising problems using Toshiba's Simulated Bifurcation algorithm.
147/// The algorithm simulates the adiabatic bifurcation of a nonlinear oscillator
148/// network to find low-energy configurations.
149///
150/// # Example
151///
152/// ```
153/// use quantrs2_tytan::sampler::{SBSampler, SBVariant, Sampler};
154/// use std::collections::HashMap;
155/// use scirs2_core::ndarray::Array;
156///
157/// // K3 Max-Cut QUBO
158/// let mut q = Array::<f64, _>::zeros((3, 3));
159/// q[[0, 0]] = -1.0;
160/// q[[1, 1]] = -1.0;
161/// q[[2, 2]] = -1.0;
162/// q[[0, 1]] = 2.0;
163/// q[[0, 2]] = 2.0;
164/// q[[1, 2]] = 2.0;
165///
166/// let mut var_map = HashMap::new();
167/// var_map.insert("x0".to_string(), 0);
168/// var_map.insert("x1".to_string(), 1);
169/// var_map.insert("x2".to_string(), 2);
170///
171/// let sampler = SBSampler::new()
172///     .with_seed(42)
173///     .with_variant(SBVariant::Discrete)
174///     .with_time_steps(200);
175///
176/// let results = sampler.run_qubo(&(q, var_map), 5).expect("SB sampler failed");
177/// assert!(!results.is_empty());
178/// println!("Best energy: {}", results[0].energy);
179/// ```
180#[derive(Debug, Clone)]
181pub struct SBSampler {
182    /// Random number generator seed
183    pub seed: Option<u64>,
184    /// SB algorithm parameters
185    pub params: SBParams,
186}
187
188impl SBSampler {
189    /// Create a new Simulated Bifurcation sampler with default parameters
190    #[must_use]
191    pub fn new() -> Self {
192        Self {
193            seed: None,
194            params: SBParams::default(),
195        }
196    }
197
198    /// Set the random seed for reproducibility
199    #[must_use]
200    pub fn with_seed(mut self, seed: u64) -> Self {
201        self.seed = Some(seed);
202        self
203    }
204
205    /// Set the SB variant (Ballistic or Discrete)
206    #[must_use]
207    pub fn with_variant(mut self, variant: SBVariant) -> Self {
208        self.params.variant = variant;
209        self
210    }
211
212    /// Set the number of time steps
213    #[must_use]
214    pub fn with_time_steps(mut self, time_steps: usize) -> Self {
215        self.params.time_steps = time_steps;
216        self
217    }
218
219    /// Set the time step size
220    #[must_use]
221    pub fn with_dt(mut self, dt: f64) -> Self {
222        self.params.dt = dt;
223        self
224    }
225
226    /// Set the coupling scale factor
227    #[must_use]
228    pub fn with_c0(mut self, c0: f64) -> Self {
229        self.params.c0 = c0;
230        self
231    }
232
233    /// Set the pump parameter range
234    #[must_use]
235    pub fn with_pump_range(mut self, a_init: f64, a_final: f64) -> Self {
236        self.params.a_init = a_init;
237        self.params.a_final = a_final;
238        self
239    }
240
241    /// Convert QUBO matrix to symmetrized Ising parameters (J, h)
242    ///
243    /// Given QUBO E(x) = x^T Q x, substituting x_i = (1 + s_i) / 2:
244    ///
245    /// E = const + Σ_i h[i] s_i + (1/4) Σ_{i≠j} Q_sym[i,j] s_i s_j
246    ///
247    /// where:
248    /// - Q_sym[i,j] = (Q[i,j] + Q[j,i]) / 2   (symmetrized coupling)
249    /// - h[i] = Q[i,i]/2 + Σ_{j≠i} Q_sym[i,j]/2   (linear field)
250    ///
251    /// For the SB dynamics, we use the coupling matrix J (off-diagonal part of
252    /// the symmetrized Q, scaled for the gradient of the Ising Hamiltonian):
253    /// - J[i,j] = Q_sym[i,j] / 4  for i ≠ j
254    fn qubo_to_ising(q_matrix: &[f64], n: usize) -> (Vec<f64>, Vec<f64>) {
255        let mut j = vec![0.0f64; n * n]; // off-diagonal coupling (J[i,i] = 0)
256        let mut h = vec![0.0f64; n];
257
258        // Linear field from diagonal Q[i,i]
259        for i in 0..n {
260            h[i] += q_matrix[i * n + i] / 2.0;
261        }
262
263        // Off-diagonal couplings and their contributions to the linear field
264        for i in 0..n {
265            for jj in 0..n {
266                if i == jj {
267                    continue;
268                }
269                let q_sym = (q_matrix[i * n + jj] + q_matrix[jj * n + i]) / 2.0;
270                j[i * n + jj] = q_sym / 4.0;
271                // Off-diagonal contribution to linear field: Q_sym[i,j] / 2
272                h[i] += q_sym / 2.0;
273            }
274        }
275
276        (j, h)
277    }
278
279    /// Compute QUBO energy: E = x^T Q x (using binary {0,1} variables)
280    ///
281    /// Delegates to the SIMD-accelerated implementation in [`super::energy::energy_full_simd`],
282    /// which uses `std::simd::f64x4` for n ≥ 32 and falls back to a scalar loop for smaller n.
283    fn compute_qubo_energy(q_matrix: &[f64], state: &[bool], n: usize) -> f64 {
284        energy_full_simd(state, q_matrix, n)
285    }
286
287    /// Run a single SB trajectory on a QUBO problem
288    fn run_single_qubo(&self, q_flat: &[f64], n: usize, rng: &mut StdRng) -> (Vec<bool>, f64) {
289        if n == 0 {
290            return (vec![], 0.0);
291        }
292
293        let (j_mat, h_vec) = Self::qubo_to_ising(q_flat, n);
294
295        let c0 = if self.params.c0 > 0.0 {
296            self.params.c0
297        } else {
298            0.5 / (n as f64).sqrt()
299        };
300        let dt = self.params.dt;
301        let a_init = self.params.a_init;
302        let a_final = self.params.a_final;
303        let time_steps = self.params.time_steps;
304
305        // Initialize x and y with small random values
306        let mut x_vec: Vec<f64> = (0..n).map(|_| rng.random_range(-0.1f64..0.1f64)).collect();
307        let mut y_vec: Vec<f64> = (0..n).map(|_| rng.random_range(-0.1f64..0.1f64)).collect();
308
309        for t in 0..time_steps {
310            let a = a_init + (a_final - a_init) * t as f64 / time_steps as f64;
311
312            match self.params.variant {
313                SBVariant::Ballistic => {
314                    // bSB: coupling uses x directly
315                    for i in 0..n {
316                        let mut coupling = 0.0;
317                        for jj in 0..n {
318                            if i != jj {
319                                coupling += j_mat[i * n + jj] * x_vec[jj];
320                            }
321                        }
322                        y_vec[i] += (-a * x_vec[i] - c0 * coupling - h_vec[i]) * dt;
323                    }
324                }
325                SBVariant::Discrete => {
326                    // dSB: coupling uses sign(x)
327                    for i in 0..n {
328                        let mut coupling = 0.0;
329                        for jj in 0..n {
330                            if i != jj {
331                                coupling += j_mat[i * n + jj] * x_vec[jj].signum();
332                            }
333                        }
334                        y_vec[i] += (-a * x_vec[i] - c0 * coupling - h_vec[i]) * dt;
335                    }
336                }
337            }
338
339            // Update positions and apply clipping
340            for i in 0..n {
341                x_vec[i] += y_vec[i] * dt;
342                if x_vec[i] > 1.0 {
343                    x_vec[i] = 1.0;
344                    y_vec[i] = 0.0;
345                } else if x_vec[i] < -1.0 {
346                    x_vec[i] = -1.0;
347                    y_vec[i] = 0.0;
348                }
349            }
350        }
351
352        // Readout: s_i = sign(x_i) → binary x_i = (s_i + 1) / 2
353        let binary_state: Vec<bool> = x_vec.iter().map(|&xi| xi >= 0.0).collect();
354        let energy = Self::compute_qubo_energy(q_flat, &binary_state, n);
355
356        (binary_state, energy)
357    }
358
359    /// Run generic sampler
360    fn run_generic<D>(
361        &self,
362        matrix_or_tensor: &Array<f64, D>,
363        var_map: &HashMap<String, usize>,
364        shots: usize,
365    ) -> SamplerResult<Vec<SampleResult>>
366    where
367        D: scirs2_core::ndarray::Dimension + 'static,
368    {
369        let shots = shots.max(1);
370        let n_vars = var_map.len();
371        if n_vars == 0 {
372            return Err(SamplerError::InvalidParameter(
373                "Variable map is empty".to_string(),
374            ));
375        }
376
377        if matrix_or_tensor.ndim() != 2 {
378            return Err(SamplerError::UnsupportedOperation(
379                "SBSampler only supports QUBO (2D matrix) problems. \
380                 Convert HOBO to QUBO first."
381                    .to_string(),
382            ));
383        }
384
385        let idx_to_var: HashMap<usize, String> = var_map
386            .iter()
387            .map(|(var, &idx)| (idx, var.clone()))
388            .collect();
389
390        let q2 = matrix_or_tensor
391            .to_owned()
392            .into_dimensionality::<Ix2>()
393            .map_err(|e| SamplerError::InvalidParameter(format!("Array cast error: {e}")))?;
394
395        let n = q2.dim().0;
396        if n != q2.dim().1 {
397            return Err(SamplerError::InvalidParameter(
398                "QUBO matrix must be square".to_string(),
399            ));
400        }
401
402        let q_flat: Vec<f64> = q2
403            .as_slice()
404            .ok_or_else(|| {
405                SamplerError::InvalidParameter("Non-contiguous QUBO matrix".to_string())
406            })?
407            .to_vec();
408
409        let mut solution_counts: HashMap<Vec<bool>, (f64, usize)> = HashMap::new();
410
411        for shot_idx in 0..shots {
412            let seed = match self.seed {
413                Some(s) => s.wrapping_add(shot_idx as u64),
414                None => {
415                    let mut rng_tmp = thread_rng();
416                    rng_tmp.random()
417                }
418            };
419            let mut rng = StdRng::seed_from_u64(seed);
420            let (state, energy) = self.run_single_qubo(&q_flat, n, &mut rng);
421
422            let entry = solution_counts.entry(state).or_insert((energy, 0));
423            entry.1 += 1;
424        }
425
426        // Sort by (energy, state) for deterministic ordering
427        let mut pairs: Vec<(Vec<bool>, SampleResult)> = solution_counts
428            .into_iter()
429            .map(|(state, (energy, count))| {
430                let assignments: HashMap<String, bool> = state
431                    .iter()
432                    .enumerate()
433                    .filter_map(|(idx, &value)| {
434                        idx_to_var.get(&idx).map(|name| (name.clone(), value))
435                    })
436                    .collect();
437                let result = SampleResult {
438                    assignments,
439                    energy,
440                    occurrences: count,
441                };
442                (state, result)
443            })
444            .collect();
445
446        pairs.sort_by(|(state_a, a), (state_b, b)| {
447            a.energy
448                .partial_cmp(&b.energy)
449                .unwrap_or(std::cmp::Ordering::Equal)
450                .then_with(|| state_a.cmp(state_b))
451        });
452
453        let results: Vec<SampleResult> = pairs.into_iter().map(|(_, r)| r).collect();
454        Ok(results)
455    }
456}
457
458impl Sampler for SBSampler {
459    fn run_qubo(
460        &self,
461        qubo: &(Array<f64, Ix2>, HashMap<String, usize>),
462        shots: usize,
463    ) -> SamplerResult<Vec<SampleResult>> {
464        self.run_generic(&qubo.0, &qubo.1, shots)
465    }
466
467    fn run_hobo(
468        &self,
469        hobo: &(ArrayD<f64>, HashMap<String, usize>),
470        shots: usize,
471    ) -> SamplerResult<Vec<SampleResult>> {
472        self.run_generic(&hobo.0, &hobo.1, shots)
473    }
474}
475
476#[cfg(test)]
477mod tests {
478    use super::*;
479    use scirs2_core::ndarray::Array2;
480
481    /// Build K3 Max-Cut QUBO matrix.
482    ///
483    /// Optimal energy: -2.0 (2 edges cut)
484    fn build_k3_maxcut_qubo() -> (Array2<f64>, HashMap<String, usize>) {
485        let mut q = Array2::<f64>::zeros((3, 3));
486        q[[0, 0]] = -2.0;
487        q[[1, 1]] = -2.0;
488        q[[2, 2]] = -2.0;
489        q[[0, 1]] = 2.0;
490        q[[0, 2]] = 2.0;
491        q[[1, 2]] = 2.0;
492
493        let mut var_map = HashMap::new();
494        var_map.insert("x0".to_string(), 0);
495        var_map.insert("x1".to_string(), 1);
496        var_map.insert("x2".to_string(), 2);
497
498        (q, var_map)
499    }
500
501    #[test]
502    fn test_sb_3var_maxcut() {
503        let (q, var_map) = build_k3_maxcut_qubo();
504        let sampler = SBSampler::new()
505            .with_seed(42)
506            .with_variant(SBVariant::Discrete)
507            .with_time_steps(1000);
508
509        let results = sampler
510            .run_qubo(&(q, var_map), 50)
511            .expect("SB run_qubo failed");
512
513        assert!(!results.is_empty(), "Expected non-empty results");
514        let best_energy = results[0].energy;
515        assert!(
516            best_energy <= -2.0 + 1e-9,
517            "Expected optimal energy <= -2.0, got {best_energy}"
518        );
519    }
520
521    #[test]
522    fn test_sb_ballistic_maxcut() {
523        let (q, var_map) = build_k3_maxcut_qubo();
524        let sampler = SBSampler::new()
525            .with_seed(99)
526            .with_variant(SBVariant::Ballistic)
527            .with_time_steps(1000);
528
529        let results = sampler
530            .run_qubo(&(q, var_map), 50)
531            .expect("SB ballistic failed");
532
533        assert!(!results.is_empty());
534        let best_energy = results[0].energy;
535        assert!(
536            best_energy <= -2.0 + 1e-9,
537            "Ballistic SB: expected energy <= -2.0, got {best_energy}"
538        );
539    }
540
541    #[test]
542    fn test_sb_determinism() {
543        let (q, var_map) = build_k3_maxcut_qubo();
544
545        let s1 = SBSampler::new().with_seed(42).with_time_steps(500);
546        let s2 = SBSampler::new().with_seed(42).with_time_steps(500);
547
548        let r1 = s1
549            .run_qubo(&(q.clone(), var_map.clone()), 10)
550            .expect("Run 1 failed");
551        let r2 = s2.run_qubo(&(q, var_map), 10).expect("Run 2 failed");
552
553        assert_eq!(r1.len(), r2.len(), "Result lengths differ");
554        for (a, b) in r1.iter().zip(r2.iter()) {
555            assert!(
556                (a.energy - b.energy).abs() < 1e-12,
557                "Energies differ: {} vs {}",
558                a.energy,
559                b.energy
560            );
561            assert_eq!(
562                a.assignments, b.assignments,
563                "Assignments differ for same seed"
564            );
565        }
566    }
567
568    #[test]
569    fn test_sb_ising_chain() {
570        // 1D Ising chain: J[i,i+1] = -1 (ferromagnetic), no field
571        // Ground state: all spins aligned (all 0 or all 1 in QUBO)
572        // QUBO: minimize E = Σ_{i} (2*x_i*x_{i+1} - x_i - x_{i+1}) + const
573        // Which means Q[i,i] = -1, Q[i,i+1] = Q[i+1,i] = 1 (upper + lower)
574        let n = 4;
575        let mut q = Array2::<f64>::zeros((n, n));
576        for i in 0..n {
577            q[[i, i]] = -1.0; // Linear: -x_i (partial)
578        }
579        // Pair terms
580        for i in 0..(n - 1) {
581            q[[i, i + 1]] = 2.0;
582            // Note: lower triangular left as 0 (upper-triangular convention)
583        }
584        // Adjust diagonal to properly encode the chain
585        // E = sum_{i=0}^{n-2} (x_i - x_{i+1})^2 - (n-1) = min at all-same
586        // = sum x_i^2 - 2*x_i*x_{i+1} + x_{i+1}^2 - (n-1)
587        // Since x^2 = x for binary: = sum x_i + x_{i+1} - 2*x_i*x_{i+1} - (n-1)
588        // QUBO: Q[i,i] = 1 (from x_{i-1} chain) + 1 (from x_{i+1} chain), Q[i,i+1] = -2
589        let mut q2 = Array2::<f64>::zeros((n, n));
590        for i in 0..n {
591            q2[[i, i]] = 1.0; // Will be adjusted
592        }
593        // Pair interactions: minimize (x_i - x_{i+1})^2 = x_i + x_{i+1} - 2*x_i*x_{i+1}
594        for i in 0..(n - 1) {
595            q2[[i, i]] += 0.0; // Already 1 from initialization
596            q2[[i + 1, i + 1]] += 0.0;
597            q2[[i, i + 1]] = -2.0; // quadratic penalty
598        }
599        // Actually build proper chain QUBO:
600        // E = sum_{i=0}^{n-2} (x_i + x_{i+1} - 2*x_i*x_{i+1})
601        // Minimum = 0 (all-zero or all-one)
602        let mut q_chain = Array2::<f64>::zeros((n, n));
603        for i in 0..(n - 1) {
604            q_chain[[i, i]] += 1.0;
605            q_chain[[i + 1, i + 1]] += 1.0;
606            q_chain[[i, i + 1]] = -2.0;
607        }
608
609        let mut var_map = HashMap::new();
610        for i in 0..n {
611            var_map.insert(format!("s{i}"), i);
612        }
613
614        let sampler = SBSampler::new()
615            .with_seed(42)
616            .with_variant(SBVariant::Discrete)
617            .with_time_steps(1000);
618
619        let results = sampler
620            .run_qubo(&(q_chain, var_map), 30)
621            .expect("Ising chain failed");
622
623        assert!(!results.is_empty());
624        // Ground state energy = 0 (all-same-spin)
625        let best_energy = results[0].energy;
626        assert!(
627            best_energy <= 1e-9,
628            "Expected energy <= 0.0 for ferromagnetic chain, got {best_energy}"
629        );
630    }
631
632    #[test]
633    fn test_sb_hobo_returns_error() {
634        // SBSampler does not support HOBO (ndim > 2)
635        use scirs2_core::ndarray::Array3;
636        let tensor = Array3::<f64>::zeros((2, 2, 2));
637        let mut var_map = HashMap::new();
638        var_map.insert("a".to_string(), 0);
639        var_map.insert("b".to_string(), 1);
640
641        let sampler = SBSampler::new().with_seed(1);
642        let result = sampler.run_hobo(&(tensor.into_dyn(), var_map), 10);
643        assert!(result.is_err(), "Expected error for 3D HOBO in SBSampler");
644    }
645}