Skip to main content

geographdb_core/algorithms/
sparse_attn.rs

1//! Sparse attention via octree nearest-neighbor search and causal time masking.
2//!
3//! Each token occupies a position in 3D concept space. For each query token the
4//! octree finds the `k` spatially closest key tokens in O(n log n) rather than
5//! O(n²). A causal mask then removes keys whose `time_step` lies strictly after
6//! the query, producing an auto-regressive attention pattern. Attention weights
7//! are computed as a softmax over negative squared distances scaled by a
8//! temperature parameter.
9
10use crate::spatial::octree::Octree;
11use crate::storage::data_structures::NodePoint;
12use glam::Vec3;
13use std::collections::HashMap;
14
15// ── Public Types ──────────────────────────────────────────────────────────────
16
17/// A token embedded in 3D concept space with a causal time index.
18pub struct Token {
19    pub id: u64,
20    /// Position in concept space; distance determines attention affinity.
21    pub position: Vec3,
22    /// Auto-regressive time index; queries only attend to keys with `time_step
23    /// <= query.time_step`.
24    pub time_step: u64,
25}
26
27/// Attention output for a single query token.
28pub struct AttentionResult {
29    pub query_id: u64,
30    /// Causally-valid neighbors sorted by descending weight: `(key_id, weight)`.
31    /// Weights are softmax-normalised and sum to 1.0 (empty when no valid key
32    /// exists).
33    pub attended: Vec<(u64, f32)>,
34}
35
36/// Aggregate sparsity statistics over all query results.
37pub struct AttentionStats {
38    pub n_queries: usize,
39    pub mean_attended: f32,
40    pub min_attended: usize,
41    pub max_attended: usize,
42    /// n_queries * n_keys — the dense baseline cost.
43    pub total_pairs: usize,
44    /// Total (query, key) pairs with nonzero weight.
45    pub attended_pairs: usize,
46    /// `1 - attended_pairs / total_pairs`; 1.0 means fully sparse.
47    pub sparsity: f32,
48}
49
50// ── Helpers ───────────────────────────────────────────────────────────────────
51
52fn to_node_point(t: &Token) -> NodePoint {
53    NodePoint {
54        id: t.id,
55        x: t.position.x,
56        y: t.position.y,
57        z: t.position.z,
58    }
59}
60
61/// Build an octree from `keys` whose bounding box also encompasses all `query`
62/// positions. This ensures every query falls inside the octree bounds so
63/// `query_knn` uses a radius large enough to find neighbors.
64fn build_key_octree(queries: &[Token], keys: &[Token]) -> Octree {
65    use crate::spatial::octree::BoundingBox;
66
67    let all: Vec<Vec3> = queries
68        .iter()
69        .chain(keys.iter())
70        .map(|t| t.position)
71        .collect();
72
73    let mut min = all[0];
74    let mut max = all[0];
75    for &p in &all {
76        min = min.min(p);
77        max = max.max(p);
78    }
79
80    // Generous padding so the KNN radius covers the full space.
81    let span = (max - min).length().max(1.0);
82    let pad = Vec3::splat(span * 0.25);
83    let bounds = BoundingBox::new(min - pad, max + pad);
84
85    let mut octree = Octree::new(bounds);
86    for key in keys {
87        octree.insert(to_node_point(key));
88    }
89    octree
90}
91
92fn softmax_weights(neg_dist_sq: &[f32]) -> Vec<f32> {
93    let max = neg_dist_sq
94        .iter()
95        .cloned()
96        .fold(f32::NEG_INFINITY, f32::max);
97    let exps: Vec<f32> = neg_dist_sq.iter().map(|&v| (v - max).exp()).collect();
98    let sum: f32 = exps.iter().sum();
99    exps.iter().map(|&e| e / sum).collect()
100}
101
102// ── Public API ────────────────────────────────────────────────────────────────
103
104/// Run sparse causal attention.
105///
106/// For each query the octree returns at most `k` nearest key candidates, then
107/// causal masking removes keys with `time_step > query.time_step`. Weights are
108/// softmax-normalised over the remaining candidates.
109///
110/// `temperature` scales the negative distances before softmax: higher values
111/// produce a flatter (more uniform) distribution; lower values sharpen toward
112/// the single nearest token.
113pub fn sparse_attention(
114    queries: &[Token],
115    keys: &[Token],
116    k: usize,
117    temperature: f32,
118) -> Vec<AttentionResult> {
119    if queries.is_empty() {
120        return Vec::new();
121    }
122    if keys.is_empty() {
123        return queries
124            .iter()
125            .map(|q| AttentionResult {
126                query_id: q.id,
127                attended: Vec::new(),
128            })
129            .collect();
130    }
131
132    let octree = build_key_octree(queries, keys);
133
134    let key_time: HashMap<u64, u64> = keys.iter().map(|t| (t.id, t.time_step)).collect();
135    let temp = temperature.max(1e-6);
136
137    queries
138        .iter()
139        .map(|query| {
140            let neighbors = octree.query_knn(query.position, k);
141
142            // Apply causal mask: only keys at or before query's time step.
143            let causal: Vec<(u64, f32)> = neighbors
144                .into_iter()
145                .filter(|(np, _)| {
146                    key_time
147                        .get(&np.id)
148                        .is_some_and(|&ts| ts <= query.time_step)
149                })
150                .map(|(np, dist_sq)| (np.id, dist_sq))
151                .collect();
152
153            if causal.is_empty() {
154                return AttentionResult {
155                    query_id: query.id,
156                    attended: Vec::new(),
157                };
158            }
159
160            let neg_dists: Vec<f32> = causal.iter().map(|(_, d)| -d / temp).collect();
161            let weights = softmax_weights(&neg_dists);
162
163            let attended = causal
164                .iter()
165                .zip(weights)
166                .map(|((id, _), w)| (*id, w))
167                .collect();
168
169            AttentionResult {
170                query_id: query.id,
171                attended,
172            }
173        })
174        .collect()
175}
176
177/// Compute sparsity statistics over a batch of attention results.
178pub fn attention_stats(results: &[AttentionResult], n_keys: usize) -> AttentionStats {
179    let n_queries = results.len();
180    if n_queries == 0 {
181        return AttentionStats {
182            n_queries: 0,
183            mean_attended: 0.0,
184            min_attended: 0,
185            max_attended: 0,
186            total_pairs: 0,
187            attended_pairs: 0,
188            sparsity: 1.0,
189        };
190    }
191
192    let counts: Vec<usize> = results.iter().map(|r| r.attended.len()).collect();
193    let attended_pairs: usize = counts.iter().sum();
194    let min_attended = *counts.iter().min().unwrap();
195    let max_attended = *counts.iter().max().unwrap();
196    let mean_attended = attended_pairs as f32 / n_queries as f32;
197    let total_pairs = n_queries * n_keys;
198    let sparsity = if total_pairs == 0 {
199        1.0
200    } else {
201        1.0 - attended_pairs as f32 / total_pairs as f32
202    };
203
204    AttentionStats {
205        n_queries,
206        mean_attended,
207        min_attended,
208        max_attended,
209        total_pairs,
210        attended_pairs,
211        sparsity,
212    }
213}
214
215// ── Tests ─────────────────────────────────────────────────────────────────────
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220    use glam::Vec3;
221
222    fn tok(id: u64, x: f32, y: f32, z: f32, t: u64) -> Token {
223        Token {
224            id,
225            position: Vec3::new(x, y, z),
226            time_step: t,
227        }
228    }
229
230    #[test]
231    fn test_empty_queries_returns_empty() {
232        let result = sparse_attention(&[], &[tok(0, 0.0, 0.0, 0.0, 0)], 3, 1.0);
233        assert!(result.is_empty());
234    }
235
236    #[test]
237    fn test_empty_keys_returns_empty_attended() {
238        let queries = vec![tok(0, 0.0, 0.0, 0.0, 0)];
239        let result = sparse_attention(&queries, &[], 3, 1.0);
240        assert_eq!(result.len(), 1);
241        assert!(result[0].attended.is_empty());
242    }
243
244    #[test]
245    fn test_single_pair_weight_is_one() {
246        let queries = vec![tok(0, 0.0, 0.0, 0.0, 5)];
247        let keys = vec![tok(1, 0.1, 0.0, 0.0, 3)];
248        let result = sparse_attention(&queries, &keys, 1, 1.0);
249        assert_eq!(result[0].attended.len(), 1);
250        let (id, w) = result[0].attended[0];
251        assert_eq!(id, 1);
252        assert!(
253            (w - 1.0).abs() < 1e-5,
254            "single key must get weight 1.0, got {w}"
255        );
256    }
257
258    #[test]
259    fn test_causal_mask_excludes_future_keys() {
260        // Query at time 2; only key 1 (time 1) should be attended; key 2 (time 3) masked.
261        let queries = vec![tok(0, 0.0, 0.0, 0.0, 2)];
262        let keys = vec![tok(1, 0.1, 0.0, 0.0, 1), tok(2, 0.2, 0.0, 0.0, 3)];
263        let result = sparse_attention(&queries, &keys, 5, 1.0);
264        let ids: Vec<u64> = result[0].attended.iter().map(|(id, _)| *id).collect();
265        assert!(ids.contains(&1), "past key must be attended");
266        assert!(!ids.contains(&2), "future key must be masked");
267    }
268
269    #[test]
270    fn test_same_time_step_is_allowed() {
271        let queries = vec![tok(0, 0.0, 0.0, 0.0, 3)];
272        let keys = vec![tok(1, 0.1, 0.0, 0.0, 3)];
273        let result = sparse_attention(&queries, &keys, 1, 1.0);
274        assert_eq!(
275            result[0].attended.len(),
276            1,
277            "equal time_step should not be masked"
278        );
279    }
280
281    #[test]
282    fn test_k_limits_attended_count() {
283        let queries = vec![tok(0, 0.5, 0.5, 0.5, 10)];
284        let keys: Vec<Token> = (1u64..=8)
285            .map(|i| tok(i, i as f32 * 0.1, 0.0, 0.0, i))
286            .collect();
287        let result = sparse_attention(&queries, &keys, 3, 1.0);
288        assert!(
289            result[0].attended.len() <= 3,
290            "attended count must not exceed k"
291        );
292    }
293
294    #[test]
295    fn test_weights_sum_to_one() {
296        let queries = vec![tok(0, 0.0, 0.0, 0.0, 10)];
297        let keys: Vec<Token> = (1u64..=5)
298            .map(|i| tok(i, i as f32 * 0.2, 0.0, 0.0, i))
299            .collect();
300        let result = sparse_attention(&queries, &keys, 5, 1.0);
301        let sum: f32 = result[0].attended.iter().map(|(_, w)| w).sum();
302        assert!(
303            (sum - 1.0).abs() < 1e-5,
304            "weights must sum to 1.0, got {sum}"
305        );
306    }
307
308    #[test]
309    fn test_closer_token_gets_higher_weight() {
310        let queries = vec![tok(0, 0.0, 0.0, 0.0, 10)];
311        let keys = vec![
312            tok(1, 0.1, 0.0, 0.0, 1), // close
313            tok(2, 5.0, 0.0, 0.0, 2), // far
314        ];
315        let result = sparse_attention(&queries, &keys, 5, 1.0);
316        let w1 = result[0]
317            .attended
318            .iter()
319            .find(|(id, _)| *id == 1)
320            .map(|(_, w)| *w)
321            .expect("close key must be attended");
322        let w2 = result[0]
323            .attended
324            .iter()
325            .find(|(id, _)| *id == 2)
326            .map(|(_, w)| *w)
327            .expect("far key must be attended");
328        assert!(
329            w1 > w2,
330            "closer token must have higher weight ({w1:.4} vs {w2:.4})"
331        );
332    }
333
334    #[test]
335    fn test_attention_stats_fields() {
336        let queries: Vec<Token> = (0..4).map(|i| tok(i, i as f32, 0.0, 0.0, 10)).collect();
337        let keys: Vec<Token> = (10..14)
338            .map(|i| tok(i, (i - 10) as f32 * 0.5, 0.0, 0.0, 5))
339            .collect();
340        let results = sparse_attention(&queries, &keys, 2, 1.0);
341        let stats = attention_stats(&results, keys.len());
342        assert_eq!(stats.n_queries, 4);
343        assert_eq!(stats.total_pairs, 16);
344        assert!(stats.sparsity >= 0.0 && stats.sparsity <= 1.0);
345        assert!(stats.attended_pairs <= stats.total_pairs);
346    }
347}