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}