Skip to main content

entrenar/transformer/
attention.rs

1//! Multi-head attention module
2//!
3//! This module provides multi-head self-attention with grouped-query attention support.
4
5use crate::autograd::{matmul, matmul_nt, BackwardOp};
6use crate::Tensor;
7use ndarray::Array1;
8use std::cell::RefCell;
9use std::collections::HashMap;
10use std::rc::Rc;
11
12use super::config::TransformerConfig;
13
14/// Add a bias vector to a projected tensor: output[s] += bias for each sequence position.
15/// Input shape: (seq_len × dim) flattened. Bias shape: (dim).
16fn add_bias(x: &Tensor, bias: &Tensor, seq_len: usize) -> Tensor {
17    let xd = x.data();
18    let x_slice = xd.as_slice().expect("contiguous projection");
19    let bd = bias.data();
20    let b_slice = bd.as_slice().expect("contiguous bias");
21    let dim = b_slice.len();
22    let mut out = Vec::with_capacity(x_slice.len());
23    for s in 0..seq_len {
24        let base = s * dim;
25        for d in 0..dim {
26            out.push(x_slice[base + d] + b_slice[d]);
27        }
28    }
29    Tensor::from_vec(out, x.requires_grad())
30}
31
32/// Apply per-head RMSNorm to Q or K (Qwen3 QK-norm, ENT-269).
33///
34/// Input: [seq_len * total_dim] where total_dim = num_heads * head_dim.
35/// Norm weight: [head_dim]. Applied independently to each head's head_dim slice.
36fn apply_qk_norm(
37    x: &Tensor,
38    norm_weight: &Tensor,
39    seq_len: usize,
40    num_heads: usize,
41    head_dim: usize,
42) -> Tensor {
43    let xd = x.data();
44    let x_slice = xd.as_slice().expect("contiguous qk");
45    let wd = norm_weight.data();
46    let w_slice = wd.as_slice().expect("contiguous norm weight");
47    let total_dim = num_heads * head_dim;
48    let eps = 1e-6_f32;
49    let mut out = vec![0.0f32; seq_len * total_dim];
50
51    for s in 0..seq_len {
52        for h in 0..num_heads {
53            let offset = s * total_dim + h * head_dim;
54            // RMSNorm: x * weight / sqrt(mean(x^2) + eps)
55            let mut sum_sq = 0.0f32;
56            for d in 0..head_dim {
57                let v = x_slice[offset + d];
58                sum_sq += v * v;
59            }
60            let rms = (sum_sq / head_dim as f32 + eps).sqrt();
61            let inv_rms = 1.0 / rms;
62            for d in 0..head_dim {
63                out[offset + d] = x_slice[offset + d] * inv_rms * w_slice[d];
64            }
65        }
66    }
67
68    Tensor::from_vec(out, x.requires_grad())
69}
70
71/// Apply Rotary Position Embedding (RoPE) to Q or K tensor (ENT-269).
72///
73/// Uses Llama/Qwen3 half-rotation layout (NOT interleaved pairs):
74///   x1 = x[..., :half_dim], x2 = x[..., half_dim:]
75///   rotate_half(x) = [-x2, x1]
76///   result = x * cos + rotate_half(x) * sin
77///
78/// freq[i] = 1 / (theta ^ (2i / head_dim))
79fn apply_rope(
80    x: &Tensor,
81    seq_len: usize,
82    num_heads: usize,
83    head_dim: usize,
84    rope_theta: f32,
85) -> Tensor {
86    let xd = x.data();
87    let x_slice = xd.as_slice().expect("contiguous qk for rope");
88    let total_dim = num_heads * head_dim;
89    let half_dim = head_dim / 2;
90    let mut out = vec![0.0f32; seq_len * total_dim];
91
92    // Precompute inverse frequencies: 1 / (theta ^ (2i / head_dim))
93    let inv_freq: Vec<f32> =
94        (0..half_dim).map(|i| 1.0 / rope_theta.powf(2.0 * i as f32 / head_dim as f32)).collect();
95
96    for pos in 0..seq_len {
97        for h in 0..num_heads {
98            let offset = pos * total_dim + h * head_dim;
99            for i in 0..half_dim {
100                let freq = pos as f32 * inv_freq[i];
101                let cos_f = freq.cos();
102                let sin_f = freq.sin();
103                // Half-rotation: pair (x[i], x[i + half_dim])
104                let x_first = x_slice[offset + i];
105                let x_second = x_slice[offset + i + half_dim];
106                // rotate_half: [-x_second, x_first]
107                out[offset + i] = x_first * cos_f - x_second * sin_f;
108                out[offset + i + half_dim] = x_second * cos_f + x_first * sin_f;
109            }
110        }
111    }
112
113    let requires_grad = x.requires_grad();
114    let mut result = Tensor::from_vec(out, requires_grad);
115
116    // PMAT-805: RoPE is a fixed per-position orthogonal rotation. Without a
117    // backward op the returned tensor is an autograd leaf, which SEVERS the
118    // graph for Q/K — gradients never reach the Q/K projections (and the Q
119    // LoRA adapter never trains). Attach the transpose-rotation backward so
120    // the graph stays connected through RoPE.
121    if requires_grad {
122        let backward_op = Rc::new(RopeBackward {
123            x: x.clone(),
124            inv_freq,
125            seq_len,
126            num_heads,
127            head_dim,
128            half_dim,
129            total_dim,
130            result_grad: result.grad_cell(),
131        });
132        result.set_backward_op(backward_op);
133    }
134
135    contract_post_rope!(result.data().as_slice().unwrap_or(&[]));
136    result
137}
138
139/// Backward for [`apply_rope`].
140///
141/// RoPE applies a 2×2 rotation `R(θ)` to each `(x[i], x[i+half])` pair:
142///   out[i]      =  x1·cos − x2·sin
143///   out[i+half] =  x2·cos + x1·sin
144/// The rotation is orthogonal, so the Jacobian-transpose (the gradient) is the
145/// inverse rotation `R(−θ)`:
146///   ∂L/∂x1 =  g[i]·cos + g[i+half]·sin
147///   ∂L/∂x2 = −g[i]·sin + g[i+half]·cos
148struct RopeBackward {
149    x: Tensor,
150    inv_freq: Vec<f32>,
151    seq_len: usize,
152    num_heads: usize,
153    head_dim: usize,
154    half_dim: usize,
155    total_dim: usize,
156    result_grad: Rc<RefCell<Option<Array1<f32>>>>,
157}
158
159impl BackwardOp for RopeBackward {
160    fn backward(&self) {
161        if !self.x.requires_grad() {
162            return;
163        }
164        let Some(grad_out) = self.result_grad.borrow().as_ref().cloned() else { return };
165        let go = grad_out.as_slice().expect("rope grad contiguous");
166        let mut grad_x = vec![0.0f32; self.seq_len * self.total_dim];
167
168        for pos in 0..self.seq_len {
169            for h in 0..self.num_heads {
170                let offset = pos * self.total_dim + h * self.head_dim;
171                for i in 0..self.half_dim {
172                    let freq = pos as f32 * self.inv_freq[i];
173                    let cos_f = freq.cos();
174                    let sin_f = freq.sin();
175                    let g_first = go[offset + i];
176                    let g_second = go[offset + i + self.half_dim];
177                    // Inverse rotation R(-θ) = R(θ)^T
178                    grad_x[offset + i] = g_first * cos_f + g_second * sin_f;
179                    grad_x[offset + i + self.half_dim] = -g_first * sin_f + g_second * cos_f;
180                }
181            }
182        }
183
184        self.x.accumulate_grad(Array1::from(grad_x));
185        if let Some(op) = self.x.backward_op() {
186            op.backward();
187        }
188    }
189}
190
191// ---------------------------------------------------------------------------
192// AttentionBlockBackward: combined backward for multi-head attention
193//
194// Orchestrates gradient flow: concat → per-head attention → Q/K/V projections.
195// Calls each Q/K/V matmul backward exactly once to avoid gradient inflation.
196// ---------------------------------------------------------------------------
197
198struct AttentionBlockBackward {
199    q: Tensor,
200    k: Tensor,
201    v: Tensor,
202    head_q_tensors: Vec<Tensor>,
203    head_k_tensors: Vec<Tensor>,
204    head_v_tensors: Vec<Tensor>,
205    head_outputs: Vec<Tensor>,
206    head_kv_indices: Vec<usize>,
207    seq_len: usize,
208    head_dim: usize,
209    q_dim: usize,
210    kv_hidden_size: usize,
211    result_grad: Rc<RefCell<Option<Array1<f32>>>>,
212}
213
214impl BackwardOp for AttentionBlockBackward {
215    fn backward(&self) {
216        let Some(grad_out) = self.result_grad.borrow().as_ref().cloned() else { return };
217        let go = grad_out.as_slice().expect("grad contiguous");
218        let h = self.head_dim;
219
220        // Step 1: Split concat grad per head and trigger each attention backward
221        split_and_backward_heads(go, &self.head_outputs, self.seq_len, h, self.q_dim);
222
223        // Step 2-4: Scatter per-head grads into full Q/K/V
224        scatter_head_grads_q(&self.q, &self.head_q_tensors, self.seq_len, h, self.q_dim);
225        scatter_head_grads_kv(
226            &self.k,
227            &self.head_k_tensors,
228            &self.head_kv_indices,
229            self.seq_len,
230            h,
231            self.kv_hidden_size,
232        );
233        scatter_head_grads_kv(
234            &self.v,
235            &self.head_v_tensors,
236            &self.head_kv_indices,
237            self.seq_len,
238            h,
239            self.kv_hidden_size,
240        );
241
242        // Step 5: Propagate backward through Q/K/V matmuls (once each)
243        for proj in [&self.q, &self.k, &self.v] {
244            if let Some(op) = proj.backward_op() {
245                op.backward();
246            }
247        }
248    }
249}
250
251/// Split concat gradient per head and trigger each head's attention backward
252fn split_and_backward_heads(
253    go: &[f32],
254    head_outputs: &[Tensor],
255    seq_len: usize,
256    head_dim: usize,
257    q_dim: usize,
258) {
259    for (head_idx, head_out) in head_outputs.iter().enumerate() {
260        let mut grad_head = vec![0.0_f32; seq_len * head_dim];
261        for s in 0..seq_len {
262            let src_base = s * q_dim + head_idx * head_dim;
263            let dst_base = s * head_dim;
264            grad_head[dst_base..dst_base + head_dim]
265                .copy_from_slice(&go[src_base..src_base + head_dim]);
266        }
267        head_out.accumulate_grad(Array1::from(grad_head));
268        if let Some(op) = head_out.backward_op() {
269            op.backward();
270        }
271    }
272}
273
274/// Scatter per-head Q gradients into the full Q projection tensor
275fn scatter_head_grads_q(
276    q: &Tensor,
277    head_q_tensors: &[Tensor],
278    seq_len: usize,
279    head_dim: usize,
280    q_dim: usize,
281) {
282    if !q.requires_grad() {
283        return;
284    }
285    let mut grad_q = vec![0.0_f32; seq_len * q_dim];
286    for (head_idx, head_q) in head_q_tensors.iter().enumerate() {
287        if let Some(hgrad) = head_q.grad() {
288            let hg = hgrad.as_slice().expect("contiguous");
289            for s in 0..seq_len {
290                let src_base = s * head_dim;
291                let dst_base = s * q_dim + head_idx * head_dim;
292                for d in 0..head_dim {
293                    grad_q[dst_base + d] += hg[src_base + d];
294                }
295            }
296        }
297    }
298    q.accumulate_grad(Array1::from(grad_q));
299}
300
301/// Scatter per-head K or V gradients into the full K/V projection tensor (GQA-correct)
302fn scatter_head_grads_kv(
303    target: &Tensor,
304    head_tensors: &[Tensor],
305    kv_indices: &[usize],
306    seq_len: usize,
307    head_dim: usize,
308    kv_hidden_size: usize,
309) {
310    if !target.requires_grad() {
311        return;
312    }
313    let mut grad = vec![0.0_f32; seq_len * kv_hidden_size];
314    for (head_idx, head_t) in head_tensors.iter().enumerate() {
315        let kv_h = kv_indices[head_idx];
316        if let Some(hgrad) = head_t.grad() {
317            let hg = hgrad.as_slice().expect("contiguous");
318            for s in 0..seq_len {
319                let src_base = s * head_dim;
320                let dst_base = s * kv_hidden_size + kv_h * head_dim;
321                for d in 0..head_dim {
322                    grad[dst_base + d] += hg[src_base + d];
323                }
324            }
325        }
326    }
327    target.accumulate_grad(Array1::from(grad));
328}
329
330/// Multi-head self-attention layer
331pub struct MultiHeadAttention {
332    /// Configuration
333    config: TransformerConfig,
334    /// Query projection weight (hidden_size x hidden_size)
335    pub w_q: Tensor,
336    /// Key projection weight (hidden_size x kv_hidden_size)
337    pub w_k: Tensor,
338    /// Value projection weight (hidden_size x kv_hidden_size)
339    pub w_v: Tensor,
340    /// Output projection weight (hidden_size x hidden_size)
341    pub w_o: Tensor,
342    /// Optional query bias (Qwen2 uses attention biases)
343    pub b_q: Option<Tensor>,
344    /// Optional key bias
345    pub b_k: Option<Tensor>,
346    /// Optional value bias
347    pub b_v: Option<Tensor>,
348    /// Optional Q RMSNorm weight (Qwen3 uses QK-norm, shape=[head_dim])
349    pub q_norm: Option<Tensor>,
350    /// Optional K RMSNorm weight (Qwen3 uses QK-norm, shape=[head_dim])
351    pub k_norm: Option<Tensor>,
352}
353
354impl MultiHeadAttention {
355    /// Create new attention layer with initialized weights.
356    ///
357    /// When `config.use_bias == true` (Qwen2 family), allocates Q/K/V
358    /// projection biases as zero tensors. The forward pass already
359    /// honors `Option<Tensor>` biases (lines 388-395 add via
360    /// `add_bias` when `Some`); without allocating them here, biases
361    /// stay `None` and `populate_trainer_from_init_tensors` silently
362    /// drops the corresponding init tensors during fine-tune from a
363    /// Qwen APR checkpoint — see FALSIFY-APR-PRETRAIN-INIT-POPULATE-
364    /// COVERAGE-001/002 in `transformer::config::tests` for the
365    /// 290-vs-218 named-parameters gap that surfaced this bug.
366    ///
367    /// Zero-init for biases matches HuggingFace LLaMA / Qwen
368    /// convention (PyTorch `nn.Linear(bias=True)` initializes the
369    /// weight with `kaiming_uniform_` but the bias as the all-zeros
370    /// tensor — see `torch.nn.modules.linear.Linear.reset_parameters`).
371    pub fn new(config: &TransformerConfig) -> Self {
372        use super::init::{get_init_seed, rand_normal_seeded};
373        let hidden_size = config.hidden_size;
374        let q_dim = config.q_dim();
375        let kv_hidden_size = config.num_kv_heads * config.head_dim();
376        let seed = get_init_seed();
377
378        let (b_q, b_k, b_v) = if config.use_bias {
379            (
380                Some(Tensor::from_vec(vec![0.0_f32; q_dim], true)),
381                Some(Tensor::from_vec(vec![0.0_f32; kv_hidden_size], true)),
382                Some(Tensor::from_vec(vec![0.0_f32; kv_hidden_size], true)),
383            )
384        } else {
385            (None, None, None)
386        };
387
388        // C-INIT-001: normal(0, 0.02) matching HuggingFace LLaMA
389        Self {
390            config: config.clone(),
391            w_q: Tensor::from_vec(rand_normal_seeded(q_dim * hidden_size, seed, "w_q"), true),
392            w_k: Tensor::from_vec(
393                rand_normal_seeded(kv_hidden_size * hidden_size, seed, "w_k"),
394                true,
395            ),
396            w_v: Tensor::from_vec(
397                rand_normal_seeded(kv_hidden_size * hidden_size, seed, "w_v"),
398                true,
399            ),
400            w_o: Tensor::from_vec(rand_normal_seeded(hidden_size * q_dim, seed, "w_o"), true),
401            b_q,
402            b_k,
403            b_v,
404            q_norm: None,
405            k_norm: None,
406        }
407    }
408
409    /// Create attention layer from parameter map
410    ///
411    /// Expected parameter names (following HuggingFace convention):
412    /// - `{prefix}.q_proj.weight`
413    /// - `{prefix}.k_proj.weight`
414    /// - `{prefix}.v_proj.weight`
415    /// - `{prefix}.o_proj.weight`
416    /// # Contract (PMAT-331)
417    /// Validates Q/K/V/O projection shapes against config dimensions.
418    /// Returns None if any key is missing or shape is wrong.
419    pub fn from_params(
420        config: &TransformerConfig,
421        params: &HashMap<String, Tensor>,
422        prefix: &str,
423    ) -> Option<Self> {
424        let w_q = params.get(&format!("{prefix}.q_proj.weight"))?.clone();
425        let w_k = params.get(&format!("{prefix}.k_proj.weight"))?.clone();
426        let w_v = params.get(&format!("{prefix}.v_proj.weight"))?.clone();
427        let w_o = params.get(&format!("{prefix}.o_proj.weight"))?.clone();
428
429        let hidden = config.hidden_size;
430        let q_dim = config.q_dim();
431        let kv_hidden = config.num_kv_heads * config.head_dim();
432
433        // PMAT-331: Shape validation for attention projections
434        // Q: [q_dim, hidden], K: [kv_hidden, hidden], V: [kv_hidden, hidden], O: [hidden, q_dim]
435        let checks: &[(&str, &Tensor, usize)] = &[
436            ("q_proj", &w_q, q_dim * hidden),
437            ("k_proj", &w_k, kv_hidden * hidden),
438            ("v_proj", &w_v, kv_hidden * hidden),
439            ("o_proj", &w_o, hidden * q_dim),
440        ];
441        for &(name, tensor, expected) in checks {
442            if tensor.len() != expected {
443                eprintln!(
444                    "[PMAT-331] {prefix}.{name}: shape mismatch — got {} elements, expected {expected}",
445                    tensor.len()
446                );
447                return None;
448            }
449        }
450
451        // Optional attention biases (Qwen2 uses Q/K/V biases)
452        let b_q = params.get(&format!("{prefix}.q_proj.bias")).cloned();
453        let b_k = params.get(&format!("{prefix}.k_proj.bias")).cloned();
454        let b_v = params.get(&format!("{prefix}.v_proj.bias")).cloned();
455
456        // Optional Q/K RMSNorm (Qwen3 uses QK-norm, ENT-269)
457        let q_norm = params.get(&format!("{prefix}.q_norm.weight")).cloned();
458        let k_norm = params.get(&format!("{prefix}.k_norm.weight")).cloned();
459
460        Some(Self { config: config.clone(), w_q, w_k, w_v, w_o, b_q, b_k, b_v, q_norm, k_norm })
461    }
462
463    /// Forward pass
464    ///
465    /// # Arguments
466    /// * `x` - Input tensor (seq_len * hidden_size, flattened)
467    /// * `seq_len` - Sequence length
468    ///
469    /// # Returns
470    /// Output tensor (seq_len * hidden_size, flattened)
471    pub fn forward(&self, x: &Tensor, seq_len: usize) -> Tensor {
472        contract_pre_attention!(x.data());
473        let hidden_size = self.config.hidden_size;
474        let num_heads = self.config.num_attention_heads;
475        let num_kv_heads = self.config.num_kv_heads;
476        let head_dim = self.config.head_dim();
477        let q_dim = self.config.q_dim();
478        let kv_hidden_size = num_kv_heads * head_dim;
479
480        // Project Q, K, V — HF weights are [out_dim, in_dim], use matmul_nt (ENT-269)
481        let mut q = matmul_nt(x, &self.w_q, seq_len, hidden_size, q_dim);
482        let mut k = matmul_nt(x, &self.w_k, seq_len, hidden_size, kv_hidden_size);
483        let mut v = matmul_nt(x, &self.w_v, seq_len, hidden_size, kv_hidden_size);
484
485        // Apply attention biases if present (Qwen2 architecture)
486        if let Some(ref b_q) = self.b_q {
487            q = add_bias(&q, b_q, seq_len);
488        }
489        if let Some(ref b_k) = self.b_k {
490            k = add_bias(&k, b_k, seq_len);
491        }
492        if let Some(ref b_v) = self.b_v {
493            v = add_bias(&v, b_v, seq_len);
494        }
495
496        // Apply Q/K RMSNorm if present (Qwen3 QK-norm, ENT-269)
497        if let Some(ref qn) = self.q_norm {
498            q = apply_qk_norm(&q, qn, seq_len, num_heads, head_dim);
499        }
500        if let Some(ref kn) = self.k_norm {
501            k = apply_qk_norm(&k, kn, seq_len, num_kv_heads, head_dim);
502        }
503
504        // Apply Rotary Position Embedding (RoPE) to Q and K (ENT-269)
505        // Skip for encoder models (BERT/RoBERTa use learned positions, not RoPE)
506        if self.config.rope_theta > 0.0 {
507            q = apply_rope(&q, seq_len, num_heads, head_dim, self.config.rope_theta);
508            k = apply_rope(&k, seq_len, num_kv_heads, head_dim, self.config.rope_theta);
509        }
510
511        let requires_grad = q.requires_grad() || k.requires_grad() || v.requires_grad();
512        let heads_per_kv = num_heads / num_kv_heads;
513
514        // KAIZEN-016: Hoist data borrows outside the head loop to avoid
515        // num_heads × seq_len × 3 redundant RefCell borrows per attention call.
516        let q_data = q.data();
517        let q_slice = q_data.as_slice().expect("contiguous Q");
518        let k_data = k.data();
519        let k_slice = k_data.as_slice().expect("contiguous K");
520        let v_data = v.data();
521        let v_slice = v_data.as_slice().expect("contiguous V");
522
523        // Per-head attention with gradient tracking
524        let mut head_q_tensors = Vec::with_capacity(num_heads);
525        let mut head_k_tensors = Vec::with_capacity(num_heads);
526        let mut head_v_tensors = Vec::with_capacity(num_heads);
527        let mut head_outputs = Vec::with_capacity(num_heads);
528        let mut head_kv_indices = Vec::with_capacity(num_heads);
529
530        for h in 0..num_heads {
531            let kv_h = h / heads_per_kv;
532            head_kv_indices.push(kv_h);
533
534            // KAIZEN-016: Use extend_from_slice instead of flat_map+to_vec.
535            // Eliminates num_heads × 3 × seq_len intermediate Vec allocations
536            // (3.5M allocs/forward for Qwen3-4B).
537            let mut q_head = Vec::with_capacity(seq_len * head_dim);
538            for s in 0..seq_len {
539                let start = s * q_dim + h * head_dim;
540                q_head.extend_from_slice(&q_slice[start..start + head_dim]);
541            }
542
543            let mut k_head = Vec::with_capacity(seq_len * head_dim);
544            for s in 0..seq_len {
545                let start = s * kv_hidden_size + kv_h * head_dim;
546                k_head.extend_from_slice(&k_slice[start..start + head_dim]);
547            }
548
549            let mut v_head = Vec::with_capacity(seq_len * head_dim);
550            for s in 0..seq_len {
551                let start = s * kv_hidden_size + kv_h * head_dim;
552                v_head.extend_from_slice(&v_slice[start..start + head_dim]);
553            }
554
555            let q_tensor = Tensor::from_vec(q_head, requires_grad);
556            let k_tensor = Tensor::from_vec(k_head, requires_grad);
557            let v_tensor = Tensor::from_vec(v_head, requires_grad);
558
559            let attn_out = crate::autograd::attention(
560                &q_tensor, &k_tensor, &v_tensor, seq_len, head_dim, seq_len, head_dim,
561            );
562
563            head_q_tensors.push(q_tensor);
564            head_k_tensors.push(k_tensor);
565            head_v_tensors.push(v_tensor);
566            head_outputs.push(attn_out);
567        }
568
569        // Concatenate heads: reorder from per-head (head, seq, dim) to (seq, head*dim)
570        let mut concat_output = vec![0.0; seq_len * q_dim];
571        for (h, head_out) in head_outputs.iter().enumerate() {
572            let hd = head_out.data();
573            let hdata = hd.as_slice().expect("contiguous attention output");
574            for s in 0..seq_len {
575                let src_base = s * head_dim;
576                let dst_base = s * q_dim + h * head_dim;
577                concat_output[dst_base..dst_base + head_dim]
578                    .copy_from_slice(&hdata[src_base..src_base + head_dim]);
579            }
580        }
581
582        let mut concat_tensor = Tensor::from_vec(concat_output, requires_grad);
583
584        if requires_grad {
585            let backward_op = Rc::new(AttentionBlockBackward {
586                q: q.clone(),
587                k: k.clone(),
588                v: v.clone(),
589                head_q_tensors,
590                head_k_tensors,
591                head_v_tensors,
592                head_outputs,
593                head_kv_indices,
594                seq_len,
595                head_dim,
596                q_dim,
597                kv_hidden_size,
598                result_grad: concat_tensor.grad_cell(),
599            });
600            concat_tensor.set_backward_op(backward_op);
601        }
602
603        // Output projection — w_o is [hidden_size, q_dim] in HF (ENT-269)
604        let result = matmul_nt(&concat_tensor, &self.w_o, seq_len, q_dim, hidden_size);
605        contract_post_attention!(result.data().as_slice().unwrap_or(&[]));
606        result
607    }
608
609    /// Forward pass with LoRA adjusts on Q and V projections (KAIZEN-010).
610    ///
611    /// Applies LoRA adapters to Q and V during the forward pass so that
612    /// gradients flow through LoRA A/B matrices on non-CUDA paths.
613    ///
614    /// # Arguments
615    /// * `x` - Input tensor (seq_len * hidden_size)
616    /// * `seq_len` - Sequence length
617    /// * `lora_a_q`, `lora_b_q` - Q projection LoRA matrices (rank×d_in, d_out×rank)
618    /// * `lora_a_v`, `lora_b_v` - V projection LoRA matrices (rank×d_in, d_out×rank)
619    /// * `lora_rank` - LoRA rank
620    /// * `lora_scale` - LoRA scaling factor (alpha/rank)
621    pub fn forward_with_lora(
622        &self,
623        x: &Tensor,
624        seq_len: usize,
625        lora_a_q: &Tensor,
626        // contract_pre_attention applied via forward()
627        lora_b_q: &Tensor,
628        lora_a_v: &Tensor,
629        lora_b_v: &Tensor,
630        lora_rank: usize,
631        lora_scale: f32,
632    ) -> Tensor {
633        contract_pre_lora_forward!();
634        let hidden_size = self.config.hidden_size;
635        let num_heads = self.config.num_attention_heads;
636        let num_kv_heads = self.config.num_kv_heads;
637        let head_dim = self.config.head_dim();
638        let q_dim = self.config.q_dim();
639        let kv_hidden_size = num_kv_heads * head_dim;
640
641        // Q projection with LoRA: Q = x @ W_q + scale * (x @ A_q^T) @ B_q^T
642        //
643        // KAIZEN-011: Use matmul_nt to compute x @ A^T directly on the ORIGINAL
644        // LoRA tensors. Previous impl created transposed copies via Tensor::from_vec
645        // which broke gradient flow — gradients accumulated on ephemeral copies
646        // instead of the actual trainable LoRA parameters.
647        //
648        // LoRA layout: A is (rank, d_in), B is (d_out, rank)
649        // matmul_nt(x, A, seq, d_in, rank) computes x @ A^T = (seq, d_in) @ (d_in, rank) = (seq, rank)
650        // matmul_nt(mid, B, seq, rank, d_out) computes mid @ B^T = (seq, rank) @ (rank, d_out) = (seq, d_out)
651        let q_base = matmul_nt(x, &self.w_q, seq_len, hidden_size, q_dim);
652        let q_mid = crate::autograd::matmul_nt(x, lora_a_q, seq_len, hidden_size, lora_rank);
653        let q_lora = crate::autograd::matmul_nt(&q_mid, lora_b_q, seq_len, lora_rank, q_dim);
654        let q = crate::autograd::add_scaled(&q_base, &q_lora, lora_scale);
655
656        // K projection (no LoRA) — HF weights [out, in] (ENT-269)
657        let k = matmul_nt(x, &self.w_k, seq_len, hidden_size, kv_hidden_size);
658
659        // V projection with LoRA (same pattern as Q)
660        let v_base = matmul_nt(x, &self.w_v, seq_len, hidden_size, kv_hidden_size);
661        let v_mid = crate::autograd::matmul_nt(x, lora_a_v, seq_len, hidden_size, lora_rank);
662        let v_lora =
663            crate::autograd::matmul_nt(&v_mid, lora_b_v, seq_len, lora_rank, kv_hidden_size);
664        let v = crate::autograd::add_scaled(&v_base, &v_lora, lora_scale);
665
666        // Apply Q/K RMSNorm if present (Qwen3 QK-norm, ENT-269)
667        let q = if let Some(ref qn) = self.q_norm {
668            apply_qk_norm(&q, qn, seq_len, num_heads, head_dim)
669        } else {
670            q
671        };
672        let k = if let Some(ref kn) = self.k_norm {
673            apply_qk_norm(&k, kn, seq_len, num_kv_heads, head_dim)
674        } else {
675            k
676        };
677
678        // Apply Rotary Position Embedding (RoPE) to Q and K (ENT-269)
679        // Skip for encoder models (BERT/RoBERTa use learned positions, not RoPE)
680        let (q, k) = if self.config.rope_theta > 0.0 {
681            (
682                apply_rope(&q, seq_len, num_heads, head_dim, self.config.rope_theta),
683                apply_rope(&k, seq_len, num_kv_heads, head_dim, self.config.rope_theta),
684            )
685        } else {
686            (q, k)
687        };
688
689        let requires_grad = q.requires_grad() || k.requires_grad() || v.requires_grad();
690        let heads_per_kv = num_heads / num_kv_heads;
691
692        // KAIZEN-016: Hoist data borrows outside head loop (same optimization as forward())
693        let q_data = q.data();
694        let q_slice = q_data.as_slice().expect("contiguous Q");
695        let k_data = k.data();
696        let k_slice = k_data.as_slice().expect("contiguous K");
697        let v_data = v.data();
698        let v_slice = v_data.as_slice().expect("contiguous V");
699
700        // Per-head attention (same as forward())
701        let mut head_q_tensors = Vec::with_capacity(num_heads);
702        let mut head_k_tensors = Vec::with_capacity(num_heads);
703        let mut head_v_tensors = Vec::with_capacity(num_heads);
704        let mut head_outputs = Vec::with_capacity(num_heads);
705        let mut head_kv_indices = Vec::with_capacity(num_heads);
706
707        for h in 0..num_heads {
708            let kv_h = h / heads_per_kv;
709            head_kv_indices.push(kv_h);
710
711            // KAIZEN-016: extend_from_slice replaces flat_map+to_vec
712            let mut q_head = Vec::with_capacity(seq_len * head_dim);
713            for s in 0..seq_len {
714                let start = s * q_dim + h * head_dim;
715                q_head.extend_from_slice(&q_slice[start..start + head_dim]);
716            }
717
718            let mut k_head = Vec::with_capacity(seq_len * head_dim);
719            for s in 0..seq_len {
720                let start = s * kv_hidden_size + kv_h * head_dim;
721                k_head.extend_from_slice(&k_slice[start..start + head_dim]);
722            }
723
724            let mut v_head = Vec::with_capacity(seq_len * head_dim);
725            for s in 0..seq_len {
726                let start = s * kv_hidden_size + kv_h * head_dim;
727                v_head.extend_from_slice(&v_slice[start..start + head_dim]);
728            }
729
730            let q_tensor = Tensor::from_vec(q_head, requires_grad);
731            let k_tensor = Tensor::from_vec(k_head, requires_grad);
732            let v_tensor = Tensor::from_vec(v_head, requires_grad);
733
734            let attn_out = crate::autograd::attention(
735                &q_tensor, &k_tensor, &v_tensor, seq_len, head_dim, seq_len, head_dim,
736            );
737
738            head_q_tensors.push(q_tensor);
739            head_k_tensors.push(k_tensor);
740            head_v_tensors.push(v_tensor);
741            head_outputs.push(attn_out);
742        }
743
744        // Concatenate heads
745        let mut concat_output = vec![0.0; seq_len * q_dim];
746        for (h, head_out) in head_outputs.iter().enumerate() {
747            let hd = head_out.data();
748            let hdata = hd.as_slice().expect("contiguous attention output");
749            for s in 0..seq_len {
750                let src_base = s * head_dim;
751                let dst_base = s * q_dim + h * head_dim;
752                concat_output[dst_base..dst_base + head_dim]
753                    .copy_from_slice(&hdata[src_base..src_base + head_dim]);
754            }
755        }
756
757        let mut concat_tensor = Tensor::from_vec(concat_output, requires_grad);
758
759        if requires_grad {
760            let backward_op = Rc::new(AttentionBlockBackward {
761                q: q.clone(),
762                k: k.clone(),
763                v: v.clone(),
764                head_q_tensors,
765                head_k_tensors,
766                head_v_tensors,
767                head_outputs,
768                head_kv_indices,
769                seq_len,
770                head_dim,
771                q_dim,
772                kv_hidden_size,
773                result_grad: concat_tensor.grad_cell(),
774            });
775            concat_tensor.set_backward_op(backward_op);
776        }
777
778        // Output projection — w_o is [hidden_size, q_dim] in HF (ENT-269)
779        let result = matmul_nt(&concat_tensor, &self.w_o, seq_len, q_dim, hidden_size);
780        contract_post_lora_forward!(result);
781        result
782    }
783
784    /// Get all parameters as a vector
785    pub fn parameters(&self) -> Vec<&Tensor> {
786        let mut params = vec![&self.w_q, &self.w_k, &self.w_v, &self.w_o];
787        if let Some(ref b) = self.b_q {
788            params.push(b);
789        }
790        if let Some(ref b) = self.b_k {
791            params.push(b);
792        }
793        if let Some(ref b) = self.b_v {
794            params.push(b);
795        }
796        params
797    }
798
799    /// Get all parameters as mutable references for optimizer
800    pub fn parameters_mut(&mut self) -> Vec<&mut Tensor> {
801        let mut params = vec![&mut self.w_q, &mut self.w_k, &mut self.w_v, &mut self.w_o];
802        if let Some(ref mut b) = self.b_q {
803            params.push(b);
804        }
805        if let Some(ref mut b) = self.b_k {
806            params.push(b);
807        }
808        if let Some(ref mut b) = self.b_v {
809            params.push(b);
810        }
811        params
812    }
813
814    /// Whether this attention layer has QKV biases
815    pub fn has_biases(&self) -> bool {
816        self.b_q.is_some()
817    }
818
819    /// Get named parameters for checkpoint serialization
820    pub fn named_parameters(&self, prefix: &str) -> Vec<(String, &Tensor)> {
821        let mut params = vec![
822            (format!("{prefix}.q_proj.weight"), &self.w_q),
823            (format!("{prefix}.k_proj.weight"), &self.w_k),
824            (format!("{prefix}.v_proj.weight"), &self.w_v),
825            (format!("{prefix}.o_proj.weight"), &self.w_o),
826        ];
827        if let Some(ref b) = self.b_q {
828            params.push((format!("{prefix}.q_proj.bias"), b));
829        }
830        if let Some(ref b) = self.b_k {
831            params.push((format!("{prefix}.k_proj.bias"), b));
832        }
833        if let Some(ref b) = self.b_v {
834            params.push((format!("{prefix}.v_proj.bias"), b));
835        }
836        params
837    }
838
839    /// ENT-282: Set a named parameter by suffix (after "self_attn.").
840    ///
841    /// Bias suffixes route to `b_q` / `b_k` / `b_v` only when those
842    /// fields are already `Some` (i.e., `MultiHeadAttention::new`
843    /// allocated them because `config.use_bias == true`). If the
844    /// caller asks to set a bias on an attention that doesn't have
845    /// one, return false — same semantic as setting an unrecognized
846    /// suffix. This keeps `populate_trainer_from_init_tensors`
847    /// honest: a Qwen-init APR's biases populate iff the target
848    /// `Transformer` was built from a `use_bias=true` config.
849    pub fn set_named_parameter(&mut self, suffix: &str, value: Tensor) -> bool {
850        match suffix {
851            "self_attn.q_proj.weight" => {
852                self.w_q = value;
853                true
854            }
855            "self_attn.k_proj.weight" => {
856                self.w_k = value;
857                true
858            }
859            "self_attn.v_proj.weight" => {
860                self.w_v = value;
861                true
862            }
863            "self_attn.o_proj.weight" => {
864                self.w_o = value;
865                true
866            }
867            "self_attn.q_proj.bias" => {
868                if self.b_q.is_some() {
869                    self.b_q = Some(value);
870                    true
871                } else {
872                    false
873                }
874            }
875            "self_attn.k_proj.bias" => {
876                if self.b_k.is_some() {
877                    self.b_k = Some(value);
878                    true
879                } else {
880                    false
881                }
882            }
883            "self_attn.v_proj.bias" => {
884                if self.b_v.is_some() {
885                    self.b_v = Some(value);
886                    true
887                } else {
888                    false
889                }
890            }
891            _ => false,
892        }
893    }
894}
895
896/// LoRA-enabled linear projection
897///
898/// Computes: y = x @ W + scale * (x @ A) @ B
899/// Where W is frozen base weight, A and B are trainable LoRA adapters
900pub struct LoRAProjection {
901    /// Base weight (frozen), shape (d_in × d_out)
902    pub base_weight: Tensor,
903    /// LoRA A matrix (down-projection), shape (d_in × rank)
904    pub lora_a: Tensor,
905    /// LoRA B matrix (up-projection), shape (rank × d_out)
906    pub lora_b: Tensor,
907    /// Input dimension
908    pub d_in: usize,
909    /// Output dimension
910    pub d_out: usize,
911    /// LoRA rank
912    pub rank: usize,
913    /// Scaling factor (alpha / rank)
914    pub scale: f32,
915}
916
917impl LoRAProjection {
918    /// Create a new LoRA projection
919    ///
920    /// # Arguments
921    /// * `base_weight` - Frozen base weight [d_in × d_out]
922    /// * `d_in` - Input dimension
923    /// * `d_out` - Output dimension
924    /// * `rank` - LoRA rank (typically 4, 8, 16, 32, or 64)
925    /// * `alpha` - LoRA scaling parameter
926    pub fn new(base_weight: Tensor, d_in: usize, d_out: usize, rank: usize, alpha: f32) -> Self {
927        assert_eq!(base_weight.len(), d_in * d_out, "Base weight size mismatch");
928
929        // Freeze base weight — only LoRA adapters are trainable
930        let mut base_weight = base_weight;
931        base_weight.set_requires_grad(false);
932
933        // Initialize A with Kaiming uniform (standard LoRA paper)
934        let lora_a = Tensor::from_vec(
935            (0..d_in * rank).map(|i| (i as f32 * 0.123).sin() * 0.01).collect(),
936            true, // requires_grad
937        );
938
939        // Initialize B with zeros (LoRA invariant: ΔW = B @ A = 0 at init)
940        let lora_b = Tensor::zeros(rank * d_out, true);
941
942        Self { base_weight, lora_a, lora_b, d_in, d_out, rank, scale: alpha / rank as f32 }
943    }
944
945    /// Forward pass with LoRA
946    ///
947    /// Computes: y = x @ W + scale * (x @ A) @ B
948    ///
949    /// # Arguments
950    /// * `x` - Input tensor [seq_len × d_in]
951    /// * `seq_len` - Sequence length
952    ///
953    /// # Returns
954    /// Output tensor [seq_len × d_out]
955    pub fn forward(&self, x: &Tensor, seq_len: usize) -> Tensor {
956        // Base projection: x @ W, (seq × d_in) @ (d_in × d_out) = (seq × d_out)
957        let base_out = matmul(x, &self.base_weight, seq_len, self.d_in, self.d_out);
958
959        // LoRA path: scale * (x @ A) @ B
960        // Step 1: x @ A, (seq × d_in) @ (d_in × rank) = (seq × rank)
961        let lora_intermediate = matmul(x, &self.lora_a, seq_len, self.d_in, self.rank);
962
963        // Step 2: (x @ A) @ B, (seq × rank) @ (rank × d_out) = (seq × d_out)
964        let lora_out = matmul(&lora_intermediate, &self.lora_b, seq_len, self.rank, self.d_out);
965
966        // Combine: base + scale * lora
967        // Use autograd-compatible addition
968        crate::autograd::add_scaled(&base_out, &lora_out, self.scale)
969    }
970
971    /// Get trainable LoRA parameters
972    pub fn lora_params(&self) -> Vec<&Tensor> {
973        vec![&self.lora_a, &self.lora_b]
974    }
975
976    /// Get mutable trainable LoRA parameters
977    pub fn lora_params_mut(&mut self) -> Vec<&mut Tensor> {
978        vec![&mut self.lora_a, &mut self.lora_b]
979    }
980}
981
982/// Multi-head attention with deep LoRA injection
983///
984/// LoRA adapters are applied to Q, K, V, O projections during forward pass
985pub struct MultiHeadAttentionWithLoRA {
986    /// Configuration
987    pub config: TransformerConfig,
988    /// Query projection with LoRA
989    pub q_proj: LoRAProjection,
990    /// Key projection with LoRA
991    pub k_proj: LoRAProjection,
992    /// Value projection with LoRA
993    pub v_proj: LoRAProjection,
994    /// Output projection with LoRA
995    pub o_proj: LoRAProjection,
996}
997
998impl MultiHeadAttentionWithLoRA {
999    /// Create LoRA-enabled attention from existing attention weights
1000    ///
1001    /// # Arguments
1002    /// * `attn` - Base MultiHeadAttention with pretrained weights
1003    /// * `rank` - LoRA rank
1004    /// * `alpha` - LoRA alpha scaling factor
1005    pub fn from_attention(attn: &MultiHeadAttention, rank: usize, alpha: f32) -> Self {
1006        let hidden_size = attn.config.hidden_size;
1007        let q_dim = attn.config.q_dim();
1008        let kv_hidden_size = attn.config.num_kv_heads * attn.config.head_dim();
1009
1010        Self {
1011            config: attn.config.clone(),
1012            q_proj: LoRAProjection::new(attn.w_q.clone(), hidden_size, q_dim, rank, alpha),
1013            k_proj: LoRAProjection::new(attn.w_k.clone(), hidden_size, kv_hidden_size, rank, alpha),
1014            v_proj: LoRAProjection::new(attn.w_v.clone(), hidden_size, kv_hidden_size, rank, alpha),
1015            o_proj: LoRAProjection::new(attn.w_o.clone(), q_dim, hidden_size, rank, alpha),
1016        }
1017    }
1018
1019    /// Forward pass with deep LoRA injection
1020    ///
1021    /// LoRA is applied to all Q, K, V, O projections
1022    pub fn forward(&self, x: &Tensor, seq_len: usize) -> Tensor {
1023        let num_heads = self.config.num_attention_heads;
1024        let num_kv_heads = self.config.num_kv_heads;
1025        let head_dim = self.config.head_dim();
1026        let q_dim = self.config.q_dim();
1027        let kv_hidden_size = num_kv_heads * head_dim;
1028
1029        // Project Q, K, V with LoRA
1030        let q = self.q_proj.forward(x, seq_len);
1031        let k = self.k_proj.forward(x, seq_len);
1032        let v = self.v_proj.forward(x, seq_len);
1033
1034        // Multi-head attention with grouped-query attention support
1035        let mut attn_outputs = Vec::with_capacity(num_heads * seq_len * head_dim);
1036        let heads_per_kv = num_heads / num_kv_heads;
1037
1038        // KAIZEN-016: Hoist data borrows outside head loop
1039        let q_data = q.data();
1040        let q_slice = q_data.as_slice().expect("contiguous Q tensor");
1041        let k_data = k.data();
1042        let k_slice = k_data.as_slice().expect("contiguous K tensor");
1043        let v_data = v.data();
1044        let v_slice = v_data.as_slice().expect("contiguous V tensor");
1045
1046        for h in 0..num_heads {
1047            let kv_h = h / heads_per_kv;
1048
1049            // KAIZEN-016: extend_from_slice replaces flat_map+to_vec
1050            let mut q_head = Vec::with_capacity(seq_len * head_dim);
1051            for s in 0..seq_len {
1052                let start = s * q_dim + h * head_dim;
1053                q_head.extend_from_slice(&q_slice[start..start + head_dim]);
1054            }
1055
1056            let mut k_head = Vec::with_capacity(seq_len * head_dim);
1057            for s in 0..seq_len {
1058                let start = s * kv_hidden_size + kv_h * head_dim;
1059                k_head.extend_from_slice(&k_slice[start..start + head_dim]);
1060            }
1061
1062            let mut v_head = Vec::with_capacity(seq_len * head_dim);
1063            for s in 0..seq_len {
1064                let start = s * kv_hidden_size + kv_h * head_dim;
1065                v_head.extend_from_slice(&v_slice[start..start + head_dim]);
1066            }
1067
1068            // Scaled dot-product attention
1069            let q_tensor = Tensor::from_vec(q_head, false);
1070            let k_tensor = Tensor::from_vec(k_head, false);
1071            let v_tensor = Tensor::from_vec(v_head, false);
1072
1073            let attn_out = crate::autograd::attention(
1074                &q_tensor, &k_tensor, &v_tensor, seq_len, head_dim, seq_len, head_dim,
1075            );
1076
1077            attn_outputs.extend_from_slice(
1078                attn_out.data().as_slice().expect("contiguous attention output"),
1079            );
1080        }
1081
1082        // Concatenate heads and reorder: (seq_len, q_dim)
1083        let mut concat_output = vec![0.0; seq_len * q_dim];
1084        for h in 0..num_heads {
1085            for s in 0..seq_len {
1086                let src_idx = h * seq_len * head_dim + s * head_dim;
1087                let dst_idx = s * q_dim + h * head_dim;
1088                concat_output[dst_idx..dst_idx + head_dim]
1089                    .copy_from_slice(&attn_outputs[src_idx..src_idx + head_dim]);
1090            }
1091        }
1092
1093        let concat_tensor = Tensor::from_vec(concat_output, true);
1094
1095        // Output projection with LoRA: (seq_len, q_dim) -> (seq_len, hidden_size)
1096        self.o_proj.forward(&concat_tensor, seq_len)
1097    }
1098
1099    /// Get all trainable LoRA parameters
1100    pub fn lora_params(&self) -> Vec<&Tensor> {
1101        let mut params = Vec::new();
1102        params.extend(self.q_proj.lora_params());
1103        params.extend(self.k_proj.lora_params());
1104        params.extend(self.v_proj.lora_params());
1105        params.extend(self.o_proj.lora_params());
1106        params
1107    }
1108
1109    /// Get all trainable LoRA parameters as mutable references
1110    pub fn lora_params_mut(&mut self) -> Vec<&mut Tensor> {
1111        let mut params = Vec::new();
1112        params.extend(self.q_proj.lora_params_mut());
1113        params.extend(self.k_proj.lora_params_mut());
1114        params.extend(self.v_proj.lora_params_mut());
1115        params.extend(self.o_proj.lora_params_mut());
1116        params
1117    }
1118
1119    /// Count total LoRA parameters
1120    pub fn lora_param_count(&self) -> usize {
1121        // Each projection has A (d_in × rank) + B (rank × d_out)
1122        let hidden = self.config.hidden_size;
1123        let kv_hidden = self.config.num_kv_heads * self.config.head_dim();
1124        let rank = self.q_proj.rank;
1125
1126        // Q: (hidden × rank) + (rank × hidden)
1127        // K: (hidden × rank) + (rank × kv_hidden)
1128        // V: (hidden × rank) + (rank × kv_hidden)
1129        // O: (hidden × rank) + (rank × hidden)
1130        (hidden * rank + rank * hidden)      // Q
1131            + (hidden * rank + rank * kv_hidden) // K
1132            + (hidden * rank + rank * kv_hidden) // V
1133            + (hidden * rank + rank * hidden) // O
1134    }
1135}
1136
1137#[cfg(test)]
1138mod tests {
1139    use super::*;
1140
1141    /// PMAT-805: RoPE must propagate gradients (it is no longer an autograd
1142    /// leaf). Validate `RopeBackward` against finite differences of a scalar
1143    /// loss `L = sum(rope(x) * w)` so dL/dx = rope_backward(w).
1144    #[test]
1145    fn falsify_pmat805_rope_backward_matches_finite_difference() {
1146        let seq_len = 3usize;
1147        let num_heads = 2usize;
1148        let head_dim = 4usize; // half_dim = 2
1149        let total = seq_len * num_heads * head_dim;
1150        let theta = 10000.0f32;
1151
1152        // Deterministic input + upstream gradient weights.
1153        let x_data: Vec<f32> =
1154            (0..total).map(|i| ((i as f32 * 0.37).sin() * 1.5) + 0.1).collect();
1155        let w: Vec<f32> = (0..total).map(|i| ((i as f32 * 0.21).cos() * 0.8) - 0.05).collect();
1156
1157        // Analytical gradient via RopeBackward.
1158        let x = Tensor::from_vec(x_data.clone(), true);
1159        let y = apply_rope(&x, seq_len, num_heads, head_dim, theta);
1160        y.set_grad(Array1::from(w.clone()));
1161        y.backward_op().expect("rope must have a backward op").backward();
1162        let analytical = x.grad().expect("x must have grad after rope backward").to_vec();
1163
1164        // Numerical gradient: dL/dx_j ≈ (L(x+h) - L(x-h)) / 2h, L = sum(rope(x) · w).
1165        let h = 1e-3f32;
1166        let loss = |xv: &[f32]| -> f32 {
1167            let t = Tensor::from_vec(xv.to_vec(), false);
1168            let r = apply_rope(&t, seq_len, num_heads, head_dim, theta);
1169            r.data().iter().zip(&w).map(|(a, b)| a * b).sum()
1170        };
1171        for j in 0..total {
1172            let mut xp = x_data.clone();
1173            let mut xm = x_data.clone();
1174            xp[j] += h;
1175            xm[j] -= h;
1176            let numerical = (loss(&xp) - loss(&xm)) / (2.0 * h);
1177            let diff = (analytical[j] - numerical).abs();
1178            assert!(
1179                diff < 1e-2,
1180                "FALSIFIED: RoPE grad[{j}] analytical={} numerical={} diff={diff}",
1181                analytical[j],
1182                numerical
1183            );
1184        }
1185    }
1186
1187    #[test]
1188    fn test_multi_head_attention_tiny() {
1189        let config = TransformerConfig::tiny();
1190        let attn = MultiHeadAttention::new(&config);
1191        let x = Tensor::from_vec(vec![0.1; 2 * config.hidden_size], true);
1192        let output = attn.forward(&x, 2);
1193        assert_eq!(output.len(), 2 * config.hidden_size);
1194    }
1195
1196    #[test]
1197    fn test_multi_head_attention_parameters() {
1198        let config = TransformerConfig::tiny();
1199        let attn = MultiHeadAttention::new(&config);
1200        let params = attn.parameters();
1201        assert_eq!(params.len(), 4); // w_q, w_k, w_v, w_o
1202    }
1203
1204    #[test]
1205    fn test_attention_longer_sequence() {
1206        let config = TransformerConfig::tiny();
1207        let attn = MultiHeadAttention::new(&config);
1208        let x = Tensor::from_vec(vec![0.1; 8 * config.hidden_size], true);
1209        let output = attn.forward(&x, 8);
1210        assert_eq!(output.len(), 8 * config.hidden_size);
1211    }
1212
1213    #[test]
1214    fn test_attention_weight_sizes() {
1215        let config = TransformerConfig::tiny();
1216        let attn = MultiHeadAttention::new(&config);
1217        let kv_hidden = config.num_kv_heads * config.head_dim();
1218        assert_eq!(attn.w_q.len(), config.hidden_size * config.hidden_size);
1219        assert_eq!(attn.w_k.len(), config.hidden_size * kv_hidden);
1220        assert_eq!(attn.w_v.len(), config.hidden_size * kv_hidden);
1221        assert_eq!(attn.w_o.len(), config.hidden_size * config.hidden_size);
1222    }
1223
1224    #[test]
1225    fn test_multi_head_attention_from_params_success() {
1226        let config = TransformerConfig::tiny();
1227        let hidden_size = config.hidden_size;
1228        let kv_hidden_size = config.num_kv_heads * config.head_dim();
1229
1230        let mut params = HashMap::new();
1231        params.insert(
1232            "attn.q_proj.weight".to_string(),
1233            Tensor::from_vec(vec![0.1; hidden_size * hidden_size], true),
1234        );
1235        params.insert(
1236            "attn.k_proj.weight".to_string(),
1237            Tensor::from_vec(vec![0.1; hidden_size * kv_hidden_size], true),
1238        );
1239        params.insert(
1240            "attn.v_proj.weight".to_string(),
1241            Tensor::from_vec(vec![0.1; hidden_size * kv_hidden_size], true),
1242        );
1243        params.insert(
1244            "attn.o_proj.weight".to_string(),
1245            Tensor::from_vec(vec![0.1; hidden_size * hidden_size], true),
1246        );
1247
1248        let attn = MultiHeadAttention::from_params(&config, &params, "attn");
1249        assert!(attn.is_some());
1250        let attn = attn.expect("operation should succeed");
1251        assert_eq!(attn.w_q.len(), hidden_size * hidden_size);
1252    }
1253
1254    #[test]
1255    fn test_multi_head_attention_from_params_missing_key() {
1256        let config = TransformerConfig::tiny();
1257        let hidden_size = config.hidden_size;
1258
1259        let mut params = HashMap::new();
1260        params.insert(
1261            "attn.q_proj.weight".to_string(),
1262            Tensor::from_vec(vec![0.1; hidden_size * hidden_size], true),
1263        );
1264        // Missing k_proj, v_proj, o_proj
1265
1266        let attn = MultiHeadAttention::from_params(&config, &params, "attn");
1267        assert!(attn.is_none());
1268    }
1269
1270    #[test]
1271    fn test_attention_projections_backward() {
1272        // Test that Q, K, V projection matmuls have gradients
1273        // (isolated from the full attention which has intermediate tensor issues)
1274        let config = TransformerConfig::tiny();
1275        let attn = MultiHeadAttention::new(&config);
1276        let hidden_size = config.hidden_size;
1277        let seq_len = 2;
1278
1279        let x = Tensor::from_vec(vec![0.1; seq_len * hidden_size], true);
1280
1281        // Test Q projection
1282        let mut q = crate::autograd::matmul(&x, &attn.w_q, seq_len, hidden_size, hidden_size);
1283        let grad_out = ndarray::Array1::ones(seq_len * hidden_size);
1284        crate::autograd::backward(&mut q, Some(grad_out));
1285
1286        assert!(attn.w_q.grad().is_some());
1287        let grad_q = attn.w_q.grad().expect("gradient should be available");
1288        assert!(grad_q.iter().all(|&v| v.is_finite()));
1289    }
1290
1291    #[test]
1292    fn test_output_projection_backward() {
1293        // Test output projection in isolation
1294        let config = TransformerConfig::tiny();
1295        let attn = MultiHeadAttention::new(&config);
1296        let hidden_size = config.hidden_size;
1297        let seq_len = 2;
1298
1299        // Simulate concatenated attention output
1300        let concat_out = Tensor::from_vec(vec![0.1; seq_len * hidden_size], true);
1301
1302        // Output projection
1303        let mut output =
1304            crate::autograd::matmul(&concat_out, &attn.w_o, seq_len, hidden_size, hidden_size);
1305
1306        let grad_out = ndarray::Array1::ones(seq_len * hidden_size);
1307        crate::autograd::backward(&mut output, Some(grad_out));
1308
1309        assert!(attn.w_o.grad().is_some());
1310        let grad_o = attn.w_o.grad().expect("gradient should be available");
1311        assert!(grad_o.iter().all(|&v| v.is_finite()));
1312        let sum: f32 = grad_o.iter().map(|v| v.abs()).sum();
1313        assert!(sum > 0.0, "Output projection gradient should not be all zero");
1314    }
1315
1316    /// ALB-038: Full attention forward must propagate gradients to Q/K/V weights
1317    ///
1318    /// NOTE: Currently fails because apply_rope() has no backward op — it severs
1319    /// the autograd chain for Q and K. Needs a proper RoPE backward implementation
1320    /// (ENT-272). Skipped until then.
1321    #[test]
1322    #[ignore = "apply_rope() severs autograd chain — needs backward op (ENT-272)"]
1323    fn test_attention_full_forward_qkv_gradients() {
1324        let config = TransformerConfig::tiny();
1325        let attn = MultiHeadAttention::new(&config);
1326        let hidden_size = config.hidden_size;
1327        let seq_len = 3;
1328
1329        // Non-uniform input: different positions must have different representations
1330        // so softmax produces non-uniform weights with non-zero score gradients
1331        let x_data: Vec<f32> =
1332            (0..seq_len * hidden_size).map(|i| ((i as f32) * 0.17).sin() * 0.5).collect();
1333        let x = Tensor::from_vec(x_data, true);
1334        let mut output = attn.forward(&x, seq_len);
1335
1336        let grad_out = ndarray::Array1::ones(seq_len * hidden_size);
1337        crate::autograd::backward(&mut output, Some(grad_out));
1338
1339        // All four projection weights must receive gradients
1340        for (name, param) in
1341            [("w_q", &attn.w_q), ("w_k", &attn.w_k), ("w_v", &attn.w_v), ("w_o", &attn.w_o)]
1342        {
1343            assert!(
1344                param.grad().is_some(),
1345                "ALB-038: {name} must have gradient after full attention forward"
1346            );
1347            let grad = param.grad().expect("gradient available");
1348            assert!(grad.iter().all(|&v| v.is_finite()), "ALB-038: {name} gradient must be finite");
1349            assert!(
1350                grad.iter().any(|&v| v.abs() > 1e-10),
1351                "ALB-038: {name} gradient must be non-zero"
1352            );
1353        }
1354
1355        // Input must also receive gradient (enables gradient flow through model)
1356        assert!(x.grad().is_some(), "ALB-038: input x must have gradient");
1357    }
1358
1359    // ============================================================================
1360    // LoRAProjection tests
1361    // ============================================================================
1362
1363    #[test]
1364    fn test_lora_projection_new() {
1365        let d_in = 32;
1366        let d_out = 16;
1367        let rank = 4;
1368        let alpha = 8.0;
1369
1370        let base_weight = Tensor::from_vec(vec![0.1; d_in * d_out], false);
1371        let lora = LoRAProjection::new(base_weight, d_in, d_out, rank, alpha);
1372
1373        assert_eq!(lora.d_in, d_in);
1374        assert_eq!(lora.d_out, d_out);
1375        assert_eq!(lora.rank, rank);
1376        assert!((lora.scale - 2.0).abs() < 1e-6); // alpha / rank = 8 / 4 = 2
1377        assert_eq!(lora.lora_a.len(), d_in * rank);
1378        assert_eq!(lora.lora_b.len(), rank * d_out);
1379    }
1380
1381    #[test]
1382    fn test_lora_projection_forward() {
1383        let d_in = 32;
1384        let d_out = 16;
1385        let rank = 4;
1386        let alpha = 8.0;
1387        let seq_len = 2;
1388
1389        let base_weight = Tensor::from_vec(vec![0.1; d_in * d_out], false);
1390        let lora = LoRAProjection::new(base_weight, d_in, d_out, rank, alpha);
1391
1392        let x = Tensor::from_vec(vec![0.1; seq_len * d_in], false);
1393        let output = lora.forward(&x, seq_len);
1394
1395        assert_eq!(output.len(), seq_len * d_out);
1396        // Check output is finite
1397        assert!(output.data().iter().all(|&v| v.is_finite()));
1398    }
1399
1400    #[test]
1401    fn test_lora_projection_params() {
1402        let d_in = 32;
1403        let d_out = 16;
1404        let rank = 4;
1405
1406        let base_weight = Tensor::from_vec(vec![0.1; d_in * d_out], false);
1407        let lora = LoRAProjection::new(base_weight, d_in, d_out, rank, 8.0);
1408
1409        let params = lora.lora_params();
1410        assert_eq!(params.len(), 2); // lora_a and lora_b
1411    }
1412
1413    #[test]
1414    fn test_lora_projection_params_mut() {
1415        let d_in = 32;
1416        let d_out = 16;
1417        let rank = 4;
1418
1419        let base_weight = Tensor::from_vec(vec![0.1; d_in * d_out], false);
1420        let mut lora = LoRAProjection::new(base_weight, d_in, d_out, rank, 8.0);
1421
1422        let params = lora.lora_params_mut();
1423        assert_eq!(params.len(), 2);
1424    }
1425
1426    #[test]
1427    #[should_panic(expected = "Base weight size mismatch")]
1428    fn test_lora_projection_size_mismatch() {
1429        let d_in = 32;
1430        let d_out = 16;
1431        let rank = 4;
1432
1433        // Wrong base weight size
1434        let base_weight = Tensor::from_vec(vec![0.1; d_in * d_out + 1], false);
1435        let _ = LoRAProjection::new(base_weight, d_in, d_out, rank, 8.0);
1436    }
1437
1438    // ============================================================================
1439    // MultiHeadAttentionWithLoRA tests
1440    // ============================================================================
1441
1442    #[test]
1443    fn test_mha_with_lora_creation() {
1444        let config = TransformerConfig::tiny();
1445        let attn = MultiHeadAttention::new(&config);
1446        let rank = 4;
1447        let alpha = 8.0;
1448
1449        let lora_attn = MultiHeadAttentionWithLoRA::from_attention(&attn, rank, alpha);
1450
1451        assert_eq!(lora_attn.q_proj.rank, rank);
1452        assert_eq!(lora_attn.k_proj.rank, rank);
1453        assert_eq!(lora_attn.v_proj.rank, rank);
1454        assert_eq!(lora_attn.o_proj.rank, rank);
1455    }
1456
1457    #[test]
1458    fn test_mha_with_lora_forward() {
1459        let config = TransformerConfig::tiny();
1460        let attn = MultiHeadAttention::new(&config);
1461        let lora_attn = MultiHeadAttentionWithLoRA::from_attention(&attn, 4, 8.0);
1462
1463        let seq_len = 2;
1464        let x = Tensor::from_vec(vec![0.1; seq_len * config.hidden_size], false);
1465        let output = lora_attn.forward(&x, seq_len);
1466
1467        assert_eq!(output.len(), seq_len * config.hidden_size);
1468        // Check output is finite and non-zero
1469        assert!(output.data().iter().all(|&v| v.is_finite()));
1470    }
1471
1472    #[test]
1473    fn test_mha_with_lora_params() {
1474        let config = TransformerConfig::tiny();
1475        let attn = MultiHeadAttention::new(&config);
1476        let lora_attn = MultiHeadAttentionWithLoRA::from_attention(&attn, 4, 8.0);
1477
1478        let params = lora_attn.lora_params();
1479        // 4 projections × 2 params each = 8
1480        assert_eq!(params.len(), 8);
1481    }
1482
1483    #[test]
1484    fn test_mha_with_lora_params_mut() {
1485        let config = TransformerConfig::tiny();
1486        let attn = MultiHeadAttention::new(&config);
1487        let mut lora_attn = MultiHeadAttentionWithLoRA::from_attention(&attn, 4, 8.0);
1488
1489        let params = lora_attn.lora_params_mut();
1490        assert_eq!(params.len(), 8);
1491    }
1492
1493    #[test]
1494    fn test_mha_with_lora_param_count() {
1495        let config = TransformerConfig::tiny();
1496        let attn = MultiHeadAttention::new(&config);
1497        let rank = 4;
1498        let lora_attn = MultiHeadAttentionWithLoRA::from_attention(&attn, rank, 8.0);
1499
1500        let param_count = lora_attn.lora_param_count();
1501
1502        // Calculate expected:
1503        let hidden = config.hidden_size;
1504        let kv_hidden = config.num_kv_heads * config.head_dim();
1505        let expected = (hidden * rank + rank * hidden)      // Q
1506            + (hidden * rank + rank * kv_hidden) // K
1507            + (hidden * rank + rank * kv_hidden) // V
1508            + (hidden * rank + rank * hidden); // O
1509
1510        assert_eq!(param_count, expected);
1511        assert!(param_count > 0);
1512    }
1513
1514    #[test]
1515    fn test_mha_with_lora_longer_sequence() {
1516        let config = TransformerConfig::tiny();
1517        let attn = MultiHeadAttention::new(&config);
1518        let lora_attn = MultiHeadAttentionWithLoRA::from_attention(&attn, 4, 8.0);
1519
1520        let seq_len = 8;
1521        let x = Tensor::from_vec(vec![0.1; seq_len * config.hidden_size], false);
1522        let output = lora_attn.forward(&x, seq_len);
1523
1524        assert_eq!(output.len(), seq_len * config.hidden_size);
1525    }
1526
1527    #[test]
1528    fn test_parameters_mut() {
1529        let config = TransformerConfig::tiny();
1530        let mut attn = MultiHeadAttention::new(&config);
1531
1532        let params = attn.parameters_mut();
1533        assert_eq!(params.len(), 4);
1534    }
1535
1536    // =========================================================================
1537    // FALSIFY-A: §2.1.3 Attention Projections — Five-Whys Gap Analysis (Refs PMAT-331)
1538    //
1539    // Contract: tensor-layout-v1.yaml §tensors.q_proj/k_proj/v_proj/o_proj
1540    //   q_proj: [num_heads*head_dim, hidden] (= [hidden, hidden] for MHA)
1541    //   k_proj: [num_kv_heads*head_dim, hidden] (smaller for GQA)
1542    //   v_proj: [num_kv_heads*head_dim, hidden] (smaller for GQA)
1543    //   o_proj: [hidden, num_heads*head_dim]
1544    //
1545    // Five-Whys:
1546    //   Why 1: Trained model's attention weights could be wrong shape
1547    //   Why 2: from_params accepts any tensor without shape validation
1548    //   Why 3: No ValidatedWeight in entrenar
1549    //   Why 4: entrenar predates the Poka-Yoke contract
1550    //   Why 5: No cross-crate contract enforcement for training weights
1551    //
1552    // Popper (1959): "These tests attempt to falsify the claim that
1553    // entrenar's attention weight handling prevents degenerate models."
1554    // =========================================================================
1555
1556    /// FALSIFY-A1e: from_params rejects wrong-shape Q weight (PMAT-331 fix)
1557    ///
1558    /// from_params now validates Q projection shape against config dimensions.
1559    /// A tensor of 50 elements is rejected when hidden*hidden is expected.
1560    #[test]
1561    fn falsify_a1e_from_params_rejects_wrong_shape_q_weight() {
1562        let config = TransformerConfig::tiny();
1563        let hidden_size = config.hidden_size;
1564        let kv_hidden_size = config.num_kv_heads * config.head_dim();
1565
1566        let mut params = HashMap::new();
1567        // WRONG-SHAPE q_proj: 50 elements instead of hidden*hidden
1568        params.insert("attn.q_proj.weight".to_string(), Tensor::from_vec(vec![0.1; 50], true));
1569        // Correct k, v, o
1570        params.insert(
1571            "attn.k_proj.weight".to_string(),
1572            Tensor::from_vec(vec![0.1; hidden_size * kv_hidden_size], true),
1573        );
1574        params.insert(
1575            "attn.v_proj.weight".to_string(),
1576            Tensor::from_vec(vec![0.1; hidden_size * kv_hidden_size], true),
1577        );
1578        params.insert(
1579            "attn.o_proj.weight".to_string(),
1580            Tensor::from_vec(vec![0.1; hidden_size * hidden_size], true),
1581        );
1582
1583        let attn = MultiHeadAttention::from_params(&config, &params, "attn");
1584        // FIXED (PMAT-331): now rejected
1585        assert!(
1586            attn.is_none(),
1587            "FALSIFY-A1e: PMAT-331 fix — from_params MUST reject wrong-shape q_proj"
1588        );
1589    }
1590
1591    /// FALSIFY-A2e: GQA init produces correct K/V dimensions
1592    ///
1593    /// For GQA (num_kv_heads < num_heads), K/V must be smaller than Q.
1594    /// If init uses num_heads for K/V, the shapes are wrong.
1595    #[test]
1596    fn falsify_a2e_gqa_init_correct_kv_dimensions() {
1597        let mut config = TransformerConfig::tiny();
1598        config.num_kv_heads = 1; // Force GQA: 1 KV head, but num_heads > 1
1599
1600        let attn = MultiHeadAttention::new(&config);
1601        let head_dim = config.head_dim();
1602        let kv_hidden = config.num_kv_heads * head_dim; // 1 * head_dim
1603
1604        // Q: hidden * hidden
1605        assert_eq!(
1606            attn.w_q.len(),
1607            config.hidden_size * config.hidden_size,
1608            "FALSIFY-A2e: Q projection must be hidden*hidden"
1609        );
1610
1611        // K: hidden * kv_hidden (smaller than Q for GQA)
1612        assert_eq!(
1613            attn.w_k.len(),
1614            config.hidden_size * kv_hidden,
1615            "FALSIFY-A2e: K projection must use num_kv_heads, not num_heads"
1616        );
1617
1618        // V: hidden * kv_hidden (same as K)
1619        assert_eq!(
1620            attn.w_v.len(),
1621            config.hidden_size * kv_hidden,
1622            "FALSIFY-A2e: V projection must use num_kv_heads, not num_heads"
1623        );
1624
1625        // O: hidden * hidden (matches Q output)
1626        assert_eq!(
1627            attn.w_o.len(),
1628            config.hidden_size * config.hidden_size,
1629            "FALSIFY-A2e: O projection must be hidden*hidden"
1630        );
1631
1632        // K/V must be SMALLER than Q for GQA
1633        assert!(
1634            attn.w_k.len() < attn.w_q.len(),
1635            "FALSIFY-A2e: For GQA, K weight must be smaller than Q weight"
1636        );
1637    }
1638
1639    /// FALSIFY-A3e: GQA forward produces correct output dimensions
1640    ///
1641    /// With num_kv_heads < num_heads, the forward pass must still produce
1642    /// [seq_len, hidden_size] output (not [seq_len, kv_hidden]).
1643    #[test]
1644    fn falsify_a3e_gqa_forward_correct_output_dims() {
1645        let mut config = TransformerConfig::tiny();
1646        config.num_kv_heads = 1; // Force GQA
1647
1648        let attn = MultiHeadAttention::new(&config);
1649        let seq_len = 3;
1650        let x = Tensor::from_vec(vec![0.1; seq_len * config.hidden_size], true);
1651        let output = attn.forward(&x, seq_len);
1652
1653        assert_eq!(
1654            output.len(),
1655            seq_len * config.hidden_size,
1656            "FALSIFY-A3e: GQA output must be seq_len * hidden_size, not seq_len * kv_hidden"
1657        );
1658    }
1659
1660    /// FALSIFY-A4e: Attention init produces non-degenerate values
1661    ///
1662    /// Like FALSIFY-E7a for embeddings: init must produce varied, finite values.
1663    #[test]
1664    fn falsify_a4e_init_produces_valid_attention_weights() {
1665        let config = TransformerConfig::tiny();
1666        let attn = MultiHeadAttention::new(&config);
1667
1668        for (name, w) in
1669            [("w_q", &attn.w_q), ("w_k", &attn.w_k), ("w_v", &attn.w_v), ("w_o", &attn.w_o)]
1670        {
1671            let data = w.data();
1672            let slice = data.as_slice().expect("data as slice");
1673
1674            // No NaN
1675            let nan_count = slice.iter().filter(|v| v.is_nan()).count();
1676            assert_eq!(nan_count, 0, "FALSIFY-A4e: {name} init must not contain NaN");
1677
1678            // No Inf
1679            let inf_count = slice.iter().filter(|v| v.is_infinite()).count();
1680            assert_eq!(inf_count, 0, "FALSIFY-A4e: {name} init must not contain Inf");
1681
1682            // Values vary
1683            let min = slice.iter().copied().fold(f32::INFINITY, f32::min);
1684            let max = slice.iter().copied().fold(f32::NEG_INFINITY, f32::max);
1685            assert!(
1686                (max - min).abs() > 1e-6,
1687                "FALSIFY-A4e: {name} init values are constant ({min}..{max}) — degenerate weight"
1688            );
1689        }
1690    }
1691
1692    /// FALSIFY-A5e: Attention forward produces finite outputs
1693    ///
1694    /// If any attention weight is degenerate, output should still be finite
1695    /// (the init is designed to prevent this).
1696    #[test]
1697    fn falsify_a5e_forward_produces_finite_output() {
1698        let config = TransformerConfig::tiny();
1699        let attn = MultiHeadAttention::new(&config);
1700        let seq_len = 4;
1701        let x = Tensor::from_vec(vec![0.1; seq_len * config.hidden_size], true);
1702        let output = attn.forward(&x, seq_len);
1703
1704        let data = output.data();
1705        let nan_count = data.iter().filter(|v| v.is_nan()).count();
1706        let inf_count = data.iter().filter(|v| v.is_infinite()).count();
1707        assert_eq!(nan_count, 0, "FALSIFY-A5e: Attention output must not contain NaN");
1708        assert_eq!(inf_count, 0, "FALSIFY-A5e: Attention output must not contain Inf");
1709    }
1710
1711    // =========================================================================
1712    // FALSIFY-GQ: gqa-kernel-v1.yaml contract (entrenar MultiHeadAttention GQA)
1713    //
1714    // Five-Whys (PMAT-354):
1715    //   Why 1: entrenar had FALSIFY-A tests but zero FALSIFY-GQ-* tests
1716    //   Why 2: FALSIFY-A tests verify projections/shapes, not GQA invariants
1717    //   Why 3: no mapping from gqa-kernel-v1.yaml to entrenar test names
1718    //   Why 4: entrenar's GQA support added after FALSIFY-A tests
1719    //   Why 5: GQA was "obviously correct" (just index K/V by h/heads_per_kv)
1720    //
1721    // References:
1722    //   - provable-contracts/contracts/gqa-kernel-v1.yaml
1723    //   - Ainslie et al. (2023) "GQA: Training Generalized MQT Models"
1724    // =========================================================================
1725
1726    /// FALSIFY-GQ-001e: GQA output shape correct for various head configs
1727    #[test]
1728    fn falsify_gq_001e_output_shape() {
1729        for (num_heads, num_kv_heads) in [(2, 2), (4, 2), (4, 1), (2, 1)] {
1730            let mut config = TransformerConfig::tiny();
1731            config.num_attention_heads = num_heads;
1732            config.num_kv_heads = num_kv_heads;
1733
1734            let attn = MultiHeadAttention::new(&config);
1735            let seq_len = 3;
1736            let x = Tensor::from_vec(vec![0.1; seq_len * config.hidden_size], true);
1737            let output = attn.forward(&x, seq_len);
1738
1739            assert_eq!(
1740                output.len(),
1741                seq_len * config.hidden_size,
1742                "FALSIFIED GQ-001e: output len mismatch for heads={num_heads},kv={num_kv_heads}"
1743            );
1744        }
1745    }
1746
1747    /// FALSIFY-GQ-002e: MHA degeneration — kv_heads == num_heads produces finite output
1748    #[test]
1749    fn falsify_gq_002e_mha_degeneration() {
1750        let config = TransformerConfig::tiny(); // num_heads == num_kv_heads == 2
1751        assert_eq!(config.num_attention_heads, config.num_kv_heads);
1752
1753        let attn = MultiHeadAttention::new(&config);
1754        let seq_len = 4;
1755        let x = Tensor::from_vec(
1756            (0..seq_len * config.hidden_size).map(|i| (i as f32 * 0.37).sin()).collect(),
1757            true,
1758        );
1759        let output = attn.forward(&x, seq_len);
1760
1761        let data = output.data();
1762        for (i, v) in data.iter().enumerate() {
1763            assert!(v.is_finite(), "FALSIFIED GQ-002e: MHA output[{i}] = {v} (not finite)");
1764        }
1765    }
1766
1767    /// FALSIFY-GQ-004e: Head divisibility — GQA requires num_heads % num_kv_heads == 0
1768    #[test]
1769    fn falsify_gq_004e_head_divisibility() {
1770        // Valid configurations should not panic
1771        for (nh, nkv) in [(2, 1), (2, 2), (4, 1), (4, 2), (4, 4), (8, 2), (8, 4)] {
1772            let mut config = TransformerConfig::tiny();
1773            config.num_attention_heads = nh;
1774            config.num_kv_heads = nkv;
1775            assert_eq!(nh % nkv, 0, "FALSIFIED GQ-004e: test config has invalid head ratio");
1776            // Should not panic during construction or forward
1777            let attn = MultiHeadAttention::new(&config);
1778            let x = Tensor::from_vec(vec![0.1; 2 * config.hidden_size], true);
1779            let _ = attn.forward(&x, 2);
1780        }
1781    }
1782
1783    /// FALSIFY-GQ-006e: MQA boundary — kv_heads=1 broadcasts single KV to all heads
1784    #[test]
1785    fn falsify_gq_006e_mqa_boundary() {
1786        let mut config = TransformerConfig::tiny();
1787        config.num_attention_heads = 4;
1788        config.num_kv_heads = 1;
1789        // Adjust hidden_size to be divisible by 4 heads
1790        config.hidden_size = 64;
1791
1792        let attn = MultiHeadAttention::new(&config);
1793        let seq_len = 3;
1794        let x = Tensor::from_vec(
1795            (0..seq_len * config.hidden_size).map(|i| (i as f32 * 0.73).cos()).collect(),
1796            true,
1797        );
1798        let output = attn.forward(&x, seq_len);
1799
1800        assert_eq!(
1801            output.len(),
1802            seq_len * config.hidden_size,
1803            "FALSIFIED GQ-006e: MQA output size wrong"
1804        );
1805
1806        // All finite
1807        let data = output.data();
1808        for (i, v) in data.iter().enumerate() {
1809            assert!(v.is_finite(), "FALSIFIED GQ-006e: MQA output[{i}] = {v} (not finite)");
1810        }
1811    }
1812
1813    mod gq_proptest_falsify {
1814        use super::*;
1815        use proptest::prelude::*;
1816
1817        // FALSIFY-GQ-001e-prop: GQA output shape for random configs
1818        proptest! {
1819            #![proptest_config(ProptestConfig::with_cases(50))]
1820
1821            #[test]
1822            fn falsify_gq_001e_prop_output_shape(
1823                config_idx in 0..4usize,
1824                seq_len in 2..=6usize,
1825                seed in 0..500u32,
1826            ) {
1827                let configs: [(usize, usize); 4] = [
1828                    (2, 2), (2, 1), (4, 2), (4, 1),
1829                ];
1830                let (num_heads, num_kv_heads) = configs[config_idx];
1831                let mut config = TransformerConfig::tiny();
1832                config.num_attention_heads = num_heads;
1833                config.num_kv_heads = num_kv_heads;
1834
1835                let attn = MultiHeadAttention::new(&config);
1836                let data: Vec<f32> = (0..seq_len * config.hidden_size)
1837                    .map(|i| ((i as f32 + seed as f32) * 0.37).sin())
1838                    .collect();
1839                let x = Tensor::from_vec(data, true);
1840                let output = attn.forward(&x, seq_len);
1841
1842                prop_assert_eq!(
1843                    output.len(),
1844                    seq_len * config.hidden_size,
1845                    "FALSIFIED GQ-001e-prop: output len mismatch"
1846                );
1847
1848                // All finite
1849                for v in output.data() {
1850                    prop_assert!(
1851                        v.is_finite(),
1852                        "FALSIFIED GQ-001e-prop: non-finite output"
1853                    );
1854                }
1855            }
1856        }
1857
1858        // FALSIFY-GQ-006e-prop: MQA boundary with random inputs
1859        proptest! {
1860            #![proptest_config(ProptestConfig::with_cases(30))]
1861
1862            #[test]
1863            fn falsify_gq_006e_prop_mqa_boundary(
1864                seed in 0..500u32,
1865                seq_len in 2..=5usize,
1866            ) {
1867                let mut config = TransformerConfig::tiny();
1868                config.num_attention_heads = 4;
1869                config.num_kv_heads = 1;
1870                config.hidden_size = 64;
1871
1872                let attn = MultiHeadAttention::new(&config);
1873                let data: Vec<f32> = (0..seq_len * config.hidden_size)
1874                    .map(|i| ((i as f32 + seed as f32) * 0.73).cos())
1875                    .collect();
1876                let x = Tensor::from_vec(data, true);
1877                let output = attn.forward(&x, seq_len);
1878
1879                prop_assert_eq!(
1880                    output.len(),
1881                    seq_len * config.hidden_size,
1882                    "FALSIFIED GQ-006e-prop: MQA output len mismatch"
1883                );
1884
1885                for v in output.data() {
1886                    prop_assert!(
1887                        v.is_finite(),
1888                        "FALSIFIED GQ-006e-prop: non-finite MQA output"
1889                    );
1890                }
1891            }
1892        }
1893    }
1894
1895    #[test]
1896    fn test_attention_from_params_with_biases() {
1897        let config = TransformerConfig::tiny();
1898        let hidden_size = config.hidden_size;
1899        let kv_hidden_size = config.num_kv_heads * config.head_dim();
1900
1901        let mut params = HashMap::new();
1902        params.insert(
1903            "attn.q_proj.weight".to_string(),
1904            Tensor::from_vec(vec![0.1; hidden_size * hidden_size], true),
1905        );
1906        params.insert(
1907            "attn.k_proj.weight".to_string(),
1908            Tensor::from_vec(vec![0.1; hidden_size * kv_hidden_size], true),
1909        );
1910        params.insert(
1911            "attn.v_proj.weight".to_string(),
1912            Tensor::from_vec(vec![0.1; hidden_size * kv_hidden_size], true),
1913        );
1914        params.insert(
1915            "attn.o_proj.weight".to_string(),
1916            Tensor::from_vec(vec![0.1; hidden_size * hidden_size], true),
1917        );
1918        params.insert(
1919            "attn.q_proj.bias".to_string(),
1920            Tensor::from_vec(vec![0.01; hidden_size], true),
1921        );
1922        params.insert(
1923            "attn.k_proj.bias".to_string(),
1924            Tensor::from_vec(vec![0.01; kv_hidden_size], true),
1925        );
1926        params.insert(
1927            "attn.v_proj.bias".to_string(),
1928            Tensor::from_vec(vec![0.01; kv_hidden_size], true),
1929        );
1930
1931        let attn = MultiHeadAttention::from_params(&config, &params, "attn");
1932        assert!(attn.is_some());
1933        let attn = attn.expect("should load with biases");
1934        assert!(attn.has_biases());
1935        assert_eq!(attn.parameters().len(), 7);
1936    }
1937
1938    #[test]
1939    fn test_attention_named_parameters_with_biases() {
1940        let config = TransformerConfig::tiny();
1941        let hidden_size = config.hidden_size;
1942        let kv_hidden_size = config.num_kv_heads * config.head_dim();
1943
1944        let mut params = HashMap::new();
1945        params.insert(
1946            "attn.q_proj.weight".to_string(),
1947            Tensor::from_vec(vec![0.1; hidden_size * hidden_size], true),
1948        );
1949        params.insert(
1950            "attn.k_proj.weight".to_string(),
1951            Tensor::from_vec(vec![0.1; hidden_size * kv_hidden_size], true),
1952        );
1953        params.insert(
1954            "attn.v_proj.weight".to_string(),
1955            Tensor::from_vec(vec![0.1; hidden_size * kv_hidden_size], true),
1956        );
1957        params.insert(
1958            "attn.o_proj.weight".to_string(),
1959            Tensor::from_vec(vec![0.1; hidden_size * hidden_size], true),
1960        );
1961        params.insert(
1962            "attn.q_proj.bias".to_string(),
1963            Tensor::from_vec(vec![0.01; hidden_size], true),
1964        );
1965        params.insert(
1966            "attn.k_proj.bias".to_string(),
1967            Tensor::from_vec(vec![0.01; kv_hidden_size], true),
1968        );
1969        params.insert(
1970            "attn.v_proj.bias".to_string(),
1971            Tensor::from_vec(vec![0.01; kv_hidden_size], true),
1972        );
1973
1974        let attn = MultiHeadAttention::from_params(&config, &params, "attn").expect("should load");
1975        let named = attn.named_parameters("attn");
1976        assert_eq!(named.len(), 7);
1977        let names: Vec<&str> = named.iter().map(|(n, _)| n.as_str()).collect();
1978        assert!(names.contains(&"attn.q_proj.bias"));
1979        assert!(names.contains(&"attn.k_proj.bias"));
1980        assert!(names.contains(&"attn.v_proj.bias"));
1981    }
1982
1983    #[test]
1984    fn test_attention_forward_with_biases() {
1985        let config = TransformerConfig::tiny();
1986        let hidden_size = config.hidden_size;
1987        let kv_hidden_size = config.num_kv_heads * config.head_dim();
1988
1989        let mut params = HashMap::new();
1990        params.insert(
1991            "attn.q_proj.weight".to_string(),
1992            Tensor::from_vec(vec![0.1; hidden_size * hidden_size], true),
1993        );
1994        params.insert(
1995            "attn.k_proj.weight".to_string(),
1996            Tensor::from_vec(vec![0.1; hidden_size * kv_hidden_size], true),
1997        );
1998        params.insert(
1999            "attn.v_proj.weight".to_string(),
2000            Tensor::from_vec(vec![0.1; hidden_size * kv_hidden_size], true),
2001        );
2002        params.insert(
2003            "attn.o_proj.weight".to_string(),
2004            Tensor::from_vec(vec![0.1; hidden_size * hidden_size], true),
2005        );
2006        params
2007            .insert("attn.q_proj.bias".to_string(), Tensor::from_vec(vec![0.5; hidden_size], true));
2008        params.insert(
2009            "attn.k_proj.bias".to_string(),
2010            Tensor::from_vec(vec![0.5; kv_hidden_size], true),
2011        );
2012        params.insert(
2013            "attn.v_proj.bias".to_string(),
2014            Tensor::from_vec(vec![0.5; kv_hidden_size], true),
2015        );
2016
2017        let attn = MultiHeadAttention::from_params(&config, &params, "attn").expect("should load");
2018        let x = Tensor::from_vec(vec![0.1; 2 * hidden_size], false);
2019        let output = attn.forward(&x, 2);
2020        assert_eq!(output.len(), 2 * hidden_size);
2021        assert!(output.data().iter().all(|v| v.is_finite()));
2022    }
2023}