Skip to main content

qualia_core_db/solvers/
attention.rs

1//! Scaled dot-product attention — the STEM definition of the transformer's attention
2//! operation, as the composition it actually is:
3//!
4//! ```text
5//! Attention(Q, K, V) = softmax( (Q·Kᵀ) · scale ) · V
6//! ```
7//!
8//! There is nothing proprietary here: it is two matrix multiplies
9//! ([`super::linear_algebra::gemm`]) with a row-wise normalized exponential
10//! ([`super::activation::softmax`]) between them. This module is the inspectable home for
11//! that math. The LLM runtime's `cpu_attention_pass` / GPU attention shaders are *backends*
12//! that compute this same function (plus the integrated KV-cache, RoPE and projection
13//! plumbing); `gguf` is only the weight file format.
14//!
15//! Caller-owned, zero internal allocation: the `n_q × n_k` score matrix and the `n_q × d_v`
16//! output are caller-supplied buffers.
17
18use crate::solvers::activation::softmax;
19use crate::solvers::linear_algebra::gemm::{gemm, Transpose};
20use crate::solvers::SolversError;
21
22/// Compute `O = softmax((Q·Kᵀ)·scale) · V`, row-major, caller-owned.
23///
24/// - `q`: `n_q × d`  (queries)
25/// - `k`: `n_k × d`  (keys)
26/// - `v`: `n_k × d_v` (values)
27/// - `scale`: the dot-product scaling (transformers use `1/√d`)
28/// - `causal`: if `true`, query `i` may attend only to keys at position `≤ (n_k − n_q + i)`
29///   (autoregressive masking; for full self-attention `n_q == n_k` this is `j ≤ i`)
30/// - `scores`: scratch + attention weights, length `n_q * n_k` (overwritten)
31/// - `out`: result, length `n_q * d_v` (overwritten)
32///
33/// On return, `scores` holds the row-stochastic attention weights and `out` the context.
34/// Fails closed ([`SolversError::InvalidDimension`]) on any length/shape mismatch.
35#[allow(clippy::too_many_arguments)]
36pub fn scaled_dot_product_attention(
37    n_q: usize,
38    n_k: usize,
39    d: usize,
40    d_v: usize,
41    q: &[f64],
42    k: &[f64],
43    v: &[f64],
44    scale: f64,
45    causal: bool,
46    scores: &mut [f64],
47    out: &mut [f64],
48) -> Result<(), SolversError> {
49    if q.len() != n_q * d
50        || k.len() != n_k * d
51        || v.len() != n_k * d_v
52        || scores.len() != n_q * n_k
53        || out.len() != n_q * d_v
54    {
55        return Err(SolversError::InvalidDimension);
56    }
57    if n_q > n_k {
58        // Causal alignment assumes the queries are the last n_q positions of the n_k keys.
59        return Err(SolversError::InvalidDimension);
60    }
61
62    // 1) scores = (Q · Kᵀ) · scale   — op(K)=Kᵀ since K is stored n_k×d.
63    gemm(
64        Transpose::No,
65        Transpose::Yes,
66        n_q,
67        n_k,
68        d,
69        scale,
70        q,
71        k,
72        0.0,
73        scores,
74    )?;
75
76    // 2) optional causal mask, then row-wise softmax (each query's weights over the keys).
77    for i in 0..n_q {
78        let row = &mut scores[i * n_k..(i + 1) * n_k];
79        if causal {
80            let last_allowed = n_k - n_q + i; // position of query i within the key sequence
81            for j in (last_allowed + 1)..n_k {
82                row[j] = f64::NEG_INFINITY;
83            }
84        }
85        softmax(row);
86    }
87
88    // 3) out = scores · V   — the value-weighted context.
89    gemm(
90        Transpose::No,
91        Transpose::No,
92        n_q,
93        d_v,
94        n_k,
95        1.0,
96        scores,
97        v,
98        0.0,
99        out,
100    )?;
101    Ok(())
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107
108    fn approx(a: &[f64], b: &[f64], tol: f64) {
109        assert_eq!(a.len(), b.len());
110        for i in 0..a.len() {
111            assert!(
112                (a[i] - b[i]).abs() < tol,
113                "idx {i}: {} != {} (tol {tol})",
114                a[i],
115                b[i]
116            );
117        }
118    }
119
120    #[test]
121    fn single_key_returns_that_value() {
122        // One key/value ⇒ softmax over a single score = 1 ⇒ output = V.
123        let q = [1.0, 2.0]; // 1×2
124        let k = [0.5, 0.5]; // 1×2
125        let v = [7.0, -3.0, 9.0]; // 1×3
126        let mut scores = [0.0; 1];
127        let mut out = [0.0; 3];
128        scaled_dot_product_attention(1, 1, 2, 3, &q, &k, &v, 1.0, false, &mut scores, &mut out)
129            .unwrap();
130        approx(&scores, &[1.0], 1e-12);
131        approx(&out, &v, 1e-12);
132    }
133
134    #[test]
135    fn uniform_scores_average_the_values() {
136        // Q=0 ⇒ all scores 0 ⇒ softmax uniform ⇒ output = mean of the value rows.
137        let q = [0.0, 0.0]; // 1×2
138        let k = [1.0, 0.0, 0.0, 1.0]; // 2×2
139        let v = [2.0, 4.0, 6.0, 8.0]; // 2×2  (rows [2,4],[6,8])
140        let mut scores = [0.0; 2];
141        let mut out = [0.0; 2];
142        scaled_dot_product_attention(1, 2, 2, 2, &q, &k, &v, 1.0, false, &mut scores, &mut out)
143            .unwrap();
144        approx(&scores, &[0.5, 0.5], 1e-12);
145        approx(&out, &[4.0, 6.0], 1e-12); // ([2,4]+[6,8])/2
146    }
147
148    #[test]
149    fn matches_hand_computed_softmax_qkt_v() {
150        // Q (1×2), K (2×2), V (2×1); scale = 1. Verify against an independent hand computation.
151        let q = [1.0, 0.0];
152        let k = [1.0, 0.0, 0.0, 1.0]; // rows k0=[1,0], k1=[0,1]
153        let v = [10.0, 20.0]; // v0=10, v1=20
154        let scale = 1.0;
155        let mut scores = [0.0; 2];
156        let mut out = [0.0; 1];
157        scaled_dot_product_attention(1, 2, 2, 1, &q, &k, &v, scale, false, &mut scores, &mut out)
158            .unwrap();
159        // s0 = q·k0 = 1, s1 = q·k1 = 0. softmax([1,0]) = [e/(e+1), 1/(e+1)].
160        let e = std::f64::consts::E;
161        let w0 = e / (e + 1.0);
162        let w1 = 1.0 / (e + 1.0);
163        approx(&scores, &[w0, w1], 1e-12);
164        approx(&out, &[w0 * 10.0 + w1 * 20.0], 1e-12);
165    }
166
167    #[test]
168    fn causal_mask_blocks_future_keys() {
169        // n_q = n_k = 2, causal: query 0 sees only key 0; query 1 sees both.
170        let q = [1.0, 1.0]; // 2×1 queries (each scalar)
171        let k = [1.0, 1.0]; // 2×1 keys
172        let v = [5.0, 9.0]; // 2×1 values
173        let mut scores = [0.0; 4];
174        let mut out = [0.0; 2];
175        scaled_dot_product_attention(2, 2, 1, 1, &q, &k, &v, 1.0, true, &mut scores, &mut out)
176            .unwrap();
177        // Row 0 (query 0): only key 0 allowed ⇒ weight [1, 0] ⇒ out = v0 = 5.
178        approx(&scores[0..2], &[1.0, 0.0], 1e-12);
179        approx(&out[0..1], &[5.0], 1e-12);
180        // Row 1 (query 1): both keys, equal scores ⇒ [0.5, 0.5] ⇒ out = (5+9)/2 = 7.
181        approx(&scores[2..4], &[0.5, 0.5], 1e-12);
182        approx(&out[1..2], &[7.0], 1e-12);
183    }
184
185    #[test]
186    fn rejects_bad_dims() {
187        let q = [1.0];
188        let k = [1.0];
189        let v = [1.0];
190        let mut scores = [0.0; 2]; // wrong: should be 1
191        let mut out = [0.0; 1];
192        assert!(matches!(
193            scaled_dot_product_attention(1, 1, 1, 1, &q, &k, &v, 1.0, false, &mut scores, &mut out),
194            Err(SolversError::InvalidDimension)
195        ));
196    }
197}