quantrs2_tytan/sampler/simulated_annealing.rs
1//! Simulated Annealing Sampler Implementation
2
3use scirs2_core::ndarray::{Array, Ix2};
4use scirs2_core::random::prelude::*;
5use scirs2_core::random::rngs::StdRng;
6use std::collections::HashMap;
7
8use quantrs2_anneal::{
9 simulator::{AnnealingParams, ClassicalAnnealingSimulator, TemperatureSchedule},
10 QuboModel,
11};
12
13use super::{SampleResult, Sampler, SamplerResult};
14
15#[cfg(feature = "parallel")]
16use scirs2_core::parallel_ops::*;
17
18/// Simulated Annealing Sampler
19///
20/// This sampler uses simulated annealing to find solutions to
21/// QUBO/HOBO problems. It is a local search method that uses
22/// temperature to control the acceptance of worse solutions.
23#[derive(Clone)]
24pub struct SASampler {
25 /// Random number generator seed
26 seed: Option<u64>,
27 /// Annealing parameters
28 params: AnnealingParams,
29}
30
31impl SASampler {
32 /// Create a new Simulated Annealing sampler
33 ///
34 /// # Arguments
35 ///
36 /// * `seed` - An optional random seed for reproducibility
37 #[must_use]
38 pub fn new(seed: Option<u64>) -> Self {
39 // Create default annealing parameters
40 let mut params = AnnealingParams::default();
41
42 // Customize based on seed
43 if let Some(seed) = seed {
44 params.seed = Some(seed);
45 }
46
47 Self { seed, params }
48 }
49
50 /// Create a new Simulated Annealing sampler with custom parameters
51 ///
52 /// # Arguments
53 ///
54 /// * `seed` - An optional random seed for reproducibility
55 /// * `params` - Custom annealing parameters
56 #[must_use]
57 pub const fn with_params(seed: Option<u64>, params: AnnealingParams) -> Self {
58 let mut params = params;
59
60 // Override seed if provided
61 if let Some(seed) = seed {
62 params.seed = Some(seed);
63 }
64
65 Self { seed, params }
66 }
67
68 /// Set beta range for simulated annealing
69 pub fn with_beta_range(mut self, beta_min: f64, beta_max: f64) -> Self {
70 // Convert beta (inverse temperature) to temperature
71 self.params.initial_temperature = 1.0 / beta_max;
72 // Use exponential temperature schedule to approximate final beta
73 self.params.temperature_schedule = TemperatureSchedule::Exponential(beta_min / beta_max);
74 self
75 }
76
77 /// Set number of sweeps
78 pub const fn with_sweeps(mut self, sweeps: usize) -> Self {
79 self.params.num_sweeps = sweeps;
80 self
81 }
82
83 /// Run the sampler on a QUBO/HOBO problem
84 ///
85 /// This is a generic implementation that works for both QUBO and HOBO
86 /// by converting the input to a format compatible with the underlying
87 /// annealing simulator.
88 ///
89 /// # Arguments
90 ///
91 /// * `matrix_or_tensor` - The problem matrix/tensor
92 /// * `var_map` - The variable mapping
93 /// * `shots` - The number of samples to take
94 ///
95 /// # Returns
96 ///
97 /// A vector of sample results, sorted by energy (best solutions first)
98 fn run_generic<D>(
99 &self,
100 matrix_or_tensor: &Array<f64, D>,
101 var_map: &HashMap<String, usize>,
102 shots: usize,
103 ) -> SamplerResult<Vec<SampleResult>>
104 where
105 D: scirs2_core::ndarray::Dimension + 'static,
106 {
107 // Make sure shots is reasonable
108 let shots = std::cmp::max(shots, 1);
109
110 // Get the problem dimension (number of variables)
111 let n_vars = var_map.len();
112
113 // Map from indices back to variable names
114 let idx_to_var: HashMap<usize, String> = var_map
115 .iter()
116 .map(|(var, &idx)| (idx, var.clone()))
117 .collect();
118
119 // For QUBO problems, convert to quantrs-anneal format
120 if matrix_or_tensor.ndim() == 2 {
121 // Convert ndarray to a QuboModel
122 let mut qubo = QuboModel::new(n_vars);
123
124 // Set linear and quadratic terms
125 for i in 0..n_vars {
126 let diag_val = match matrix_or_tensor.ndim() {
127 2 => {
128 // For 2D matrices (QUBO)
129 let matrix = matrix_or_tensor
130 .to_owned()
131 .into_dimensionality::<Ix2>()
132 .ok();
133 matrix.map_or(0.0, |m| m[[i, i]])
134 }
135 _ => 0.0, // For higher dimensions, assume 0 for diagonal elements
136 };
137
138 if diag_val != 0.0 {
139 qubo.set_linear(i, diag_val)?;
140 }
141
142 for j in (i + 1)..n_vars {
143 let quad_val = match matrix_or_tensor.ndim() {
144 2 => {
145 // For 2D matrices (QUBO)
146 let matrix = matrix_or_tensor
147 .to_owned()
148 .into_dimensionality::<Ix2>()
149 .ok();
150 matrix.map_or(0.0, |m| m[[i, j]])
151 }
152 _ => 0.0, // Higher dimensions would need separate handling
153 };
154
155 if quad_val != 0.0 {
156 qubo.set_quadratic(i, j, quad_val)?;
157 }
158 }
159 }
160
161 // Configure annealing parameters
162 let mut params = self.params.clone();
163 params.num_repetitions = shots;
164
165 // Create annealing simulator
166 let simulator = ClassicalAnnealingSimulator::new(params)?;
167
168 // Convert QUBO to Ising model
169 let (ising_model, _) = qubo.to_ising();
170
171 // Solve the problem
172 let annealing_result = simulator.solve(&ising_model)?;
173
174 // Convert to our result format
175 let mut results = Vec::new();
176
177 // Convert spins to binary variables
178 let binary_vars: Vec<bool> = annealing_result
179 .best_spins
180 .iter()
181 .map(|&spin| spin > 0)
182 .collect();
183
184 // Convert binary array to HashMap
185 let assignments: HashMap<String, bool> = binary_vars
186 .iter()
187 .enumerate()
188 .filter_map(|(idx, &value)| {
189 idx_to_var
190 .get(&idx)
191 .map(|var_name| (var_name.clone(), value))
192 })
193 .collect();
194
195 // Create a result
196 let result = SampleResult {
197 assignments,
198 energy: annealing_result.best_energy,
199 occurrences: 1,
200 };
201
202 results.push(result);
203
204 return Ok(results);
205 }
206
207 // For higher-order tensors (HOBO problems)
208 self.run_hobo_tensor(matrix_or_tensor, var_map, shots)
209 }
210
211 /// Run simulated annealing on a HOBO problem represented as a tensor
212 fn run_hobo_tensor<D>(
213 &self,
214 tensor: &Array<f64, D>,
215 var_map: &HashMap<String, usize>,
216 shots: usize,
217 ) -> SamplerResult<Vec<SampleResult>>
218 where
219 D: scirs2_core::ndarray::Dimension + 'static,
220 {
221 // Get the problem dimension (number of variables)
222 let n_vars = var_map.len();
223
224 // Map from indices back to variable names
225 let idx_to_var: HashMap<usize, String> = var_map
226 .iter()
227 .map(|(var, &idx)| (idx, var.clone()))
228 .collect();
229
230 // Create RNG with seed if provided
231 let mut rng = if let Some(seed) = self.seed {
232 StdRng::seed_from_u64(seed)
233 } else {
234 let seed: u64 = thread_rng().random();
235 StdRng::seed_from_u64(seed)
236 };
237
238 // Store solutions and their frequencies
239 let mut solution_counts: HashMap<Vec<bool>, (f64, usize)> = HashMap::new();
240
241 // Maximum parallel runs
242 #[cfg(feature = "parallel")]
243 let num_threads = scirs2_core::parallel_ops::current_num_threads();
244 #[cfg(not(feature = "parallel"))]
245 let num_threads = 1;
246
247 // Divide shots across threads
248 let shots_per_thread = shots / num_threads + usize::from(shots % num_threads > 0);
249 let total_runs = shots_per_thread * num_threads;
250
251 // Set up annealing parameters
252 let initial_temp = 10.0;
253 let final_temp = 0.1;
254 let sweeps = 1000;
255
256 // Function to evaluate HOBO energy
257 let evaluate_energy = |state: &[bool]| -> f64 {
258 let mut energy = 0.0;
259
260 // We'll match based on tensor dimension to handle differently
261 // Handle the tensor processing based on its dimensions
262 if tensor.ndim() == 3 {
263 let tensor3d = tensor
264 .to_owned()
265 .into_dimensionality::<scirs2_core::ndarray::Ix3>()
266 .ok();
267 if let Some(t) = tensor3d {
268 // Calculate energy for 3D tensor
269 for i in 0..std::cmp::min(n_vars, t.dim().0) {
270 if !state[i] {
271 continue;
272 }
273 for j in 0..std::cmp::min(n_vars, t.dim().1) {
274 if !state[j] {
275 continue;
276 }
277 for k in 0..std::cmp::min(n_vars, t.dim().2) {
278 if state[k] {
279 energy += t[[i, j, k]];
280 }
281 }
282 }
283 }
284 }
285 } else {
286 // For other dimensions, we'll do a brute force approach
287 let shape = tensor.shape();
288 if shape.len() == 2 {
289 // Handle 2D specifically
290 if let Ok(tensor2d) = tensor
291 .to_owned()
292 .into_dimensionality::<scirs2_core::ndarray::Ix2>()
293 {
294 for i in 0..std::cmp::min(n_vars, tensor2d.dim().0) {
295 if !state[i] {
296 continue;
297 }
298 for j in 0..std::cmp::min(n_vars, tensor2d.dim().1) {
299 if state[j] {
300 energy += tensor2d[[i, j]];
301 }
302 }
303 }
304 }
305 } else {
306 // Fallback for other dimensions - just return the energy as is
307 // This should be specialized for other tensor dimensions if needed
308 if !tensor.is_empty() {
309 println!(
310 "Warning: Processing tensor with shape {shape:?} not specifically optimized"
311 );
312 }
313 }
314 }
315
316 energy
317 };
318
319 // Vector to store thread-local solutions
320 #[allow(unused_assignments)]
321 let mut all_solutions = Vec::with_capacity(total_runs);
322
323 // Run annealing process
324 #[cfg(feature = "parallel")]
325 {
326 // Create seeds for each parallel run
327 let seeds: Vec<u64> = (0..total_runs)
328 .map(|i| match self.seed {
329 Some(seed) => seed.wrapping_add(i as u64),
330 None => thread_rng().random(),
331 })
332 .collect();
333
334 // Run in parallel
335 all_solutions = seeds
336 .into_par_iter()
337 .map(|seed| {
338 let mut thread_rng = StdRng::seed_from_u64(seed);
339
340 // Initialize random state
341 let mut state = vec![false; n_vars];
342 for bit in &mut state {
343 *bit = thread_rng.gen_bool(0.5);
344 }
345
346 // Evaluate initial energy
347 let mut energy = evaluate_energy(&state);
348 let mut best_state = state.clone();
349 let mut best_energy = energy;
350
351 // Simulated annealing
352 for sweep in 0..sweeps {
353 // Calculate temperature for this step
354 let temp = initial_temp
355 * f64::powf(final_temp / initial_temp, sweep as f64 / sweeps as f64);
356
357 // Perform n_vars updates per sweep
358 for _ in 0..n_vars {
359 // Select random bit to flip
360 let idx = thread_rng.gen_range(0..n_vars);
361
362 // Flip the bit
363 state[idx] = !state[idx];
364
365 // Calculate new energy
366 let new_energy = evaluate_energy(&state);
367 let delta_e = new_energy - energy;
368
369 // Metropolis acceptance criterion
370 let accept = delta_e <= 0.0
371 || thread_rng.gen_range(0.0..1.0) < (-delta_e / temp).exp();
372
373 if accept {
374 energy = new_energy;
375 if energy < best_energy {
376 best_energy = energy;
377 best_state = state.clone();
378 }
379 } else {
380 // Revert flip
381 state[idx] = !state[idx];
382 }
383 }
384 }
385
386 (best_state, best_energy)
387 })
388 .collect();
389 }
390
391 #[cfg(not(feature = "parallel"))]
392 {
393 for _ in 0..total_runs {
394 // Initialize random state
395 let mut state = vec![false; n_vars];
396 for bit in &mut state {
397 *bit = rng.gen_bool(0.5);
398 }
399
400 // Evaluate initial energy
401 let mut energy = evaluate_energy(&state);
402 let mut best_state = state.clone();
403 let mut best_energy = energy;
404
405 // Simulated annealing
406 for sweep in 0..sweeps {
407 // Calculate temperature for this step
408 let temp = initial_temp
409 * f64::powf(final_temp / initial_temp, sweep as f64 / sweeps as f64);
410
411 // Perform n_vars updates per sweep
412 for _ in 0..n_vars {
413 // Select random bit to flip
414 let mut idx = rng.gen_range(0..n_vars);
415
416 // Flip the bit
417 state[idx] = !state[idx];
418
419 // Calculate new energy
420 let new_energy = evaluate_energy(&state);
421 let delta_e = new_energy - energy;
422
423 // Metropolis acceptance criterion
424 let accept =
425 delta_e <= 0.0 || rng.gen_range(0.0..1.0) < f64::exp(-delta_e / temp);
426
427 if accept {
428 energy = new_energy;
429 if energy < best_energy {
430 best_energy = energy;
431 best_state = state.clone();
432 }
433 } else {
434 // Revert flip
435 state[idx] = !state[idx];
436 }
437 }
438 }
439
440 all_solutions.push((best_state, best_energy));
441 }
442 }
443
444 // Process results from all threads
445 for (state, energy) in all_solutions {
446 let entry = solution_counts.entry(state).or_insert((energy, 0));
447 entry.1 += 1;
448 }
449
450 // Convert to SampleResult format
451 let mut results: Vec<SampleResult> = solution_counts
452 .into_iter()
453 .map(|(state, (energy, count))| {
454 // Convert to variable assignments
455 let assignments: HashMap<String, bool> = state
456 .iter()
457 .enumerate()
458 .filter_map(|(idx, &value)| {
459 idx_to_var
460 .get(&idx)
461 .map(|var_name| (var_name.clone(), value))
462 })
463 .collect();
464
465 SampleResult {
466 assignments,
467 energy,
468 occurrences: count,
469 }
470 })
471 .collect();
472
473 // Sort by energy (best solutions first)
474 results.sort_by(|a, b| {
475 a.energy
476 .partial_cmp(&b.energy)
477 .unwrap_or(std::cmp::Ordering::Equal)
478 });
479
480 // Limit to requested number of shots if we have more
481 if results.len() > shots {
482 results.truncate(shots);
483 }
484
485 Ok(results)
486 }
487}
488
489impl Sampler for SASampler {
490 fn run_qubo(
491 &self,
492 qubo: &(
493 Array<f64, scirs2_core::ndarray::Ix2>,
494 HashMap<String, usize>,
495 ),
496 shots: usize,
497 ) -> SamplerResult<Vec<SampleResult>> {
498 self.run_generic(&qubo.0, &qubo.1, shots)
499 }
500
501 fn run_hobo(
502 &self,
503 hobo: &(
504 Array<f64, scirs2_core::ndarray::IxDyn>,
505 HashMap<String, usize>,
506 ),
507 shots: usize,
508 ) -> SamplerResult<Vec<SampleResult>> {
509 self.run_generic(&hobo.0, &hobo.1, shots)
510 }
511}