Skip to main content

scirs2_spatial/quantum_inspired/
classical_adaptation.rs

1//! Classical Adaptation of Quantum-Inspired Algorithms
2//!
3//! This module bridges the gap between full quantum simulation and classical
4//! computation by providing quantum-inspired enhancements to classical algorithms.
5//! Rather than simulating quantum circuits exactly (which is exponentially costly),
6//! these adaptations borrow key ideas — controlled randomness, tunnelling noise,
7//! and quantum annealing schedules — to improve classical algorithm performance.
8//!
9//! # Overview
10//!
11//! The primary abstraction is [`ClassicalAdaptation`], which offers:
12//!
13//! - **Coherent noise injection** (`adapt`): adds Gaussian noise scaled by the
14//!   adaptation rate and quantum noise level to a parameter vector, simulating
15//!   quantum-state fluctuation to escape local optima.
16//! - **Quantum annealing step** (`anneal`): computes a quantum tunnelling
17//!   acceptance probability that allows uphill moves with a probability that
18//!   decays as the system temperature drops, analogous to the transverse-field
19//!   Ising model's ground-state search.
20//! - **Decoherence modelling** (`decohere`): applies an exponential amplitude
21//!   decay to a state vector, modelling T₂ relaxation in a quantum system.
22//! - **Parameter landscape smoothing** (`smooth_landscape`): convolves a
23//!   1-D energy landscape with a Gaussian kernel to remove spurious minima,
24//!   emulating the quantum superposition effect over neighbouring states.
25//!
26//! # Theoretical Motivation
27//!
28//! Quantum annealing replaces the thermal fluctuations of classical simulated
29//! annealing with quantum tunnelling through energy barriers. For the transverse-
30//! field Ising Hamiltonian H = -J Σ σᵢᶻσⱼᶻ - Γ Σ σᵢˣ, the tunnelling term Γ
31//! allows transitions through barriers of height ΔE that would be exponentially
32//! suppressed classically. This module emulates that effect on a classical
33//! computer by using temperature-scaled, energy-aware acceptance probabilities.
34
35use crate::error::{SpatialError, SpatialResult};
36use scirs2_core::ndarray::Array1;
37use scirs2_core::random::{Rng, RngExt};
38use std::f64::consts::PI;
39
40/// Quantum-Inspired Classical Adaptation Engine
41///
42/// Provides quantum-inspired enhancements to classical optimisation algorithms
43/// by injecting controlled randomness (coherent noise), modelling quantum
44/// annealing acceptance criteria, and simulating decoherence effects.
45///
46/// # Fields
47/// - `adaptation_rate` — scales the amplitude of coherent noise injected into
48///   parameter vectors (analogous to the magnitude of quantum fluctuations).
49/// - `quantum_noise_level` — baseline noise variance; combined with
50///   `adaptation_rate` to determine the actual noise standard deviation.
51///
52/// # Example
53/// ```rust
54/// use scirs2_core::ndarray::Array1;
55/// use scirs2_spatial::quantum_inspired::classical_adaptation::ClassicalAdaptation;
56///
57/// let adapter = ClassicalAdaptation::new(0.05, 0.01);
58///
59/// // Add quantum-inspired noise to a parameter vector
60/// let params = Array1::from_vec(vec![1.0, 2.0, 3.0]);
61/// let noisy = adapter.adapt(&params);
62/// assert_eq!(noisy.len(), params.len());
63///
64/// // Quantum annealing acceptance for an uphill move
65/// let accept_prob = adapter.anneal(1.5, 2.0);
66/// assert!(accept_prob >= 0.0 && accept_prob <= 1.0);
67/// ```
68#[derive(Debug, Clone)]
69pub struct ClassicalAdaptation {
70    /// Controls the amplitude of quantum coherent noise added during adaptation.
71    /// Larger values allow wider exploration of the parameter landscape.
72    adaptation_rate: f64,
73    /// Baseline quantum noise level (variance contribution).
74    /// Simulates intrinsic noise from a quantum environment.
75    quantum_noise_level: f64,
76}
77
78impl ClassicalAdaptation {
79    /// Construct a new `ClassicalAdaptation` engine.
80    ///
81    /// # Arguments
82    /// * `adaptation_rate` — Amplitude scale for coherent noise (> 0 recommended).
83    /// * `quantum_noise_level` — Baseline quantum noise variance (≥ 0).
84    pub fn new(adaptation_rate: f64, quantum_noise_level: f64) -> Self {
85        Self {
86            adaptation_rate,
87            quantum_noise_level,
88        }
89    }
90
91    /// Return the adaptation rate.
92    pub fn adaptation_rate(&self) -> f64 {
93        self.adaptation_rate
94    }
95
96    /// Return the quantum noise level.
97    pub fn quantum_noise_level(&self) -> f64 {
98        self.quantum_noise_level
99    }
100
101    /// Inject quantum-inspired coherent noise into a parameter vector.
102    ///
103    /// Each component `pᵢ` is perturbed as:
104    /// ```text
105    /// p̃ᵢ = pᵢ + σ · ηᵢ,   ηᵢ ~ N(0, 1)
106    /// ```
107    /// where `σ = adaptation_rate · √(1 + quantum_noise_level)`.
108    ///
109    /// The resulting perturbations model quantum fluctuations around the current
110    /// parameter values and can help classical optimisers escape local minima.
111    ///
112    /// # Arguments
113    /// * `params` — Parameter vector to perturb.
114    ///
115    /// # Returns
116    /// A new `Array1<f64>` with the perturbed parameters.
117    pub fn adapt(&self, params: &Array1<f64>) -> Array1<f64> {
118        let sigma = self.adaptation_rate * (1.0 + self.quantum_noise_level).sqrt();
119        let mut rng = scirs2_core::random::rng();
120        let mut result = params.clone();
121
122        for val in result.iter_mut() {
123            let noise = gaussian_noise(&mut rng, sigma);
124            *val += noise;
125        }
126        result
127    }
128
129    /// Compute a quantum annealing acceptance probability for an energy transition.
130    ///
131    /// This models the probability that the quantum annealer accepts a move from
132    /// the current state to a new state with the given `energy` at the given
133    /// `temperature`. The formula is:
134    ///
135    /// ```text
136    /// P(accept) = exp(-energy / (temperature · (1 + Γ)))
137    /// ```
138    ///
139    /// where `Γ = quantum_noise_level` represents the transverse field strength.
140    /// Compared with classical Metropolis, the denominator is larger (because
141    /// Γ > 0), giving a *higher* acceptance probability — this models quantum
142    /// tunnelling that can traverse energy barriers classical Metropolis cannot.
143    ///
144    /// # Arguments
145    /// * `energy` — Current energy (or energy difference) of the proposed state.
146    ///   Positive values correspond to uphill moves.
147    /// * `temperature` — Current annealing temperature. Must be > 0 for
148    ///   non-trivial acceptance; returns `0.0` for `temperature ≤ 0`.
149    ///
150    /// # Returns
151    /// Acceptance probability in `[0, 1]`.
152    pub fn anneal(&self, energy: f64, temperature: f64) -> f64 {
153        if temperature <= 0.0 {
154            // At absolute zero, only downhill moves are accepted
155            if energy <= 0.0 {
156                1.0
157            } else {
158                0.0
159            }
160        } else {
161            let transverse_field = 1.0 + self.quantum_noise_level;
162            let exponent = -energy / (temperature * transverse_field);
163            // Clamp to avoid overflow
164            exponent.exp().clamp(0.0, 1.0)
165        }
166    }
167
168    /// Simulate decoherence on a quantum-state amplitude vector.
169    ///
170    /// Applies an exponential T₂ decay to each amplitude element:
171    /// ```text
172    /// ψ̃ᵢ = ψᵢ · exp(-t / T₂)
173    /// ```
174    /// where `T₂ = 1 / (adaptation_rate · quantum_noise_level + ε)` and
175    /// `t` is the elapsed (normalised) time.
176    ///
177    /// # Arguments
178    /// * `amplitudes` — Quantum-state amplitude vector.
179    /// * `elapsed_time` — Normalised elapsed time in `[0, ∞)`.
180    ///
181    /// # Returns
182    /// Decayed amplitude vector. Returns `amplitudes` unchanged if both
183    /// `adaptation_rate` and `quantum_noise_level` are zero.
184    ///
185    /// # Errors
186    /// Returns [`SpatialError::InvalidInput`] if `elapsed_time < 0`.
187    pub fn decohere(
188        &self,
189        amplitudes: &Array1<f64>,
190        elapsed_time: f64,
191    ) -> SpatialResult<Array1<f64>> {
192        if elapsed_time < 0.0 {
193            return Err(SpatialError::InvalidInput(
194                "elapsed_time must be non-negative".to_string(),
195            ));
196        }
197
198        let decoherence_rate = self.adaptation_rate * self.quantum_noise_level + 1e-12;
199        let t2 = 1.0 / decoherence_rate;
200        let decay = (-elapsed_time / t2).exp();
201
202        Ok(amplitudes.mapv(|a| a * decay))
203    }
204
205    /// Smooth a 1-D energy landscape using a Gaussian kernel.
206    ///
207    /// Convolves the energy array with a Gaussian of standard deviation
208    /// `sigma_smooth = adaptation_rate * (landscape.len() as f64).sqrt()`,
209    /// using a finite-support approximation (kernel half-width = 3σ).
210    /// This emulates the quantum superposition effect: the effective energy
211    /// at each point is averaged over nearby states weighted by the quantum
212    /// probability of tunnelling to them.
213    ///
214    /// # Arguments
215    /// * `landscape` — 1-D energy landscape to smooth.
216    ///
217    /// # Returns
218    /// Smoothed energy array of the same length.
219    ///
220    /// # Errors
221    /// Returns [`SpatialError::InvalidInput`] if the landscape is empty.
222    pub fn smooth_landscape(&self, landscape: &Array1<f64>) -> SpatialResult<Array1<f64>> {
223        let n = landscape.len();
224        if n == 0 {
225            return Err(SpatialError::InvalidInput(
226                "landscape must be non-empty".to_string(),
227            ));
228        }
229
230        let sigma = (self.adaptation_rate * (n as f64).sqrt()).max(0.5);
231        let half_width = (3.0 * sigma).ceil() as usize;
232
233        // Build 1-D Gaussian kernel (not pre-normalised; we normalise below)
234        let kernel_len = 2 * half_width + 1;
235        let mut kernel = vec![0.0f64; kernel_len];
236        let mut kernel_sum = 0.0f64;
237        for (k, kval) in kernel.iter_mut().enumerate() {
238            let offset = k as f64 - half_width as f64;
239            let g = (-0.5 * (offset / sigma).powi(2)).exp() / (sigma * (2.0 * PI).sqrt());
240            *kval = g;
241            kernel_sum += g;
242        }
243        // Normalise
244        if kernel_sum > 1e-12 {
245            for kval in kernel.iter_mut() {
246                *kval /= kernel_sum;
247            }
248        }
249
250        // Convolve landscape with kernel (reflect padding at boundaries)
251        let mut smoothed = Array1::<f64>::zeros(n);
252        for i in 0..n {
253            let mut acc = 0.0f64;
254            for (k, &kval) in kernel.iter().enumerate() {
255                let offset = k as isize - half_width as isize;
256                let idx = (i as isize + offset).clamp(0, n as isize - 1) as usize;
257                acc += kval * landscape[idx];
258            }
259            smoothed[i] = acc;
260        }
261
262        Ok(smoothed)
263    }
264}
265
266/// Draw a single Gaussian-distributed sample via Box-Muller transform.
267///
268/// Uses two uniform samples from `rng` to produce one N(0, sigma) variate.
269fn gaussian_noise(rng: &mut impl Rng, sigma: f64) -> f64 {
270    if sigma.abs() < 1e-15 {
271        return 0.0;
272    }
273    let u1: f64 = rng.random_range(1e-10_f64..1.0_f64);
274    let u2: f64 = rng.random_range(0.0_f64..1.0_f64);
275    sigma * (-2.0 * u1.ln()).sqrt() * (2.0 * PI * u2).cos()
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281    use scirs2_core::ndarray::Array1;
282
283    #[test]
284    fn test_adapt_output_shape_and_finite() {
285        let adapter = ClassicalAdaptation::new(0.1, 0.05);
286        let params = Array1::from_vec(vec![1.0, 2.0, -1.0, 0.5]);
287        let adapted = adapter.adapt(&params);
288        assert_eq!(adapted.len(), params.len());
289        for &v in adapted.iter() {
290            assert!(v.is_finite(), "adapted value must be finite");
291        }
292    }
293
294    #[test]
295    fn test_anneal_probability_bounds() {
296        let adapter = ClassicalAdaptation::new(0.1, 0.05);
297
298        // Uphill move: probability in (0, 1)
299        let p_uphill = adapter.anneal(10.0, 1.0);
300        assert!(p_uphill > 0.0 && p_uphill < 1.0);
301
302        // Downhill / zero move: probability = 1.0 (since exp(0) = 1)
303        let p_down = adapter.anneal(-5.0, 1.0);
304        assert!(p_down >= 1.0 - 1e-9, "downhill acceptance should be ≥ 1");
305
306        // Temperature → 0: uphill rejected, downhill accepted
307        let p_cold_up = adapter.anneal(1.0, 0.0);
308        assert_eq!(p_cold_up, 0.0);
309        let p_cold_down = adapter.anneal(-1.0, 0.0);
310        assert_eq!(p_cold_down, 1.0);
311    }
312
313    #[test]
314    fn test_decohere_decay() {
315        let adapter = ClassicalAdaptation::new(1.0, 1.0);
316        let amps = Array1::from_vec(vec![1.0, 0.5, -0.5]);
317
318        // At t=0, amplitudes are unchanged
319        let t0 = adapter.decohere(&amps, 0.0).expect("t=0 is valid");
320        for (&a, &b) in amps.iter().zip(t0.iter()) {
321            assert!((a - b).abs() < 1e-12);
322        }
323
324        // At t>0, amplitudes are strictly reduced in magnitude
325        let t1 = adapter.decohere(&amps, 1.0).expect("t=1 is valid");
326        for (&a, &b) in amps.iter().zip(t1.iter()) {
327            assert!(b.abs() < a.abs() + 1e-12);
328        }
329
330        // Negative time is rejected
331        assert!(adapter.decohere(&amps, -1.0).is_err());
332    }
333
334    #[test]
335    fn test_smooth_landscape_energy_preservation() {
336        let adapter = ClassicalAdaptation::new(0.5, 0.1);
337        let landscape = Array1::from_vec(vec![0.0, 1.0, 5.0, 1.0, 0.0, 2.0, 0.0]);
338        let smoothed = adapter
339            .smooth_landscape(&landscape)
340            .expect("smoothing should succeed");
341
342        assert_eq!(smoothed.len(), landscape.len());
343
344        // All values should be finite
345        for &v in smoothed.iter() {
346            assert!(v.is_finite());
347        }
348
349        // The global maximum in the smoothed landscape must occur near where the
350        // original maximum was (index 2), within a small neighbourhood
351        let max_idx = smoothed
352            .iter()
353            .enumerate()
354            .max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
355            .map(|(i, _)| i)
356            .unwrap_or(0);
357        assert!(
358            max_idx <= 4,
359            "smoothed peak should remain near original spike, got index {max_idx}"
360        );
361
362        // Empty landscape should error
363        let empty = Array1::<f64>::zeros(0);
364        assert!(adapter.smooth_landscape(&empty).is_err());
365    }
366}