Skip to main content

geographdb_core/algorithms/
attention.rs

1use crate::acceleration::cpu_kernels;
2use crate::algorithms::mlp::{cross_entropy_from_logits, MlpClassifier, MlpGradients};
3
4/// Single-head graph-attention classifier.
5///
6/// Each context token attends to itself plus its pre-computed geometric
7/// neighbors.  The attended representation of the *last* context token is
8/// passed through a two-layer MLP head for classification.
9#[derive(Debug, Clone)]
10pub struct GraphAttentionClassifier {
11    vocab_size: usize,
12    embed_dim: usize,
13    num_neighbors: usize,
14    /// Optional RoPE theta. When `Some(theta)`, context-token embeddings are
15    /// rotated by their sequence distance before computing Q/K/V.
16    rope_theta: Option<f32>,
17    /// Token embedding table, row-major `[vocab_size, embed_dim]`.
18    pub embedding: Vec<f32>,
19    /// Query projection, row-major `[embed_dim, embed_dim]`.
20    pub w_q: Vec<f32>,
21    /// Key projection, row-major `[embed_dim, embed_dim]`.
22    pub w_k: Vec<f32>,
23    /// Value projection, row-major `[embed_dim, embed_dim]`.
24    pub w_v: Vec<f32>,
25    /// Learnable raw edge weights, row-major `[vocab_size, num_neighbors]`.
26    /// The actual positive weight is `exp(raw)` so gradients are well behaved
27    /// and weights stay non-negative.  When plasticity is disabled this vector
28    /// is empty.
29    pub edge_weights_raw: Vec<f32>,
30    /// MLP head: embed_dim -> hidden_dim -> output_dim.
31    pub mlp: MlpClassifier,
32    /// When true, attention is restricted to the current token plus its
33    /// geometric neighbors.  This drops the full self-attention term and
34    /// reduces complexity from O(L^2) to O(L * k).  Defaults to false to
35    /// preserve the original hybrid attention behaviour.
36    geometric_attention_only: bool,
37}
38
39/// Gradients for [`GraphAttentionClassifier`].
40#[derive(Debug, Clone)]
41pub struct GraphAttentionGradients {
42    pub dembedding: Vec<f32>,
43    pub dw_q: Vec<f32>,
44    pub dw_k: Vec<f32>,
45    pub dw_v: Vec<f32>,
46    pub dedge_weights_raw: Vec<f32>,
47    pub mlp: MlpGradients,
48}
49
50/// Configuration for the tiebreak residual-correction prototype.
51#[derive(Debug, Clone, Copy)]
52pub struct TiebreakConfig {
53    /// Positions whose maximum geometric attention weight is below this
54    /// threshold are recomputed with full hybrid attention.
55    pub min_max_weight: f32,
56    /// Minimum gap between the top two geometric attention weights.  If the
57    /// gap is smaller than this, the position is marked uncertain.
58    pub min_margin: f32,
59    /// Maximum box-counting dimension of the local geometric-attention signal
60    /// for a position to be kept as geometric-only.  A sharply peaked or smooth
61    /// (low-dimensional) pattern is considered reliable; a spread out or
62    /// rapidly varying (high-dimensional) pattern triggers a full hybrid
63    /// recompute.  `f32::INFINITY` disables the fractal criterion.
64    pub max_fractal_dimension: f32,
65    /// Size of the sliding window used to estimate the fractal dimension of the
66    /// per-position maximum geometric attention weight sequence.
67    pub fractal_window_size: usize,
68}
69
70impl Default for TiebreakConfig {
71    fn default() -> Self {
72        Self {
73            min_max_weight: 0.5,
74            min_margin: 0.1,
75            max_fractal_dimension: f32::INFINITY,
76            fractal_window_size: 8,
77        }
78    }
79}
80
81/// Multiply a row vector `[cols]` by a matrix `[cols, out]` to get `[out]`.
82fn vec_matmul(v: &[f32], cols: usize, m: &[f32], out: usize) -> Vec<f32> {
83    assert_eq!(v.len(), cols);
84    assert_eq!(m.len(), cols * out);
85    let mut res = vec![0.0f32; out];
86    for k in 0..cols {
87        for j in 0..out {
88            res[j] += v[k] * m[k * out + j];
89        }
90    }
91    res
92}
93
94/// Multiply a transposed matrix `[rows, cols]^T` by a column vector `[rows]` to
95/// get `[cols]`.
96fn mat_t_vec(m: &[f32], rows: usize, cols: usize, v: &[f32]) -> Vec<f32> {
97    assert_eq!(m.len(), rows * cols);
98    assert_eq!(v.len(), rows);
99    let mut res = vec![0.0f32; cols];
100    for i in 0..rows {
101        for j in 0..cols {
102            res[j] += m[i * cols + j] * v[i];
103        }
104    }
105    res
106}
107
108fn softmax(scores: &mut [f32]) -> Vec<f32> {
109    cpu_kernels::softmax_in_place(scores);
110    scores.to_vec()
111}
112
113fn top_two(weights: &[f32]) -> (f32, f32) {
114    let mut max = f32::NEG_INFINITY;
115    let mut second = f32::NEG_INFINITY;
116    for &w in weights {
117        if w > max {
118            second = max;
119            max = w;
120        } else if w > second {
121            second = w;
122        }
123    }
124    (max, second)
125}
126
127fn xavier_init(rows: usize, cols: usize, rng: &mut impl FnMut() -> f32) -> Vec<f32> {
128    let scale = (2.0 / (rows + cols) as f32).sqrt();
129    (0..rows * cols).map(|_| rng() * scale).collect()
130}
131
132/// Apply standard RoPE rotation to a single embedding vector in place.
133///
134/// `position` is the 1-based sequence distance from the target (e.g. 1 for the
135/// previous token).  `embed_dim` must be even.
136fn apply_rope_in_place(vec: &mut [f32], embed_dim: usize, position: usize, theta: f32) {
137    cpu_kernels::apply_rope_in_place(vec, embed_dim, position, theta);
138}
139
140/// Apply the inverse RoPE rotation to a single embedding vector in place.
141fn apply_rope_inv_in_place(vec: &mut [f32], embed_dim: usize, position: usize, theta: f32) {
142    cpu_kernels::apply_rope_inv_in_place(vec, embed_dim, position, theta);
143}
144
145impl GraphAttentionClassifier {
146    /// Create a fresh classifier with Xavier-like random weights.
147    ///
148    /// When `plasticity` is `true`, a learnable positive weight is attached to
149    /// each geometric neighbor edge and updated during training.
150    pub fn new(
151        vocab_size: usize,
152        embed_dim: usize,
153        hidden_dim: usize,
154        output_dim: usize,
155        num_neighbors: usize,
156        seed: u32,
157        rope_theta: Option<f32>,
158        plasticity: bool,
159    ) -> Self {
160        if let Some(theta) = rope_theta {
161            assert!(
162                theta > 0.0,
163                "GraphAttentionClassifier: rope_theta must be positive"
164            );
165            assert!(
166                embed_dim % 2 == 0,
167                "GraphAttentionClassifier: RoPE requires even embed_dim"
168            );
169        }
170
171        let mut rng_state = seed;
172        let mut next_rand = || {
173            rng_state ^= rng_state << 13;
174            rng_state ^= rng_state >> 17;
175            rng_state ^= rng_state << 5;
176            (rng_state as f32 / u32::MAX as f32) - 0.5
177        };
178
179        let embedding = xavier_init(vocab_size, embed_dim, &mut next_rand);
180        let w_q = xavier_init(embed_dim, embed_dim, &mut next_rand);
181        let w_k = xavier_init(embed_dim, embed_dim, &mut next_rand);
182        let w_v = xavier_init(embed_dim, embed_dim, &mut next_rand);
183        let mlp = MlpClassifier::new(embed_dim, hidden_dim, output_dim, seed.wrapping_add(1));
184        let edge_weights_raw = if plasticity {
185            // Initialize exp(raw) == 1.0 so training starts from the original
186            // unweighted graph and learns deviations.
187            vec![0.0f32; vocab_size * num_neighbors.max(1)]
188        } else {
189            Vec::new()
190        };
191
192        Self {
193            vocab_size,
194            embed_dim,
195            num_neighbors,
196            rope_theta,
197            embedding,
198            w_q,
199            w_k,
200            w_v,
201            edge_weights_raw,
202            mlp,
203            geometric_attention_only: false,
204        }
205    }
206
207    /// Enable or disable geometric-only attention.
208    ///
209    /// In geometric-only mode each context token attends only to itself and
210    /// its pre-computed geometric neighbors, removing the O(L^2) full
211    /// self-attention term.  This is intended as a fast CPU-native variant
212    /// for long contexts.
213    pub fn set_geometric_attention_only(&mut self, enabled: bool) {
214        self.geometric_attention_only = enabled;
215    }
216
217    fn plasticity_enabled(&self) -> bool {
218        !self.edge_weights_raw.is_empty()
219    }
220
221    fn edge_weight(&self, token_idx: usize, nbr_pos: usize) -> f32 {
222        if !self.plasticity_enabled() {
223            return 1.0f32;
224        }
225        let idx = token_idx * self.num_neighbors + nbr_pos;
226        self.edge_weights_raw[idx.min(self.edge_weights_raw.len() - 1)].exp()
227    }
228
229    /// Build the attended-index list for position `j` and the number of
230    /// entries in that list that count as "context" positions (as opposed to
231    /// geometric neighbours).  Edge weights are only applied to neighbours.
232    fn build_attended_indices(
233        &self,
234        j: usize,
235        n_context: usize,
236        nbrs: &[usize],
237        geometric_only: bool,
238    ) -> (Vec<usize>, usize) {
239        let mut attended = Vec::with_capacity(1 + n_context + self.num_neighbors);
240        attended.push(j);
241        if !geometric_only {
242            for k in 0..n_context {
243                if k != j {
244                    attended.push(k);
245                }
246            }
247        }
248        for &nbr in nbrs.iter().take(self.num_neighbors) {
249            attended.push(nbr);
250        }
251        let n_context_attended = if geometric_only { 1 } else { n_context };
252        (attended, n_context_attended)
253    }
254
255    fn lookup(&self, token_id: u32) -> &[f32] {
256        let idx = (token_id as usize).min(self.vocab_size - 1);
257        &self.embedding[idx * self.embed_dim..(idx + 1) * self.embed_dim]
258    }
259
260    /// Forward pass for a single example.
261    ///
262    /// `token_ids` has length `n_context`.  `neighbors[j]` lists the dense
263    /// neighbor indices for `token_ids[j]` (may be empty).  The token itself is
264    /// implicitly included as the first attended element.
265    pub fn forward(&self, token_ids: &[u32], neighbors: &[&[usize]]) -> Vec<f32> {
266        let h = self.attend(token_ids, neighbors, self.geometric_attention_only);
267        let last = h.last().expect("empty context");
268        self.mlp.forward(last).logits
269    }
270
271    /// Forward pass using the full hybrid attention graph.
272    pub fn forward_hybrid(&self, token_ids: &[u32], neighbors: &[&[usize]]) -> Vec<f32> {
273        let h = self.attend(token_ids, neighbors, false);
274        let last = h.last().expect("empty context");
275        self.mlp.forward(last).logits
276    }
277
278    /// Forward pass using only geometric (neighbor) attention.
279    pub fn forward_geometric_only(&self, token_ids: &[u32], neighbors: &[&[usize]]) -> Vec<f32> {
280        let h = self.attend(token_ids, neighbors, true);
281        let last = h.last().expect("empty context");
282        self.mlp.forward(last).logits
283    }
284
285    /// Forward pass that also returns the MLP hidden state for the last
286    /// context position.  This is useful for diagnosing the FFN head, e.g.
287    /// low-rank approximation experiments that want real hidden-state
288    /// activations rather than random probes.
289    pub fn forward_hidden(
290        &self,
291        token_ids: &[u32],
292        neighbors: &[&[usize]],
293    ) -> (Vec<f32>, Vec<f32>) {
294        let h = self.attend(token_ids, neighbors, self.geometric_attention_only);
295        let last = h.last().expect("empty context");
296        let fwd = self.mlp.forward(last);
297        (fwd.logits, fwd.hidden)
298    }
299
300    /// Hidden state for the last context position without the output projection.
301    /// Used by confidence-gated FFN routing to decide whether to compute the
302    /// cheap low-rank logits or the full dense logits.
303    pub fn forward_hidden_only(&self, token_ids: &[u32], neighbors: &[&[usize]]) -> Vec<f32> {
304        let h = self.attend(token_ids, neighbors, self.geometric_attention_only);
305        let last = h.last().expect("empty context");
306        self.mlp.forward_hidden(last)
307    }
308
309    /// Cross-entropy loss for a single example.
310    pub fn loss(&self, token_ids: &[u32], neighbors: &[&[usize]], target: usize) -> f32 {
311        let logits = self.forward(token_ids, neighbors);
312        cross_entropy_from_logits(&logits, target)
313    }
314
315    /// Predicted class for a single example.
316    pub fn predict(&self, token_ids: &[u32], neighbors: &[&[usize]]) -> usize {
317        let logits = self.forward(token_ids, neighbors);
318        logits
319            .iter()
320            .enumerate()
321            .max_by(|a, b| a.1.partial_cmp(b.1).unwrap())
322            .map(|(i, _)| i)
323            .unwrap_or(0)
324    }
325
326    /// Compute the attended hidden states for all context positions.
327    pub(crate) fn attend(
328        &self,
329        token_ids: &[u32],
330        neighbors: &[&[usize]],
331        geometric_only: bool,
332    ) -> Vec<Vec<f32>> {
333        let n_context = token_ids.len();
334        assert_eq!(neighbors.len(), n_context);
335
336        let d = self.embed_dim;
337        let scale = (d as f32).sqrt();
338
339        // Pre-compute embeddings and RoPE-rotated versions for context tokens.
340        let embeddings: Vec<Vec<f32>> =
341            token_ids.iter().map(|&t| self.lookup(t).to_vec()).collect();
342        let mut rotated_embeddings = embeddings.clone();
343        if let Some(theta) = self.rope_theta {
344            for (j, emb) in rotated_embeddings.iter_mut().enumerate() {
345                let position = n_context - j;
346                apply_rope_in_place(emb, d, position, theta);
347            }
348        }
349
350        let mut hiddens = Vec::with_capacity(n_context);
351
352        for j in 0..n_context {
353            let q = vec_matmul(&rotated_embeddings[j], d, &self.w_q, d);
354
355            // Build attended set: self + (optionally all other context positions)
356            // + geometric neighbors.  In geometric-only mode this becomes a pure
357            // graph-attention step with O(k) complexity per position.
358            let (attended_indices, n_context_attended) =
359                self.build_attended_indices(j, n_context, neighbors[j], geometric_only);
360
361            let mut scores = Vec::with_capacity(attended_indices.len());
362            let mut values = Vec::with_capacity(attended_indices.len());
363
364            for (pos, &idx) in attended_indices.iter().enumerate() {
365                let e_owned: Vec<f32>;
366                let e: &[f32] = if idx < n_context {
367                    // Context tokens use RoPE-rotated embeddings so sequence
368                    // position matters.
369                    &rotated_embeddings[idx]
370                } else {
371                    // Geometric neighbors are not sequence positions; use the
372                    // unrotated embedding.
373                    let dense_idx = idx.min(self.vocab_size - 1);
374                    e_owned = self.lookup(dense_idx as u32).to_vec();
375                    &e_owned
376                };
377                let k = vec_matmul(e, d, &self.w_k, d);
378                let v = vec_matmul(e, d, &self.w_v, d);
379
380                let mut score: f32 =
381                    q.iter().zip(k.iter()).map(|(a, b)| a * b).sum::<f32>() / scale;
382                // Edge weights apply only to the geometric neighbours, which
383                // appear after all context positions in `attended_indices`.
384                if pos >= n_context_attended {
385                    let nbr_pos = pos - n_context_attended;
386                    let edge_w = self.edge_weight(token_ids[j] as usize, nbr_pos);
387                    score *= edge_w;
388                }
389                scores.push(score);
390                values.push(v);
391            }
392
393            let weights = softmax(&mut scores);
394            let mut context = vec![0.0f32; d];
395            for (w, v) in weights.iter().zip(values.iter()) {
396                for i in 0..d {
397                    context[i] += w * v[i];
398                }
399            }
400
401            // Residual update uses the unrotated embedding.
402            let h: Vec<f32> = embeddings[j]
403                .iter()
404                .zip(context.iter())
405                .map(|(e, c)| e + c)
406                .collect();
407            hiddens.push(h);
408        }
409
410        hiddens
411    }
412
413    /// Compute attended hidden states using a per-position attention mode.
414    ///
415    /// `per_position_geometric[j]` selects geometric-only attention for
416    /// position `j`; otherwise the full context is attended.  This is the
417    /// primitive used to measure approximation divergence and to prototype
418    /// residual correction tie-breaks.
419    pub fn attend_mixed(
420        &self,
421        token_ids: &[u32],
422        neighbors: &[&[usize]],
423        per_position_geometric: &[bool],
424    ) -> Vec<Vec<f32>> {
425        let n_context = token_ids.len();
426        assert_eq!(neighbors.len(), n_context);
427        assert_eq!(per_position_geometric.len(), n_context);
428
429        let d = self.embed_dim;
430        let scale = (d as f32).sqrt();
431
432        let embeddings: Vec<Vec<f32>> =
433            token_ids.iter().map(|&t| self.lookup(t).to_vec()).collect();
434        let mut rotated_embeddings = embeddings.clone();
435        if let Some(theta) = self.rope_theta {
436            for (j, emb) in rotated_embeddings.iter_mut().enumerate() {
437                let position = n_context - j;
438                apply_rope_in_place(emb, d, position, theta);
439            }
440        }
441
442        let mut hiddens = Vec::with_capacity(n_context);
443
444        for j in 0..n_context {
445            let q = vec_matmul(&rotated_embeddings[j], d, &self.w_q, d);
446            let geometric_only = per_position_geometric[j];
447            let (attended_indices, n_context_attended) =
448                self.build_attended_indices(j, n_context, neighbors[j], geometric_only);
449
450            let mut scores = Vec::with_capacity(attended_indices.len());
451            let mut values = Vec::with_capacity(attended_indices.len());
452
453            for (pos, &idx) in attended_indices.iter().enumerate() {
454                let e_owned: Vec<f32>;
455                let e: &[f32] = if idx < n_context {
456                    &rotated_embeddings[idx]
457                } else {
458                    let dense_idx = idx.min(self.vocab_size - 1);
459                    e_owned = self.lookup(dense_idx as u32).to_vec();
460                    &e_owned
461                };
462                let k = vec_matmul(e, d, &self.w_k, d);
463                let v = vec_matmul(e, d, &self.w_v, d);
464
465                let mut score: f32 =
466                    q.iter().zip(k.iter()).map(|(a, b)| a * b).sum::<f32>() / scale;
467                if pos >= n_context_attended {
468                    let nbr_pos = pos - n_context_attended;
469                    let edge_w = self.edge_weight(token_ids[j] as usize, nbr_pos);
470                    score *= edge_w;
471                }
472                scores.push(score);
473                values.push(v);
474            }
475
476            let weights = softmax(&mut scores);
477            let mut context = vec![0.0f32; d];
478            for (w, v) in weights.iter().zip(values.iter()) {
479                for i in 0..d {
480                    context[i] += w * v[i];
481                }
482            }
483
484            let h: Vec<f32> = embeddings[j]
485                .iter()
486                .zip(context.iter())
487                .map(|(e, c)| e + c)
488                .collect();
489            hiddens.push(h);
490        }
491
492        hiddens
493    }
494
495    /// Forward pass with a per-position mixed attention mode.
496    pub fn forward_mixed(
497        &self,
498        token_ids: &[u32],
499        neighbors: &[&[usize]],
500        per_position_geometric: &[bool],
501    ) -> Vec<f32> {
502        let h = self.attend_mixed(token_ids, neighbors, per_position_geometric);
503        let last = h.last().expect("empty context");
504        self.mlp.forward(last).logits
505    }
506
507    /// Tiebreak-corrected forward pass.
508    ///
509    /// Runs cheap geometric-only attention, identifies uncertain positions by
510    /// inspecting the geometric attention distribution, then recomputes those
511    /// positions with full hybrid attention via [`Self::forward_mixed`].  This
512    /// is the prototype residual-correction stage for the approximate
513    /// transformer math experiment.
514    pub fn forward_tiebreak(
515        &self,
516        token_ids: &[u32],
517        neighbors: &[&[usize]],
518        config: &TiebreakConfig,
519    ) -> Vec<f32> {
520        let mask = self.tiebreak_mask(token_ids, neighbors, config);
521        self.forward_mixed(token_ids, neighbors, &mask)
522    }
523
524    /// Build the per-position mixed-attention mask used by
525    /// [`Self::forward_tiebreak`] and [`Self::forward_mixed`].
526    ///
527    /// A returned value of `true` means "keep the cheap geometric-only result
528    /// for this position"; `false` means "recompute this position with full
529    /// hybrid attention".  This matches the semantics expected by
530    /// [`Self::forward_mixed`].
531    pub fn tiebreak_mask(
532        &self,
533        token_ids: &[u32],
534        neighbors: &[&[usize]],
535        config: &TiebreakConfig,
536    ) -> Vec<bool> {
537        let n_context = token_ids.len();
538        assert_eq!(neighbors.len(), n_context);
539
540        let d = self.embed_dim;
541        let scale = (d as f32).sqrt();
542
543        let embeddings: Vec<Vec<f32>> =
544            token_ids.iter().map(|&t| self.lookup(t).to_vec()).collect();
545        let mut rotated_embeddings = embeddings.clone();
546        if let Some(theta) = self.rope_theta {
547            for (j, emb) in rotated_embeddings.iter_mut().enumerate() {
548                let position = n_context - j;
549                apply_rope_in_place(emb, d, position, theta);
550            }
551        }
552
553        // First pass: cheap geometric-only attention for every position.
554        // Record classic attention-threshold uncertainty and the maximum
555        // geometric attention weight per position.
556        let mut max_weights = Vec::with_capacity(n_context);
557        let mut attention_uncertain = Vec::with_capacity(n_context);
558        for j in 0..n_context {
559            let q = vec_matmul(&rotated_embeddings[j], d, &self.w_q, d);
560            let (attended, n_context_attended) =
561                self.build_attended_indices(j, n_context, neighbors[j], true);
562
563            let mut scores = Vec::with_capacity(attended.len());
564            for (pos, &idx) in attended.iter().enumerate() {
565                let e_owned: Vec<f32>;
566                let e: &[f32] = if idx < n_context {
567                    &rotated_embeddings[idx]
568                } else {
569                    let dense_idx = idx.min(self.vocab_size - 1);
570                    e_owned = self.lookup(dense_idx as u32).to_vec();
571                    &e_owned
572                };
573                let k = vec_matmul(e, d, &self.w_k, d);
574                let mut score: f32 =
575                    q.iter().zip(k.iter()).map(|(a, b)| a * b).sum::<f32>() / scale;
576                if pos >= n_context_attended {
577                    let nbr_pos = pos - n_context_attended;
578                    let edge_w = self.edge_weight(token_ids[j] as usize, nbr_pos);
579                    score *= edge_w;
580                }
581                scores.push(score);
582            }
583
584            let weights = softmax(&mut scores);
585            let (max, second) = top_two(&weights);
586            max_weights.push(max);
587            attention_uncertain
588                .push(max < config.min_max_weight || (max - second) < config.min_margin);
589        }
590
591        // Second pass: apply the fractal-dimension criterion over a sliding
592        // window of the maximum-weight signal.  A rapidly changing attention
593        // landscape (high local dimension) is treated as unreliable.
594        let mut mask = Vec::with_capacity(n_context);
595        for j in 0..n_context {
596            let start = j.saturating_sub(config.fractal_window_size.saturating_sub(1));
597            let window = &max_weights[start..=j];
598            let fractal_dim = if window.len() >= 4 {
599                super::fractal::box_counting_dimension_1d(window)
600            } else {
601                // Not enough samples: assume line-like complexity so the
602                // threshold must be explicitly set above one to force hybrid.
603                1.0
604            };
605            let uncertain = attention_uncertain[j] || fractal_dim > config.max_fractal_dimension;
606            mask.push(!uncertain);
607        }
608
609        mask
610    }
611
612    /// Forward pass for a batch.
613    pub fn forward_batch(
614        &self,
615        token_ids: &[u32],
616        neighbors: &[Vec<Vec<usize>>],
617        batch_size: usize,
618    ) -> Vec<f32> {
619        let n_context = token_ids.len() / batch_size;
620        let mut h_matrix = Vec::with_capacity(batch_size * self.embed_dim);
621        for b in 0..batch_size {
622            let start = b * n_context;
623            let ids = &token_ids[start..start + n_context];
624            let nbr_refs: Vec<&[usize]> = neighbors[b].iter().map(|v| v.as_slice()).collect();
625            let h = self.attend(ids, &nbr_refs, self.geometric_attention_only);
626            h_matrix.extend_from_slice(h.last().unwrap());
627        }
628        self.mlp
629            .forward_batch(&h_matrix, batch_size)
630            .logits
631            .iter()
632            .copied()
633            .collect()
634    }
635
636    /// Back-propagation for a batch.
637    ///
638    /// `neighbors[b][j]` is the neighbor list for the j-th context token of
639    /// example b.  Returns gradients averaged over the batch and the average
640    /// cross-entropy loss.
641    pub fn backward_batch(
642        &self,
643        token_ids: &[u32],
644        neighbors: &[Vec<Vec<usize>>],
645        targets: &[usize],
646        batch_size: usize,
647    ) -> (GraphAttentionGradients, f32) {
648        let n_context = token_ids.len() / batch_size;
649        let d = self.embed_dim;
650        let scale = (d as f32).sqrt();
651
652        // Forward cache.
653        let mut embeddings_cache: Vec<Vec<Vec<f32>>> = Vec::with_capacity(batch_size);
654        let mut rotated_embeddings_cache: Vec<Vec<Vec<f32>>> = Vec::with_capacity(batch_size);
655        let mut queries_cache: Vec<Vec<Vec<f32>>> = Vec::with_capacity(batch_size);
656        let mut values_cache: Vec<Vec<Vec<Vec<f32>>>> = Vec::with_capacity(batch_size);
657        let mut scores_cache: Vec<Vec<Vec<f32>>> = Vec::with_capacity(batch_size);
658        let mut weights_cache: Vec<Vec<Vec<f32>>> = Vec::with_capacity(batch_size);
659        let mut hiddens_cache: Vec<Vec<Vec<f32>>> = Vec::with_capacity(batch_size);
660        let mut h_matrix: Vec<f32> = Vec::with_capacity(batch_size * d);
661
662        for b in 0..batch_size {
663            let start = b * n_context;
664            let ids = &token_ids[start..start + n_context];
665            let nbr_refs: Vec<&[usize]> = neighbors[b].iter().map(|v| v.as_slice()).collect();
666
667            let embeddings: Vec<Vec<f32>> = ids.iter().map(|&t| self.lookup(t).to_vec()).collect();
668            let mut rotated_embeddings = embeddings.clone();
669            if let Some(theta) = self.rope_theta {
670                for (j, emb) in rotated_embeddings.iter_mut().enumerate() {
671                    let position = n_context - j;
672                    apply_rope_in_place(emb, d, position, theta);
673                }
674            }
675
676            let mut queries = Vec::with_capacity(n_context);
677            let mut batch_values = Vec::with_capacity(n_context);
678            let mut batch_scores = Vec::with_capacity(n_context);
679            let mut batch_weights = Vec::with_capacity(n_context);
680            let mut hiddens = Vec::with_capacity(n_context);
681
682            for j in 0..n_context {
683                let q = vec_matmul(&rotated_embeddings[j], d, &self.w_q, d);
684
685                let (attended, n_context_attended) = self.build_attended_indices(
686                    j,
687                    n_context,
688                    nbr_refs[j],
689                    self.geometric_attention_only,
690                );
691
692                let mut scores = Vec::with_capacity(attended.len());
693                let mut values = Vec::with_capacity(attended.len());
694
695                for (pos, &idx) in attended.iter().enumerate() {
696                    let e_owned: Vec<f32>;
697                    let e: &[f32] = if idx < n_context {
698                        &rotated_embeddings[idx]
699                    } else {
700                        let dense_idx = idx.min(self.vocab_size - 1);
701                        e_owned = self.lookup(dense_idx as u32).to_vec();
702                        &e_owned
703                    };
704                    let k = vec_matmul(e, d, &self.w_k, d);
705                    let v = vec_matmul(e, d, &self.w_v, d);
706                    let mut score = q.iter().zip(k.iter()).map(|(a, b)| a * b).sum::<f32>() / scale;
707                    if pos >= n_context_attended {
708                        let nbr_pos = pos - n_context_attended;
709                        let edge_w = self.edge_weight(ids[j] as usize, nbr_pos);
710                        score *= edge_w;
711                    }
712                    scores.push(score);
713                    values.push(v);
714                }
715
716                let weights = softmax(&mut scores);
717                let mut context = vec![0.0f32; d];
718                for (w, v) in weights.iter().zip(values.iter()) {
719                    for i in 0..d {
720                        context[i] += w * v[i];
721                    }
722                }
723
724                let h: Vec<f32> = embeddings[j]
725                    .iter()
726                    .zip(context.iter())
727                    .map(|(e, c)| e + c)
728                    .collect();
729
730                queries.push(q);
731                batch_values.push(values);
732                batch_scores.push(scores);
733                batch_weights.push(weights);
734                hiddens.push(h.clone());
735                if j == n_context - 1 {
736                    h_matrix.extend_from_slice(&h);
737                }
738            }
739
740            embeddings_cache.push(embeddings);
741            rotated_embeddings_cache.push(rotated_embeddings);
742            queries_cache.push(queries);
743            values_cache.push(batch_values);
744            scores_cache.push(batch_scores);
745            weights_cache.push(batch_weights);
746            hiddens_cache.push(hiddens);
747        }
748
749        // MLP head backward.
750        let (mlp_grad, dh_matrix, loss) = self.mlp.backward_batch(&h_matrix, targets, batch_size);
751
752        // Backprop through attention.
753        let mut dembedding = vec![0.0f32; self.vocab_size * d];
754        let mut dw_q = vec![0.0f32; d * d];
755        let mut dw_k = vec![0.0f32; d * d];
756        let mut dw_v = vec![0.0f32; d * d];
757        let mut dedge_weights_raw = vec![0.0f32; self.edge_weights_raw.len()];
758
759        for b in 0..batch_size {
760            let start = b * n_context;
761            let ids = &token_ids[start..start + n_context];
762
763            for j in 0..n_context {
764                // Only the last context position contributes to the MLP head.
765                let dh = if j == n_context - 1 {
766                    &dh_matrix[b * d..(b + 1) * d]
767                } else {
768                    &[] as &[f32]
769                };
770
771                if dh.is_empty() {
772                    continue;
773                }
774                let dh: Vec<f32> = dh.to_vec();
775
776                let q = &queries_cache[b][j];
777                let values = &values_cache[b][j];
778                let weights = &weights_cache[b][j];
779
780                let (attended, n_context_attended) = self.build_attended_indices(
781                    j,
782                    n_context,
783                    &neighbors[b][j],
784                    self.geometric_attention_only,
785                );
786
787                // dcontext = dh (residual: h = e + context)
788                let dcontext = dh.clone();
789
790                // dvalue_i = weight_i * dcontext
791                // dweight_i = value_i · dcontext
792                let mut dvalues: Vec<Vec<f32>> = Vec::with_capacity(attended.len());
793                let mut dscores = Vec::with_capacity(attended.len());
794                for (w, v) in weights.iter().zip(values.iter()) {
795                    let dv: Vec<f32> = dcontext.iter().map(|&dc| w * dc).collect();
796                    let dw: f32 = v
797                        .iter()
798                        .zip(dcontext.iter())
799                        .map(|(vi, dci)| vi * dci)
800                        .sum();
801                    dvalues.push(dv);
802                    dscores.push(dw);
803                }
804
805                // Backprop through softmax.
806                // dscore_i = weight_i * (dscore_i - Σ_j weight_j * dscore_j)
807                let weighted_sum: f32 = weights
808                    .iter()
809                    .zip(dscores.iter())
810                    .map(|(w, ds)| w * ds)
811                    .sum();
812                let dscores: Vec<f32> = weights
813                    .iter()
814                    .zip(dscores.iter())
815                    .map(|(w, ds)| w * (ds - weighted_sum))
816                    .collect();
817
818                // Backprop through the learnable edge weights.
819                // score_i = (q·k_i / scale) * exp(raw_i), so
820                // draw_i = dscore_i * score_i.
821                if self.plasticity_enabled() {
822                    let scores = &scores_cache[b][j];
823                    let src_idx = ids[j] as usize;
824                    for (pos, (&ds, &score)) in dscores.iter().zip(scores.iter()).enumerate() {
825                        if pos < n_context_attended {
826                            continue;
827                        }
828                        let nbr_pos = pos - n_context_attended;
829                        let edge_idx = src_idx * self.num_neighbors + nbr_pos;
830                        dedge_weights_raw[edge_idx] += ds * score;
831                    }
832                }
833
834                // Backprop through score = q·k / scale.
835                // dq = Σ_i dscore_i * k_i / scale
836                // dk_i = dscore_i * q / scale
837                let mut dq = vec![0.0f32; d];
838                let mut dk_list: Vec<Vec<f32>> = Vec::with_capacity(attended.len());
839                for (ds, _v) in dscores.iter().zip(values.iter()) {
840                    // k_i is not cached; recompute from embedding and w_k below.
841                    let idx = attended[dk_list.len()];
842                    let e_owned: Vec<f32>;
843                    let e: &[f32] = if idx < n_context {
844                        &rotated_embeddings_cache[b][idx]
845                    } else {
846                        let dense_idx = idx.min(self.vocab_size - 1);
847                        e_owned = self.lookup(dense_idx as u32).to_vec();
848                        &e_owned
849                    };
850                    let k = vec_matmul(e, d, &self.w_k, d);
851                    for i in 0..d {
852                        dq[i] += ds * k[i] / scale;
853                    }
854                    let dk: Vec<f32> = q.iter().map(|&qi| ds * qi / scale).collect();
855                    dk_list.push(dk);
856                }
857
858                // Backprop through projections.
859                // de_j += dh (residual) — uses the unrotated embedding.
860                for i in 0..d {
861                    dembedding[ids[j] as usize * d + i] += dh[i];
862                }
863
864                // de_j (rotated) += dq · w_q^T
865                let de_q_rotated = mat_t_vec(&self.w_q, d, d, &dq);
866                // Apply inverse RoPE to get gradient w.r.t. unrotated embedding.
867                let mut de_q = de_q_rotated;
868                if let Some(theta) = self.rope_theta {
869                    let position = n_context - j;
870                    apply_rope_inv_in_place(&mut de_q, d, position, theta);
871                }
872                for i in 0..d {
873                    dembedding[ids[j] as usize * d + i] += de_q[i];
874                }
875
876                // dw_q += rotated_e_j^T · dq
877                for k in 0..d {
878                    for l in 0..d {
879                        dw_q[k * d + l] += rotated_embeddings_cache[b][j][k] * dq[l];
880                    }
881                }
882
883                // For each attended token i:
884                // de_i (rotated or not) += dvalue_i · w_v^T
885                // de_i (rotated or not) += dk_i · w_k^T
886                // dw_k += e_i^T · dk_i
887                // dw_v += e_i^T · dvalue_i
888                for (idx, (dv, dk)) in attended.iter().zip(dvalues.iter().zip(dk_list.iter())) {
889                    let idx = *idx;
890                    let is_context = idx < n_context;
891
892                    let e_unrot_owned: Vec<f32>;
893                    let e_for_proj: &[f32] = if is_context {
894                        &rotated_embeddings_cache[b][idx]
895                    } else {
896                        let dense_idx = idx.min(self.vocab_size - 1);
897                        e_unrot_owned = self.lookup(dense_idx as u32).to_vec();
898                        &e_unrot_owned
899                    };
900
901                    let de_v_rotated = mat_t_vec(&self.w_v, d, d, dv);
902                    let de_k_rotated = mat_t_vec(&self.w_k, d, d, dk);
903
904                    let token_idx = if is_context {
905                        ids[idx] as usize
906                    } else {
907                        idx.min(self.vocab_size - 1)
908                    };
909
910                    // If the attended token is a context token, its K/V used a
911                    // RoPE-rotated embedding, so apply the inverse rotation.
912                    let mut de_v = de_v_rotated;
913                    let mut de_k = de_k_rotated;
914                    if is_context {
915                        if let Some(theta) = self.rope_theta {
916                            let position = n_context - idx;
917                            apply_rope_inv_in_place(&mut de_v, d, position, theta);
918                            apply_rope_inv_in_place(&mut de_k, d, position, theta);
919                        }
920                    }
921
922                    for i in 0..d {
923                        dembedding[token_idx * d + i] += de_v[i] + de_k[i];
924                    }
925
926                    for k in 0..d {
927                        for l in 0..d {
928                            dw_k[k * d + l] += e_for_proj[k] * dk[l];
929                            dw_v[k * d + l] += e_for_proj[k] * dv[l];
930                        }
931                    }
932                }
933            }
934        }
935
936        // Average gradients over batch.
937        let inv_b = 1.0 / batch_size as f32;
938        for v in dembedding.iter_mut() {
939            *v *= inv_b;
940        }
941        for v in dw_q.iter_mut() {
942            *v *= inv_b;
943        }
944        for v in dw_k.iter_mut() {
945            *v *= inv_b;
946        }
947        for v in dw_v.iter_mut() {
948            *v *= inv_b;
949        }
950        for v in dedge_weights_raw.iter_mut() {
951            *v *= inv_b;
952        }
953
954        (
955            GraphAttentionGradients {
956                dembedding,
957                dw_q,
958                dw_k,
959                dw_v,
960                dedge_weights_raw,
961                mlp: mlp_grad,
962            },
963            loss,
964        )
965    }
966
967    /// Apply a single SGD step using the supplied gradients.
968    pub fn apply_sgd(&mut self, grad: &GraphAttentionGradients, lr: f32) {
969        for i in 0..self.embedding.len() {
970            self.embedding[i] -= lr * grad.dembedding[i];
971        }
972        for i in 0..self.w_q.len() {
973            self.w_q[i] -= lr * grad.dw_q[i];
974        }
975        for i in 0..self.w_k.len() {
976            self.w_k[i] -= lr * grad.dw_k[i];
977        }
978        for i in 0..self.w_v.len() {
979            self.w_v[i] -= lr * grad.dw_v[i];
980        }
981        for i in 0..self.edge_weights_raw.len() {
982            self.edge_weights_raw[i] -= lr * grad.dedge_weights_raw[i];
983        }
984        self.mlp.apply_sgd(&grad.mlp, lr);
985    }
986
987    /// Flatten all learnable parameters into one vector, ordered
988    /// `[embedding, w_q, w_k, w_v, edge_weights_raw, mlp]`.
989    pub fn flatten_params(&self) -> Vec<f32> {
990        let mut params = Vec::with_capacity(
991            self.embedding.len()
992                + self.w_q.len()
993                + self.w_k.len()
994                + self.w_v.len()
995                + self.edge_weights_raw.len()
996                + self.mlp.flatten_params().len(),
997        );
998        params.extend_from_slice(&self.embedding);
999        params.extend_from_slice(&self.w_q);
1000        params.extend_from_slice(&self.w_k);
1001        params.extend_from_slice(&self.w_v);
1002        params.extend_from_slice(&self.edge_weights_raw);
1003        params.extend_from_slice(&self.mlp.flatten_params());
1004        params
1005    }
1006
1007    /// Restore parameters from a flat vector produced by [`Self::flatten_params`].
1008    pub fn load_flat_params(&mut self, params: &[f32]) {
1009        let embed_len = self.embedding.len();
1010        let wq_len = self.w_q.len();
1011        let wk_len = self.w_k.len();
1012        let wv_len = self.w_v.len();
1013        let edge_len = self.edge_weights_raw.len();
1014        let mlp_len = self.mlp.flatten_params().len();
1015        let expected = embed_len + wq_len + wk_len + wv_len + edge_len + mlp_len;
1016        assert_eq!(params.len(), expected, "load_flat_params: size mismatch");
1017
1018        let mut off = 0;
1019        self.embedding
1020            .copy_from_slice(&params[off..off + embed_len]);
1021        off += embed_len;
1022        self.w_q.copy_from_slice(&params[off..off + wq_len]);
1023        off += wq_len;
1024        self.w_k.copy_from_slice(&params[off..off + wk_len]);
1025        off += wk_len;
1026        self.w_v.copy_from_slice(&params[off..off + wv_len]);
1027        off += wv_len;
1028        self.edge_weights_raw
1029            .copy_from_slice(&params[off..off + edge_len]);
1030        off += edge_len;
1031        self.mlp.load_flat_params(&params[off..off + mlp_len]);
1032    }
1033}
1034
1035#[cfg(test)]
1036mod tests {
1037    use super::*;
1038
1039    #[test]
1040    fn forward_produces_logits() {
1041        let model = GraphAttentionClassifier::new(10, 8, 16, 3, 2, 1, None, false);
1042        let token_ids = vec![0u32, 1, 2];
1043        let neighbors_owned: Vec<Vec<usize>> = vec![vec![0], vec![1], vec![2]];
1044        let neighbors: Vec<&[usize]> = neighbors_owned.iter().map(|v| v.as_slice()).collect();
1045        let logits = model.forward(&token_ids, &neighbors);
1046        assert_eq!(logits.len(), 3);
1047    }
1048
1049    #[test]
1050    fn learns_simple_task() {
1051        // Task: if the first and second tokens are the same, predict class 0,
1052        // otherwise class 1.
1053        let mut model = GraphAttentionClassifier::new(4, 8, 16, 2, 1, 7, None, false);
1054        let lr = 0.5;
1055
1056        for _ in 0..2000 {
1057            let examples: Vec<(Vec<u32>, Vec<Vec<usize>>, usize)> = vec![
1058                (vec![0, 0, 1], vec![vec![0], vec![1], vec![2]], 0),
1059                (vec![0, 1, 2], vec![vec![0], vec![1], vec![2]], 1),
1060                (vec![1, 1, 0], vec![vec![0], vec![1], vec![2]], 0),
1061                (vec![1, 2, 0], vec![vec![0], vec![1], vec![2]], 1),
1062            ];
1063            for (ids, nbrs, target) in examples {
1064                let (grad, _) = model.backward_batch(&ids, &[nbrs], &[target], 1);
1065                model.apply_sgd(&grad, lr);
1066            }
1067        }
1068
1069        let neighbors_owned: Vec<Vec<usize>> = vec![vec![0], vec![1], vec![2]];
1070        let neighbors: Vec<&[usize]> = neighbors_owned.iter().map(|v| v.as_slice()).collect();
1071        assert_eq!(model.predict(&[0, 0, 1], &neighbors), 0);
1072        assert_eq!(model.predict(&[0, 1, 2], &neighbors), 1);
1073    }
1074
1075    #[test]
1076    fn learns_with_rope() {
1077        // Same task but with RoPE enabled; even embed_dim is required.
1078        let mut model = GraphAttentionClassifier::new(4, 8, 16, 2, 1, 7, Some(10000.0), false);
1079        let lr = 0.5;
1080
1081        for _ in 0..3000 {
1082            let examples: Vec<(Vec<u32>, Vec<Vec<usize>>, usize)> = vec![
1083                (vec![0, 0, 1], vec![vec![0], vec![1], vec![2]], 0),
1084                (vec![0, 1, 2], vec![vec![0], vec![1], vec![2]], 1),
1085                (vec![1, 1, 0], vec![vec![0], vec![1], vec![2]], 0),
1086                (vec![1, 2, 0], vec![vec![0], vec![1], vec![2]], 1),
1087            ];
1088            for (ids, nbrs, target) in examples {
1089                let (grad, _) = model.backward_batch(&ids, &[nbrs], &[target], 1);
1090                model.apply_sgd(&grad, lr);
1091            }
1092        }
1093
1094        let neighbors_owned: Vec<Vec<usize>> = vec![vec![0], vec![1], vec![2]];
1095        let neighbors: Vec<&[usize]> = neighbors_owned.iter().map(|v| v.as_slice()).collect();
1096        assert_eq!(model.predict(&[0, 0, 1], &neighbors), 0);
1097        assert_eq!(model.predict(&[0, 1, 2], &neighbors), 1);
1098    }
1099
1100    #[test]
1101    fn learns_simple_task_geometric_only() {
1102        // Geometric-only attention removes full self-attention but the task
1103        // is small enough that attending to self + one neighbour per position
1104        // still carries enough signal.
1105        let mut model = GraphAttentionClassifier::new(4, 8, 16, 2, 1, 7, None, false);
1106        model.set_geometric_attention_only(true);
1107        let lr = 0.5;
1108
1109        for _ in 0..2000 {
1110            let examples: Vec<(Vec<u32>, Vec<Vec<usize>>, usize)> = vec![
1111                (vec![0, 0, 1], vec![vec![0], vec![1], vec![2]], 0),
1112                (vec![0, 1, 2], vec![vec![0], vec![1], vec![2]], 1),
1113                (vec![1, 1, 0], vec![vec![0], vec![1], vec![2]], 0),
1114                (vec![1, 2, 0], vec![vec![0], vec![1], vec![2]], 1),
1115            ];
1116            for (ids, nbrs, target) in examples {
1117                let (grad, _) = model.backward_batch(&ids, &[nbrs], &[target], 1);
1118                model.apply_sgd(&grad, lr);
1119            }
1120        }
1121
1122        let neighbors_owned: Vec<Vec<usize>> = vec![vec![0], vec![1], vec![2]];
1123        let neighbors: Vec<&[usize]> = neighbors_owned.iter().map(|v| v.as_slice()).collect();
1124        assert_eq!(model.predict(&[0, 0, 1], &neighbors), 0);
1125        assert_eq!(model.predict(&[0, 1, 2], &neighbors), 1);
1126    }
1127}