problemreductions/models/optimization/
spin_glass.rs

1//! Spin Glass (Ising model) problem implementation.
2//!
3//! The Spin Glass problem minimizes the Ising Hamiltonian energy.
4
5use crate::graph_types::SimpleGraph;
6use crate::traits::Problem;
7use crate::types::{EnergyMode, ProblemSize, SolutionSize};
8use serde::{Deserialize, Serialize};
9
10/// The Spin Glass (Ising model) problem.
11///
12/// Given n spin variables s_i ∈ {-1, +1}, interaction coefficients J_ij,
13/// and on-site fields h_i, minimize the Hamiltonian:
14///
15/// H(s) = Σ_{i<j} J_ij * s_i * s_j + Σ_i h_i * s_i
16///
17/// # Representation
18///
19/// Variables are binary (0 or 1), mapped to spins via: s = 2*x - 1
20/// - x = 0 → s = -1
21/// - x = 1 → s = +1
22///
23/// # Example
24///
25/// ```
26/// use problemreductions::models::optimization::SpinGlass;
27/// use problemreductions::{Problem, Solver, BruteForce};
28///
29/// // Two spins with antiferromagnetic coupling J_01 = 1
30/// let problem = SpinGlass::new(2, vec![((0, 1), 1.0)], vec![0.0, 0.0]);
31///
32/// let solver = BruteForce::new();
33/// let solutions = solver.find_best(&problem);
34///
35/// // Ground state has opposite spins
36/// for sol in &solutions {
37///     assert!(sol[0] != sol[1]); // Antiferromagnetic: opposite spins
38/// }
39/// ```
40#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct SpinGlass<W = f64> {
42    /// Number of spins.
43    num_spins: usize,
44    /// Interaction terms J_ij as ((i, j), value).
45    interactions: Vec<((usize, usize), W)>,
46    /// On-site fields h_i.
47    fields: Vec<W>,
48}
49
50impl<W: Clone + Default> SpinGlass<W> {
51    /// Create a new Spin Glass problem.
52    ///
53    /// # Arguments
54    /// * `num_spins` - Number of spin variables
55    /// * `interactions` - Coupling terms J_ij as ((i, j), value)
56    /// * `fields` - On-site fields h_i
57    pub fn new(num_spins: usize, interactions: Vec<((usize, usize), W)>, fields: Vec<W>) -> Self {
58        assert_eq!(fields.len(), num_spins);
59        Self {
60            num_spins,
61            interactions,
62            fields,
63        }
64    }
65
66    /// Create a Spin Glass with no on-site fields.
67    pub fn without_fields(num_spins: usize, interactions: Vec<((usize, usize), W)>) -> Self
68    where
69        W: num_traits::Zero,
70    {
71        let fields = vec![W::zero(); num_spins];
72        Self {
73            num_spins,
74            interactions,
75            fields,
76        }
77    }
78
79    /// Get the number of spins.
80    pub fn num_spins(&self) -> usize {
81        self.num_spins
82    }
83
84    /// Get the interactions.
85    pub fn interactions(&self) -> &[((usize, usize), W)] {
86        &self.interactions
87    }
88
89    /// Get the on-site fields.
90    pub fn fields(&self) -> &[W] {
91        &self.fields
92    }
93
94    /// Convert binary config (0,1) to spin config (-1,+1).
95    pub fn config_to_spins(config: &[usize]) -> Vec<i32> {
96        config.iter().map(|&x| 2 * x as i32 - 1).collect()
97    }
98}
99
100impl<W> Problem for SpinGlass<W>
101where
102    W: Clone
103        + Default
104        + PartialOrd
105        + num_traits::Num
106        + num_traits::Zero
107        + std::ops::AddAssign
108        + std::ops::Mul<Output = W>
109        + From<i32>
110        + 'static,
111{
112    const NAME: &'static str = "SpinGlass";
113    type GraphType = SimpleGraph;
114    type Weight = W;
115    type Size = W;
116
117    fn num_variables(&self) -> usize {
118        self.num_spins
119    }
120
121    fn num_flavors(&self) -> usize {
122        2 // Binary: 0 → -1 spin, 1 → +1 spin
123    }
124
125    fn problem_size(&self) -> ProblemSize {
126        ProblemSize::new(vec![
127            ("num_spins", self.num_spins),
128            ("num_interactions", self.interactions.len()),
129        ])
130    }
131
132    fn energy_mode(&self) -> EnergyMode {
133        EnergyMode::SmallerSizeIsBetter // Minimize energy
134    }
135
136    fn solution_size(&self, config: &[usize]) -> SolutionSize<Self::Size> {
137        let spins = Self::config_to_spins(config);
138        let energy = self.compute_energy(&spins);
139        SolutionSize::valid(energy)
140    }
141}
142
143impl<W> SpinGlass<W>
144where
145    W: Clone + num_traits::Zero + std::ops::AddAssign + std::ops::Mul<Output = W> + From<i32>,
146{
147    /// Compute the Hamiltonian energy for a spin configuration.
148    pub fn compute_energy(&self, spins: &[i32]) -> W {
149        let mut energy = W::zero();
150
151        // Interaction terms: Σ J_ij * s_i * s_j
152        for ((i, j), j_val) in &self.interactions {
153            let s_i = spins.get(*i).copied().unwrap_or(1);
154            let s_j = spins.get(*j).copied().unwrap_or(1);
155            let product: i32 = s_i * s_j;
156            energy += j_val.clone() * W::from(product);
157        }
158
159        // On-site terms: Σ h_i * s_i
160        for (i, h_val) in self.fields.iter().enumerate() {
161            let s_i = spins.get(i).copied().unwrap_or(1);
162            energy += h_val.clone() * W::from(s_i);
163        }
164
165        energy
166    }
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172    use crate::solvers::{BruteForce, Solver};
173
174    #[test]
175    fn test_spin_glass_creation() {
176        let problem = SpinGlass::new(3, vec![((0, 1), 1.0), ((1, 2), -1.0)], vec![0.0, 0.0, 0.0]);
177        assert_eq!(problem.num_spins(), 3);
178        assert_eq!(problem.interactions().len(), 2);
179        assert_eq!(problem.fields().len(), 3);
180    }
181
182    #[test]
183    fn test_spin_glass_without_fields() {
184        let problem = SpinGlass::<f64>::without_fields(3, vec![((0, 1), 1.0)]);
185        assert_eq!(problem.fields(), &[0.0, 0.0, 0.0]);
186    }
187
188    #[test]
189    fn test_config_to_spins() {
190        assert_eq!(SpinGlass::<f64>::config_to_spins(&[0, 0]), vec![-1, -1]);
191        assert_eq!(SpinGlass::<f64>::config_to_spins(&[1, 1]), vec![1, 1]);
192        assert_eq!(SpinGlass::<f64>::config_to_spins(&[0, 1]), vec![-1, 1]);
193        assert_eq!(SpinGlass::<f64>::config_to_spins(&[1, 0]), vec![1, -1]);
194    }
195
196    #[test]
197    fn test_compute_energy() {
198        // Two spins with J = 1 (ferromagnetic prefers aligned)
199        let problem = SpinGlass::new(2, vec![((0, 1), 1.0)], vec![0.0, 0.0]);
200
201        // Aligned spins: energy = J * s1 * s2 = 1 * 1 * 1 = 1 or 1 * (-1) * (-1) = 1
202        assert_eq!(problem.compute_energy(&[1, 1]), 1.0);
203        assert_eq!(problem.compute_energy(&[-1, -1]), 1.0);
204
205        // Anti-aligned spins: energy = J * s1 * s2 = 1 * 1 * (-1) = -1
206        assert_eq!(problem.compute_energy(&[1, -1]), -1.0);
207        assert_eq!(problem.compute_energy(&[-1, 1]), -1.0);
208    }
209
210    #[test]
211    fn test_compute_energy_with_fields() {
212        let problem = SpinGlass::new(2, vec![], vec![1.0, -1.0]);
213
214        // Energy = h1*s1 + h2*s2 = 1*s1 + (-1)*s2
215        assert_eq!(problem.compute_energy(&[1, 1]), 0.0); // 1 - 1 = 0
216        assert_eq!(problem.compute_energy(&[-1, -1]), 0.0); // -1 + 1 = 0
217        assert_eq!(problem.compute_energy(&[1, -1]), 2.0); // 1 + 1 = 2
218        assert_eq!(problem.compute_energy(&[-1, 1]), -2.0); // -1 - 1 = -2
219    }
220
221    #[test]
222    fn test_solution_size() {
223        let problem = SpinGlass::new(2, vec![((0, 1), 1.0)], vec![0.0, 0.0]);
224
225        // config [0,0] -> spins [-1,-1] -> energy = 1
226        let sol = problem.solution_size(&[0, 0]);
227        assert!(sol.is_valid);
228        assert_eq!(sol.size, 1.0);
229
230        // config [0,1] -> spins [-1,1] -> energy = -1
231        let sol = problem.solution_size(&[0, 1]);
232        assert_eq!(sol.size, -1.0);
233    }
234
235    #[test]
236    fn test_brute_force_ferromagnetic() {
237        // Ferromagnetic: J > 0 prefers aligned spins to minimize energy
238        // But wait, energy = J*s1*s2, so J>0 with aligned gives positive energy
239        // For minimization, we want anti-aligned for J>0
240        let problem = SpinGlass::new(2, vec![((0, 1), 1.0)], vec![0.0, 0.0]);
241        let solver = BruteForce::new();
242
243        let solutions = solver.find_best(&problem);
244        // Minimum energy is -1 (anti-aligned)
245        for sol in &solutions {
246            assert_ne!(sol[0], sol[1]);
247            assert_eq!(problem.solution_size(sol).size, -1.0);
248        }
249    }
250
251    #[test]
252    fn test_brute_force_antiferromagnetic() {
253        // Antiferromagnetic: J < 0, energy = J*s1*s2
254        // J<0 with aligned spins gives negative energy (good for minimization)
255        let problem = SpinGlass::new(2, vec![((0, 1), -1.0)], vec![0.0, 0.0]);
256        let solver = BruteForce::new();
257
258        let solutions = solver.find_best(&problem);
259        // Minimum energy is -1 (aligned)
260        for sol in &solutions {
261            assert_eq!(sol[0], sol[1]);
262            assert_eq!(problem.solution_size(sol).size, -1.0);
263        }
264    }
265
266    #[test]
267    fn test_energy_mode() {
268        let problem = SpinGlass::<f64>::without_fields(2, vec![]);
269        assert!(problem.energy_mode().is_minimization());
270    }
271
272    #[test]
273    fn test_num_variables_flavors() {
274        let problem = SpinGlass::<f64>::without_fields(5, vec![]);
275        assert_eq!(problem.num_variables(), 5);
276        assert_eq!(problem.num_flavors(), 2);
277    }
278
279    #[test]
280    fn test_problem_size() {
281        let problem = SpinGlass::new(3, vec![((0, 1), 1.0), ((1, 2), 1.0)], vec![0.0, 0.0, 0.0]);
282        let size = problem.problem_size();
283        assert_eq!(size.get("num_spins"), Some(3));
284        assert_eq!(size.get("num_interactions"), Some(2));
285    }
286
287    #[test]
288    fn test_triangle_frustration() {
289        // Triangle with all antiferromagnetic couplings - frustrated system
290        let problem = SpinGlass::new(
291            3,
292            vec![((0, 1), 1.0), ((1, 2), 1.0), ((0, 2), 1.0)],
293            vec![0.0, 0.0, 0.0],
294        );
295        let solver = BruteForce::new();
296
297        let solutions = solver.find_best(&problem);
298        // Best we can do is satisfy 2 out of 3 interactions
299        // Energy = -1 -1 + 1 = -1 (one frustrated)
300        for sol in &solutions {
301            assert_eq!(problem.solution_size(sol).size, -1.0);
302        }
303    }
304}