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}