Skip to main content

quantrs2_anneal/
simulator.rs

1//! Simulated quantum annealing simulator
2//!
3//! This module provides a simulator for quantum annealing, which can be used
4//! to solve optimization problems formulated as Ising models or QUBO problems.
5
6use scirs2_core::random::prelude::*;
7use scirs2_core::random::ChaCha8Rng;
8use scirs2_core::random::{Rng, SeedableRng};
9use std::time::{Duration, Instant};
10use thiserror::Error;
11
12use crate::ising::{IsingError, IsingModel};
13
14/// Errors that can occur during simulated annealing
15#[derive(Error, Debug, Clone)]
16#[non_exhaustive]
17pub enum AnnealingError {
18    /// Error in the underlying Ising model
19    #[error("Ising error: {0}")]
20    IsingError(#[from] IsingError),
21
22    /// Error when the annealing schedule is invalid
23    #[error("Invalid annealing schedule: {0}")]
24    InvalidSchedule(String),
25
26    /// Error when the annealing parameters are invalid
27    #[error("Invalid annealing parameter: {0}")]
28    InvalidParameter(String),
29
30    /// Error when the annealing process times out
31    #[error("Annealing timeout after {0:?}")]
32    Timeout(Duration),
33}
34
35/// Result type for annealing operations
36pub type AnnealingResult<T> = Result<T, AnnealingError>;
37
38/// Transverse field strength schedule for quantum annealing
39///
40/// The transverse field represents the quantum tunneling term in the Hamiltonian,
41/// which decreases over time during the annealing process.
42#[derive(Debug, Clone)]
43pub enum TransverseFieldSchedule {
44    /// Linear schedule: A(t) = `A_0` * (1 - `t/t_f`)
45    Linear,
46
47    /// Exponential schedule: A(t) = `A_0` * exp(-alpha * `t/t_f`)
48    Exponential(f64), // alpha parameter
49
50    /// Custom schedule function: A(t, `t_f`) -> A
51    Custom(fn(f64, f64) -> f64),
52}
53
54impl TransverseFieldSchedule {
55    /// Calculate the transverse field strength at time t
56    #[must_use]
57    pub fn calculate(&self, t: f64, t_f: f64, a_0: f64) -> f64 {
58        match self {
59            Self::Linear => a_0 * (1.0 - t / t_f),
60            Self::Exponential(alpha) => a_0 * (-alpha * t / t_f).exp(),
61            Self::Custom(func) => func(t, t_f),
62        }
63    }
64}
65
66/// Temperature schedule for simulated quantum annealing
67///
68/// The temperature controls the probability of accepting non-improving moves,
69/// and typically decreases over time during the annealing process.
70#[derive(Debug, Clone)]
71pub enum TemperatureSchedule {
72    /// Linear schedule: T(t) = `T_0` * (1 - `t/t_f`)
73    Linear,
74
75    /// Exponential schedule: T(t) = `T_0` * exp(-alpha * `t/t_f`)
76    Exponential(f64), // alpha parameter
77
78    /// Geometric schedule: T(t) = `T_0` * `alpha^(t/delta_t)`
79    Geometric(f64, f64), // alpha and delta_t parameters
80
81    /// Custom schedule function: T(t, `t_f`) -> T
82    Custom(fn(f64, f64) -> f64),
83}
84
85impl TemperatureSchedule {
86    /// Calculate the temperature at time t
87    #[must_use]
88    pub fn calculate(&self, t: f64, t_f: f64, t_0: f64) -> f64 {
89        match self {
90            Self::Linear => t_0 * (1.0 - t / t_f),
91            Self::Exponential(alpha) => t_0 * (-alpha * t / t_f).exp(),
92            Self::Geometric(alpha, delta_t) => t_0 * alpha.powf(t / delta_t),
93            Self::Custom(func) => func(t, t_f),
94        }
95    }
96}
97
98/// Parameters for simulated quantum annealing
99#[derive(Debug, Clone)]
100pub struct AnnealingParams {
101    /// Initial transverse field strength
102    pub initial_transverse_field: f64,
103
104    /// Transverse field schedule
105    pub transverse_field_schedule: TransverseFieldSchedule,
106
107    /// Initial temperature
108    pub initial_temperature: f64,
109
110    /// Final temperature
111    pub final_temperature: f64,
112
113    /// Temperature schedule
114    pub temperature_schedule: TemperatureSchedule,
115
116    /// Number of Monte Carlo steps
117    pub num_sweeps: usize,
118
119    /// Number of spins to update per sweep
120    pub updates_per_sweep: Option<usize>,
121
122    /// Number of repetitions/restarts
123    pub num_repetitions: usize,
124
125    /// Random seed for reproducibility
126    pub seed: Option<u64>,
127
128    /// Maximum runtime in seconds
129    pub timeout: Option<f64>,
130
131    /// Number of Trotter slices for quantum annealing
132    pub trotter_slices: usize,
133}
134
135impl AnnealingParams {
136    /// Create new annealing parameters with default values
137    #[must_use]
138    pub const fn new() -> Self {
139        Self {
140            initial_transverse_field: 2.0,
141            transverse_field_schedule: TransverseFieldSchedule::Linear,
142            initial_temperature: 2.0,
143            final_temperature: 0.01,
144            temperature_schedule: TemperatureSchedule::Exponential(3.0),
145            num_sweeps: 1000,
146            updates_per_sweep: None,
147            num_repetitions: 10,
148            seed: None,
149            timeout: Some(60.0), // 60 seconds default timeout
150            trotter_slices: 20,
151        }
152    }
153
154    /// Validate the annealing parameters
155    pub fn validate(&self) -> AnnealingResult<()> {
156        // Check transverse field
157        if self.initial_transverse_field <= 0.0 || !self.initial_transverse_field.is_finite() {
158            return Err(AnnealingError::InvalidParameter(format!(
159                "Initial transverse field must be positive and finite, got {}",
160                self.initial_transverse_field
161            )));
162        }
163
164        // Check temperature
165        if self.initial_temperature <= 0.0 || !self.initial_temperature.is_finite() {
166            return Err(AnnealingError::InvalidParameter(format!(
167                "Initial temperature must be positive and finite, got {}",
168                self.initial_temperature
169            )));
170        }
171
172        // Check final temperature
173        if self.final_temperature <= 0.0 || !self.final_temperature.is_finite() {
174            return Err(AnnealingError::InvalidParameter(format!(
175                "Final temperature must be positive and finite, got {}",
176                self.final_temperature
177            )));
178        }
179
180        // Check sweeps
181        if self.num_sweeps == 0 {
182            return Err(AnnealingError::InvalidParameter(
183                "Number of sweeps must be positive".to_string(),
184            ));
185        }
186
187        // Check repetitions
188        if self.num_repetitions == 0 {
189            return Err(AnnealingError::InvalidParameter(
190                "Number of repetitions must be positive".to_string(),
191            ));
192        }
193
194        // Check timeout
195        if let Some(timeout) = self.timeout {
196            if timeout <= 0.0 || !timeout.is_finite() {
197                return Err(AnnealingError::InvalidParameter(format!(
198                    "Timeout must be positive and finite, got {timeout}"
199                )));
200            }
201        }
202
203        // Check Trotter slices
204        if self.trotter_slices == 0 {
205            return Err(AnnealingError::InvalidParameter(
206                "Number of Trotter slices must be positive".to_string(),
207            ));
208        }
209
210        Ok(())
211    }
212}
213
214impl Default for AnnealingParams {
215    fn default() -> Self {
216        Self::new()
217    }
218}
219
220/// Result of a simulated quantum annealing run
221#[derive(Debug, Clone)]
222pub struct AnnealingSolution {
223    /// Best spin configuration found
224    pub best_spins: Vec<i8>,
225
226    /// Energy of the best configuration
227    pub best_energy: f64,
228
229    /// Number of repetitions performed
230    pub repetitions: usize,
231
232    /// Total number of sweeps performed
233    pub total_sweeps: usize,
234
235    /// Time taken for the annealing process
236    pub runtime: Duration,
237
238    /// Information about the annealing process
239    pub info: String,
240}
241
242/// Simulated quantum annealing solver
243///
244/// This uses path integral Monte Carlo to simulate quantum annealing,
245/// which can be used to find low-energy states of Ising models.
246///
247/// # Examples
248///
249/// ```rust
250/// use quantrs2_anneal::ising::IsingModel;
251/// use quantrs2_anneal::simulator::{QuantumAnnealingSimulator, AnnealingParams};
252///
253/// let mut model = IsingModel::new(2);
254/// model.set_bias(0, -1.0).expect("bias");
255/// model.set_coupling(0, 1, -0.5).expect("coupling");
256///
257/// let mut params = AnnealingParams::new();
258/// params.num_sweeps = 30;
259/// params.num_repetitions = 1;
260/// params.trotter_slices = 4;
261/// params.seed = Some(0);
262///
263/// let sim = QuantumAnnealingSimulator::new(params).expect("valid params");
264/// let result = sim.solve(&model).expect("simulation succeeded");
265/// assert!(result.best_energy <= 0.0);
266/// ```
267#[derive(Debug, Clone)]
268pub struct QuantumAnnealingSimulator {
269    /// Parameters for the annealing process
270    params: AnnealingParams,
271}
272
273impl QuantumAnnealingSimulator {
274    /// Create a new quantum annealing simulator with the given parameters
275    pub fn new(params: AnnealingParams) -> AnnealingResult<Self> {
276        // Validate parameters
277        params.validate()?;
278
279        Ok(Self { params })
280    }
281
282    /// Create a new quantum annealing simulator with default parameters
283    pub fn with_default_params() -> AnnealingResult<Self> {
284        Self::new(AnnealingParams::default())
285    }
286}
287
288impl Default for QuantumAnnealingSimulator {
289    fn default() -> Self {
290        Self::with_default_params().expect("Default parameters should be valid")
291    }
292}
293
294impl QuantumAnnealingSimulator {
295    /// Find the ground state of an Ising model using simulated quantum annealing
296    pub fn solve(&self, model: &IsingModel) -> AnnealingResult<AnnealingSolution> {
297        // Start timer
298        let start_time = Instant::now();
299
300        // Create random number generator
301        let mut rng = match self.params.seed {
302            Some(seed) => ChaCha8Rng::seed_from_u64(seed),
303            None => ChaCha8Rng::seed_from_u64(thread_rng().random()),
304        };
305
306        // Initialize best result
307        let num_qubits = model.num_qubits;
308        let mut best_spins = vec![1; num_qubits]; // Start with all spins up
309        let mut best_energy = match model.energy(&best_spins) {
310            Ok(energy) => energy,
311            Err(err) => return Err(AnnealingError::IsingError(err)),
312        };
313
314        // Track sweeps and repetitions
315        let mut total_sweeps = 0;
316        let mut completed_repetitions = 0;
317
318        // Determine updates per sweep
319        let updates_per_sweep = self.params.updates_per_sweep.unwrap_or(num_qubits);
320
321        // Prepare for quantum annealing with path integral Monte Carlo
322        let trotter_slices = self.params.trotter_slices;
323
324        // Perform multiple repetitions
325        for _ in 0..self.params.num_repetitions {
326            // Initialize random spin configuration for each Trotter slice
327            let mut trotter_spins = vec![vec![0; num_qubits]; trotter_slices];
328            for slice in &mut trotter_spins {
329                for spin in slice.iter_mut() {
330                    *spin = if rng.random_bool(0.5) { 1 } else { -1 };
331                }
332            }
333
334            // Perform simulated quantum annealing
335            for sweep in 0..self.params.num_sweeps {
336                // Check timeout
337                if let Some(timeout) = self.params.timeout {
338                    let elapsed = start_time.elapsed().as_secs_f64();
339                    if elapsed > timeout {
340                        return Err(AnnealingError::Timeout(Duration::from_secs_f64(elapsed)));
341                    }
342                }
343
344                // Calculate current normalized time
345                let t = sweep as f64 / self.params.num_sweeps as f64;
346                let t_f = 1.0; // Final time (normalized)
347
348                // Calculate current transverse field and temperature
349                let transverse_field = self.params.transverse_field_schedule.calculate(
350                    t,
351                    t_f,
352                    self.params.initial_transverse_field,
353                );
354                let temperature = self.params.temperature_schedule.calculate(
355                    t,
356                    t_f,
357                    self.params.initial_temperature,
358                );
359
360                // Calculate coupling between Trotter slices (J_perp)
361                let j_perp = -0.5
362                    * temperature
363                    * (trotter_slices as f64)
364                    * (transverse_field / temperature).ln_1p().abs();
365
366                // Perform Monte Carlo updates
367                for _ in 0..updates_per_sweep {
368                    // Choose a random qubit and Trotter slice
369                    let qubit = rng.random_range(0..num_qubits);
370                    let slice = rng.random_range(0..trotter_slices);
371
372                    // Calculate energy change from flipping the spin
373                    let current_spin = trotter_spins[slice][qubit];
374                    let new_spin = -current_spin;
375
376                    // Temporary change to calculate energy difference
377                    trotter_spins[slice][qubit] = new_spin;
378
379                    // Calculate energy of this Trotter slice
380                    let mut delta_e = match model.energy(&trotter_spins[slice]) {
381                        Ok(energy_new) => {
382                            // Revert change to calculate original energy
383                            trotter_spins[slice][qubit] = current_spin;
384                            let energy_old = model.energy(&trotter_spins[slice])?;
385                            energy_new - energy_old
386                        }
387                        Err(err) => return Err(AnnealingError::IsingError(err)),
388                    };
389
390                    // Add contribution from neighboring Trotter slices
391                    let prev_slice = (slice + trotter_slices - 1) % trotter_slices;
392                    let next_slice = (slice + 1) % trotter_slices;
393
394                    // Convert spins to f64 for the calculations
395                    let new_spin_f64 = f64::from(new_spin);
396                    let current_spin_f64 = f64::from(current_spin);
397                    let neighbor_sum = f64::from(
398                        trotter_spins[prev_slice][qubit] + trotter_spins[next_slice][qubit],
399                    );
400
401                    delta_e += j_perp * new_spin_f64 * neighbor_sum;
402                    delta_e -= j_perp * current_spin_f64 * neighbor_sum;
403
404                    // Metropolis acceptance criterion
405                    let accept = delta_e <= 0.0 || {
406                        let p = (-delta_e / temperature).exp();
407                        rng.random_range(0.0..1.0) < p
408                    };
409
410                    // Apply the spin flip if accepted
411                    if accept {
412                        trotter_spins[slice][qubit] = new_spin;
413                    }
414                }
415
416                // Increment sweep counter
417                total_sweeps += 1;
418            }
419
420            // After annealing, compute the average spin configuration
421            let mut avg_spins = vec![0; num_qubits];
422            for qubit in 0..num_qubits {
423                let sum: i32 = trotter_spins
424                    .iter()
425                    .map(|slice| i32::from(slice[qubit]))
426                    .sum();
427                avg_spins[qubit] = if sum >= 0 { 1 } else { -1 };
428            }
429
430            // Check if this is a better solution
431            match model.energy(&avg_spins) {
432                Ok(energy) => {
433                    if energy < best_energy {
434                        best_energy = energy;
435                        best_spins = avg_spins;
436                    }
437                }
438                Err(err) => return Err(AnnealingError::IsingError(err)),
439            }
440
441            // Increment repetition counter
442            completed_repetitions += 1;
443        }
444
445        // Calculate runtime
446        let runtime = start_time.elapsed();
447
448        // Build result
449        Ok(AnnealingSolution {
450            best_spins,
451            best_energy,
452            repetitions: completed_repetitions,
453            total_sweeps,
454            runtime,
455            info: format!(
456                "Performed {completed_repetitions} repetitions with {total_sweeps} total sweeps in {runtime:?}"
457            ),
458        })
459    }
460}
461
462/// Classical simulated annealing solver
463///
464/// This uses Metropolis-Hastings algorithm for simulated annealing,
465/// which can be used to find low-energy states of Ising models.
466///
467/// # Examples
468///
469/// ```rust
470/// use quantrs2_anneal::ising::IsingModel;
471/// use quantrs2_anneal::simulator::{ClassicalAnnealingSimulator, AnnealingParams};
472///
473/// // Build a ferromagnetic Ising chain (prefers aligned spins)
474/// let mut model = IsingModel::new(3);
475/// model.set_coupling(0, 1, -1.0).expect("coupling 0-1");
476/// model.set_coupling(1, 2, -1.0).expect("coupling 1-2");
477///
478/// // Run with fast (small) parameters for doctest speed
479/// let mut params = AnnealingParams::new();
480/// params.num_sweeps = 50;
481/// params.num_repetitions = 2;
482/// params.seed = Some(42);
483///
484/// let sim = ClassicalAnnealingSimulator::new(params).expect("valid params");
485/// let result = sim.solve(&model).expect("annealing succeeded");
486/// // The ground state should have all spins aligned (energy ≈ -2.0)
487/// assert!(result.best_energy <= 0.0);
488/// ```
489#[derive(Debug, Clone)]
490pub struct ClassicalAnnealingSimulator {
491    /// Parameters for the annealing process
492    params: AnnealingParams,
493}
494
495impl ClassicalAnnealingSimulator {
496    /// Create a new classical annealing simulator with the given parameters
497    pub fn new(params: AnnealingParams) -> AnnealingResult<Self> {
498        // Validate parameters
499        params.validate()?;
500
501        Ok(Self { params })
502    }
503
504    /// Create a new classical annealing simulator with default parameters
505    pub fn with_default_params() -> AnnealingResult<Self> {
506        Self::new(AnnealingParams::default())
507    }
508}
509
510impl Default for ClassicalAnnealingSimulator {
511    fn default() -> Self {
512        Self::with_default_params().expect("Default parameters should be valid")
513    }
514}
515
516impl ClassicalAnnealingSimulator {
517    /// Find the ground state of an Ising model using classical simulated annealing
518    pub fn solve(&self, model: &IsingModel) -> AnnealingResult<AnnealingSolution> {
519        // Start timer
520        let start_time = Instant::now();
521
522        // Create random number generator
523        let mut rng = match self.params.seed {
524            Some(seed) => ChaCha8Rng::seed_from_u64(seed),
525            None => ChaCha8Rng::seed_from_u64(thread_rng().random()),
526        };
527
528        // Initialize best result
529        let num_qubits = model.num_qubits;
530        let mut best_spins = vec![1; num_qubits]; // Start with all spins up
531        let mut best_energy = match model.energy(&best_spins) {
532            Ok(energy) => energy,
533            Err(err) => return Err(AnnealingError::IsingError(err)),
534        };
535
536        // Track sweeps and repetitions
537        let mut total_sweeps = 0;
538        let mut completed_repetitions = 0;
539
540        // Determine updates per sweep
541        let updates_per_sweep = self.params.updates_per_sweep.unwrap_or(num_qubits);
542
543        // Perform multiple repetitions
544        for _ in 0..self.params.num_repetitions {
545            // Initialize random spin configuration
546            let mut spins = vec![0; num_qubits];
547            for spin in &mut spins {
548                *spin = if rng.random_bool(0.5) { 1 } else { -1 };
549            }
550
551            // Calculate initial energy
552            let mut current_energy = match model.energy(&spins) {
553                Ok(energy) => energy,
554                Err(err) => return Err(AnnealingError::IsingError(err)),
555            };
556
557            // Perform simulated annealing
558            for sweep in 0..self.params.num_sweeps {
559                // Check timeout
560                if let Some(timeout) = self.params.timeout {
561                    let elapsed = start_time.elapsed().as_secs_f64();
562                    if elapsed > timeout {
563                        return Err(AnnealingError::Timeout(Duration::from_secs_f64(elapsed)));
564                    }
565                }
566
567                // Calculate current normalized time
568                let t = sweep as f64 / self.params.num_sweeps as f64;
569                let t_f = 1.0; // Final time (normalized)
570
571                // Calculate current temperature
572                let temperature = self.params.temperature_schedule.calculate(
573                    t,
574                    t_f,
575                    self.params.initial_temperature,
576                );
577
578                // Perform Monte Carlo updates
579                for _ in 0..updates_per_sweep {
580                    // Choose a random qubit
581                    let qubit = rng.random_range(0..num_qubits);
582
583                    // Calculate energy change from flipping the spin
584                    let current_spin = spins[qubit];
585                    let new_spin = -current_spin;
586
587                    // Temporary change to calculate energy difference
588                    spins[qubit] = new_spin;
589
590                    // Calculate new energy
591                    let new_energy = match model.energy(&spins) {
592                        Ok(energy) => energy,
593                        Err(err) => return Err(AnnealingError::IsingError(err)),
594                    };
595
596                    let delta_e = new_energy - current_energy;
597
598                    // Metropolis acceptance criterion
599                    let accept = delta_e <= 0.0 || {
600                        let p = (-delta_e / temperature).exp();
601                        rng.random_range(0.0..1.0) < p
602                    };
603
604                    // Apply the spin flip if accepted
605                    if accept {
606                        current_energy = new_energy;
607                    } else {
608                        // Revert the change
609                        spins[qubit] = current_spin;
610                    }
611                }
612
613                // Increment sweep counter
614                total_sweeps += 1;
615            }
616
617            // Check if this is a better solution
618            if current_energy < best_energy {
619                best_energy = current_energy;
620                best_spins = spins.clone();
621            }
622
623            // Increment repetition counter
624            completed_repetitions += 1;
625        }
626
627        // Calculate runtime
628        let runtime = start_time.elapsed();
629
630        // Build result
631        Ok(AnnealingSolution {
632            best_spins,
633            best_energy,
634            repetitions: completed_repetitions,
635            total_sweeps,
636            runtime,
637            info: format!(
638                "Performed {completed_repetitions} repetitions with {total_sweeps} total sweeps in {runtime:?}"
639            ),
640        })
641    }
642}
643
644#[cfg(test)]
645mod tests {
646    use super::*;
647    #[allow(unused_imports)]
648    use crate::ising::QuboModel;
649
650    #[test]
651    fn test_annealing_params() {
652        // Create default parameters
653        let params = AnnealingParams::default();
654
655        // Validate parameters
656        assert!(params.validate().is_ok());
657
658        // Test invalid parameters
659        let mut invalid_params = params.clone();
660        invalid_params.initial_temperature = 0.0;
661        assert!(invalid_params.validate().is_err());
662
663        invalid_params = params.clone();
664        invalid_params.num_sweeps = 0;
665        assert!(invalid_params.validate().is_err());
666    }
667
668    #[test]
669    fn test_classical_annealing_simple() {
670        // Create a simple 2-qubit ferromagnetic Ising model
671        let mut model = IsingModel::new(2);
672        model
673            .set_coupling(0, 1, -1.0)
674            .expect("Failed to set coupling"); // Ferromagnetic coupling
675
676        // Create annealing simulator with fixed seed for reproducibility
677        let mut params = AnnealingParams::default();
678        params.seed = Some(42);
679        params.num_sweeps = 100;
680        params.num_repetitions = 5;
681
682        let simulator =
683            ClassicalAnnealingSimulator::new(params).expect("Failed to create simulator");
684
685        // Solve the model
686        let result = simulator.solve(&model).expect("Failed to solve model");
687
688        // Check that we found the ground state (all spins aligned)
689        assert_eq!(result.best_spins.len(), 2);
690        assert!(
691            (result.best_spins[0] == 1 && result.best_spins[1] == 1)
692                || (result.best_spins[0] == -1 && result.best_spins[1] == -1)
693        );
694
695        // Check energy
696        assert_eq!(result.best_energy, -1.0);
697    }
698
699    #[test]
700    fn test_quantum_annealing_simple() {
701        // Create a simple 2-qubit ferromagnetic Ising model
702        let mut model = IsingModel::new(2);
703        model
704            .set_coupling(0, 1, -1.0)
705            .expect("Failed to set coupling"); // Ferromagnetic coupling
706
707        // Create annealing simulator with fixed seed for reproducibility
708        let mut params = AnnealingParams::default();
709        params.seed = Some(42);
710        params.num_sweeps = 100;
711        params.num_repetitions = 5;
712        params.trotter_slices = 10;
713
714        let simulator =
715            QuantumAnnealingSimulator::new(params).expect("Failed to create quantum simulator");
716
717        // Solve the model
718        let result = simulator.solve(&model).expect("Failed to solve model");
719
720        // Check that we found the ground state (all spins aligned)
721        assert_eq!(result.best_spins.len(), 2);
722        assert!(
723            (result.best_spins[0] == 1 && result.best_spins[1] == 1)
724                || (result.best_spins[0] == -1 && result.best_spins[1] == -1)
725        );
726
727        // Check energy
728        assert_eq!(result.best_energy, -1.0);
729    }
730
731    #[test]
732    fn test_classical_annealing_frustrated() {
733        // Create a 3-qubit frustrated Ising model
734        let mut model = IsingModel::new(3);
735        model
736            .set_coupling(0, 1, -1.0)
737            .expect("Failed to set coupling"); // Ferromagnetic coupling
738        model
739            .set_coupling(1, 2, -1.0)
740            .expect("Failed to set coupling"); // Ferromagnetic coupling
741        model
742            .set_coupling(0, 2, 1.0)
743            .expect("Failed to set coupling"); // Antiferromagnetic coupling
744
745        // Create annealing simulator with fixed seed for reproducibility
746        let mut params = AnnealingParams::default();
747        params.seed = Some(42);
748        params.num_sweeps = 200;
749        params.num_repetitions = 10;
750
751        let simulator =
752            ClassicalAnnealingSimulator::new(params).expect("Failed to create simulator");
753
754        // Solve the model
755        let result = simulator.solve(&model).expect("Failed to solve model");
756
757        // Check energy (should be -1.0 for the ground state)
758        assert!(result.best_energy <= -1.0);
759    }
760}