Skip to main content

quantrs2_tytan/sampler/
simulated_annealing.rs

1//! # Simulated Annealing (SA) Sampler
2//!
3//! Simulated Annealing (SA) is a probabilistic optimization algorithm inspired by
4//! the annealing process in metallurgy, where controlled cooling reduces crystal
5//! defects. In combinatorial optimization, SA accepts worse solutions with a
6//! decreasing probability as a "temperature" parameter T decreases, allowing
7//! the search to escape local optima early in the run.
8//!
9//! ## Algorithm
10//!
11//! At each step:
12//!
13//! 1. Select a random bit flip (neighbour).
14//! 2. Compute ΔE = energy(flipped) − energy(current).
15//! 3. Accept the flip if ΔE < 0 (improvement), or with probability
16//!    `exp(-ΔE / T)` otherwise (Metropolis criterion).
17//! 4. Decrease T according to the cooling schedule.
18//!
19//! Geometric cooling: `T_k = T₀ · α^k`, where α ∈ (0, 1) is the cooling rate.
20//!
21//! ## Mathematical Formulation
22//!
23//! Given a QUBO matrix Q, the objective is to minimise:
24//!
25//! ```text
26//! E(x) = Σ_{i,j} Q[i,j] · x[i] · x[j],   x[i] ∈ {0, 1}
27//! ```
28//!
29//! The acceptance probability at temperature T for a move with energy change ΔE:
30//!
31//! ```text
32//! P(accept) = min(1, exp(-ΔE / T))
33//! ```
34//!
35//! ## Citation
36//!
37//! Kirkpatrick, S., Gelatt, C. D., & Vecchi, M. P. (1983).
38//! Optimization by Simulated Annealing.
39//! *Science*, 220(4598), 671–680.
40//! <https://doi.org/10.1126/science.220.4598.671>
41//!
42//! ## Parameters
43//!
44//! See [`quantrs2_anneal::simulator::AnnealingParams`] for all tunable parameters
45//! (initial temperature, cooling schedule, number of sweeps).
46//!
47//! ## When to Use
48//!
49//! - **Best for**: general-purpose QUBO/HOBO problems of any size.
50//! - **Strengths**: robust, easy to tune, works out of the box.
51//! - **Limitations**: slow on very large dense matrices compared to [`crate::sampler::SBSampler`].
52//!
53//! ## Usage
54//!
55//! ```
56//! use quantrs2_tytan::sampler::{SASampler, Sampler};
57//! use scirs2_core::ndarray::Array;
58//! use std::collections::HashMap;
59//!
60//! // Minimise: -x0 - x1 + 2*x0*x1  (mutual exclusion problem)
61//! let mut q = Array::<f64, _>::zeros((2, 2));
62//! q[[0, 0]] = -1.0;
63//! q[[1, 1]] = -1.0;
64//! q[[0, 1]] = 2.0;
65//!
66//! let mut var_map = HashMap::new();
67//! var_map.insert("x0".to_string(), 0);
68//! var_map.insert("x1".to_string(), 1);
69//!
70//! let sampler = SASampler::new(Some(42));
71//! let results = sampler.run_qubo(&(q, var_map), 5).expect("SA sampler failed");
72//! assert!(!results.is_empty());
73//! println!("Best energy: {}", results[0].energy);
74//! ```
75
76use scirs2_core::ndarray::{Array, Ix2};
77use scirs2_core::random::prelude::*;
78use scirs2_core::random::rngs::StdRng;
79use std::collections::HashMap;
80
81use quantrs2_anneal::{
82    simulator::{AnnealingParams, ClassicalAnnealingSimulator, TemperatureSchedule},
83    QuboModel,
84};
85
86use super::{SampleResult, Sampler, SamplerResult};
87
88#[cfg(feature = "parallel")]
89use scirs2_core::parallel_ops::*;
90
91/// Simulated Annealing Sampler
92///
93/// Probabilistic metaheuristic for QUBO/HOBO optimisation that uses a
94/// temperature schedule to control the probability of accepting worse
95/// solutions, allowing escape from local optima.
96///
97/// # Example
98///
99/// ```
100/// use quantrs2_tytan::sampler::{SASampler, Sampler};
101/// use scirs2_core::ndarray::Array;
102/// use std::collections::HashMap;
103///
104/// // Minimise: -x0 - x1 + 2*x0*x1  (mutual exclusion)
105/// let mut q = Array::<f64, _>::zeros((2, 2));
106/// q[[0, 0]] = -1.0;
107/// q[[1, 1]] = -1.0;
108/// q[[0, 1]] = 2.0;
109///
110/// let mut var_map = HashMap::new();
111/// var_map.insert("x0".to_string(), 0);
112/// var_map.insert("x1".to_string(), 1);
113///
114/// let sampler = SASampler::new(Some(42));
115/// let results = sampler.run_qubo(&(q, var_map), 5).expect("SA sampler failed");
116/// assert!(!results.is_empty());
117/// println!("Best energy: {}", results[0].energy);
118/// ```
119#[derive(Clone)]
120pub struct SASampler {
121    /// Random number generator seed
122    seed: Option<u64>,
123    /// Annealing parameters
124    params: AnnealingParams,
125}
126
127impl SASampler {
128    /// Create a new Simulated Annealing sampler
129    ///
130    /// # Arguments
131    ///
132    /// * `seed` - An optional random seed for reproducibility
133    #[must_use]
134    pub fn new(seed: Option<u64>) -> Self {
135        // Create default annealing parameters
136        let mut params = AnnealingParams::default();
137
138        // Customize based on seed
139        if let Some(seed) = seed {
140            params.seed = Some(seed);
141        }
142
143        Self { seed, params }
144    }
145
146    /// Create a new Simulated Annealing sampler with custom parameters
147    ///
148    /// # Arguments
149    ///
150    /// * `seed` - An optional random seed for reproducibility
151    /// * `params` - Custom annealing parameters
152    #[must_use]
153    pub const fn with_params(seed: Option<u64>, params: AnnealingParams) -> Self {
154        let mut params = params;
155
156        // Override seed if provided
157        if let Some(seed) = seed {
158            params.seed = Some(seed);
159        }
160
161        Self { seed, params }
162    }
163
164    /// Set beta range for simulated annealing
165    pub fn with_beta_range(mut self, beta_min: f64, beta_max: f64) -> Self {
166        // Convert beta (inverse temperature) to temperature
167        self.params.initial_temperature = 1.0 / beta_max;
168        // Use exponential temperature schedule to approximate final beta
169        self.params.temperature_schedule = TemperatureSchedule::Exponential(beta_min / beta_max);
170        self
171    }
172
173    /// Set number of sweeps
174    pub const fn with_sweeps(mut self, sweeps: usize) -> Self {
175        self.params.num_sweeps = sweeps;
176        self
177    }
178
179    /// Run the sampler on a QUBO/HOBO problem
180    ///
181    /// This is a generic implementation that works for both QUBO and HOBO
182    /// by converting the input to a format compatible with the underlying
183    /// annealing simulator.
184    ///
185    /// # Arguments
186    ///
187    /// * `matrix_or_tensor` - The problem matrix/tensor
188    /// * `var_map` - The variable mapping
189    /// * `shots` - The number of samples to take
190    ///
191    /// # Returns
192    ///
193    /// A vector of sample results, sorted by energy (best solutions first)
194    fn run_generic<D>(
195        &self,
196        matrix_or_tensor: &Array<f64, D>,
197        var_map: &HashMap<String, usize>,
198        shots: usize,
199    ) -> SamplerResult<Vec<SampleResult>>
200    where
201        D: scirs2_core::ndarray::Dimension + 'static,
202    {
203        // Make sure shots is reasonable
204        let shots = std::cmp::max(shots, 1);
205
206        // Get the problem dimension (number of variables)
207        let n_vars = var_map.len();
208
209        // Map from indices back to variable names
210        let idx_to_var: HashMap<usize, String> = var_map
211            .iter()
212            .map(|(var, &idx)| (idx, var.clone()))
213            .collect();
214
215        // For QUBO problems, convert to quantrs-anneal format.
216        //
217        // Note: SASampler cannot use the SIMD energy functions from `super::energy` because
218        // it delegates all inner-loop computation to `quantrs2_anneal::ClassicalAnnealingSimulator`,
219        // which manages its own internal state representation and energy bookkeeping.
220        // The SIMD functions (energy_full_simd, energy_delta_simd, etc.) are designed for
221        // samplers that own a flat Vec<f64> q_matrix and iterate over bool states directly
222        // (see TabuSampler, PASampler, SBSampler). Integrating them here would require
223        // rewriting SA from scratch and abandoning the well-tested anneal crate backend.
224        if matrix_or_tensor.ndim() == 2 {
225            // Convert ndarray to a QuboModel
226            let mut qubo = QuboModel::new(n_vars);
227
228            // Set linear and quadratic terms
229            for i in 0..n_vars {
230                let diag_val = match matrix_or_tensor.ndim() {
231                    2 => {
232                        // For 2D matrices (QUBO)
233                        let matrix = matrix_or_tensor
234                            .to_owned()
235                            .into_dimensionality::<Ix2>()
236                            .ok();
237                        matrix.map_or(0.0, |m| m[[i, i]])
238                    }
239                    _ => 0.0, // For higher dimensions, assume 0 for diagonal elements
240                };
241
242                if diag_val != 0.0 {
243                    qubo.set_linear(i, diag_val)?;
244                }
245
246                for j in (i + 1)..n_vars {
247                    let quad_val = match matrix_or_tensor.ndim() {
248                        2 => {
249                            // For 2D matrices (QUBO)
250                            let matrix = matrix_or_tensor
251                                .to_owned()
252                                .into_dimensionality::<Ix2>()
253                                .ok();
254                            matrix.map_or(0.0, |m| m[[i, j]])
255                        }
256                        _ => 0.0, // Higher dimensions would need separate handling
257                    };
258
259                    if quad_val != 0.0 {
260                        qubo.set_quadratic(i, j, quad_val)?;
261                    }
262                }
263            }
264
265            // Configure annealing parameters
266            // Note: We respect the user's configured num_repetitions instead of
267            // overriding it with shots. The shots parameter in QUBO solving
268            // represents the number of independent samples desired, but for now
269            // we return the best solution found in the configured repetitions.
270            let params = self.params.clone();
271
272            // Create annealing simulator
273            let simulator = ClassicalAnnealingSimulator::new(params)?;
274
275            // Convert QUBO to Ising model
276            let (ising_model, _) = qubo.to_ising();
277
278            // Solve the problem
279            let annealing_result = simulator.solve(&ising_model)?;
280
281            // Convert to our result format
282            let mut results = Vec::new();
283
284            // Convert spins to binary variables
285            let binary_vars: Vec<bool> = annealing_result
286                .best_spins
287                .iter()
288                .map(|&spin| spin > 0)
289                .collect();
290
291            // Convert binary array to HashMap
292            let assignments: HashMap<String, bool> = binary_vars
293                .iter()
294                .enumerate()
295                .filter_map(|(idx, &value)| {
296                    idx_to_var
297                        .get(&idx)
298                        .map(|var_name| (var_name.clone(), value))
299                })
300                .collect();
301
302            // Create a result
303            let result = SampleResult {
304                assignments,
305                energy: annealing_result.best_energy,
306                occurrences: 1,
307            };
308
309            results.push(result);
310
311            return Ok(results);
312        }
313
314        // For higher-order tensors (HOBO problems)
315        self.run_hobo_tensor(matrix_or_tensor, var_map, shots)
316    }
317
318    /// Run simulated annealing on a HOBO problem represented as a tensor
319    fn run_hobo_tensor<D>(
320        &self,
321        tensor: &Array<f64, D>,
322        var_map: &HashMap<String, usize>,
323        shots: usize,
324    ) -> SamplerResult<Vec<SampleResult>>
325    where
326        D: scirs2_core::ndarray::Dimension + 'static,
327    {
328        // Get the problem dimension (number of variables)
329        let n_vars = var_map.len();
330
331        // Map from indices back to variable names
332        let idx_to_var: HashMap<usize, String> = var_map
333            .iter()
334            .map(|(var, &idx)| (idx, var.clone()))
335            .collect();
336
337        // Create RNG with seed if provided
338        let mut rng = if let Some(seed) = self.seed {
339            StdRng::seed_from_u64(seed)
340        } else {
341            let seed: u64 = thread_rng().random();
342            StdRng::seed_from_u64(seed)
343        };
344
345        // Convert tensor to dynamic dimensionality once, reused by the closure below
346        let tensor_dyn: scirs2_core::ndarray::ArrayD<f64> = tensor.to_owned().into_dyn();
347
348        // Store solutions and their frequencies
349        let mut solution_counts: HashMap<Vec<bool>, (f64, usize)> = HashMap::new();
350
351        // Maximum parallel runs
352        #[cfg(feature = "parallel")]
353        let num_threads = scirs2_core::parallel_ops::current_num_threads();
354        #[cfg(not(feature = "parallel"))]
355        let num_threads = 1;
356
357        // Divide shots across threads
358        let shots_per_thread = shots / num_threads + usize::from(shots % num_threads > 0);
359        let total_runs = shots_per_thread * num_threads;
360
361        // Set up annealing parameters
362        let initial_temp = 10.0;
363        let final_temp = 0.1;
364        let sweeps = 1000;
365
366        // Function to evaluate HOBO energy via the unified dispatch function
367        let evaluate_energy = |state: &[bool]| -> f64 {
368            super::energy::hobo_energy_full_dispatch(state, &tensor_dyn)
369        };
370
371        // Vector to store thread-local solutions
372        #[allow(unused_assignments)]
373        let mut all_solutions = Vec::with_capacity(total_runs);
374
375        // Run annealing process
376        #[cfg(feature = "parallel")]
377        {
378            // Create seeds for each parallel run
379            let seeds: Vec<u64> = (0..total_runs)
380                .map(|i| match self.seed {
381                    Some(seed) => seed.wrapping_add(i as u64),
382                    None => thread_rng().random(),
383                })
384                .collect();
385
386            // Run in parallel
387            all_solutions = seeds
388                .into_par_iter()
389                .map(|seed| {
390                    let mut thread_rng = StdRng::seed_from_u64(seed);
391
392                    // Initialize random state
393                    let mut state = vec![false; n_vars];
394                    for bit in &mut state {
395                        *bit = thread_rng.random_bool(0.5);
396                    }
397
398                    // Evaluate initial energy
399                    let mut energy = evaluate_energy(&state);
400                    let mut best_state = state.clone();
401                    let mut best_energy = energy;
402
403                    // Simulated annealing
404                    for sweep in 0..sweeps {
405                        // Calculate temperature for this step
406                        let temp = initial_temp
407                            * f64::powf(final_temp / initial_temp, sweep as f64 / sweeps as f64);
408
409                        // Perform n_vars updates per sweep
410                        for _ in 0..n_vars {
411                            // Select random bit to flip
412                            let idx = thread_rng.random_range(0..n_vars);
413
414                            // Flip the bit
415                            state[idx] = !state[idx];
416
417                            // Calculate new energy
418                            let new_energy = evaluate_energy(&state);
419                            let delta_e = new_energy - energy;
420
421                            // Metropolis acceptance criterion
422                            let accept = delta_e <= 0.0
423                                || thread_rng.random_range(0.0..1.0) < (-delta_e / temp).exp();
424
425                            if accept {
426                                energy = new_energy;
427                                if energy < best_energy {
428                                    best_energy = energy;
429                                    best_state = state.clone();
430                                }
431                            } else {
432                                // Revert flip
433                                state[idx] = !state[idx];
434                            }
435                        }
436                    }
437
438                    (best_state, best_energy)
439                })
440                .collect();
441        }
442
443        #[cfg(not(feature = "parallel"))]
444        {
445            for _ in 0..total_runs {
446                // Initialize random state
447                let mut state = vec![false; n_vars];
448                for bit in &mut state {
449                    *bit = rng.random_bool(0.5);
450                }
451
452                // Evaluate initial energy
453                let mut energy = evaluate_energy(&state);
454                let mut best_state = state.clone();
455                let mut best_energy = energy;
456
457                // Simulated annealing
458                for sweep in 0..sweeps {
459                    // Calculate temperature for this step
460                    let temp = initial_temp
461                        * f64::powf(final_temp / initial_temp, sweep as f64 / sweeps as f64);
462
463                    // Perform n_vars updates per sweep
464                    for _ in 0..n_vars {
465                        // Select random bit to flip
466                        let mut idx = rng.random_range(0..n_vars);
467
468                        // Flip the bit
469                        state[idx] = !state[idx];
470
471                        // Calculate new energy
472                        let new_energy = evaluate_energy(&state);
473                        let delta_e = new_energy - energy;
474
475                        // Metropolis acceptance criterion
476                        let accept = delta_e <= 0.0
477                            || rng.random_range(0.0..1.0) < f64::exp(-delta_e / temp);
478
479                        if accept {
480                            energy = new_energy;
481                            if energy < best_energy {
482                                best_energy = energy;
483                                best_state = state.clone();
484                            }
485                        } else {
486                            // Revert flip
487                            state[idx] = !state[idx];
488                        }
489                    }
490                }
491
492                all_solutions.push((best_state, best_energy));
493            }
494        }
495
496        // Process results from all threads
497        for (state, energy) in all_solutions {
498            let entry = solution_counts.entry(state).or_insert((energy, 0));
499            entry.1 += 1;
500        }
501
502        // Convert to SampleResult format
503        let mut results: Vec<SampleResult> = solution_counts
504            .into_iter()
505            .map(|(state, (energy, count))| {
506                // Convert to variable assignments
507                let assignments: HashMap<String, bool> = state
508                    .iter()
509                    .enumerate()
510                    .filter_map(|(idx, &value)| {
511                        idx_to_var
512                            .get(&idx)
513                            .map(|var_name| (var_name.clone(), value))
514                    })
515                    .collect();
516
517                SampleResult {
518                    assignments,
519                    energy,
520                    occurrences: count,
521                }
522            })
523            .collect();
524
525        // Sort by energy (best solutions first)
526        results.sort_by(|a, b| {
527            a.energy
528                .partial_cmp(&b.energy)
529                .unwrap_or(std::cmp::Ordering::Equal)
530        });
531
532        // Limit to requested number of shots if we have more
533        if results.len() > shots {
534            results.truncate(shots);
535        }
536
537        Ok(results)
538    }
539}
540
541impl Sampler for SASampler {
542    fn run_qubo(
543        &self,
544        qubo: &(
545            Array<f64, scirs2_core::ndarray::Ix2>,
546            HashMap<String, usize>,
547        ),
548        shots: usize,
549    ) -> SamplerResult<Vec<SampleResult>> {
550        self.run_generic(&qubo.0, &qubo.1, shots)
551    }
552
553    fn run_hobo(
554        &self,
555        hobo: &(
556            Array<f64, scirs2_core::ndarray::IxDyn>,
557            HashMap<String, usize>,
558        ),
559        shots: usize,
560    ) -> SamplerResult<Vec<SampleResult>> {
561        self.run_generic(&hobo.0, &hobo.1, shots)
562    }
563}