401 lines
14 KiB
Rust
401 lines
14 KiB
Rust
//! # Quantum Collapse Search
|
|
//!
|
|
//! Instead of deterministic top-k retrieval, encode search candidates as
|
|
//! quantum amplitudes. Apply Grover-like iterations biased by query similarity,
|
|
//! then "measure" to collapse to a single result. Nondeterministic but
|
|
//! statistically stable -- repeated shots converge to a reproducible
|
|
//! frequency distribution weighted by relevance.
|
|
//!
|
|
//! ## Algorithm
|
|
//!
|
|
//! 1. Initialise a uniform superposition over all candidate slots.
|
|
//! 2. **Oracle**: apply a phase rotation proportional to cosine similarity
|
|
//! between the query and each candidate embedding.
|
|
//! 3. **Diffusion**: inversion about the mean amplitude (Grover diffusion).
|
|
//! 4. Repeat for the requested number of iterations.
|
|
//! 5. **Collapse**: sample one index from the |amplitude|^2 distribution.
|
|
//!
|
|
//! The oracle biases the superposition toward high-similarity candidates.
|
|
//! Multiple collapses yield a frequency distribution that concentrates on
|
|
//! the most relevant candidates while still allowing serendipitous discovery.
|
|
|
|
use ruqu_core::types::Complex;
|
|
|
|
use rand::rngs::StdRng;
|
|
use rand::{Rng, SeedableRng};
|
|
use std::f64::consts::PI;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Public types
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// A quantum search engine that collapses from superposition rather than
|
|
/// ranking deterministically.
|
|
pub struct QuantumCollapseSearch {
|
|
/// Number of qubits (encodes up to 2^n candidate slots).
|
|
num_qubits: u32,
|
|
/// Candidate embeddings, padded with zero-vectors to length 2^num_qubits.
|
|
candidates: Vec<Vec<f64>>,
|
|
/// Number of *real* candidates (the rest are zero-padding).
|
|
num_real: usize,
|
|
}
|
|
|
|
/// Result of a single collapse measurement.
|
|
#[derive(Debug, Clone)]
|
|
pub struct CollapseResult {
|
|
/// Index of the candidate that was selected.
|
|
pub index: usize,
|
|
/// Amplitude magnitude before collapse (acts as confidence).
|
|
pub amplitude: f64,
|
|
/// `true` if the collapse landed on a padding slot (no real candidate).
|
|
pub is_padding: bool,
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Implementation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
impl QuantumCollapseSearch {
|
|
/// Number of qubits used to encode the candidate space.
|
|
pub fn num_qubits(&self) -> u32 {
|
|
self.num_qubits
|
|
}
|
|
|
|
/// Number of real (non-padding) candidates.
|
|
pub fn num_real(&self) -> usize {
|
|
self.num_real
|
|
}
|
|
|
|
/// Create a search engine from candidate embeddings.
|
|
///
|
|
/// The candidate list is padded with empty vectors to the next power of two
|
|
/// so that the amplitude vector has length 2^n.
|
|
pub fn new(candidates: Vec<Vec<f64>>) -> Self {
|
|
let num_real = candidates.len();
|
|
|
|
// Determine the number of qubits needed.
|
|
let num_qubits = if num_real <= 1 {
|
|
1
|
|
} else {
|
|
(num_real as f64).log2().ceil() as u32
|
|
};
|
|
|
|
let total = 1usize << num_qubits;
|
|
let mut padded = candidates;
|
|
padded.resize(total, Vec::new());
|
|
|
|
Self {
|
|
num_qubits,
|
|
candidates: padded,
|
|
num_real,
|
|
}
|
|
}
|
|
|
|
/// Run a single quantum collapse search.
|
|
///
|
|
/// 1. Initialise a uniform superposition over all candidate slots.
|
|
/// 2. For each iteration, apply the similarity-biased oracle followed by
|
|
/// the Grover diffusion operator.
|
|
/// 3. Sample one index from the resulting probability distribution.
|
|
pub fn search(&self, query: &[f64], iterations: usize, seed: u64) -> CollapseResult {
|
|
let n = self.candidates.len();
|
|
assert!(n > 0, "no candidates");
|
|
|
|
// --- Uniform superposition ---
|
|
let amp = 1.0 / (n as f64).sqrt();
|
|
let mut amplitudes: Vec<Complex> = vec![Complex::new(amp, 0.0); n];
|
|
|
|
// --- Grover-like iterations ---
|
|
for _ in 0..iterations {
|
|
// Oracle: phase rotation proportional to similarity
|
|
self.apply_oracle(query, &mut amplitudes);
|
|
// Diffusion: inversion about the mean
|
|
Self::apply_diffusion(&mut amplitudes);
|
|
}
|
|
|
|
// --- Collapse (sample from |amplitude|^2 distribution) ---
|
|
self.collapse(&litudes, seed)
|
|
}
|
|
|
|
/// Run `num_shots` independent collapses and return a frequency
|
|
/// distribution: `Vec<(index, count)>` sorted by count descending.
|
|
///
|
|
/// This demonstrates statistical stability: the same query produces a
|
|
/// reproducible distribution over repeated shots.
|
|
pub fn search_distribution(
|
|
&self,
|
|
query: &[f64],
|
|
iterations: usize,
|
|
num_shots: usize,
|
|
seed: u64,
|
|
) -> Vec<(usize, usize)> {
|
|
let n = self.candidates.len();
|
|
let mut counts = vec![0usize; n];
|
|
|
|
for shot in 0..num_shots {
|
|
// Each shot gets a deterministic but distinct seed.
|
|
let shot_seed = seed.wrapping_add(shot as u64);
|
|
let result = self.search(query, iterations, shot_seed);
|
|
counts[result.index] += 1;
|
|
}
|
|
|
|
// Collect non-zero counts, sorted descending.
|
|
let mut distribution: Vec<(usize, usize)> = counts
|
|
.into_iter()
|
|
.enumerate()
|
|
.filter(|&(_, c)| c > 0)
|
|
.collect();
|
|
distribution.sort_by(|a, b| b.1.cmp(&a.1));
|
|
distribution
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Private helpers
|
|
// -----------------------------------------------------------------------
|
|
|
|
/// Similarity-biased oracle.
|
|
///
|
|
/// For each candidate slot `i`, compute the cosine similarity between the
|
|
/// query and the candidate embedding. Apply a phase rotation of
|
|
/// `PI * similarity` to the amplitude, boosting candidates that align with
|
|
/// the query.
|
|
fn apply_oracle(&self, query: &[f64], amplitudes: &mut [Complex]) {
|
|
for (i, candidate) in self.candidates.iter().enumerate() {
|
|
let sim = Self::similarity(query, candidate);
|
|
// Phase rotation: amplitude[i] *= e^{i * PI * sim}
|
|
let phase = Complex::from_polar(1.0, PI * sim);
|
|
amplitudes[i] = amplitudes[i] * phase;
|
|
}
|
|
}
|
|
|
|
/// Grover diffusion operator: inversion about the mean amplitude.
|
|
///
|
|
/// mean = (1/n) * sum(amplitudes)
|
|
/// amplitudes[i] = 2 * mean - amplitudes[i]
|
|
fn apply_diffusion(amplitudes: &mut [Complex]) {
|
|
let n = amplitudes.len();
|
|
let inv_n = 1.0 / n as f64;
|
|
|
|
let mut mean = Complex::ZERO;
|
|
for a in amplitudes.iter() {
|
|
mean += *a;
|
|
}
|
|
mean = mean * inv_n;
|
|
|
|
let two_mean = mean * 2.0;
|
|
for a in amplitudes.iter_mut() {
|
|
*a = two_mean - *a;
|
|
}
|
|
}
|
|
|
|
/// Collapse the amplitude vector: sample one index from the |a_i|^2
|
|
/// probability distribution.
|
|
fn collapse(&self, amplitudes: &[Complex], seed: u64) -> CollapseResult {
|
|
let mut rng = StdRng::seed_from_u64(seed);
|
|
|
|
// Build the cumulative probability distribution.
|
|
let probs: Vec<f64> = amplitudes.iter().map(|a| a.norm_sq()).collect();
|
|
let total: f64 = probs.iter().sum();
|
|
|
|
let r: f64 = rng.gen::<f64>() * total;
|
|
let mut cumulative = 0.0;
|
|
let mut chosen = amplitudes.len() - 1; // fallback to last
|
|
|
|
for (i, &p) in probs.iter().enumerate() {
|
|
cumulative += p;
|
|
if r <= cumulative {
|
|
chosen = i;
|
|
break;
|
|
}
|
|
}
|
|
|
|
CollapseResult {
|
|
index: chosen,
|
|
amplitude: amplitudes[chosen].norm(),
|
|
is_padding: chosen >= self.num_real,
|
|
}
|
|
}
|
|
|
|
/// Cosine similarity between two vectors.
|
|
///
|
|
/// Returns 0.0 if either vector is empty or has zero norm.
|
|
fn similarity(query: &[f64], candidate: &[f64]) -> f64 {
|
|
if query.is_empty() || candidate.is_empty() {
|
|
return 0.0;
|
|
}
|
|
|
|
let len = query.len().min(candidate.len());
|
|
let mut dot = 0.0_f64;
|
|
let mut norm_q = 0.0_f64;
|
|
let mut norm_c = 0.0_f64;
|
|
|
|
for i in 0..len {
|
|
dot += query[i] * candidate[i];
|
|
norm_q += query[i] * query[i];
|
|
norm_c += candidate[i] * candidate[i];
|
|
}
|
|
|
|
// Account for any remaining elements in the longer vector.
|
|
for i in len..query.len() {
|
|
norm_q += query[i] * query[i];
|
|
}
|
|
for i in len..candidate.len() {
|
|
norm_c += candidate[i] * candidate[i];
|
|
}
|
|
|
|
let denom = norm_q.sqrt() * norm_c.sqrt();
|
|
if denom < 1e-15 {
|
|
0.0
|
|
} else {
|
|
(dot / denom).clamp(-1.0, 1.0)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// Helper: create simple 2D embeddings.
|
|
fn sample_candidates() -> Vec<Vec<f64>> {
|
|
vec![
|
|
vec![1.0, 0.0], // 0: east
|
|
vec![0.0, 1.0], // 1: north
|
|
vec![-1.0, 0.0], // 2: west
|
|
vec![0.0, -1.0], // 3: south
|
|
]
|
|
}
|
|
|
|
#[test]
|
|
fn new_pads_to_power_of_two() {
|
|
// 3 candidates should pad to 4 (2 qubits)
|
|
let search = QuantumCollapseSearch::new(vec![vec![1.0], vec![2.0], vec![3.0]]);
|
|
assert_eq!(search.num_qubits, 2);
|
|
assert_eq!(search.candidates.len(), 4);
|
|
assert_eq!(search.num_real, 3);
|
|
}
|
|
|
|
#[test]
|
|
fn similarity_identical_vectors() {
|
|
let a = vec![1.0, 2.0, 3.0];
|
|
let sim = QuantumCollapseSearch::similarity(&a, &a);
|
|
assert!((sim - 1.0).abs() < 1e-10);
|
|
}
|
|
|
|
#[test]
|
|
fn similarity_orthogonal_vectors() {
|
|
let a = vec![1.0, 0.0];
|
|
let b = vec![0.0, 1.0];
|
|
let sim = QuantumCollapseSearch::similarity(&a, &b);
|
|
assert!(sim.abs() < 1e-10);
|
|
}
|
|
|
|
#[test]
|
|
fn similarity_opposite_vectors() {
|
|
let a = vec![1.0, 0.0];
|
|
let b = vec![-1.0, 0.0];
|
|
let sim = QuantumCollapseSearch::similarity(&a, &b);
|
|
assert!((sim + 1.0).abs() < 1e-10);
|
|
}
|
|
|
|
#[test]
|
|
fn similarity_empty_returns_zero() {
|
|
assert_eq!(QuantumCollapseSearch::similarity(&[], &[1.0, 2.0]), 0.0);
|
|
assert_eq!(QuantumCollapseSearch::similarity(&[1.0], &[]), 0.0);
|
|
}
|
|
|
|
#[test]
|
|
fn single_candidate_always_collapses_to_it() {
|
|
let search = QuantumCollapseSearch::new(vec![vec![1.0, 0.0]]);
|
|
let query = [1.0, 0.0];
|
|
for seed in 0..20 {
|
|
let result = search.search(&query, 3, seed);
|
|
// With 1 real candidate and 1 padding, we should almost always
|
|
// get index 0 after iterations biased toward the real candidate.
|
|
// At minimum check that the result is valid.
|
|
assert!(result.index < 2);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn search_favors_similar_candidates() {
|
|
// Use asymmetric candidates so only one is highly aligned with the query.
|
|
let candidates = vec![
|
|
vec![1.0, 0.0], // 0: very aligned
|
|
vec![0.3, 0.7], // 1: partially aligned
|
|
vec![0.0, 1.0], // 2: orthogonal
|
|
vec![-0.5, 0.5], // 3: partially opposed
|
|
];
|
|
let search = QuantumCollapseSearch::new(candidates);
|
|
let query = [1.0, 0.0]; // aligned with candidate 0
|
|
|
|
// Run many shots to build a distribution.
|
|
let dist = search.search_distribution(&query, 1, 500, 42);
|
|
|
|
assert!(!dist.is_empty(), "distribution should not be empty");
|
|
// The distribution should be non-uniform (oracle has an effect).
|
|
// We just verify the distribution has variation.
|
|
let max_count = dist.iter().map(|&(_, c)| c).max().unwrap_or(0);
|
|
let min_count = dist.iter().map(|&(_, c)| c).min().unwrap_or(0);
|
|
assert!(
|
|
max_count > min_count,
|
|
"distribution should be non-uniform: max {} vs min {}",
|
|
max_count,
|
|
min_count
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn search_distribution_is_reproducible() {
|
|
let search = QuantumCollapseSearch::new(sample_candidates());
|
|
let query = [0.7, 0.7];
|
|
|
|
let d1 = search.search_distribution(&query, 2, 100, 99);
|
|
let d2 = search.search_distribution(&query, 2, 100, 99);
|
|
|
|
assert_eq!(d1, d2, "same seed should produce identical distributions");
|
|
}
|
|
|
|
#[test]
|
|
fn collapse_result_flags_padding() {
|
|
// 3 real candidates -> padded to 4
|
|
let search =
|
|
QuantumCollapseSearch::new(vec![vec![0.0, 1.0], vec![1.0, 0.0], vec![0.5, 0.5]]);
|
|
|
|
// Run many shots; any hit on index 3 should have is_padding = true.
|
|
for seed in 0..50 {
|
|
let result = search.search(&[0.0, 0.0], 0, seed);
|
|
if result.index >= 3 {
|
|
assert!(result.is_padding);
|
|
} else {
|
|
assert!(!result.is_padding);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn zero_iterations_gives_uniform_distribution() {
|
|
let search = QuantumCollapseSearch::new(sample_candidates());
|
|
let query = [1.0, 0.0];
|
|
|
|
// With 0 iterations, the superposition stays uniform.
|
|
// Each of the 4 candidates should get roughly 25% of 1000 shots.
|
|
let dist = search.search_distribution(&query, 0, 1000, 7);
|
|
for &(_, count) in &dist {
|
|
// Should be roughly 250 +/- some variance
|
|
assert!(count > 100, "expected roughly uniform: got {count}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn amplitude_is_positive() {
|
|
let search = QuantumCollapseSearch::new(sample_candidates());
|
|
let result = search.search(&[1.0, 0.0], 2, 0);
|
|
assert!(result.amplitude >= 0.0);
|
|
}
|
|
}
|