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 // Note: We respect the user's configured num_repetitions instead of
163 // overriding it with shots. The shots parameter in QUBO solving
164 // represents the number of independent samples desired, but for now
165 // we return the best solution found in the configured repetitions.
166 let params = self.params.clone();
167
168 // Create annealing simulator
169 let simulator = ClassicalAnnealingSimulator::new(params)?;
170
171 // Convert QUBO to Ising model
172 let (ising_model, _) = qubo.to_ising();
173
174 // Solve the problem
175 let annealing_result = simulator.solve(&ising_model)?;
176
177 // Convert to our result format
178 let mut results = Vec::new();
179
180 // Convert spins to binary variables
181 let binary_vars: Vec<bool> = annealing_result
182 .best_spins
183 .iter()
184 .map(|&spin| spin > 0)
185 .collect();
186
187 // Convert binary array to HashMap
188 let assignments: HashMap<String, bool> = binary_vars
189 .iter()
190 .enumerate()
191 .filter_map(|(idx, &value)| {
192 idx_to_var
193 .get(&idx)
194 .map(|var_name| (var_name.clone(), value))
195 })
196 .collect();
197
198 // Create a result
199 let result = SampleResult {
200 assignments,
201 energy: annealing_result.best_energy,
202 occurrences: 1,
203 };
204
205 results.push(result);
206
207 return Ok(results);
208 }
209
210 // For higher-order tensors (HOBO problems)
211 self.run_hobo_tensor(matrix_or_tensor, var_map, shots)
212 }
213
214 /// Run simulated annealing on a HOBO problem represented as a tensor
215 fn run_hobo_tensor<D>(
216 &self,
217 tensor: &Array<f64, D>,
218 var_map: &HashMap<String, usize>,
219 shots: usize,
220 ) -> SamplerResult<Vec<SampleResult>>
221 where
222 D: scirs2_core::ndarray::Dimension + 'static,
223 {
224 // Get the problem dimension (number of variables)
225 let n_vars = var_map.len();
226
227 // Map from indices back to variable names
228 let idx_to_var: HashMap<usize, String> = var_map
229 .iter()
230 .map(|(var, &idx)| (idx, var.clone()))
231 .collect();
232
233 // Create RNG with seed if provided
234 let mut rng = if let Some(seed) = self.seed {
235 StdRng::seed_from_u64(seed)
236 } else {
237 let seed: u64 = thread_rng().random();
238 StdRng::seed_from_u64(seed)
239 };
240
241 // Store solutions and their frequencies
242 let mut solution_counts: HashMap<Vec<bool>, (f64, usize)> = HashMap::new();
243
244 // Maximum parallel runs
245 #[cfg(feature = "parallel")]
246 let num_threads = scirs2_core::parallel_ops::current_num_threads();
247 #[cfg(not(feature = "parallel"))]
248 let num_threads = 1;
249
250 // Divide shots across threads
251 let shots_per_thread = shots / num_threads + usize::from(shots % num_threads > 0);
252 let total_runs = shots_per_thread * num_threads;
253
254 // Set up annealing parameters
255 let initial_temp = 10.0;
256 let final_temp = 0.1;
257 let sweeps = 1000;
258
259 // Function to evaluate HOBO energy
260 let evaluate_energy = |state: &[bool]| -> f64 {
261 let mut energy = 0.0;
262
263 // We'll match based on tensor dimension to handle differently
264 // Handle the tensor processing based on its dimensions
265 if tensor.ndim() == 3 {
266 let tensor3d = tensor
267 .to_owned()
268 .into_dimensionality::<scirs2_core::ndarray::Ix3>()
269 .ok();
270 if let Some(t) = tensor3d {
271 // Calculate energy for 3D tensor
272 for i in 0..std::cmp::min(n_vars, t.dim().0) {
273 if !state[i] {
274 continue;
275 }
276 for j in 0..std::cmp::min(n_vars, t.dim().1) {
277 if !state[j] {
278 continue;
279 }
280 for k in 0..std::cmp::min(n_vars, t.dim().2) {
281 if state[k] {
282 energy += t[[i, j, k]];
283 }
284 }
285 }
286 }
287 }
288 } else {
289 // For other dimensions, we'll do a brute force approach
290 let shape = tensor.shape();
291 if shape.len() == 2 {
292 // Handle 2D specifically
293 if let Ok(tensor2d) = tensor
294 .to_owned()
295 .into_dimensionality::<scirs2_core::ndarray::Ix2>()
296 {
297 for i in 0..std::cmp::min(n_vars, tensor2d.dim().0) {
298 if !state[i] {
299 continue;
300 }
301 for j in 0..std::cmp::min(n_vars, tensor2d.dim().1) {
302 if state[j] {
303 energy += tensor2d[[i, j]];
304 }
305 }
306 }
307 }
308 } else {
309 // Fallback for other dimensions - just return the energy as is
310 // This should be specialized for other tensor dimensions if needed
311 if !tensor.is_empty() {
312 println!(
313 "Warning: Processing tensor with shape {shape:?} not specifically optimized"
314 );
315 }
316 }
317 }
318
319 energy
320 };
321
322 // Vector to store thread-local solutions
323 #[allow(unused_assignments)]
324 let mut all_solutions = Vec::with_capacity(total_runs);
325
326 // Run annealing process
327 #[cfg(feature = "parallel")]
328 {
329 // Create seeds for each parallel run
330 let seeds: Vec<u64> = (0..total_runs)
331 .map(|i| match self.seed {
332 Some(seed) => seed.wrapping_add(i as u64),
333 None => thread_rng().random(),
334 })
335 .collect();
336
337 // Run in parallel
338 all_solutions = seeds
339 .into_par_iter()
340 .map(|seed| {
341 let mut thread_rng = StdRng::seed_from_u64(seed);
342
343 // Initialize random state
344 let mut state = vec![false; n_vars];
345 for bit in &mut state {
346 *bit = thread_rng.gen_bool(0.5);
347 }
348
349 // Evaluate initial energy
350 let mut energy = evaluate_energy(&state);
351 let mut best_state = state.clone();
352 let mut best_energy = energy;
353
354 // Simulated annealing
355 for sweep in 0..sweeps {
356 // Calculate temperature for this step
357 let temp = initial_temp
358 * f64::powf(final_temp / initial_temp, sweep as f64 / sweeps as f64);
359
360 // Perform n_vars updates per sweep
361 for _ in 0..n_vars {
362 // Select random bit to flip
363 let idx = thread_rng.gen_range(0..n_vars);
364
365 // Flip the bit
366 state[idx] = !state[idx];
367
368 // Calculate new energy
369 let new_energy = evaluate_energy(&state);
370 let delta_e = new_energy - energy;
371
372 // Metropolis acceptance criterion
373 let accept = delta_e <= 0.0
374 || thread_rng.gen_range(0.0..1.0) < (-delta_e / temp).exp();
375
376 if accept {
377 energy = new_energy;
378 if energy < best_energy {
379 best_energy = energy;
380 best_state = state.clone();
381 }
382 } else {
383 // Revert flip
384 state[idx] = !state[idx];
385 }
386 }
387 }
388
389 (best_state, best_energy)
390 })
391 .collect();
392 }
393
394 #[cfg(not(feature = "parallel"))]
395 {
396 for _ in 0..total_runs {
397 // Initialize random state
398 let mut state = vec![false; n_vars];
399 for bit in &mut state {
400 *bit = rng.gen_bool(0.5);
401 }
402
403 // Evaluate initial energy
404 let mut energy = evaluate_energy(&state);
405 let mut best_state = state.clone();
406 let mut best_energy = energy;
407
408 // Simulated annealing
409 for sweep in 0..sweeps {
410 // Calculate temperature for this step
411 let temp = initial_temp
412 * f64::powf(final_temp / initial_temp, sweep as f64 / sweeps as f64);
413
414 // Perform n_vars updates per sweep
415 for _ in 0..n_vars {
416 // Select random bit to flip
417 let mut idx = rng.gen_range(0..n_vars);
418
419 // Flip the bit
420 state[idx] = !state[idx];
421
422 // Calculate new energy
423 let new_energy = evaluate_energy(&state);
424 let delta_e = new_energy - energy;
425
426 // Metropolis acceptance criterion
427 let accept =
428 delta_e <= 0.0 || rng.gen_range(0.0..1.0) < f64::exp(-delta_e / temp);
429
430 if accept {
431 energy = new_energy;
432 if energy < best_energy {
433 best_energy = energy;
434 best_state = state.clone();
435 }
436 } else {
437 // Revert flip
438 state[idx] = !state[idx];
439 }
440 }
441 }
442
443 all_solutions.push((best_state, best_energy));
444 }
445 }
446
447 // Process results from all threads
448 for (state, energy) in all_solutions {
449 let entry = solution_counts.entry(state).or_insert((energy, 0));
450 entry.1 += 1;
451 }
452
453 // Convert to SampleResult format
454 let mut results: Vec<SampleResult> = solution_counts
455 .into_iter()
456 .map(|(state, (energy, count))| {
457 // Convert to variable assignments
458 let assignments: HashMap<String, bool> = state
459 .iter()
460 .enumerate()
461 .filter_map(|(idx, &value)| {
462 idx_to_var
463 .get(&idx)
464 .map(|var_name| (var_name.clone(), value))
465 })
466 .collect();
467
468 SampleResult {
469 assignments,
470 energy,
471 occurrences: count,
472 }
473 })
474 .collect();
475
476 // Sort by energy (best solutions first)
477 results.sort_by(|a, b| {
478 a.energy
479 .partial_cmp(&b.energy)
480 .unwrap_or(std::cmp::Ordering::Equal)
481 });
482
483 // Limit to requested number of shots if we have more
484 if results.len() > shots {
485 results.truncate(shots);
486 }
487
488 Ok(results)
489 }
490}
491
492impl Sampler for SASampler {
493 fn run_qubo(
494 &self,
495 qubo: &(
496 Array<f64, scirs2_core::ndarray::Ix2>,
497 HashMap<String, usize>,
498 ),
499 shots: usize,
500 ) -> SamplerResult<Vec<SampleResult>> {
501 self.run_generic(&qubo.0, &qubo.1, shots)
502 }
503
504 fn run_hobo(
505 &self,
506 hobo: &(
507 Array<f64, scirs2_core::ndarray::IxDyn>,
508 HashMap<String, usize>,
509 ),
510 shots: usize,
511 ) -> SamplerResult<Vec<SampleResult>> {
512 self.run_generic(&hobo.0, &hobo.1, shots)
513 }
514}