Skip to main content

ipfrs_tensorlogic/
attention_mechanism.rs

1//! Scaled dot-product attention and multi-head attention for transformer-style models.
2//!
3//! This module provides the fundamental attention building blocks used in modern
4//! transformer architectures (Vaswani et al. 2017 "Attention Is All You Need"):
5//!
6//! * **Scaled dot-product attention** — computes Q·Kᵀ / scale, applies an
7//!   optional boolean mask (e.g. causal / padding), runs row-wise softmax, then
8//!   blends the values: output = softmax(QKᵀ/scale) · V.
9//! * **Multi-head attention wrapper** — splits the embedding dimension across
10//!   `num_heads` independent heads, runs scaled dot-product attention on each,
11//!   then concatenates the results.
12//! * **Causal mask generation** — produces the upper-triangular boolean mask
13//!   required for autoregressive (decoder-style) generation.
14//! * **Primitive matrix operations** — `matmul`, `transpose`, and numerically
15//!   stable row-wise `softmax_1d`, all implemented purely in terms of
16//!   `Vec<Vec<f64>>` with no external linear-algebra dependency.
17//! * **AttentionMatrix** — row-major 2-D matrix with full operator support.
18//! * **AttentionMechanism** — production-grade multi-head attention with learned
19//!   projection matrices, sinusoidal positional encoding, causal masking, entropy
20//!   and peak attention analysis, and a `forward()` pass.
21//!
22//! # Example
23//!
24//! ```rust
25//! use ipfrs_tensorlogic::{AttentionConfig, AttentionMechanism};
26//!
27//! let cfg = AttentionConfig {
28//!     num_heads: 2,
29//!     head_dim: 4,
30//!     dropout_rate: 0.0,
31//!     use_causal_mask: false,
32//! };
33//! let mut attn = AttentionMechanism::new(cfg, 64);
34//!
35//! // 3 tokens, d_model = 8 (num_heads * head_dim)
36//! use ipfrs_tensorlogic::AttentionMatrix;
37//! let input = AttentionMatrix::zeros(3, 8);
38//! let out = attn.forward(&input).expect("example: should succeed in docs");
39//! assert_eq!(out.output.rows, 3);
40//! assert_eq!(out.output.cols, 8);
41//! ```
42
43// ─────────────────────────────────────────────────────────────────────────────
44// Error type
45// ─────────────────────────────────────────────────────────────────────────────
46
47/// Errors that can occur during attention computation.
48#[derive(Debug, Clone)]
49pub enum AttnError {
50    /// A matrix operation received incompatible dimensions.
51    DimensionMismatch {
52        op: String,
53        expected: String,
54        got: String,
55    },
56    /// The input sequence has zero tokens.
57    EmptyInput,
58    /// The `AttentionConfig` is invalid.
59    InvalidConfig(String),
60}
61
62impl std::fmt::Display for AttnError {
63    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64        match self {
65            Self::DimensionMismatch { op, expected, got } => {
66                write!(
67                    f,
68                    "DimensionMismatch in {op}: expected {expected}, got {got}"
69                )
70            }
71            Self::EmptyInput => write!(f, "EmptyInput: sequence length is 0"),
72            Self::InvalidConfig(msg) => write!(f, "InvalidConfig: {msg}"),
73        }
74    }
75}
76
77impl std::error::Error for AttnError {}
78
79// ─────────────────────────────────────────────────────────────────────────────
80// AttentionMatrix — row-major 2-D matrix
81// ─────────────────────────────────────────────────────────────────────────────
82
83/// A row-major 2-D matrix used throughout the attention computation.
84#[derive(Debug, Clone)]
85pub struct AttentionMatrix {
86    /// Flat row-major storage; length == `rows * cols`.
87    pub values: Vec<f64>,
88    /// Number of rows.
89    pub rows: usize,
90    /// Number of columns.
91    pub cols: usize,
92}
93
94impl AttentionMatrix {
95    /// Construct a zero-filled matrix of the given dimensions.
96    pub fn zeros(rows: usize, cols: usize) -> Self {
97        Self {
98            values: vec![0.0; rows * cols],
99            rows,
100            cols,
101        }
102    }
103
104    /// Get the value at `(row, col)`.  Returns `0.0` for out-of-bounds access.
105    #[inline]
106    pub fn get(&self, row: usize, col: usize) -> f64 {
107        if row < self.rows && col < self.cols {
108            self.values[row * self.cols + col]
109        } else {
110            0.0
111        }
112    }
113
114    /// Set the value at `(row, col)`.  No-ops for out-of-bounds.
115    #[inline]
116    pub fn set(&mut self, row: usize, col: usize, v: f64) {
117        if row < self.rows && col < self.cols {
118            self.values[row * self.cols + col] = v;
119        }
120    }
121
122    /// Matrix multiply `a (m×k)` by `b (k×n)` → `m×n`.
123    ///
124    /// Returns an error if inner dimensions do not match.
125    pub fn matmul(a: &AttentionMatrix, b: &AttentionMatrix) -> Result<AttentionMatrix, AttnError> {
126        if a.cols != b.rows {
127            return Err(AttnError::DimensionMismatch {
128                op: "AttentionMatrix::matmul".to_string(),
129                expected: format!("b.rows == {}", a.cols),
130                got: format!("b.rows == {}", b.rows),
131            });
132        }
133        let m = a.rows;
134        let k = a.cols;
135        let n = b.cols;
136        let mut out = AttentionMatrix::zeros(m, n);
137        for i in 0..m {
138            for p in 0..k {
139                let a_val = a.values[i * k + p];
140                if a_val == 0.0 {
141                    continue;
142                }
143                for j in 0..n {
144                    out.values[i * n + j] += a_val * b.values[p * n + j];
145                }
146            }
147        }
148        Ok(out)
149    }
150
151    /// Return the transpose of this matrix: `(rows×cols)` → `(cols×rows)`.
152    pub fn transpose(&self) -> AttentionMatrix {
153        let mut out = AttentionMatrix::zeros(self.cols, self.rows);
154        for r in 0..self.rows {
155            for c in 0..self.cols {
156                out.values[c * self.rows + r] = self.values[r * self.cols + c];
157            }
158        }
159        out
160    }
161
162    /// Apply row-wise softmax with numerically stable max-subtraction.
163    pub fn softmax_rows(&self) -> AttentionMatrix {
164        let mut out = AttentionMatrix::zeros(self.rows, self.cols);
165        for r in 0..self.rows {
166            let start = r * self.cols;
167            let end = start + self.cols;
168            let row = &self.values[start..end];
169            let max_val = row.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
170            let exps: Vec<f64> = row.iter().map(|x| (x - max_val).exp()).collect();
171            let sum: f64 = exps.iter().sum();
172            let denom = if sum == 0.0 { 1.0 } else { sum };
173            for (c, exp_val) in exps.iter().enumerate() {
174                out.values[start + c] = exp_val / denom;
175            }
176        }
177        out
178    }
179
180    /// Add positional encoding rows (element-wise) to the first `seq_len` rows.
181    ///
182    /// Both matrices must have the same number of columns.
183    fn add_pos_enc(&self, pos_enc: &AttentionMatrix) -> Result<AttentionMatrix, AttnError> {
184        if self.cols != pos_enc.cols {
185            return Err(AttnError::DimensionMismatch {
186                op: "add_pos_enc".to_string(),
187                expected: format!("pos_enc.cols == {}", self.cols),
188                got: format!("pos_enc.cols == {}", pos_enc.cols),
189            });
190        }
191        let seq_len = self.rows.min(pos_enc.rows);
192        let mut out = self.clone();
193        for r in 0..seq_len {
194            for c in 0..self.cols {
195                out.values[r * self.cols + c] += pos_enc.values[r * pos_enc.cols + c];
196            }
197        }
198        Ok(out)
199    }
200
201    /// Horizontally concatenate a slice of matrices (all with the same number of rows).
202    ///
203    /// Returns an error if any matrix has a different row count.
204    fn hconcat(mats: &[AttentionMatrix]) -> Result<AttentionMatrix, AttnError> {
205        if mats.is_empty() {
206            return Ok(AttentionMatrix::zeros(0, 0));
207        }
208        let rows = mats[0].rows;
209        let total_cols: usize = mats.iter().map(|m| m.cols).sum();
210        for m in mats.iter().skip(1) {
211            if m.rows != rows {
212                return Err(AttnError::DimensionMismatch {
213                    op: "hconcat".to_string(),
214                    expected: format!("rows == {rows}"),
215                    got: format!("rows == {}", m.rows),
216                });
217            }
218        }
219        let mut out = AttentionMatrix::zeros(rows, total_cols);
220        let mut col_offset = 0usize;
221        for m in mats {
222            for r in 0..rows {
223                for c in 0..m.cols {
224                    out.values[r * total_cols + col_offset + c] = m.values[r * m.cols + c];
225                }
226            }
227            col_offset += m.cols;
228        }
229        Ok(out)
230    }
231}
232
233// ─────────────────────────────────────────────────────────────────────────────
234// Configuration
235// ─────────────────────────────────────────────────────────────────────────────
236
237/// Configuration for the production-grade [`AttentionMechanism`].
238#[derive(Debug, Clone)]
239pub struct AttentionConfig {
240    /// Number of attention heads.
241    pub num_heads: usize,
242    /// Dimension of each head.  `model_dim = num_heads * head_dim`.
243    pub head_dim: usize,
244    /// Dropout rate (stored for future stochastic implementations).
245    pub dropout_rate: f64,
246    /// When `true` a causal upper-triangular mask is applied so that each
247    /// position can only attend to itself and earlier positions.
248    pub use_causal_mask: bool,
249}
250
251impl AttentionConfig {
252    /// Return the full model dimension: `num_heads * head_dim`.
253    pub fn model_dim(&self) -> usize {
254        self.num_heads * self.head_dim
255    }
256}
257
258// ─────────────────────────────────────────────────────────────────────────────
259// AttentionHead
260// ─────────────────────────────────────────────────────────────────────────────
261
262/// A single attention head holding three projection matrices.
263///
264/// Each projection is `[head_dim × model_dim]`.
265#[derive(Debug, Clone)]
266pub struct AttentionHead {
267    /// Query projection `W_Q ∈ ℝ^{head_dim × model_dim}`.
268    pub query_proj: AttentionMatrix,
269    /// Key projection `W_K ∈ ℝ^{head_dim × model_dim}`.
270    pub key_proj: AttentionMatrix,
271    /// Value projection `W_V ∈ ℝ^{head_dim × model_dim}`.
272    pub value_proj: AttentionMatrix,
273}
274
275// ─────────────────────────────────────────────────────────────────────────────
276// AttentionOutput
277// ─────────────────────────────────────────────────────────────────────────────
278
279/// Result of one `forward` pass through [`AttentionMechanism`].
280#[derive(Debug, Clone)]
281pub struct AttentionOutput {
282    /// Final output after concatenation and output projection.
283    /// Shape: `seq_len × model_dim`.
284    pub output: AttentionMatrix,
285    /// Per-head attention weight matrices. Length = `num_heads`.
286    /// Each entry has shape `seq_len × seq_len`.
287    pub attention_weights: Vec<AttentionMatrix>,
288    /// Per-head output matrices before concatenation.  Length = `num_heads`.
289    /// Each entry has shape `seq_len × head_dim`.
290    pub head_outputs: Vec<AttentionMatrix>,
291}
292
293// ─────────────────────────────────────────────────────────────────────────────
294// PositionalEncoding
295// ─────────────────────────────────────────────────────────────────────────────
296
297/// Sinusoidal positional encoding table (Vaswani et al. 2017).
298///
299/// `PE[pos][2i]   = sin(pos / 10000^(2i/d))`
300/// `PE[pos][2i+1] = cos(pos / 10000^(2i/d))`
301#[derive(Debug, Clone)]
302pub struct PositionalEncoding {
303    /// Maximum sequence length supported.
304    pub max_seq_len: usize,
305    /// Dimensionality of the encoding vectors (= `model_dim`).
306    pub encoding_dim: usize,
307    /// Pre-computed encoding table — shape `max_seq_len × encoding_dim`.
308    pub encodings: AttentionMatrix,
309}
310
311impl PositionalEncoding {
312    /// Compute the sinusoidal encoding table.
313    pub fn new(max_seq_len: usize, encoding_dim: usize) -> Self {
314        let mut enc = AttentionMatrix::zeros(max_seq_len, encoding_dim);
315        for pos in 0..max_seq_len {
316            for i in 0..encoding_dim {
317                let half_i = (i / 2) as f64;
318                let denom = 10000_f64.powf(2.0 * half_i / encoding_dim.max(1) as f64);
319                let angle = pos as f64 / denom;
320                let v = if i % 2 == 0 { angle.sin() } else { angle.cos() };
321                enc.set(pos, i, v);
322            }
323        }
324        Self {
325            max_seq_len,
326            encoding_dim,
327            encodings: enc,
328        }
329    }
330
331    /// Extract the first `seq_len` rows as an `AttentionMatrix`.
332    pub fn slice(&self, seq_len: usize) -> AttentionMatrix {
333        let n = seq_len.min(self.max_seq_len);
334        let mut out = AttentionMatrix::zeros(n, self.encoding_dim);
335        for r in 0..n {
336            for c in 0..self.encoding_dim {
337                out.values[r * self.encoding_dim + c] =
338                    self.encodings.values[r * self.encoding_dim + c];
339            }
340        }
341        out
342    }
343}
344
345// ─────────────────────────────────────────────────────────────────────────────
346// AttnStats
347// ─────────────────────────────────────────────────────────────────────────────
348
349/// Runtime statistics for [`AttentionMechanism`].
350#[derive(Debug, Clone)]
351pub struct AttnStats {
352    /// Number of attention heads.
353    pub num_heads: usize,
354    /// Per-head dimensionality.
355    pub head_dim: usize,
356    /// Full model dimension: `num_heads * head_dim`.
357    pub model_dim: usize,
358    /// Number of times `forward` has been called successfully.
359    pub forward_count: u64,
360    /// Maximum sequence length supported by the positional encoding.
361    pub max_seq_len: usize,
362}
363
364// ─────────────────────────────────────────────────────────────────────────────
365// AttentionMechanism — production-grade multi-head attention
366// ─────────────────────────────────────────────────────────────────────────────
367
368/// Production-grade multi-head scaled dot-product attention with sinusoidal
369/// positional encoding, causal masking, learned projection matrices, and
370/// attention pattern analysis utilities.
371pub struct AttentionMechanism {
372    /// Configuration (num_heads, head_dim, …).
373    pub config: AttentionConfig,
374    /// Per-head projection matrices (`W_Q`, `W_K`, `W_V`).
375    pub heads: Vec<AttentionHead>,
376    /// Output projection `W_O ∈ ℝ^{model_dim × model_dim}`.
377    pub output_proj: AttentionMatrix,
378    /// Pre-computed sinusoidal positional encoding.
379    pub pos_enc: PositionalEncoding,
380    /// Monotonically increasing counter of successful `forward` calls.
381    pub forward_count: u64,
382}
383
384impl AttentionMechanism {
385    /// Construct a new `AttentionMechanism` with constant-initialised weights.
386    ///
387    /// All projection matrices are initialised to `1.0 / sqrt(model_dim)` for
388    /// deterministic behaviour (no randomness dependency).
389    pub fn new(config: AttentionConfig, max_seq_len: usize) -> Self {
390        let model_dim = config.model_dim();
391        let head_dim = config.head_dim;
392        let init_val = if model_dim > 0 {
393            1.0 / (model_dim as f64).sqrt()
394        } else {
395            0.0
396        };
397
398        let make_proj = |rows: usize, cols: usize| {
399            let mut m = AttentionMatrix::zeros(rows, cols);
400            for v in m.values.iter_mut() {
401                *v = init_val;
402            }
403            m
404        };
405
406        let heads: Vec<AttentionHead> = (0..config.num_heads)
407            .map(|_| AttentionHead {
408                query_proj: make_proj(head_dim, model_dim),
409                key_proj: make_proj(head_dim, model_dim),
410                value_proj: make_proj(head_dim, model_dim),
411            })
412            .collect();
413
414        let output_proj = make_proj(model_dim, model_dim);
415        let pos_enc = PositionalEncoding::new(max_seq_len, model_dim);
416
417        Self {
418            config,
419            heads,
420            output_proj,
421            pos_enc,
422            forward_count: 0,
423        }
424    }
425
426    /// Return a statistics snapshot.
427    pub fn stats(&self) -> AttnStats {
428        AttnStats {
429            num_heads: self.config.num_heads,
430            head_dim: self.config.head_dim,
431            model_dim: self.config.model_dim(),
432            forward_count: self.forward_count,
433            max_seq_len: self.pos_enc.max_seq_len,
434        }
435    }
436
437    /// Scaled dot-product attention: `softmax(Q·Kᵀ / sqrt(head_dim)) · V`.
438    ///
439    /// # Arguments
440    ///
441    /// * `q` — shape `seq_len × head_dim`.
442    /// * `k` — shape `seq_len × head_dim`.
443    /// * `v` — shape `seq_len × head_dim`.
444    /// * `mask` — optional `seq_len × seq_len` matrix; positions where the
445    ///   value equals `1.0` receive a score of `-1e9` before the softmax.
446    ///
447    /// # Returns
448    ///
449    /// `(output [seq_len × head_dim], weights [seq_len × seq_len])`.
450    pub fn scaled_dot_product(
451        &self,
452        q: &AttentionMatrix,
453        k: &AttentionMatrix,
454        v: &AttentionMatrix,
455        mask: Option<&AttentionMatrix>,
456    ) -> Result<(AttentionMatrix, AttentionMatrix), AttnError> {
457        let seq_len = q.rows;
458        let scale = if self.config.head_dim > 0 {
459            (self.config.head_dim as f64).sqrt()
460        } else {
461            1.0
462        };
463
464        // scores = Q @ K^T  →  seq_len × seq_len
465        let k_t = k.transpose();
466        let mut scores = AttentionMatrix::matmul(q, &k_t)?;
467
468        // Scale and optional masking.
469        for r in 0..seq_len {
470            for c in 0..seq_len {
471                let idx = r * seq_len + c;
472                scores.values[idx] /= scale;
473                if let Some(m) = mask {
474                    if m.get(r, c) == 1.0 {
475                        scores.values[idx] = -1e9;
476                    }
477                }
478            }
479        }
480
481        // Row-wise softmax.
482        let weights = scores.softmax_rows();
483
484        // output = weights @ V  →  seq_len × head_dim
485        let output = AttentionMatrix::matmul(&weights, v)?;
486
487        Ok((output, weights))
488    }
489
490    /// Build a causal (upper-triangular) mask where `mask[i][j] = 1.0` iff `j > i`.
491    pub fn causal_mask(seq_len: usize) -> AttentionMatrix {
492        let mut m = AttentionMatrix::zeros(seq_len, seq_len);
493        for i in 0..seq_len {
494            for j in (i + 1)..seq_len {
495                m.set(i, j, 1.0);
496            }
497        }
498        m
499    }
500
501    /// Run a full multi-head attention forward pass.
502    ///
503    /// 1. Adds sinusoidal positional encoding to the input.
504    /// 2. For each head: projects to Q, K, V; runs scaled dot-product attention.
505    /// 3. Concatenates head outputs horizontally.
506    /// 4. Applies the output projection.
507    /// 5. Increments `forward_count`.
508    pub fn forward(&mut self, input: &AttentionMatrix) -> Result<AttentionOutput, AttnError> {
509        let seq_len = input.rows;
510        if seq_len == 0 {
511            return Err(AttnError::EmptyInput);
512        }
513        let model_dim = self.config.model_dim();
514        if input.cols != model_dim {
515            return Err(AttnError::DimensionMismatch {
516                op: "forward".to_string(),
517                expected: format!("input.cols == {model_dim}"),
518                got: format!("input.cols == {}", input.cols),
519            });
520        }
521
522        // Add positional encoding.
523        let pos_slice = self.pos_enc.slice(seq_len);
524        let x = input.add_pos_enc(&pos_slice)?;
525
526        let mask_opt: Option<AttentionMatrix> = if self.config.use_causal_mask {
527            Some(Self::causal_mask(seq_len))
528        } else {
529            None
530        };
531
532        let mut head_out_list: Vec<AttentionMatrix> = Vec::with_capacity(self.config.num_heads);
533        let mut weight_list: Vec<AttentionMatrix> = Vec::with_capacity(self.config.num_heads);
534
535        for head in &self.heads {
536            // Q = x @ W_Q^T  →  seq_len × head_dim
537            let wq_t = head.query_proj.transpose();
538            let wk_t = head.key_proj.transpose();
539            let wv_t = head.value_proj.transpose();
540
541            let q = AttentionMatrix::matmul(&x, &wq_t)?;
542            let k = AttentionMatrix::matmul(&x, &wk_t)?;
543            let v = AttentionMatrix::matmul(&x, &wv_t)?;
544
545            let (h_out, h_weights) = self.scaled_dot_product(&q, &k, &v, mask_opt.as_ref())?;
546
547            head_out_list.push(h_out);
548            weight_list.push(h_weights);
549        }
550
551        // Concatenate head outputs → seq_len × model_dim.
552        let concat = AttentionMatrix::hconcat(&head_out_list)?;
553
554        // Apply output projection: final = concat @ W_O^T  → seq_len × model_dim.
555        let wo_t = self.output_proj.transpose();
556        let final_output = AttentionMatrix::matmul(&concat, &wo_t)?;
557
558        self.forward_count += 1;
559
560        Ok(AttentionOutput {
561            output: final_output,
562            attention_weights: weight_list,
563            head_outputs: head_out_list,
564        })
565    }
566
567    /// Compute per-row Shannon entropy of an attention weight matrix.
568    ///
569    /// `H[i] = -Σ_j  w[i][j] * log(w[i][j] + 1e-10)`
570    pub fn attention_entropy(weights: &AttentionMatrix) -> Vec<f64> {
571        (0..weights.rows)
572            .map(|r| {
573                let start = r * weights.cols;
574                let end = start + weights.cols;
575                weights.values[start..end]
576                    .iter()
577                    .map(|&w| -w * (w + 1e-10_f64).ln())
578                    .sum()
579            })
580            .collect()
581    }
582
583    /// Return the column index of the maximum weight in each row (argmax).
584    pub fn peak_attention(weights: &AttentionMatrix) -> Vec<usize> {
585        (0..weights.rows)
586            .map(|r| {
587                let start = r * weights.cols;
588                let end = start + weights.cols;
589                weights.values[start..end]
590                    .iter()
591                    .enumerate()
592                    .max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
593                    .map(|(i, _)| i)
594                    .unwrap_or(0)
595            })
596            .collect()
597    }
598}
599
600// ─────────────────────────────────────────────────────────────────────────────
601// Lightweight / simple API (kept for backward compatibility and convenience)
602// ─────────────────────────────────────────────────────────────────────────────
603
604/// Configuration for [`SimpleAttentionMechanism`].
605#[derive(Debug, Clone)]
606pub struct SimpleAttentionConfig {
607    /// Number of attention heads for multi-head attention.
608    pub num_heads: usize,
609    /// Dimension of each individual head.
610    pub head_dim: usize,
611    /// Dropout rate (currently stored for future use; no stochastic drop
612    /// is applied during deterministic inference).
613    pub dropout_rate: f64,
614    /// When `true` a causal (upper-triangular) mask is applied so that each
615    /// position can only attend to itself and earlier positions.
616    pub causal_mask: bool,
617    /// Override the default scale factor `1 / sqrt(head_dim)`.
618    pub scale: Option<f64>,
619}
620
621/// Result of one simple attention computation.
622#[derive(Debug, Clone)]
623pub struct SimpleAttentionOutput {
624    /// Shape: `seq_len × d_model`.
625    pub output: Vec<Vec<f64>>,
626    /// Averaged attention weights across heads. Shape: `seq_len × seq_len`.
627    pub attention_weights: Vec<Vec<f64>>,
628}
629
630/// Running statistics collected by [`SimpleAttentionMechanism`].
631#[derive(Debug, Clone, Default)]
632pub struct SimpleAttentionStats {
633    /// Total number of times [`SimpleAttentionMechanism::attend`] was called.
634    pub total_calls: u64,
635    /// Cumulative token count across all calls (= sum of sequence lengths).
636    pub total_tokens: u64,
637    /// Rolling arithmetic mean of sequence lengths seen so far.
638    pub avg_seq_len: f64,
639}
640
641/// Lightweight multi-head attention engine.
642///
643/// Manages configuration and accumulates runtime statistics. Stateless with
644/// respect to learned weights — callers supply their own Q, K, V projections.
645pub struct SimpleAttentionMechanism {
646    config: SimpleAttentionConfig,
647    stats: SimpleAttentionStats,
648}
649
650impl SimpleAttentionMechanism {
651    /// Construct a new mechanism from the given configuration.
652    pub fn new(config: SimpleAttentionConfig) -> Self {
653        Self {
654            config,
655            stats: SimpleAttentionStats::default(),
656        }
657    }
658
659    /// Return a reference to the accumulated runtime statistics.
660    pub fn stats(&self) -> &SimpleAttentionStats {
661        &self.stats
662    }
663
664    /// Run multi-head attention using the stored configuration.
665    ///
666    /// `queries`, `keys`, and `values` must all have shape
667    /// `seq_len × d_model` where `d_model = num_heads × head_dim`.
668    ///
669    /// The returned [`SimpleAttentionOutput`] contains:
670    /// * `output` — the concatenated per-head results, shape `seq_len × d_model`.
671    /// * `attention_weights` — the **mean** attention weight matrix across all
672    ///   heads, shape `seq_len × seq_len`.
673    pub fn attend(
674        &mut self,
675        queries: &[Vec<f64>],
676        keys: &[Vec<f64>],
677        values: &[Vec<f64>],
678    ) -> SimpleAttentionOutput {
679        let seq_len = queries.len();
680
681        self.stats.total_calls += 1;
682        self.stats.total_tokens += seq_len as u64;
683        let n = self.stats.total_calls as f64;
684        self.stats.avg_seq_len += (seq_len as f64 - self.stats.avg_seq_len) / n;
685
686        if seq_len == 0 {
687            return SimpleAttentionOutput {
688                output: vec![],
689                attention_weights: vec![],
690            };
691        }
692
693        let scale = self
694            .config
695            .scale
696            .unwrap_or_else(|| 1.0 / (self.config.head_dim as f64).sqrt());
697
698        let causal = if self.config.causal_mask {
699            Some(causal_mask(seq_len))
700        } else {
701            None
702        };
703        let mask_ref = causal.as_deref();
704
705        let num_heads = self.config.num_heads;
706        let head_dim = self.config.head_dim;
707        let d_model = num_heads * head_dim;
708
709        let mut head_outputs: Vec<Vec<Vec<f64>>> = Vec::with_capacity(num_heads);
710        let mut weight_sum: Vec<Vec<f64>> = vec![vec![0.0; seq_len]; seq_len];
711
712        for h in 0..num_heads {
713            let col_start = h * head_dim;
714            let col_end = col_start + head_dim;
715
716            let q_h = slice_cols(queries, col_start, col_end);
717            let k_h = slice_cols(keys, col_start, col_end);
718            let v_h = slice_cols(values, col_start, col_end);
719
720            let out_h = scaled_dot_product_attention(&q_h, &k_h, &v_h, scale, mask_ref);
721
722            for (i, row) in weight_sum.iter_mut().enumerate().take(seq_len) {
723                for (j, cell) in row.iter_mut().enumerate().take(seq_len) {
724                    *cell += out_h.attention_weights[i].get(j).copied().unwrap_or(0.0);
725                }
726            }
727
728            head_outputs.push(out_h.output);
729        }
730
731        let n_heads_f = num_heads as f64;
732        let attention_weights: Vec<Vec<f64>> = weight_sum
733            .iter()
734            .map(|row| row.iter().map(|w| w / n_heads_f).collect())
735            .collect();
736
737        let mut output = vec![vec![0.0; d_model]; seq_len];
738        for (h, head_out) in head_outputs.iter().enumerate() {
739            let col_start = h * head_dim;
740            for (i, row) in head_out.iter().enumerate() {
741                for (j, val) in row.iter().enumerate() {
742                    output[i][col_start + j] = *val;
743                }
744            }
745        }
746
747        SimpleAttentionOutput {
748            output,
749            attention_weights,
750        }
751    }
752}
753
754// ─────────────────────────────────────────────────────────────────────────────
755// Free-standing core functions (public utility API)
756// ─────────────────────────────────────────────────────────────────────────────
757
758/// Scaled dot-product attention (free function, `Vec<Vec<f64>>` API).
759///
760/// Computes `softmax(Q · Kᵀ / scale) · V`.
761///
762/// Positions where `mask[i][j] == true` are set to `-1e9` before the softmax.
763pub fn scaled_dot_product_attention(
764    queries: &[Vec<f64>],
765    keys: &[Vec<f64>],
766    values: &[Vec<f64>],
767    scale: f64,
768    mask: Option<&[Vec<bool>]>,
769) -> SimpleAttentionOutput {
770    let seq_len = queries.len();
771    if seq_len == 0 {
772        return SimpleAttentionOutput {
773            output: vec![],
774            attention_weights: vec![],
775        };
776    }
777
778    let k_t = transpose(keys);
779    let mut scores = matmul(queries, &k_t);
780
781    let safe_scale = if scale.abs() < 1e-12 { 1.0 } else { scale };
782    for (i, row) in scores.iter_mut().enumerate().take(seq_len) {
783        for (j, cell) in row.iter_mut().enumerate().take(seq_len) {
784            *cell /= safe_scale;
785            if let Some(m) = mask {
786                if m.get(i).and_then(|r| r.get(j)).copied().unwrap_or(false) {
787                    *cell = -1e9;
788                }
789            }
790        }
791    }
792
793    let attention_weights: Vec<Vec<f64>> = scores.iter().map(|row| softmax_1d(row)).collect();
794    let output = matmul(&attention_weights, values);
795
796    SimpleAttentionOutput {
797        output,
798        attention_weights,
799    }
800}
801
802/// Numerically stable softmax over a 1-D slice of logits.
803///
804/// Uses the max-subtraction trick to avoid floating-point overflow.
805pub fn softmax_1d(logits: &[f64]) -> Vec<f64> {
806    if logits.is_empty() {
807        return vec![];
808    }
809
810    let max_val = logits.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
811
812    let exps: Vec<f64> = logits.iter().map(|x| (x - max_val).exp()).collect();
813    let sum: f64 = exps.iter().sum();
814
815    if sum == 0.0 {
816        let n = logits.len() as f64;
817        return vec![1.0 / n; logits.len()];
818    }
819
820    exps.iter().map(|e| e / sum).collect()
821}
822
823/// Standard matrix multiplication: `C = A · B`.
824///
825/// `A` must be `m × k`, `B` must be `k × n`; returns `m × n`.
826pub fn matmul(a: &[Vec<f64>], b: &[Vec<f64>]) -> Vec<Vec<f64>> {
827    let m = a.len();
828    if m == 0 || b.is_empty() {
829        return vec![];
830    }
831
832    let k = b.len();
833    let n = b.first().map(|r| r.len()).unwrap_or(0);
834
835    let mut result = vec![vec![0.0; n]; m];
836    for i in 0..m {
837        let a_row = &a[i];
838        let a_len = a_row.len().min(k);
839        for p in 0..a_len {
840            let a_val = a_row[p];
841            if a_val == 0.0 {
842                continue;
843            }
844            let b_row = &b[p];
845            let b_len = b_row.len().min(n);
846            for j in 0..b_len {
847                result[i][j] += a_val * b_row[j];
848            }
849        }
850    }
851    result
852}
853
854/// Transpose a 2-D matrix represented as `Vec<Vec<f64>>`.
855pub fn transpose(m: &[Vec<f64>]) -> Vec<Vec<f64>> {
856    let rows = m.len();
857    if rows == 0 {
858        return vec![];
859    }
860
861    let cols = m.iter().map(|r| r.len()).max().unwrap_or(0);
862    if cols == 0 {
863        return vec![];
864    }
865
866    let mut out = vec![vec![0.0; rows]; cols];
867    for (i, row) in m.iter().enumerate() {
868        for (j, val) in row.iter().enumerate() {
869            out[j][i] = *val;
870        }
871    }
872    out
873}
874
875/// Build a causal (autoregressive) boolean mask of size `seq_len × seq_len`.
876///
877/// `mask[i][j] == true` iff `j > i`.
878pub fn causal_mask(seq_len: usize) -> Vec<Vec<bool>> {
879    (0..seq_len)
880        .map(|i| (0..seq_len).map(|j| j > i).collect())
881        .collect()
882}
883
884// ── Private helpers ─────────────────────────────────────────────────────────
885
886fn slice_cols(m: &[Vec<f64>], col_start: usize, col_end: usize) -> Vec<Vec<f64>> {
887    m.iter()
888        .map(|row| {
889            (col_start..col_end)
890                .map(|c| row.get(c).copied().unwrap_or(0.0))
891                .collect()
892        })
893        .collect()
894}
895
896// ─────────────────────────────────────────────────────────────────────────────
897// Tests
898// ─────────────────────────────────────────────────────────────────────────────
899
900#[cfg(test)]
901mod tests {
902    use crate::attention_mechanism::{
903        causal_mask, matmul, scaled_dot_product_attention, softmax_1d, transpose, AttentionConfig,
904        AttentionMatrix, AttentionMechanism, AttnError, PositionalEncoding, SimpleAttentionConfig,
905        SimpleAttentionMechanism,
906    };
907
908    // ── softmax_1d ────────────────────────────────────────────────────────────
909
910    #[test]
911    fn softmax_sums_to_one_uniform() {
912        let logits = vec![1.0, 2.0, 3.0, 4.0];
913        let result = softmax_1d(&logits);
914        let sum: f64 = result.iter().sum();
915        assert!((sum - 1.0).abs() < 1e-12, "softmax sum = {sum}");
916    }
917
918    #[test]
919    fn softmax_sums_to_one_negative_values() {
920        let logits = vec![-100.0, -50.0, -1.0];
921        let result = softmax_1d(&logits);
922        let sum: f64 = result.iter().sum();
923        assert!((sum - 1.0).abs() < 1e-12, "softmax sum = {sum}");
924    }
925
926    #[test]
927    fn softmax_numerical_stability_large_values() {
928        let logits = vec![1e308, 1e308 + 1.0, 1e308 + 2.0];
929        let result = softmax_1d(&logits);
930        let sum: f64 = result.iter().sum();
931        assert!((sum - 1.0).abs() < 1e-12, "softmax sum = {sum}");
932        assert!(result.iter().all(|v| v.is_finite()));
933    }
934
935    #[test]
936    fn softmax_numerical_stability_very_negative() {
937        let logits = vec![-1e308, -1e308, -1e308];
938        let result = softmax_1d(&logits);
939        let sum: f64 = result.iter().sum();
940        assert!((sum - 1.0).abs() < 1e-9, "softmax sum = {sum}");
941    }
942
943    #[test]
944    fn softmax_single_element() {
945        let result = softmax_1d(&[42.0]);
946        assert!((result[0] - 1.0).abs() < 1e-15);
947    }
948
949    #[test]
950    fn softmax_empty() {
951        assert!(softmax_1d(&[]).is_empty());
952    }
953
954    #[test]
955    fn softmax_monotone_order() {
956        let logits = vec![1.0, 3.0, 2.0];
957        let result = softmax_1d(&logits);
958        assert!(result[1] > result[2]);
959        assert!(result[2] > result[0]);
960    }
961
962    // ── matmul ────────────────────────────────────────────────────────────────
963
964    #[test]
965    fn matmul_identity() {
966        let a = vec![vec![1.0, 0.0], vec![0.0, 1.0]];
967        let b = vec![vec![5.0, 6.0], vec![7.0, 8.0]];
968        let c = matmul(&a, &b);
969        assert!((c[0][0] - 5.0).abs() < 1e-15);
970        assert!((c[0][1] - 6.0).abs() < 1e-15);
971        assert!((c[1][0] - 7.0).abs() < 1e-15);
972        assert!((c[1][1] - 8.0).abs() < 1e-15);
973    }
974
975    #[test]
976    fn matmul_known_values() {
977        let a = vec![vec![1.0, 2.0], vec![3.0, 4.0]];
978        let b = vec![vec![5.0, 6.0], vec![7.0, 8.0]];
979        let c = matmul(&a, &b);
980        assert!((c[0][0] - 19.0).abs() < 1e-12);
981        assert!((c[0][1] - 22.0).abs() < 1e-12);
982        assert!((c[1][0] - 43.0).abs() < 1e-12);
983        assert!((c[1][1] - 50.0).abs() < 1e-12);
984    }
985
986    #[test]
987    fn matmul_non_square() {
988        let a = vec![vec![1.0, 0.0, 2.0], vec![0.0, 3.0, 1.0]];
989        let b = vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![2.0, 3.0]];
990        let c = matmul(&a, &b);
991        assert!((c[0][0] - 5.0).abs() < 1e-12);
992        assert!((c[0][1] - 6.0).abs() < 1e-12);
993        assert!((c[1][0] - 2.0).abs() < 1e-12);
994        assert!((c[1][1] - 6.0).abs() < 1e-12);
995    }
996
997    #[test]
998    fn matmul_empty_returns_empty() {
999        let empty: Vec<Vec<f64>> = vec![];
1000        assert!(matmul(&empty, &empty).is_empty());
1001    }
1002
1003    // ── transpose ─────────────────────────────────────────────────────────────
1004
1005    #[test]
1006    fn transpose_square() {
1007        let m = vec![vec![1.0, 2.0], vec![3.0, 4.0]];
1008        let t = transpose(&m);
1009        assert!((t[0][0] - 1.0).abs() < 1e-15);
1010        assert!((t[0][1] - 3.0).abs() < 1e-15);
1011        assert!((t[1][0] - 2.0).abs() < 1e-15);
1012        assert!((t[1][1] - 4.0).abs() < 1e-15);
1013    }
1014
1015    #[test]
1016    fn transpose_rectangular() {
1017        let m = vec![vec![1.0, 2.0, 3.0], vec![4.0, 5.0, 6.0]];
1018        let t = transpose(&m);
1019        assert_eq!(t.len(), 3);
1020        assert_eq!(t[0].len(), 2);
1021        assert!((t[1][1] - 5.0).abs() < 1e-15);
1022        assert!((t[2][0] - 3.0).abs() < 1e-15);
1023    }
1024
1025    #[test]
1026    fn transpose_double_returns_original() {
1027        let m = vec![vec![1.0, 2.0, 3.0], vec![4.0, 5.0, 6.0]];
1028        let tt = transpose(&transpose(&m));
1029        for (r, row) in m.iter().enumerate() {
1030            for (c, val) in row.iter().enumerate() {
1031                assert!((tt[r][c] - val).abs() < 1e-15);
1032            }
1033        }
1034    }
1035
1036    #[test]
1037    fn transpose_empty() {
1038        let empty: Vec<Vec<f64>> = vec![];
1039        assert!(transpose(&empty).is_empty());
1040    }
1041
1042    // ── causal_mask ───────────────────────────────────────────────────────────
1043
1044    #[test]
1045    fn causal_mask_upper_triangle_masked() {
1046        let mask = causal_mask(4);
1047        for (i, row) in mask.iter().enumerate() {
1048            for (j, &masked) in row.iter().enumerate().take(i + 1) {
1049                assert!(!masked, "position ({i},{j}) should NOT be masked");
1050            }
1051        }
1052        for (i, row) in mask.iter().enumerate() {
1053            for (j, &masked) in row.iter().enumerate().skip(i + 1) {
1054                assert!(masked, "position ({i},{j}) should be masked");
1055            }
1056        }
1057    }
1058
1059    #[test]
1060    fn causal_mask_size_one() {
1061        let mask = causal_mask(1);
1062        assert_eq!(mask.len(), 1);
1063        assert!(!mask[0][0]);
1064    }
1065
1066    #[test]
1067    fn causal_mask_dimensions() {
1068        let n = 6;
1069        let mask = causal_mask(n);
1070        assert_eq!(mask.len(), n);
1071        assert!(mask.iter().all(|row| row.len() == n));
1072    }
1073
1074    // ── scaled_dot_product_attention ──────────────────────────────────────────
1075
1076    #[test]
1077    fn sdp_output_shape() {
1078        let q = vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![1.0, 1.0]];
1079        let k = q.clone();
1080        let v = q.clone();
1081        let out = scaled_dot_product_attention(&q, &k, &v, 1.0, None);
1082        assert_eq!(out.output.len(), 3);
1083        assert_eq!(out.output[0].len(), 2);
1084        assert_eq!(out.attention_weights.len(), 3);
1085        assert_eq!(out.attention_weights[0].len(), 3);
1086    }
1087
1088    #[test]
1089    fn sdp_attention_weights_sum_to_one_per_row() {
1090        let q = vec![vec![1.0, 2.0], vec![3.0, 4.0], vec![5.0, 6.0]];
1091        let k = q.clone();
1092        let v = q.clone();
1093        let out = scaled_dot_product_attention(&q, &k, &v, 1.0, None);
1094        for (i, row) in out.attention_weights.iter().enumerate() {
1095            let s: f64 = row.iter().sum();
1096            assert!((s - 1.0).abs() < 1e-12, "row {i} sums to {s}");
1097        }
1098    }
1099
1100    #[test]
1101    fn sdp_causal_mask_suppresses_future() {
1102        let q = vec![vec![1.0], vec![1.0], vec![1.0]];
1103        let k = q.clone();
1104        let v = vec![vec![10.0], vec![20.0], vec![30.0]];
1105        let mask = causal_mask(3);
1106        let out = scaled_dot_product_attention(&q, &k, &v, 1.0, Some(&mask));
1107        assert!(out.attention_weights[0][1] < 1e-6);
1108        assert!(out.attention_weights[0][2] < 1e-6);
1109        assert!(out.attention_weights[2][0] > 1e-6);
1110        assert!(out.attention_weights[2][1] > 1e-6);
1111    }
1112
1113    #[test]
1114    fn sdp_single_token() {
1115        let q = vec![vec![1.0, 2.0, 3.0]];
1116        let k = q.clone();
1117        let v = vec![vec![5.0, 6.0, 7.0]];
1118        let out = scaled_dot_product_attention(&q, &k, &v, 1.0, None);
1119        assert_eq!(out.output.len(), 1);
1120        assert!((out.attention_weights[0][0] - 1.0).abs() < 1e-12);
1121        assert!((out.output[0][0] - 5.0).abs() < 1e-12);
1122    }
1123
1124    // ── SimpleAttentionMechanism ───────────────────────────────────────────────
1125
1126    fn make_simple(heads: usize, head_dim: usize, causal: bool) -> SimpleAttentionMechanism {
1127        SimpleAttentionMechanism::new(SimpleAttentionConfig {
1128            num_heads: heads,
1129            head_dim,
1130            dropout_rate: 0.0,
1131            causal_mask: causal,
1132            scale: None,
1133        })
1134    }
1135
1136    #[test]
1137    fn simple_attend_output_shape() {
1138        let mut attn = make_simple(2, 4, false);
1139        let d_model = 8;
1140        let seq_len = 5;
1141        let q = vec![vec![1.0; d_model]; seq_len];
1142        let out = attn.attend(&q, &q, &q);
1143        assert_eq!(out.output.len(), seq_len);
1144        assert_eq!(out.output[0].len(), d_model);
1145        assert_eq!(out.attention_weights.len(), seq_len);
1146    }
1147
1148    #[test]
1149    fn simple_attend_stats_tracking() {
1150        let mut attn = make_simple(1, 4, false);
1151        let q = vec![vec![1.0; 4]; 3];
1152        attn.attend(&q, &q, &q);
1153        attn.attend(&q, &q, &q);
1154        assert_eq!(attn.stats().total_calls, 2);
1155        assert_eq!(attn.stats().total_tokens, 6);
1156    }
1157
1158    // ── AttentionMatrix ───────────────────────────────────────────────────────
1159
1160    #[test]
1161    fn attn_matrix_zeros() {
1162        let m = AttentionMatrix::zeros(3, 4);
1163        assert_eq!(m.rows, 3);
1164        assert_eq!(m.cols, 4);
1165        assert!(m.values.iter().all(|&v| v == 0.0));
1166    }
1167
1168    #[test]
1169    fn attn_matrix_get_set() {
1170        let mut m = AttentionMatrix::zeros(2, 3);
1171        m.set(0, 1, 7.0);
1172        assert!((m.get(0, 1) - 7.0).abs() < 1e-15);
1173        assert_eq!(m.get(0, 0), 0.0);
1174        // Out-of-bounds get returns 0.0, set is no-op.
1175        assert_eq!(m.get(10, 10), 0.0);
1176        m.set(10, 10, 99.0);
1177    }
1178
1179    #[test]
1180    fn attn_matrix_matmul_correct() {
1181        let mut a = AttentionMatrix::zeros(2, 2);
1182        a.set(0, 0, 1.0);
1183        a.set(0, 1, 2.0);
1184        a.set(1, 0, 3.0);
1185        a.set(1, 1, 4.0);
1186        let mut b = AttentionMatrix::zeros(2, 2);
1187        b.set(0, 0, 5.0);
1188        b.set(0, 1, 6.0);
1189        b.set(1, 0, 7.0);
1190        b.set(1, 1, 8.0);
1191        let c = AttentionMatrix::matmul(&a, &b).expect("test: should succeed");
1192        assert!((c.get(0, 0) - 19.0).abs() < 1e-12);
1193        assert!((c.get(0, 1) - 22.0).abs() < 1e-12);
1194        assert!((c.get(1, 0) - 43.0).abs() < 1e-12);
1195        assert!((c.get(1, 1) - 50.0).abs() < 1e-12);
1196    }
1197
1198    #[test]
1199    fn attn_matrix_matmul_dim_mismatch() {
1200        let a = AttentionMatrix::zeros(2, 3);
1201        let b = AttentionMatrix::zeros(2, 2); // inner dims don't match
1202        let result = AttentionMatrix::matmul(&a, &b);
1203        assert!(matches!(result, Err(AttnError::DimensionMismatch { .. })));
1204    }
1205
1206    #[test]
1207    fn attn_matrix_transpose() {
1208        let mut m = AttentionMatrix::zeros(2, 3);
1209        m.set(0, 0, 1.0);
1210        m.set(0, 1, 2.0);
1211        m.set(0, 2, 3.0);
1212        m.set(1, 0, 4.0);
1213        m.set(1, 1, 5.0);
1214        m.set(1, 2, 6.0);
1215        let t = m.transpose();
1216        assert_eq!(t.rows, 3);
1217        assert_eq!(t.cols, 2);
1218        assert!((t.get(0, 0) - 1.0).abs() < 1e-15);
1219        assert!((t.get(1, 0) - 2.0).abs() < 1e-15);
1220        assert!((t.get(2, 1) - 6.0).abs() < 1e-15);
1221    }
1222
1223    #[test]
1224    fn attn_matrix_softmax_rows_sums_to_one() {
1225        let mut m = AttentionMatrix::zeros(3, 4);
1226        for r in 0..3 {
1227            for c in 0..4 {
1228                m.set(r, c, ((r * 4 + c) as f64) * 0.5);
1229            }
1230        }
1231        let s = m.softmax_rows();
1232        for r in 0..3 {
1233            let row_sum: f64 = (0..4).map(|c| s.get(r, c)).sum();
1234            assert!((row_sum - 1.0).abs() < 1e-12, "row {r} sum = {row_sum}");
1235        }
1236    }
1237
1238    // ── PositionalEncoding ────────────────────────────────────────────────────
1239
1240    #[test]
1241    fn pos_enc_shape() {
1242        let pe = PositionalEncoding::new(64, 8);
1243        assert_eq!(pe.encodings.rows, 64);
1244        assert_eq!(pe.encodings.cols, 8);
1245    }
1246
1247    #[test]
1248    fn pos_enc_position_zero_even_dims_zero() {
1249        // sin(0) = 0 for all even dimensions at position 0.
1250        let pe = PositionalEncoding::new(10, 8);
1251        for i in 0..4 {
1252            let val = pe.encodings.get(0, i * 2);
1253            assert!(val.abs() < 1e-12, "PE[0][{i}*2] = {val}");
1254        }
1255    }
1256
1257    #[test]
1258    fn pos_enc_position_zero_odd_dims_one() {
1259        // cos(0) = 1.0 for all odd dimensions at position 0.
1260        let pe = PositionalEncoding::new(10, 8);
1261        for i in 0..4 {
1262            let val = pe.encodings.get(0, i * 2 + 1);
1263            assert!((val - 1.0).abs() < 1e-12, "PE[0][{i}*2+1] = {val}");
1264        }
1265    }
1266
1267    #[test]
1268    fn pos_enc_slice_correct_rows() {
1269        let pe = PositionalEncoding::new(64, 8);
1270        let sliced = pe.slice(5);
1271        assert_eq!(sliced.rows, 5);
1272        assert_eq!(sliced.cols, 8);
1273    }
1274
1275    #[test]
1276    fn pos_enc_values_bounded() {
1277        // Sinusoidal encodings must lie in [-1, 1].
1278        let pe = PositionalEncoding::new(100, 16);
1279        for v in &pe.encodings.values {
1280            assert!(
1281                *v >= -1.0 - 1e-12 && *v <= 1.0 + 1e-12,
1282                "PE value out of bounds: {v}"
1283            );
1284        }
1285    }
1286
1287    // ── AttentionMechanism (production) ───────────────────────────────────────
1288
1289    fn make_attn(
1290        heads: usize,
1291        head_dim: usize,
1292        causal: bool,
1293        max_len: usize,
1294    ) -> AttentionMechanism {
1295        AttentionMechanism::new(
1296            AttentionConfig {
1297                num_heads: heads,
1298                head_dim,
1299                dropout_rate: 0.0,
1300                use_causal_mask: causal,
1301            },
1302            max_len,
1303        )
1304    }
1305
1306    #[test]
1307    fn attn_config_model_dim() {
1308        let cfg = AttentionConfig {
1309            num_heads: 4,
1310            head_dim: 8,
1311            dropout_rate: 0.0,
1312            use_causal_mask: false,
1313        };
1314        assert_eq!(cfg.model_dim(), 32);
1315    }
1316
1317    #[test]
1318    fn attn_forward_output_shape() {
1319        let mut attn = make_attn(2, 4, false, 64);
1320        let input = AttentionMatrix::zeros(3, 8);
1321        let out = attn.forward(&input).expect("test: should succeed");
1322        assert_eq!(out.output.rows, 3);
1323        assert_eq!(out.output.cols, 8);
1324        assert_eq!(out.attention_weights.len(), 2);
1325        assert_eq!(out.head_outputs.len(), 2);
1326    }
1327
1328    #[test]
1329    fn attn_forward_weight_shape() {
1330        let mut attn = make_attn(3, 4, false, 32);
1331        let input = AttentionMatrix::zeros(5, 12);
1332        let out = attn.forward(&input).expect("test: should succeed");
1333        for w in &out.attention_weights {
1334            assert_eq!(w.rows, 5);
1335            assert_eq!(w.cols, 5);
1336        }
1337    }
1338
1339    #[test]
1340    fn attn_forward_weights_sum_to_one() {
1341        let mut attn = make_attn(2, 4, false, 64);
1342        let mut input = AttentionMatrix::zeros(4, 8);
1343        for i in 0..4 {
1344            for j in 0..8 {
1345                input.set(i, j, (i * 8 + j) as f64 * 0.01);
1346            }
1347        }
1348        let out = attn.forward(&input).expect("test: should succeed");
1349        for (h, w) in out.attention_weights.iter().enumerate() {
1350            for r in 0..w.rows {
1351                let sum: f64 = (0..w.cols).map(|c| w.get(r, c)).sum();
1352                assert!((sum - 1.0).abs() < 1e-10, "head {h} row {r} sum = {sum}");
1353            }
1354        }
1355    }
1356
1357    #[test]
1358    fn attn_forward_increments_count() {
1359        let mut attn = make_attn(1, 4, false, 16);
1360        let input = AttentionMatrix::zeros(2, 4);
1361        attn.forward(&input).expect("test: should succeed");
1362        attn.forward(&input).expect("test: should succeed");
1363        assert_eq!(attn.forward_count, 2);
1364    }
1365
1366    #[test]
1367    fn attn_forward_stats() {
1368        let attn = make_attn(2, 4, false, 32);
1369        let s = attn.stats();
1370        assert_eq!(s.num_heads, 2);
1371        assert_eq!(s.head_dim, 4);
1372        assert_eq!(s.model_dim, 8);
1373        assert_eq!(s.forward_count, 0);
1374        assert_eq!(s.max_seq_len, 32);
1375    }
1376
1377    #[test]
1378    fn attn_forward_empty_input_error() {
1379        let mut attn = make_attn(1, 4, false, 16);
1380        let empty = AttentionMatrix::zeros(0, 4);
1381        let result = attn.forward(&empty);
1382        assert!(matches!(result, Err(AttnError::EmptyInput)));
1383    }
1384
1385    #[test]
1386    fn attn_forward_dim_mismatch_error() {
1387        let mut attn = make_attn(2, 4, false, 16);
1388        // model_dim should be 8, but we pass 6 cols.
1389        let bad = AttentionMatrix::zeros(3, 6);
1390        let result = attn.forward(&bad);
1391        assert!(matches!(result, Err(AttnError::DimensionMismatch { .. })));
1392    }
1393
1394    #[test]
1395    fn attn_forward_causal_mask() {
1396        let mut attn = make_attn(1, 4, true, 16);
1397        let mut input = AttentionMatrix::zeros(4, 4);
1398        for i in 0..4 {
1399            for j in 0..4 {
1400                input.set(i, j, 1.0);
1401            }
1402        }
1403        let out = attn.forward(&input).expect("test: should succeed");
1404        let w = &out.attention_weights[0];
1405        // Token 0 should not attend to future tokens (weights should be near 0).
1406        for j in 1..4 {
1407            assert!(w.get(0, j) < 1e-5, "causal: w[0][{j}] = {}", w.get(0, j));
1408        }
1409    }
1410
1411    #[test]
1412    fn attn_causal_mask_matrix() {
1413        let m = AttentionMechanism::causal_mask(4);
1414        assert_eq!(m.rows, 4);
1415        assert_eq!(m.cols, 4);
1416        for i in 0..4 {
1417            for j in 0..=i {
1418                assert_eq!(m.get(i, j), 0.0, "({i},{j}) should be 0.0");
1419            }
1420            for j in (i + 1)..4 {
1421                assert_eq!(m.get(i, j), 1.0, "({i},{j}) should be 1.0");
1422            }
1423        }
1424    }
1425
1426    #[test]
1427    fn attn_entropy_non_negative() {
1428        let mut attn = make_attn(1, 4, false, 16);
1429        let input = AttentionMatrix::zeros(3, 4);
1430        let out = attn.forward(&input).expect("test: should succeed");
1431        let h = AttentionMechanism::attention_entropy(&out.attention_weights[0]);
1432        assert_eq!(h.len(), 3);
1433        assert!(
1434            h.iter().all(|&e| e >= 0.0),
1435            "entropy must be non-negative: {h:?}"
1436        );
1437    }
1438
1439    #[test]
1440    fn attn_entropy_uniform_distribution_is_max() {
1441        // Uniform distribution has maximum entropy = ln(n).
1442        let mut uniform = AttentionMatrix::zeros(1, 4);
1443        for c in 0..4 {
1444            uniform.set(0, c, 0.25);
1445        }
1446        let h = AttentionMechanism::attention_entropy(&uniform);
1447        let expected = (4_f64).ln();
1448        assert!(
1449            (h[0] - expected).abs() < 0.01,
1450            "entropy = {}, expected ≈ {expected}",
1451            h[0]
1452        );
1453    }
1454
1455    #[test]
1456    fn attn_peak_attention_argmax() {
1457        let mut m = AttentionMatrix::zeros(2, 4);
1458        m.set(0, 2, 1.0); // row 0 peak at col 2
1459        m.set(1, 0, 1.0); // row 1 peak at col 0
1460        let peaks = AttentionMechanism::peak_attention(&m);
1461        assert_eq!(peaks[0], 2);
1462        assert_eq!(peaks[1], 0);
1463    }
1464
1465    #[test]
1466    fn attn_head_count_in_output() {
1467        let num_heads = 4;
1468        let mut attn = make_attn(num_heads, 4, false, 32);
1469        let input = AttentionMatrix::zeros(3, 16);
1470        let out = attn.forward(&input).expect("test: should succeed");
1471        assert_eq!(out.attention_weights.len(), num_heads);
1472        assert_eq!(out.head_outputs.len(), num_heads);
1473    }
1474
1475    #[test]
1476    fn attn_scaled_dot_product_output_shape() {
1477        let attn = make_attn(1, 4, false, 16);
1478        let q = AttentionMatrix::zeros(3, 4);
1479        let k = AttentionMatrix::zeros(3, 4);
1480        let v = AttentionMatrix::zeros(3, 4);
1481        let (out, weights) = attn
1482            .scaled_dot_product(&q, &k, &v, None)
1483            .expect("test: should succeed");
1484        assert_eq!(out.rows, 3);
1485        assert_eq!(out.cols, 4);
1486        assert_eq!(weights.rows, 3);
1487        assert_eq!(weights.cols, 3);
1488    }
1489
1490    #[test]
1491    fn attn_error_display_empty_input() {
1492        let e = AttnError::EmptyInput;
1493        let s = e.to_string();
1494        assert!(s.contains("EmptyInput"));
1495    }
1496
1497    #[test]
1498    fn attn_error_display_dim_mismatch() {
1499        let e = AttnError::DimensionMismatch {
1500            op: "test".to_string(),
1501            expected: "4".to_string(),
1502            got: "8".to_string(),
1503        };
1504        let s = e.to_string();
1505        assert!(s.contains("DimensionMismatch"));
1506    }
1507
1508    #[test]
1509    fn attn_error_display_invalid_config() {
1510        let e = AttnError::InvalidConfig("num_heads must be > 0".to_string());
1511        let s = e.to_string();
1512        assert!(s.contains("InvalidConfig"));
1513    }
1514
1515    #[test]
1516    fn attn_forward_large_sequence() {
1517        let mut attn = make_attn(2, 8, false, 256);
1518        let input = AttentionMatrix::zeros(32, 16);
1519        let out = attn.forward(&input).expect("test: should succeed");
1520        assert_eq!(out.output.rows, 32);
1521        assert_eq!(out.output.cols, 16);
1522    }
1523
1524    #[test]
1525    fn attn_head_output_dim() {
1526        let mut attn = make_attn(3, 5, false, 32);
1527        let input = AttentionMatrix::zeros(4, 15);
1528        let out = attn.forward(&input).expect("test: should succeed");
1529        for h in &out.head_outputs {
1530            assert_eq!(h.rows, 4);
1531            assert_eq!(h.cols, 5);
1532        }
1533    }
1534}