problemreductions/rules/
spinglass_qubo.rs

1//! Reductions between SpinGlass and QUBO problems.
2//!
3//! QUBO: minimize x^T Q x where x ∈ {0, 1}^n
4//! SpinGlass: minimize Σ J_ij s_i s_j + Σ h_i s_i where s ∈ {-1, +1}^n
5//!
6//! Transformation: s = 2x - 1 (so x=0 → s=-1, x=1 → s=+1)
7
8use crate::models::optimization::{SpinGlass, QUBO};
9use crate::rules::traits::{ReduceTo, ReductionResult};
10use crate::traits::Problem;
11use crate::types::ProblemSize;
12
13/// Result of reducing QUBO to SpinGlass.
14#[derive(Debug, Clone)]
15pub struct ReductionQUBOToSG {
16    target: SpinGlass<f64>,
17    source_size: ProblemSize,
18}
19
20impl ReductionResult for ReductionQUBOToSG {
21    type Source = QUBO<f64>;
22    type Target = SpinGlass<f64>;
23
24    fn target_problem(&self) -> &Self::Target {
25        &self.target
26    }
27
28    /// Solution maps directly (same binary encoding).
29    fn extract_solution(&self, target_solution: &[usize]) -> Vec<usize> {
30        target_solution.to_vec()
31    }
32
33    fn source_size(&self) -> ProblemSize {
34        self.source_size.clone()
35    }
36
37    fn target_size(&self) -> ProblemSize {
38        self.target.problem_size()
39    }
40}
41
42impl ReduceTo<SpinGlass<f64>> for QUBO<f64> {
43    type Result = ReductionQUBOToSG;
44
45    fn reduce_to(&self) -> Self::Result {
46        let n = self.num_vars();
47        let matrix = self.matrix();
48
49        // Convert Q matrix to J interactions and h fields
50        // Using substitution s = 2x - 1:
51        // x = (s + 1) / 2
52        // x_i * x_j = ((s_i + 1)/2) * ((s_j + 1)/2) = (s_i*s_j + s_i + s_j + 1) / 4
53        //
54        // For off-diagonal terms Q_ij x_i x_j:
55        //   Q_ij * (s_i*s_j + s_i + s_j + 1) / 4
56        //   = Q_ij/4 * s_i*s_j + Q_ij/4 * s_i + Q_ij/4 * s_j + Q_ij/4
57        //
58        // For diagonal terms Q_ii x_i:
59        //   Q_ii * (s_i + 1) / 2 = Q_ii/2 * s_i + Q_ii/2
60        let mut interactions = Vec::new();
61        let mut onsite = vec![0.0; n];
62
63        for i in 0..n {
64            for j in i..n {
65                let q = matrix[i][j];
66                if q.abs() < 1e-10 {
67                    continue;
68                }
69
70                if i == j {
71                    // Diagonal: Q_ii * x_i = Q_ii/2 * s_i + Q_ii/2 (constant)
72                    onsite[i] += q / 2.0;
73                } else {
74                    // Off-diagonal: Q_ij * x_i * x_j
75                    // J_ij contribution
76                    let j_ij = q / 4.0;
77                    if j_ij.abs() > 1e-10 {
78                        interactions.push(((i, j), j_ij));
79                    }
80                    // h_i and h_j contributions
81                    onsite[i] += q / 4.0;
82                    onsite[j] += q / 4.0;
83                }
84            }
85        }
86
87        let target = SpinGlass::new(n, interactions, onsite);
88
89        ReductionQUBOToSG {
90            target,
91            source_size: self.problem_size(),
92        }
93    }
94}
95
96/// Result of reducing SpinGlass to QUBO.
97#[derive(Debug, Clone)]
98pub struct ReductionSGToQUBO {
99    target: QUBO<f64>,
100    source_size: ProblemSize,
101}
102
103impl ReductionResult for ReductionSGToQUBO {
104    type Source = SpinGlass<f64>;
105    type Target = QUBO<f64>;
106
107    fn target_problem(&self) -> &Self::Target {
108        &self.target
109    }
110
111    fn extract_solution(&self, target_solution: &[usize]) -> Vec<usize> {
112        target_solution.to_vec()
113    }
114
115    fn source_size(&self) -> ProblemSize {
116        self.source_size.clone()
117    }
118
119    fn target_size(&self) -> ProblemSize {
120        self.target.problem_size()
121    }
122}
123
124impl ReduceTo<QUBO<f64>> for SpinGlass<f64> {
125    type Result = ReductionSGToQUBO;
126
127    fn reduce_to(&self) -> Self::Result {
128        let n = self.num_spins();
129        let mut matrix = vec![vec![0.0; n]; n];
130
131        // Convert using s = 2x - 1:
132        // s_i * s_j = (2x_i - 1)(2x_j - 1) = 4x_i*x_j - 2x_i - 2x_j + 1
133        // s_i = 2x_i - 1
134        //
135        // J_ij * s_i * s_j = J_ij * (4x_i*x_j - 2x_i - 2x_j + 1)
136        //                  = 4*J_ij*x_i*x_j - 2*J_ij*x_i - 2*J_ij*x_j + J_ij
137        //
138        // h_i * s_i = h_i * (2x_i - 1) = 2*h_i*x_i - h_i
139        for &((i, j), j_val) in self.interactions() {
140            // Off-diagonal: 4 * J_ij
141            matrix[i][j] += 4.0 * j_val;
142            // Diagonal contributions: -2 * J_ij
143            matrix[i][i] -= 2.0 * j_val;
144            matrix[j][j] -= 2.0 * j_val;
145        }
146
147        // Convert h fields to diagonal
148        for (i, &h) in self.fields().iter().enumerate() {
149            // h_i * s_i -> 2*h_i*x_i
150            matrix[i][i] += 2.0 * h;
151        }
152
153        let target = QUBO::from_matrix(matrix);
154
155        ReductionSGToQUBO {
156            target,
157            source_size: self.problem_size(),
158        }
159    }
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165    use crate::solvers::{BruteForce, Solver};
166
167    #[test]
168    fn test_qubo_to_spinglass() {
169        // Simple 2-variable QUBO: minimize x0 + x1 - 2*x0*x1
170        // Optimal at x = [0, 0] (value 0) or x = [1, 1] (value 0)
171        let qubo = QUBO::from_matrix(vec![vec![1.0, -2.0], vec![0.0, 1.0]]);
172        let reduction = ReduceTo::<SpinGlass<f64>>::reduce_to(&qubo);
173        let sg = reduction.target_problem();
174
175        let solver = BruteForce::new();
176        let sg_solutions = solver.find_best(sg);
177        let qubo_solutions: Vec<_> = sg_solutions
178            .iter()
179            .map(|s| reduction.extract_solution(s))
180            .collect();
181
182        // Verify solutions are valid
183        assert!(!qubo_solutions.is_empty());
184
185        // Original QUBO at [0,0]: 0, at [1,1]: 1 + 1 - 2 = 0, at [0,1]: 1, at [1,0]: 1
186        // So [0,0] and [1,1] are optimal with value 0
187        for sol in &qubo_solutions {
188            let val = qubo.solution_size(sol).size;
189            assert!(val <= 0.0 + 1e-6, "Expected optimal value near 0, got {}", val);
190        }
191    }
192
193    #[test]
194    fn test_spinglass_to_qubo() {
195        // Simple SpinGlass: J_01 = -1 (ferromagnetic: prefers aligned spins)
196        // Energy: J_01 * s0 * s1 = -s0 * s1
197        // Aligned spins give -1, anti-aligned give +1
198        // Minimum is -1 at [0,0] or [1,1] (both give s=-1,-1 or s=+1,+1)
199        let sg = SpinGlass::new(2, vec![((0, 1), -1.0)], vec![0.0, 0.0]);
200        let reduction = ReduceTo::<QUBO<f64>>::reduce_to(&sg);
201        let qubo = reduction.target_problem();
202
203        let solver = BruteForce::new();
204        let qubo_solutions = solver.find_best(qubo);
205
206        // Ferromagnetic: aligned spins are optimal
207        for sol in &qubo_solutions {
208            assert_eq!(sol[0], sol[1], "Ferromagnetic should have aligned spins");
209        }
210    }
211
212    #[test]
213    fn test_roundtrip_qubo_sg_qubo() {
214        let original = QUBO::from_matrix(vec![vec![-1.0, 2.0], vec![0.0, -1.0]]);
215        let solver = BruteForce::new();
216        let original_solutions = solver.find_best(&original);
217        let _original_val = original.solution_size(&original_solutions[0]).size;
218
219        // QUBO -> SG -> QUBO
220        let reduction1 = ReduceTo::<SpinGlass<f64>>::reduce_to(&original);
221        let sg = reduction1.target_problem().clone();
222        let reduction2 = ReduceTo::<QUBO<f64>>::reduce_to(&sg);
223        let roundtrip = reduction2.target_problem();
224
225        let roundtrip_solutions = solver.find_best(roundtrip);
226        let _roundtrip_val = roundtrip.solution_size(&roundtrip_solutions[0]).size;
227
228        // The solutions should have the same configuration
229        // (optimal configs should match)
230        let orig_configs: std::collections::HashSet<_> = original_solutions.iter().collect();
231        let rt_configs: std::collections::HashSet<_> = roundtrip_solutions.iter().collect();
232        assert!(
233            orig_configs.intersection(&rt_configs).count() > 0,
234            "At least one optimal solution should match"
235        );
236    }
237
238    #[test]
239    fn test_antiferromagnetic() {
240        // Antiferromagnetic: J > 0, prefers anti-aligned spins
241        let sg = SpinGlass::new(2, vec![((0, 1), 1.0)], vec![0.0, 0.0]);
242        let reduction = ReduceTo::<QUBO<f64>>::reduce_to(&sg);
243        let qubo = reduction.target_problem();
244
245        let solver = BruteForce::new();
246        let solutions = solver.find_best(qubo);
247
248        // Anti-ferromagnetic: opposite spins are optimal
249        for sol in &solutions {
250            assert_ne!(sol[0], sol[1], "Antiferromagnetic should have opposite spins");
251        }
252    }
253
254    #[test]
255    fn test_with_onsite_fields() {
256        // SpinGlass with only on-site field h_0 = 1
257        // Energy = h_0 * s_0 = s_0
258        // Minimum at s_0 = -1, i.e., x_0 = 0
259        let sg = SpinGlass::new(1, vec![], vec![1.0]);
260        let reduction = ReduceTo::<QUBO<f64>>::reduce_to(&sg);
261        let qubo = reduction.target_problem();
262
263        let solver = BruteForce::new();
264        let solutions = solver.find_best(qubo);
265
266        assert_eq!(solutions.len(), 1);
267        assert_eq!(solutions[0], vec![0], "Should prefer x=0 (s=-1)");
268    }
269}
270
271// Register reductions with inventory for auto-discovery
272use crate::poly;
273use crate::rules::registry::{ReductionEntry, ReductionOverhead};
274
275inventory::submit! {
276    ReductionEntry {
277        source_name: "QUBO",
278        target_name: "SpinGlass",
279        source_graph: "QUBOMatrix",
280        target_graph: "SpinGlassGraph",
281        overhead_fn: || ReductionOverhead::new(vec![
282            ("num_spins", poly!(num_vars)),
283        ]),
284    }
285}
286
287inventory::submit! {
288    ReductionEntry {
289        source_name: "SpinGlass",
290        target_name: "QUBO",
291        source_graph: "SpinGlassGraph",
292        target_graph: "QUBOMatrix",
293        overhead_fn: || ReductionOverhead::new(vec![
294            ("num_vars", poly!(num_spins)),
295        ]),
296    }
297}