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}