Skip to main content

holographic_memory/core/
hopfield.rs

1// Copyright 2024-2026 WritersLogic Contributors
2// SPDX-License-Identifier: Apache-2.0
3
4//! Hopfield-Fenchel-Young (HFYN) energy-based associative memory retrieval.
5//!
6//! Implements sparse Hopfield retrieval using Tsallis α-entropy transformations
7//! (Santos et al., JMLR 2025). For sparse binary vectors, the update rule is:
8//!
9//!   scores_i = β · |query ∩ pattern_i|       (intersection count)
10//!   weights  = entmax_α(scores)               (sparse attention)
11//!   result   = patterns with non-zero weights
12//!
13//! Key properties:
14//! - **Exact retrieval**: for well-separated patterns, converges to a single
15//!   pattern in one step (margin m = 1/(α-1), requires Δ ≥ m/β).
16//! - **Sparse output**: α > 1 produces exact zeros, naturally determining
17//!   the number of relevant results (no fixed k needed).
18//! - **Exponential capacity**: O(ζ^(D/2)) patterns recoverable for dimension D.
19
20use crate::core::entangled::EntangledHVec;
21use crate::core::types::RetrievalResult;
22
23/// Hopfield retrieval configuration.
24#[derive(Clone, Debug)]
25pub struct HopfieldConfig {
26    /// Inverse temperature. Higher β = sharper retrieval (fewer results).
27    /// Must be > 0. Typical range: 10.0–500.0 (scores are Jaccard in `[0, 1]`).
28    pub beta: f64,
29    /// Tsallis entropy parameter controlling sparsity of attention weights.
30    /// α=1: softmax (dense, no exact zeros — equivalent to standard attention).
31    /// α=1.5: 1.5-entmax (moderately sparse).
32    /// α=2: sparsemax (maximally sparse, margin m=1).
33    pub alpha: f64,
34    /// Maximum Hopfield iterations for query refinement.
35    /// 1 = single-step retrieval (sufficient for well-separated patterns).
36    pub max_iter: usize,
37}
38
39impl Default for HopfieldConfig {
40    fn default() -> Self {
41        Self {
42            beta: 100.0,
43            alpha: 2.0,
44            max_iter: 1,
45        }
46    }
47}
48
49/// Perform Hopfield-HFYN retrieval against a set of stored patterns.
50///
51/// Returns patterns with non-zero entmax weights, sorted by weight descending.
52/// The number of results is determined by the sparsity of the entmax output,
53/// not by a fixed k — but at most `max_results` are returned.
54pub fn hopfield_query(
55    query: &EntangledHVec,
56    patterns: &[(String, EntangledHVec)],
57    config: &HopfieldConfig,
58    max_results: usize,
59) -> Vec<RetrievalResult> {
60    if patterns.is_empty() || config.beta <= 0.0 {
61        return Vec::new();
62    }
63
64    // Step 1: Compute similarity scores (Jaccard similarity scaled by β)
65    // Using Jaccard instead of raw intersection count because multi-scale
66    // encoding produces variable-density vectors; raw counts bias toward denser vectors.
67    let scores: Vec<f64> = patterns
68        .iter()
69        .map(|(_, pat)| config.beta * query.similarity(pat))
70        .collect();
71
72    // Step 2: Apply entmax transformation
73    let weights = if config.alpha <= 1.0 + f64::EPSILON {
74        softmax(&scores)
75    } else if (config.alpha - 1.5).abs() < f64::EPSILON {
76        entmax_15(&scores)
77    } else {
78        sparsemax(&scores)
79    };
80
81    // Step 3: Collect non-zero weights as retrieval results
82    let mut results: Vec<RetrievalResult> = weights
83        .iter()
84        .enumerate()
85        .filter(|(_, &w)| w > 1e-12)
86        .map(|(i, &w)| RetrievalResult {
87            id: patterns[i].0.clone(),
88            similarity: w,
89        })
90        .collect();
91
92    results.sort_unstable_by(|a, b| {
93        b.similarity
94            .partial_cmp(&a.similarity)
95            .unwrap_or(std::cmp::Ordering::Equal)
96    });
97    results.truncate(max_results);
98    results
99}
100
101/// Iterative Hopfield retrieval with query refinement.
102///
103/// Each iteration reconstructs the query as a weighted combination of patterns
104/// (via weighted bundle), then re-queries. Converges to an attractor basin.
105pub fn hopfield_query_iterative(
106    query: &EntangledHVec,
107    patterns: &[(String, EntangledHVec)],
108    config: &HopfieldConfig,
109    max_results: usize,
110) -> Vec<RetrievalResult> {
111    if config.max_iter <= 1 {
112        return hopfield_query(query, patterns, config, max_results);
113    }
114
115    let mut current_query = query.clone();
116
117    for _ in 0..config.max_iter - 1 {
118        let scores: Vec<f64> = patterns
119            .iter()
120            .map(|(_, pat)| config.beta * current_query.similarity(pat))
121            .collect();
122
123        let weights = if config.alpha <= 1.0 + f64::EPSILON {
124            softmax(&scores)
125        } else if (config.alpha - 1.5).abs() < f64::EPSILON {
126            entmax_15(&scores)
127        } else {
128            sparsemax(&scores)
129        };
130
131        // Reconstruct query as weighted bundle of attended patterns
132        current_query = weighted_reconstruct(patterns, &weights, query.dim);
133        if current_query.indices.is_empty() {
134            break;
135        }
136    }
137
138    hopfield_query(&current_query, patterns, config, max_results)
139}
140
141/// Sparsemax transformation (α=2 entmax).
142///
143/// Projects scores onto the probability simplex with maximum sparsity.
144/// Margin m = 1/(α-1) = 1 for α=2.
145///
146/// Algorithm (Martins & Astudillo, 2016):
147/// 1. Sort scores descending
148/// 2. Find support size k = max{j : 1 + j·z_(j) > Σ_{i≤j} z_(i)}
149/// 3. Threshold τ = (Σ_{i≤k} z_(i) - 1) / k
150/// 4. p_i = max(z_i - τ, 0)
151fn sparsemax(scores: &[f64]) -> Vec<f64> {
152    let n = scores.len();
153    if n == 0 {
154        return Vec::new();
155    }
156    if n == 1 {
157        return vec![1.0];
158    }
159
160    // Sort indices by score descending
161    let mut sorted_indices: Vec<usize> = (0..n).collect();
162    sorted_indices.sort_unstable_by(|&a, &b| {
163        scores[b]
164            .partial_cmp(&scores[a])
165            .unwrap_or(std::cmp::Ordering::Equal)
166    });
167
168    let mut cumsum = 0.0;
169    let mut support_size = 0;
170
171    for (j, &idx) in sorted_indices.iter().enumerate() {
172        cumsum += scores[idx];
173        // 1-indexed: j+1
174        if 1.0 + (j + 1) as f64 * scores[idx] > cumsum {
175            support_size = j + 1;
176        } else {
177            break;
178        }
179    }
180
181    if support_size == 0 {
182        // All scores identical or degenerate — uniform
183        let uniform = 1.0 / n as f64;
184        return vec![uniform; n];
185    }
186
187    // Recompute cumsum for the support
188    let tau_sum: f64 = sorted_indices[..support_size]
189        .iter()
190        .map(|&i| scores[i])
191        .sum();
192    let tau = (tau_sum - 1.0) / support_size as f64;
193
194    scores.iter().map(|&s| (s - tau).max(0.0)).collect()
195}
196
197/// 1.5-entmax transformation (α=1.5).
198///
199/// Intermediate sparsity between softmax and sparsemax.
200/// Margin m = 1/(α-1) = 2 for α=1.5.
201///
202/// Uses bisection to find threshold τ such that Σ max(z_i - τ, 0)^(1/(α-1)) = 1.
203/// For α=1.5: Σ max(z_i - τ, 0)^2 = 1.
204fn entmax_15(scores: &[f64]) -> Vec<f64> {
205    let n = scores.len();
206    if n == 0 {
207        return Vec::new();
208    }
209    if n == 1 {
210        return vec![1.0];
211    }
212
213    let max_score = scores.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
214
215    // Bisection for τ: find τ such that Σ max(s_i - τ, 0)^2 = 1
216    let mut lo = max_score - 1.0;
217    let mut hi = max_score;
218
219    // Expand bounds if needed
220    while sum_clipped_sq(scores, lo) < 1.0 {
221        lo -= 1.0;
222    }
223
224    for _ in 0..64 {
225        let mid = (lo + hi) / 2.0;
226        let s = sum_clipped_sq(scores, mid);
227        if s > 1.0 {
228            lo = mid;
229        } else {
230            hi = mid;
231        }
232    }
233
234    let tau = (lo + hi) / 2.0;
235    scores.iter().map(|&s| (s - tau).max(0.0).powi(2)).collect()
236}
237
238fn sum_clipped_sq(scores: &[f64], tau: f64) -> f64 {
239    scores.iter().map(|&s| (s - tau).max(0.0).powi(2)).sum()
240}
241
242/// Standard softmax (α=1 limit). Dense, no exact zeros.
243fn softmax(scores: &[f64]) -> Vec<f64> {
244    if scores.is_empty() {
245        return Vec::new();
246    }
247    let max = scores.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
248    let exps: Vec<f64> = scores.iter().map(|&s| (s - max).exp()).collect();
249    let sum: f64 = exps.iter().sum();
250    if sum == 0.0 {
251        let uniform = 1.0 / scores.len() as f64;
252        return vec![uniform; scores.len()];
253    }
254    exps.iter().map(|&e| e / sum).collect()
255}
256
257/// Reconstruct a query vector from weighted patterns.
258///
259/// For sparse binary vectors: count weighted frequency of each index across
260/// patterns with non-zero weight, keep indices with highest weighted frequency
261/// up to the target active count.
262fn weighted_reconstruct(
263    patterns: &[(String, EntangledHVec)],
264    weights: &[f64],
265    dim: usize,
266) -> EntangledHVec {
267    use crate::core::entangled::DEFAULT_RHO_DENOM;
268
269    let target = (dim / DEFAULT_RHO_DENOM).max(1);
270    let mut freq: fxhash::FxHashMap<u32, f64> =
271        fxhash::FxHashMap::with_capacity_and_hasher(target * 2, Default::default());
272
273    for (i, (_, pat)) in patterns.iter().enumerate() {
274        let w = weights[i];
275        if w < 1e-12 {
276            continue;
277        }
278        for &idx in &pat.indices {
279            *freq.entry(idx).or_insert(0.0) += w;
280        }
281    }
282
283    let mut scored: Vec<(u32, f64)> = freq.into_iter().collect();
284    if scored.len() > target {
285        scored.select_nth_unstable_by(target - 1, |a, b| {
286            b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)
287        });
288        scored.truncate(target);
289    }
290
291    let mut indices: Vec<u32> = scored.into_iter().map(|(idx, _)| idx).collect();
292    indices.sort_unstable();
293    EntangledHVec::from_indices(indices, dim)
294}
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299
300    fn make_pattern(dim: usize, seed: u64) -> EntangledHVec {
301        EntangledHVec::new_deterministic(dim, seed)
302    }
303
304    #[test]
305    fn test_sparsemax_single() {
306        let result = sparsemax(&[5.0]);
307        assert_eq!(result.len(), 1);
308        assert!((result[0] - 1.0).abs() < 1e-10);
309    }
310
311    #[test]
312    fn test_sparsemax_sparsity() {
313        // One dominant score should suppress others
314        let result = sparsemax(&[10.0, 1.0, 1.0, 1.0]);
315        assert!(result[0] > 0.9, "Dominant score should get most weight");
316        let nonzero = result.iter().filter(|&&w| w > 1e-10).count();
317        assert!(nonzero < 4, "Sparsemax should produce zeros");
318    }
319
320    #[test]
321    fn test_sparsemax_uniform() {
322        // Equal scores → equal weights
323        let result = sparsemax(&[3.0, 3.0, 3.0]);
324        for &w in &result {
325            assert!(
326                (w - 1.0 / 3.0).abs() < 1e-10,
327                "Equal scores should give uniform weights"
328            );
329        }
330    }
331
332    #[test]
333    fn test_sparsemax_sums_to_one() {
334        let result = sparsemax(&[5.0, 3.0, 1.0, 0.5, 0.1]);
335        let sum: f64 = result.iter().sum();
336        assert!(
337            (sum - 1.0).abs() < 1e-8,
338            "Sparsemax output should sum to 1, got {}",
339            sum
340        );
341    }
342
343    #[test]
344    fn test_entmax_15_sparsity() {
345        let result = entmax_15(&[10.0, 1.0, 1.0, 1.0]);
346        let nonzero = result.iter().filter(|&&w| w > 1e-10).count();
347        assert!(nonzero <= 3, "1.5-entmax should produce some zeros");
348        let sum: f64 = result.iter().sum();
349        assert!(
350            (sum - 1.0).abs() < 1e-6,
351            "1.5-entmax should sum to 1, got {}",
352            sum
353        );
354    }
355
356    #[test]
357    fn test_softmax_no_zeros() {
358        let result = softmax(&[10.0, 1.0, 1.0, 1.0]);
359        for &w in &result {
360            assert!(w > 0.0, "Softmax should never produce exact zeros");
361        }
362        let sum: f64 = result.iter().sum();
363        assert!((sum - 1.0).abs() < 1e-10);
364    }
365
366    #[test]
367    fn test_hopfield_exact_retrieval() {
368        let dim = 16384;
369        let p1 = make_pattern(dim, 100);
370        let p2 = make_pattern(dim, 200);
371        let p3 = make_pattern(dim, 300);
372
373        let patterns = vec![
374            ("p1".to_string(), p1.clone()),
375            ("p2".to_string(), p2.clone()),
376            ("p3".to_string(), p3),
377        ];
378
379        let config = HopfieldConfig {
380            beta: 100.0,
381            alpha: 2.0,
382            max_iter: 1,
383        };
384
385        // Query with p1 itself should retrieve p1 with highest weight
386        let results = hopfield_query(&p1, &patterns, &config, 10);
387        assert!(!results.is_empty(), "Should retrieve at least one pattern");
388        assert_eq!(results[0].id, "p1", "Should retrieve the exact pattern");
389
390        // Query with p2 should retrieve p2
391        let results = hopfield_query(&p2, &patterns, &config, 10);
392        assert!(!results.is_empty());
393        assert_eq!(results[0].id, "p2");
394    }
395
396    #[test]
397    fn test_hopfield_sparse_output() {
398        let dim = 16384;
399        let patterns: Vec<(String, EntangledHVec)> = (0..20)
400            .map(|i| (format!("p{}", i), make_pattern(dim, i * 100 + 1)))
401            .collect();
402
403        let config = HopfieldConfig {
404            beta: 100.0,
405            alpha: 2.0,
406            max_iter: 1,
407        };
408
409        let query = patterns[5].1.clone();
410        let results = hopfield_query(&query, &patterns, &config, 20);
411
412        // Sparsemax should produce far fewer than 20 results
413        assert!(
414            results.len() < 10,
415            "Sparsemax should produce sparse output, got {} results",
416            results.len()
417        );
418    }
419
420    #[test]
421    fn test_hopfield_iterative_convergence() {
422        let dim = 16384;
423        let p1 = make_pattern(dim, 100);
424        let p2 = make_pattern(dim, 200);
425
426        // Create a noisy query (bind p1 with random noise)
427        let noise = make_pattern(dim, 999);
428        let noisy_query = p1.bind(&noise);
429
430        let patterns = vec![("p1".to_string(), p1.clone()), ("p2".to_string(), p2)];
431
432        let config = HopfieldConfig {
433            beta: 100.0,
434            alpha: 2.0,
435            max_iter: 3,
436        };
437
438        let results = hopfield_query_iterative(&noisy_query, &patterns, &config, 10);
439        // Should still be able to retrieve something
440        assert!(!results.is_empty(), "Iterative query should return results");
441    }
442}