Skip to main content

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