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}