Skip to main content

sc_neurocore_engine/
quantum.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2// Commercial license available
3// © Concepts 1996–2026 Miroslav Šotek. All rights reserved.
4// © Code 2020–2026 Miroslav Šotek. All rights reserved.
5// ORCID: 0009-0009-3560-0851
6// Contact: www.anulum.li | protoscience@anulum.li
7// SC-NeuroCore — Rust Quantum Annealing Acceleration
8
9//! High-performance quantum annealing primitives.
10//!
11//! Accelerates the hot paths in the Python `quantum_annealing` bridge:
12//! - **Simulated annealing**: Metropolis-Hastings with exponential schedule
13//! - **Ising energy**: Vectorized energy evaluation
14//! - **Gauge transform**: Batch gauge generation and application
15//! - **Problem decomposition**: Greedy graph partitioning
16
17use rayon::prelude::*;
18use std::collections::HashMap;
19
20// ── Ising energy ─────────────────────────────────────────────────────
21
22/// Compute Ising energy for a spin configuration.
23///
24/// H = Σ h_i·s_i + Σ J_ij·s_i·s_j + offset
25pub fn ising_energy(
26    h: &[(usize, f64)],
27    j: &[((usize, usize), f64)],
28    spins: &[i8],
29    offset: f64,
30) -> f64 {
31    let mut e = offset;
32    for &(i, hi) in h {
33        if i < spins.len() {
34            e += hi * spins[i] as f64;
35        }
36    }
37    for &((i, j_idx), jij) in j {
38        if i < spins.len() && j_idx < spins.len() {
39            e += jij * spins[i] as f64 * spins[j_idx] as f64;
40        }
41    }
42    e
43}
44
45/// Batch evaluate energies for many configurations (rayon-parallelized).
46pub fn batch_ising_energy(
47    h: &[(usize, f64)],
48    j: &[((usize, usize), f64)],
49    configs: &[Vec<i8>],
50    offset: f64,
51) -> Vec<f64> {
52    configs
53        .par_iter()
54        .map(|spins| ising_energy(h, j, spins, offset))
55        .collect()
56}
57
58// ── Simulated annealing ──────────────────────────────────────────────
59
60/// Delta energy ΔE = E_after - E_before for flipping spin `qubit`
61/// from `s_q` to `-s_q` in the Ising model H = Σ h_i s_i + Σ J_ij s_i s_j.
62///
63/// ΔE = −2·s_q·(h_q + Σ_k J_qk·s_k).
64#[inline]
65fn delta_energy(
66    h: &[(usize, f64)],
67    j_by_qubit: &[Vec<(usize, f64)>],
68    spins: &[i8],
69    qubit: usize,
70) -> f64 {
71    let s = spins[qubit] as f64;
72    let h_q = h
73        .iter()
74        .find(|&&(i, _)| i == qubit)
75        .map_or(0.0, |&(_, hi)| hi);
76    let mut local_field = h_q;
77
78    for &(other, jij) in &j_by_qubit[qubit] {
79        local_field += jij * spins[other] as f64;
80    }
81    -2.0 * s * local_field
82}
83
84/// Build adjacency index for fast delta-energy lookup.
85fn build_j_index(j: &[((usize, usize), f64)], n: usize) -> Vec<Vec<(usize, f64)>> {
86    let mut idx = vec![Vec::new(); n];
87    for &((i, j_idx), jij) in j {
88        if i < n {
89            idx[i].push((j_idx, jij));
90        }
91        if j_idx < n {
92            idx[j_idx].push((i, jij));
93        }
94    }
95    idx
96}
97
98/// Run simulated annealing on an Ising model.
99///
100/// Returns (best_spins, best_energy, all_energies, all_samples).
101pub fn simulated_annealing(
102    h: &[(usize, f64)],
103    j: &[((usize, usize), f64)],
104    n_qubits: usize,
105    offset: f64,
106    n_sweeps: usize,
107    num_reads: usize,
108    beta_start: f64,
109    beta_end: f64,
110    seed: u64,
111) -> (Vec<i8>, f64, Vec<f64>, Vec<Vec<i8>>) {
112    let j_index = build_j_index(j, n_qubits);
113
114    // Parallelized multi-read SA
115    let results: Vec<(Vec<i8>, f64)> = (0..num_reads)
116        .into_par_iter()
117        .map(|read_idx| {
118            let mut rng = Xoshiro256pp::new(seed.wrapping_add(read_idx as u64));
119
120            // Random initial config
121            let mut spins: Vec<i8> = (0..n_qubits)
122                .map(|_| if rng.next_bit() { 1 } else { -1 })
123                .collect();
124            let mut energy = ising_energy(h, j, &spins, offset);
125
126            for sweep in 0..n_sweeps {
127                let beta = beta_start
128                    * ((beta_end / beta_start).powf(sweep as f64 / (n_sweeps - 1).max(1) as f64));
129
130                for qubit in 0..n_qubits {
131                    let de = delta_energy(h, &j_index, &spins, qubit);
132
133                    if de < 0.0 || rng.next_f64() < (-beta * de).exp() {
134                        spins[qubit] *= -1;
135                        energy += de;
136                    }
137                }
138            }
139
140            (spins, energy)
141        })
142        .collect();
143
144    let mut best_energy = f64::INFINITY;
145    let mut best_spins = vec![1i8; n_qubits];
146    let mut all_energies = Vec::with_capacity(num_reads);
147    let mut all_samples = Vec::with_capacity(num_reads);
148
149    for (spins, energy) in results {
150        all_energies.push(energy);
151        if energy < best_energy {
152            best_energy = energy;
153            best_spins = spins.clone();
154        }
155        all_samples.push(spins);
156    }
157
158    (best_spins, best_energy, all_energies, all_samples)
159}
160
161// ── Gauge transform ──────────────────────────────────────────────────
162
163/// Apply a gauge transform to Ising biases and couplings.
164///
165/// h'_i = g_i · h_i, J'_ij = g_i · g_j · J_ij
166#[allow(clippy::type_complexity)] // tuple shape mirrors Python's QUBO format
167pub fn gauge_transform(
168    h: &[(usize, f64)],
169    j: &[((usize, usize), f64)],
170    gauge: &[i8],
171) -> (Vec<(usize, f64)>, Vec<((usize, usize), f64)>) {
172    let h_new: Vec<(usize, f64)> = h
173        .iter()
174        .map(|&(i, hi)| {
175            let g = if i < gauge.len() {
176                gauge[i] as f64
177            } else {
178                1.0
179            };
180            (i, g * hi)
181        })
182        .collect();
183
184    let j_new: Vec<((usize, usize), f64)> = j
185        .iter()
186        .map(|&((i, j_idx), jij)| {
187            let gi = if i < gauge.len() {
188                gauge[i] as f64
189            } else {
190                1.0
191            };
192            let gj = if j_idx < gauge.len() {
193                gauge[j_idx] as f64
194            } else {
195                1.0
196            };
197            ((i, j_idx), gi * gj * jij)
198        })
199        .collect();
200
201    (h_new, j_new)
202}
203
204/// Generate n_gauges random gauge vectors.
205pub fn generate_gauges(n_qubits: usize, n_gauges: usize, seed: u64) -> Vec<Vec<i8>> {
206    (0..n_gauges)
207        .into_par_iter()
208        .map(|g| {
209            let mut rng = Xoshiro256pp::new(seed.wrapping_add(g as u64 * 7919));
210            (0..n_qubits)
211                .map(|_| if rng.next_bit() { 1 } else { -1 })
212                .collect()
213        })
214        .collect()
215}
216
217// ── Graph partitioning ───────────────────────────────────────────────
218
219/// Greedy graph partitioning for problem decomposition.
220///
221/// Returns list of partitions, each a Vec of qubit indices.
222pub fn greedy_partition(
223    n_qubits: usize,
224    j: &[((usize, usize), f64)],
225    max_partition_size: usize,
226) -> Vec<Vec<usize>> {
227    // Build adjacency
228    let mut neighbors: HashMap<usize, Vec<(usize, f64)>> = HashMap::new();
229    for &((i, j_idx), jij) in j {
230        neighbors.entry(i).or_default().push((j_idx, jij.abs()));
231        neighbors.entry(j_idx).or_default().push((i, jij.abs()));
232    }
233
234    let mut remaining: Vec<bool> = vec![true; n_qubits];
235    let mut partitions: Vec<Vec<usize>> = Vec::new();
236
237    let mut n_remaining = n_qubits;
238    while n_remaining > 0 {
239        // Find first remaining
240        let seed = remaining.iter().position(|&r| r).unwrap();
241        let mut partition = vec![seed];
242        remaining[seed] = false;
243        n_remaining -= 1;
244
245        while partition.len() < max_partition_size && n_remaining > 0 {
246            let mut best: Option<usize> = None;
247            let mut best_score = -1.0f64;
248
249            for &q in &partition {
250                if let Some(nbrs) = neighbors.get(&q) {
251                    for &(n, score) in nbrs {
252                        if n < n_qubits && remaining[n] && score > best_score {
253                            best = Some(n);
254                            best_score = score;
255                        }
256                    }
257                }
258            }
259
260            let chosen = best.unwrap_or_else(|| remaining.iter().position(|&r| r).unwrap());
261
262            partition.push(chosen);
263            remaining[chosen] = false;
264            n_remaining -= 1;
265        }
266
267        partitions.push(partition);
268    }
269
270    partitions
271}
272
273// ── Minimal xoshiro256++ PRNG ────────────────────────────────────────
274
275struct Xoshiro256pp {
276    s: [u64; 4],
277}
278
279impl Xoshiro256pp {
280    fn new(seed: u64) -> Self {
281        // SplitMix64 seeding
282        let mut s = [0u64; 4];
283        let mut z = seed;
284        for slot in &mut s {
285            z = z.wrapping_add(0x9e3779b97f4a7c15);
286            z = (z ^ (z >> 30)).wrapping_mul(0xbf58476d1ce4e5b9);
287            z = (z ^ (z >> 27)).wrapping_mul(0x94d049bb133111eb);
288            *slot = z ^ (z >> 31);
289        }
290        Self { s }
291    }
292
293    #[inline]
294    fn next_u64(&mut self) -> u64 {
295        let result = (self.s[0].wrapping_add(self.s[3]))
296            .rotate_left(23)
297            .wrapping_add(self.s[0]);
298        let t = self.s[1] << 17;
299        self.s[2] ^= self.s[0];
300        self.s[3] ^= self.s[1];
301        self.s[1] ^= self.s[2];
302        self.s[0] ^= self.s[3];
303        self.s[2] ^= t;
304        self.s[3] = self.s[3].rotate_left(45);
305        result
306    }
307
308    #[inline]
309    fn next_f64(&mut self) -> f64 {
310        (self.next_u64() >> 11) as f64 * (1.0 / (1u64 << 53) as f64)
311    }
312
313    #[inline]
314    fn next_bit(&mut self) -> bool {
315        self.next_u64() & 1 == 1
316    }
317}
318
319// ── Tests ────────────────────────────────────────────────────────────
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324
325    type HTerms = Vec<(usize, f64)>;
326    type JTerms = Vec<((usize, usize), f64)>;
327
328    fn simple_model() -> (HTerms, JTerms) {
329        let h = vec![(0, 0.1), (1, -0.2), (2, 0.0)];
330        let j = vec![((0, 1), -1.0), ((1, 2), 0.5)];
331        (h, j)
332    }
333
334    #[test]
335    fn test_ising_energy_all_up() {
336        let (h, j) = simple_model();
337        let spins = vec![1, 1, 1];
338        let e = ising_energy(&h, &j, &spins, 0.0);
339        // 0.1 + (-0.2) + 0.0 + (-1.0)*1*1 + 0.5*1*1 = -0.6
340        assert!((e - (-0.6)).abs() < 1e-10);
341    }
342
343    #[test]
344    fn test_ising_energy_mixed() {
345        let (h, j) = simple_model();
346        let spins = vec![1, -1, 1];
347        let e = ising_energy(&h, &j, &spins, 0.0);
348        // 0.1 + 0.2 + 0 + (-1.0)*1*(-1) + 0.5*(-1)*1 = 0.3 + 1.0 - 0.5 = 0.8
349        assert!((e - 0.8).abs() < 1e-10);
350    }
351
352    #[test]
353    fn test_batch_energy() {
354        let (h, j) = simple_model();
355        let configs = vec![vec![1, 1, 1], vec![1, -1, 1], vec![-1, -1, -1]];
356        let energies = batch_ising_energy(&h, &j, &configs, 0.0);
357        assert_eq!(energies.len(), 3);
358        assert!((energies[0] - (-0.6)).abs() < 1e-10);
359    }
360
361    #[test]
362    fn test_sa_finds_ground_state() {
363        // Simple ferromagnetic 2-qubit: J < 0
364        let h = vec![(0, 0.0), (1, 0.0)];
365        let j = vec![((0, 1), -1.0)];
366        let (best_spins, best_energy, _, _) =
367            simulated_annealing(&h, &j, 2, 0.0, 5000, 100, 0.1, 20.0, 42);
368        // Ground state: aligned → energy = -1.0
369        assert!(best_energy <= -0.99, "energy = {}", best_energy);
370        // Energy tracker must match recomputed energy of the
371        // returned spin configuration. This catches sign bugs in
372        // delta_energy that previously let the tracker diverge from
373        // the true energy.
374        let recomputed = ising_energy(&h, &j, &best_spins, 0.0);
375        assert!(
376            (best_energy - recomputed).abs() < 1e-9,
377            "tracker {} != recomputed {}",
378            best_energy,
379            recomputed
380        );
381    }
382
383    #[test]
384    fn test_sa_planted_ferromagnetic_8q() {
385        // 8-qubit fully-ferromagnetic (h_i = -1, J_ij = -1 ∀ edges).
386        // Planted GS = all +1; energy = -n - n_edges = -8 - 28 = -36.
387        // With the wrong-sign delta bug, the tracker drifts below
388        // -36 (impossible) while the returned spins have higher
389        // real energy. The recomputed-vs-tracker check pins this.
390        let n = 8;
391        let h: Vec<(usize, f64)> = (0..n).map(|i| (i, -1.0)).collect();
392        let mut j: Vec<((usize, usize), f64)> = Vec::new();
393        for i in 0..n {
394            for k in (i + 1)..n {
395                j.push(((i, k), -1.0));
396            }
397        }
398        let (best_spins, best_energy, _, _) =
399            simulated_annealing(&h, &j, n, 0.0, 2000, 50, 0.1, 10.0, 42);
400        let recomputed = ising_energy(&h, &j, &best_spins, 0.0);
401        assert!(
402            (best_energy - recomputed).abs() < 1e-9,
403            "tracker {} != recomputed {}",
404            best_energy,
405            recomputed
406        );
407        // Best energy must be ≥ planted GS (cannot be lower).
408        assert!(
409            best_energy >= -36.0 - 1e-9,
410            "best_energy={} below planted GS=-36",
411            best_energy
412        );
413        // SA should land at or near the planted GS.
414        assert!(
415            best_energy <= -35.0,
416            "SA failed to reach planted GS: {}",
417            best_energy
418        );
419    }
420
421    #[test]
422    fn test_sa_returns_correct_counts() {
423        let h = vec![(0, 0.0)];
424        let j = vec![];
425        let (_, _, energies, samples) = simulated_annealing(&h, &j, 1, 0.0, 100, 5, 0.1, 5.0, 42);
426        assert_eq!(energies.len(), 5);
427        assert_eq!(samples.len(), 5);
428    }
429
430    #[test]
431    fn test_gauge_transform_preserves_magnitudes() {
432        let h = vec![(0, 1.0), (1, -2.0)];
433        let j = vec![((0, 1), 0.5)];
434        let gauge = vec![1, -1];
435        let (h_new, j_new) = gauge_transform(&h, &j, &gauge);
436        assert!((h_new[0].1 - 1.0).abs() < 1e-10);
437        assert!((h_new[1].1 - 2.0).abs() < 1e-10); // -1 * -2 = 2
438        assert!((j_new[0].1 - (-0.5)).abs() < 1e-10); // 1 * -1 * 0.5 = -0.5
439    }
440
441    #[test]
442    fn test_generate_gauges() {
443        let gauges = generate_gauges(5, 10, 42);
444        assert_eq!(gauges.len(), 10);
445        for g in &gauges {
446            assert_eq!(g.len(), 5);
447            for &v in g {
448                assert!(v == 1 || v == -1);
449            }
450        }
451    }
452
453    #[test]
454    fn test_greedy_partition_small() {
455        let j = vec![((0, 1), -1.0), ((1, 2), 0.5)];
456        let parts = greedy_partition(3, &j, 100);
457        assert_eq!(parts.len(), 1); // fits in one partition
458    }
459
460    #[test]
461    fn test_greedy_partition_forced_split() {
462        let j = vec![((0, 1), -1.0), ((1, 2), 0.5), ((2, 3), 0.8), ((3, 4), 0.3)];
463        let parts = greedy_partition(5, &j, 2);
464        assert!(parts.len() >= 3);
465        for p in &parts {
466            assert!(p.len() <= 2);
467        }
468    }
469
470    #[test]
471    fn test_xoshiro_deterministic() {
472        let mut rng1 = Xoshiro256pp::new(42);
473        let mut rng2 = Xoshiro256pp::new(42);
474        for _ in 0..100 {
475            assert_eq!(rng1.next_u64(), rng2.next_u64());
476        }
477    }
478
479    #[test]
480    fn test_xoshiro_f64_range() {
481        let mut rng = Xoshiro256pp::new(123);
482        for _ in 0..1000 {
483            let v = rng.next_f64();
484            assert!((0.0..1.0).contains(&v));
485        }
486    }
487}