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}