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::{ModelArchitecture, 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            // FALSIFY-CUDA-NF4-TRAIN-LOSS-PARITY-001: decoder-only models
560            // MUST use causal attention. The unmasked path let every position
561            // attend to FUTURE tokens, leaking the training labels backwards
562            // (deceptively low train/eval loss) and diverging from the
563            // causal CUDA training forward. Encoders stay bidirectional.
564            let attn_out = if self.config.architecture == ModelArchitecture::Decoder {
565                crate::autograd::attention_causal(
566                    &q_tensor, &k_tensor, &v_tensor, seq_len, head_dim, seq_len, head_dim,
567                )
568            } else {
569                crate::autograd::attention(
570                    &q_tensor, &k_tensor, &v_tensor, seq_len, head_dim, seq_len, head_dim,
571                )
572            };
573
574            head_q_tensors.push(q_tensor);
575            head_k_tensors.push(k_tensor);
576            head_v_tensors.push(v_tensor);
577            head_outputs.push(attn_out);
578        }
579
580        // Concatenate heads: reorder from per-head (head, seq, dim) to (seq, head*dim)
581        let mut concat_output = vec![0.0; seq_len * q_dim];
582        for (h, head_out) in head_outputs.iter().enumerate() {
583            let hd = head_out.data();
584            let hdata = hd.as_slice().expect("contiguous attention output");
585            for s in 0..seq_len {
586                let src_base = s * head_dim;
587                let dst_base = s * q_dim + h * head_dim;
588                concat_output[dst_base..dst_base + head_dim]
589                    .copy_from_slice(&hdata[src_base..src_base + head_dim]);
590            }
591        }
592
593        let mut concat_tensor = Tensor::from_vec(concat_output, requires_grad);
594
595        if requires_grad {
596            let backward_op = Rc::new(AttentionBlockBackward {
597                q: q.clone(),
598                k: k.clone(),
599                v: v.clone(),
600                head_q_tensors,
601                head_k_tensors,
602                head_v_tensors,
603                head_outputs,
604                head_kv_indices,
605                seq_len,
606                head_dim,
607                q_dim,
608                kv_hidden_size,
609                result_grad: concat_tensor.grad_cell(),
610            });
611            concat_tensor.set_backward_op(backward_op);
612        }
613
614        // Output projection — w_o is [hidden_size, q_dim] in HF (ENT-269)
615        let result = matmul_nt(&concat_tensor, &self.w_o, seq_len, q_dim, hidden_size);
616        contract_post_attention!(result.data().as_slice().unwrap_or(&[]));
617        result
618    }
619
620    /// Forward pass with LoRA adjusts on Q and V projections (KAIZEN-010).
621    ///
622    /// Applies LoRA adapters to Q and V during the forward pass so that
623    /// gradients flow through LoRA A/B matrices on non-CUDA paths.
624    ///
625    /// # Arguments
626    /// * `x` - Input tensor (seq_len * hidden_size)
627    /// * `seq_len` - Sequence length
628    /// * `lora_a_q`, `lora_b_q` - Q projection LoRA matrices (rank×d_in, d_out×rank)
629    /// * `lora_a_v`, `lora_b_v` - V projection LoRA matrices (rank×d_in, d_out×rank)
630    /// * `lora_rank` - LoRA rank
631    /// * `lora_scale` - LoRA scaling factor (alpha/rank)
632    pub fn forward_with_lora(
633        &self,
634        x: &Tensor,
635        seq_len: usize,
636        lora_a_q: &Tensor,
637        // contract_pre_attention applied via forward()
638        lora_b_q: &Tensor,
639        lora_a_v: &Tensor,
640        lora_b_v: &Tensor,
641        lora_rank: usize,
642        lora_scale: f32,
643    ) -> Tensor {
644        contract_pre_lora_forward!();
645        let hidden_size = self.config.hidden_size;
646        let num_heads = self.config.num_attention_heads;
647        let num_kv_heads = self.config.num_kv_heads;
648        let head_dim = self.config.head_dim();
649        let q_dim = self.config.q_dim();
650        let kv_hidden_size = num_kv_heads * head_dim;
651
652        // Q projection with LoRA: Q = x @ W_q + scale * (x @ A_q^T) @ B_q^T
653        //
654        // KAIZEN-011: Use matmul_nt to compute x @ A^T directly on the ORIGINAL
655        // LoRA tensors. Previous impl created transposed copies via Tensor::from_vec
656        // which broke gradient flow — gradients accumulated on ephemeral copies
657        // instead of the actual trainable LoRA parameters.
658        //
659        // LoRA layout: A is (rank, d_in), B is (d_out, rank)
660        // matmul_nt(x, A, seq, d_in, rank) computes x @ A^T = (seq, d_in) @ (d_in, rank) = (seq, rank)
661        // matmul_nt(mid, B, seq, rank, d_out) computes mid @ B^T = (seq, rank) @ (rank, d_out) = (seq, d_out)
662        let q_base = matmul_nt(x, &self.w_q, seq_len, hidden_size, q_dim);
663        let q_mid = crate::autograd::matmul_nt(x, lora_a_q, seq_len, hidden_size, lora_rank);
664        let q_lora = crate::autograd::matmul_nt(&q_mid, lora_b_q, seq_len, lora_rank, q_dim);
665        let q = crate::autograd::add_scaled(&q_base, &q_lora, lora_scale);
666
667        // K projection (no LoRA) — HF weights [out, in] (ENT-269)
668        let k = matmul_nt(x, &self.w_k, seq_len, hidden_size, kv_hidden_size);
669
670        // V projection with LoRA (same pattern as Q)
671        let v_base = matmul_nt(x, &self.w_v, seq_len, hidden_size, kv_hidden_size);
672        let v_mid = crate::autograd::matmul_nt(x, lora_a_v, seq_len, hidden_size, lora_rank);
673        let v_lora =
674            crate::autograd::matmul_nt(&v_mid, lora_b_v, seq_len, lora_rank, kv_hidden_size);
675        let v = crate::autograd::add_scaled(&v_base, &v_lora, lora_scale);
676
677        // FALSIFY-CPU-LORA-QKV-BIAS-001: apply the SAME Q/K/V projection
678        // biases forward() applies (Qwen2-family use_bias=true). Dropping
679        // them makes every CPU LoRA train/eval forward compute a DIFFERENT
680        // model — measured on qwen2.5-coder-1.5b: CE 4.49 vs forward()'s
681        // 2.13 on a trivially-predictable target, matching the parity
682        // probe's biases-DROPPED oracle bit-exactly (the same defect class
683        // #2252 fixed on the GPU path). NOTE: forward()'s `add_bias` helper
684        // severs the autograd chain (Tensor::from_vec, no backward op) — a
685        // frozen-weight non-issue for forward(), but here it would orphan
686        // the LoRA A/B gradients (PMAT-805 class). We instead broadcast the
687        // bias to (seq × dim) as a non-trainable tensor and use the
688        // autograd-aware add_scaled, which propagates grad to its first
689        // argument unchanged.
690        let broadcast_bias = |bias: &Tensor| -> Tensor {
691            let bd = bias.data();
692            let b = bd.as_slice().expect("contiguous bias");
693            let mut out = Vec::with_capacity(seq_len * b.len());
694            for _ in 0..seq_len {
695                out.extend_from_slice(b);
696            }
697            Tensor::from_vec(out, false)
698        };
699        let q = if let Some(ref b_q) = self.b_q {
700            crate::autograd::add_scaled(&q, &broadcast_bias(b_q), 1.0)
701        } else {
702            q
703        };
704        let k = if let Some(ref b_k) = self.b_k {
705            crate::autograd::add_scaled(&k, &broadcast_bias(b_k), 1.0)
706        } else {
707            k
708        };
709        let v = if let Some(ref b_v) = self.b_v {
710            crate::autograd::add_scaled(&v, &broadcast_bias(b_v), 1.0)
711        } else {
712            v
713        };
714
715        // Apply Q/K RMSNorm if present (Qwen3 QK-norm, ENT-269)
716        let q = if let Some(ref qn) = self.q_norm {
717            apply_qk_norm(&q, qn, seq_len, num_heads, head_dim)
718        } else {
719            q
720        };
721        let k = if let Some(ref kn) = self.k_norm {
722            apply_qk_norm(&k, kn, seq_len, num_kv_heads, head_dim)
723        } else {
724            k
725        };
726
727        // Apply Rotary Position Embedding (RoPE) to Q and K (ENT-269)
728        // Skip for encoder models (BERT/RoBERTa use learned positions, not RoPE)
729        let (q, k) = if self.config.rope_theta > 0.0 {
730            (
731                apply_rope(&q, seq_len, num_heads, head_dim, self.config.rope_theta),
732                apply_rope(&k, seq_len, num_kv_heads, head_dim, self.config.rope_theta),
733            )
734        } else {
735            (q, k)
736        };
737
738        let requires_grad = q.requires_grad() || k.requires_grad() || v.requires_grad();
739        let heads_per_kv = num_heads / num_kv_heads;
740
741        // KAIZEN-016: Hoist data borrows outside head loop (same optimization as forward())
742        let q_data = q.data();
743        let q_slice = q_data.as_slice().expect("contiguous Q");
744        let k_data = k.data();
745        let k_slice = k_data.as_slice().expect("contiguous K");
746        let v_data = v.data();
747        let v_slice = v_data.as_slice().expect("contiguous V");
748
749        // Per-head attention (same as forward())
750        let mut head_q_tensors = Vec::with_capacity(num_heads);
751        let mut head_k_tensors = Vec::with_capacity(num_heads);
752        let mut head_v_tensors = Vec::with_capacity(num_heads);
753        let mut head_outputs = Vec::with_capacity(num_heads);
754        let mut head_kv_indices = Vec::with_capacity(num_heads);
755
756        for h in 0..num_heads {
757            let kv_h = h / heads_per_kv;
758            head_kv_indices.push(kv_h);
759
760            // KAIZEN-016: extend_from_slice replaces flat_map+to_vec
761            let mut q_head = Vec::with_capacity(seq_len * head_dim);
762            for s in 0..seq_len {
763                let start = s * q_dim + h * head_dim;
764                q_head.extend_from_slice(&q_slice[start..start + head_dim]);
765            }
766
767            let mut k_head = Vec::with_capacity(seq_len * head_dim);
768            for s in 0..seq_len {
769                let start = s * kv_hidden_size + kv_h * head_dim;
770                k_head.extend_from_slice(&k_slice[start..start + head_dim]);
771            }
772
773            let mut v_head = Vec::with_capacity(seq_len * head_dim);
774            for s in 0..seq_len {
775                let start = s * kv_hidden_size + kv_h * head_dim;
776                v_head.extend_from_slice(&v_slice[start..start + head_dim]);
777            }
778
779            let q_tensor = Tensor::from_vec(q_head, requires_grad);
780            let k_tensor = Tensor::from_vec(k_head, requires_grad);
781            let v_tensor = Tensor::from_vec(v_head, requires_grad);
782
783            // FALSIFY-CUDA-NF4-TRAIN-LOSS-PARITY-001: decoder-only models
784            // MUST use causal attention. The unmasked path let every position
785            // attend to FUTURE tokens, leaking the training labels backwards
786            // (deceptively low train/eval loss) and diverging from the
787            // causal CUDA training forward. Encoders stay bidirectional.
788            let attn_out = if self.config.architecture == ModelArchitecture::Decoder {
789                crate::autograd::attention_causal(
790                    &q_tensor, &k_tensor, &v_tensor, seq_len, head_dim, seq_len, head_dim,
791                )
792            } else {
793                crate::autograd::attention(
794                    &q_tensor, &k_tensor, &v_tensor, seq_len, head_dim, seq_len, head_dim,
795                )
796            };
797
798            head_q_tensors.push(q_tensor);
799            head_k_tensors.push(k_tensor);
800            head_v_tensors.push(v_tensor);
801            head_outputs.push(attn_out);
802        }
803
804        // Concatenate heads
805        let mut concat_output = vec![0.0; seq_len * q_dim];
806        for (h, head_out) in head_outputs.iter().enumerate() {
807            let hd = head_out.data();
808            let hdata = hd.as_slice().expect("contiguous attention output");
809            for s in 0..seq_len {
810                let src_base = s * head_dim;
811                let dst_base = s * q_dim + h * head_dim;
812                concat_output[dst_base..dst_base + head_dim]
813                    .copy_from_slice(&hdata[src_base..src_base + head_dim]);
814            }
815        }
816
817        let mut concat_tensor = Tensor::from_vec(concat_output, requires_grad);
818
819        if requires_grad {
820            let backward_op = Rc::new(AttentionBlockBackward {
821                q: q.clone(),
822                k: k.clone(),
823                v: v.clone(),
824                head_q_tensors,
825                head_k_tensors,
826                head_v_tensors,
827                head_outputs,
828                head_kv_indices,
829                seq_len,
830                head_dim,
831                q_dim,
832                kv_hidden_size,
833                result_grad: concat_tensor.grad_cell(),
834            });
835            concat_tensor.set_backward_op(backward_op);
836        }
837
838        // Output projection — w_o is [hidden_size, q_dim] in HF (ENT-269)
839        let result = matmul_nt(&concat_tensor, &self.w_o, seq_len, q_dim, hidden_size);
840        contract_post_lora_forward!(result);
841        result
842    }
843
844    /// Get all parameters as a vector
845    pub fn parameters(&self) -> Vec<&Tensor> {
846        let mut params = vec![&self.w_q, &self.w_k, &self.w_v, &self.w_o];
847        if let Some(ref b) = self.b_q {
848            params.push(b);
849        }
850        if let Some(ref b) = self.b_k {
851            params.push(b);
852        }
853        if let Some(ref b) = self.b_v {
854            params.push(b);
855        }
856        params
857    }
858
859    /// Get all parameters as mutable references for optimizer
860    pub fn parameters_mut(&mut self) -> Vec<&mut Tensor> {
861        let mut params = vec![&mut self.w_q, &mut self.w_k, &mut self.w_v, &mut self.w_o];
862        if let Some(ref mut b) = self.b_q {
863            params.push(b);
864        }
865        if let Some(ref mut b) = self.b_k {
866            params.push(b);
867        }
868        if let Some(ref mut b) = self.b_v {
869            params.push(b);
870        }
871        params
872    }
873
874    /// Whether this attention layer has QKV biases
875    pub fn has_biases(&self) -> bool {
876        self.b_q.is_some()
877    }
878
879    /// Get named parameters for checkpoint serialization
880    pub fn named_parameters(&self, prefix: &str) -> Vec<(String, &Tensor)> {
881        let mut params = vec![
882            (format!("{prefix}.q_proj.weight"), &self.w_q),
883            (format!("{prefix}.k_proj.weight"), &self.w_k),
884            (format!("{prefix}.v_proj.weight"), &self.w_v),
885            (format!("{prefix}.o_proj.weight"), &self.w_o),
886        ];
887        if let Some(ref b) = self.b_q {
888            params.push((format!("{prefix}.q_proj.bias"), b));
889        }
890        if let Some(ref b) = self.b_k {
891            params.push((format!("{prefix}.k_proj.bias"), b));
892        }
893        if let Some(ref b) = self.b_v {
894            params.push((format!("{prefix}.v_proj.bias"), b));
895        }
896        params
897    }
898
899    /// ENT-282: Set a named parameter by suffix (after "self_attn.").
900    ///
901    /// Bias suffixes route to `b_q` / `b_k` / `b_v` only when those
902    /// fields are already `Some` (i.e., `MultiHeadAttention::new`
903    /// allocated them because `config.use_bias == true`). If the
904    /// caller asks to set a bias on an attention that doesn't have
905    /// one, return false — same semantic as setting an unrecognized
906    /// suffix. This keeps `populate_trainer_from_init_tensors`
907    /// honest: a Qwen-init APR's biases populate iff the target
908    /// `Transformer` was built from a `use_bias=true` config.
909    pub fn set_named_parameter(&mut self, suffix: &str, value: Tensor) -> bool {
910        match suffix {
911            "self_attn.q_proj.weight" => {
912                self.w_q = value;
913                true
914            }
915            "self_attn.k_proj.weight" => {
916                self.w_k = value;
917                true
918            }
919            "self_attn.v_proj.weight" => {
920                self.w_v = value;
921                true
922            }
923            "self_attn.o_proj.weight" => {
924                self.w_o = value;
925                true
926            }
927            "self_attn.q_proj.bias" => {
928                if self.b_q.is_some() {
929                    self.b_q = Some(value);
930                    true
931                } else {
932                    false
933                }
934            }
935            "self_attn.k_proj.bias" => {
936                if self.b_k.is_some() {
937                    self.b_k = Some(value);
938                    true
939                } else {
940                    false
941                }
942            }
943            "self_attn.v_proj.bias" => {
944                if self.b_v.is_some() {
945                    self.b_v = Some(value);
946                    true
947                } else {
948                    false
949                }
950            }
951            _ => false,
952        }
953    }
954}
955
956/// LoRA-enabled linear projection
957///
958/// Computes: y = x @ W + scale * (x @ A) @ B
959/// Where W is frozen base weight, A and B are trainable LoRA adapters
960pub struct LoRAProjection {
961    /// Base weight (frozen), shape (d_in × d_out)
962    pub base_weight: Tensor,
963    /// LoRA A matrix (down-projection), shape (d_in × rank)
964    pub lora_a: Tensor,
965    /// LoRA B matrix (up-projection), shape (rank × d_out)
966    pub lora_b: Tensor,
967    /// Input dimension
968    pub d_in: usize,
969    /// Output dimension
970    pub d_out: usize,
971    /// LoRA rank
972    pub rank: usize,
973    /// Scaling factor (alpha / rank)
974    pub scale: f32,
975}
976
977impl LoRAProjection {
978    /// Create a new LoRA projection
979    ///
980    /// # Arguments
981    /// * `base_weight` - Frozen base weight [d_in × d_out]
982    /// * `d_in` - Input dimension
983    /// * `d_out` - Output dimension
984    /// * `rank` - LoRA rank (typically 4, 8, 16, 32, or 64)
985    /// * `alpha` - LoRA scaling parameter
986    pub fn new(base_weight: Tensor, d_in: usize, d_out: usize, rank: usize, alpha: f32) -> Self {
987        assert_eq!(base_weight.len(), d_in * d_out, "Base weight size mismatch");
988
989        // Freeze base weight — only LoRA adapters are trainable
990        let mut base_weight = base_weight;
991        base_weight.set_requires_grad(false);
992
993        // Initialize A with Kaiming uniform (standard LoRA paper)
994        let lora_a = Tensor::from_vec(
995            (0..d_in * rank).map(|i| (i as f32 * 0.123).sin() * 0.01).collect(),
996            true, // requires_grad
997        );
998
999        // Initialize B with zeros (LoRA invariant: ΔW = B @ A = 0 at init)
1000        let lora_b = Tensor::zeros(rank * d_out, true);
1001
1002        Self { base_weight, lora_a, lora_b, d_in, d_out, rank, scale: alpha / rank as f32 }
1003    }
1004
1005    /// Forward pass with LoRA
1006    ///
1007    /// Computes: y = x @ W + scale * (x @ A) @ B
1008    ///
1009    /// # Arguments
1010    /// * `x` - Input tensor [seq_len × d_in]
1011    /// * `seq_len` - Sequence length
1012    ///
1013    /// # Returns
1014    /// Output tensor [seq_len × d_out]
1015    pub fn forward(&self, x: &Tensor, seq_len: usize) -> Tensor {
1016        // Base projection: x @ W, (seq × d_in) @ (d_in × d_out) = (seq × d_out)
1017        let base_out = matmul(x, &self.base_weight, seq_len, self.d_in, self.d_out);
1018
1019        // LoRA path: scale * (x @ A) @ B
1020        // Step 1: x @ A, (seq × d_in) @ (d_in × rank) = (seq × rank)
1021        let lora_intermediate = matmul(x, &self.lora_a, seq_len, self.d_in, self.rank);
1022
1023        // Step 2: (x @ A) @ B, (seq × rank) @ (rank × d_out) = (seq × d_out)
1024        let lora_out = matmul(&lora_intermediate, &self.lora_b, seq_len, self.rank, self.d_out);
1025
1026        // Combine: base + scale * lora
1027        // Use autograd-compatible addition
1028        crate::autograd::add_scaled(&base_out, &lora_out, self.scale)
1029    }
1030
1031    /// Get trainable LoRA parameters
1032    pub fn lora_params(&self) -> Vec<&Tensor> {
1033        vec![&self.lora_a, &self.lora_b]
1034    }
1035
1036    /// Get mutable trainable LoRA parameters
1037    pub fn lora_params_mut(&mut self) -> Vec<&mut Tensor> {
1038        vec![&mut self.lora_a, &mut self.lora_b]
1039    }
1040}
1041
1042/// Multi-head attention with deep LoRA injection
1043///
1044/// LoRA adapters are applied to Q, K, V, O projections during forward pass
1045pub struct MultiHeadAttentionWithLoRA {
1046    /// Configuration
1047    pub config: TransformerConfig,
1048    /// Query projection with LoRA
1049    pub q_proj: LoRAProjection,
1050    /// Key projection with LoRA
1051    pub k_proj: LoRAProjection,
1052    /// Value projection with LoRA
1053    pub v_proj: LoRAProjection,
1054    /// Output projection with LoRA
1055    pub o_proj: LoRAProjection,
1056}
1057
1058impl MultiHeadAttentionWithLoRA {
1059    /// Create LoRA-enabled attention from existing attention weights
1060    ///
1061    /// # Arguments
1062    /// * `attn` - Base MultiHeadAttention with pretrained weights
1063    /// * `rank` - LoRA rank
1064    /// * `alpha` - LoRA alpha scaling factor
1065    pub fn from_attention(attn: &MultiHeadAttention, rank: usize, alpha: f32) -> Self {
1066        let hidden_size = attn.config.hidden_size;
1067        let q_dim = attn.config.q_dim();
1068        let kv_hidden_size = attn.config.num_kv_heads * attn.config.head_dim();
1069
1070        Self {
1071            config: attn.config.clone(),
1072            q_proj: LoRAProjection::new(attn.w_q.clone(), hidden_size, q_dim, rank, alpha),
1073            k_proj: LoRAProjection::new(attn.w_k.clone(), hidden_size, kv_hidden_size, rank, alpha),
1074            v_proj: LoRAProjection::new(attn.w_v.clone(), hidden_size, kv_hidden_size, rank, alpha),
1075            o_proj: LoRAProjection::new(attn.w_o.clone(), q_dim, hidden_size, rank, alpha),
1076        }
1077    }
1078
1079    /// Forward pass with deep LoRA injection
1080    ///
1081    /// LoRA is applied to all Q, K, V, O projections
1082    pub fn forward(&self, x: &Tensor, seq_len: usize) -> Tensor {
1083        let num_heads = self.config.num_attention_heads;
1084        let num_kv_heads = self.config.num_kv_heads;
1085        let head_dim = self.config.head_dim();
1086        let q_dim = self.config.q_dim();
1087        let kv_hidden_size = num_kv_heads * head_dim;
1088
1089        // Project Q, K, V with LoRA
1090        let q = self.q_proj.forward(x, seq_len);
1091        let k = self.k_proj.forward(x, seq_len);
1092        let v = self.v_proj.forward(x, seq_len);
1093
1094        // Multi-head attention with grouped-query attention support
1095        let mut attn_outputs = Vec::with_capacity(num_heads * seq_len * head_dim);
1096        let heads_per_kv = num_heads / num_kv_heads;
1097
1098        // KAIZEN-016: Hoist data borrows outside head loop
1099        let q_data = q.data();
1100        let q_slice = q_data.as_slice().expect("contiguous Q tensor");
1101        let k_data = k.data();
1102        let k_slice = k_data.as_slice().expect("contiguous K tensor");
1103        let v_data = v.data();
1104        let v_slice = v_data.as_slice().expect("contiguous V tensor");
1105
1106        for h in 0..num_heads {
1107            let kv_h = h / heads_per_kv;
1108
1109            // KAIZEN-016: extend_from_slice replaces flat_map+to_vec
1110            let mut q_head = Vec::with_capacity(seq_len * head_dim);
1111            for s in 0..seq_len {
1112                let start = s * q_dim + h * head_dim;
1113                q_head.extend_from_slice(&q_slice[start..start + head_dim]);
1114            }
1115
1116            let mut k_head = Vec::with_capacity(seq_len * head_dim);
1117            for s in 0..seq_len {
1118                let start = s * kv_hidden_size + kv_h * head_dim;
1119                k_head.extend_from_slice(&k_slice[start..start + head_dim]);
1120            }
1121
1122            let mut v_head = Vec::with_capacity(seq_len * head_dim);
1123            for s in 0..seq_len {
1124                let start = s * kv_hidden_size + kv_h * head_dim;
1125                v_head.extend_from_slice(&v_slice[start..start + head_dim]);
1126            }
1127
1128            // Scaled dot-product attention
1129            let q_tensor = Tensor::from_vec(q_head, false);
1130            let k_tensor = Tensor::from_vec(k_head, false);
1131            let v_tensor = Tensor::from_vec(v_head, false);
1132
1133            // FALSIFY-CUDA-NF4-TRAIN-LOSS-PARITY-001: decoder-only models
1134            // MUST use causal attention. The unmasked path let every position
1135            // attend to FUTURE tokens, leaking the training labels backwards
1136            // (deceptively low train/eval loss) and diverging from the
1137            // causal CUDA training forward. Encoders stay bidirectional.
1138            let attn_out = if self.config.architecture == ModelArchitecture::Decoder {
1139                crate::autograd::attention_causal(
1140                    &q_tensor, &k_tensor, &v_tensor, seq_len, head_dim, seq_len, head_dim,
1141                )
1142            } else {
1143                crate::autograd::attention(
1144                    &q_tensor, &k_tensor, &v_tensor, seq_len, head_dim, seq_len, head_dim,
1145                )
1146            };
1147
1148            attn_outputs.extend_from_slice(
1149                attn_out.data().as_slice().expect("contiguous attention output"),
1150            );
1151        }
1152
1153        // Concatenate heads and reorder: (seq_len, q_dim)
1154        let mut concat_output = vec![0.0; seq_len * q_dim];
1155        for h in 0..num_heads {
1156            for s in 0..seq_len {
1157                let src_idx = h * seq_len * head_dim + s * head_dim;
1158                let dst_idx = s * q_dim + h * head_dim;
1159                concat_output[dst_idx..dst_idx + head_dim]
1160                    .copy_from_slice(&attn_outputs[src_idx..src_idx + head_dim]);
1161            }
1162        }
1163
1164        let concat_tensor = Tensor::from_vec(concat_output, true);
1165
1166        // Output projection with LoRA: (seq_len, q_dim) -> (seq_len, hidden_size)
1167        self.o_proj.forward(&concat_tensor, seq_len)
1168    }
1169
1170    /// Get all trainable LoRA parameters
1171    pub fn lora_params(&self) -> Vec<&Tensor> {
1172        let mut params = Vec::new();
1173        params.extend(self.q_proj.lora_params());
1174        params.extend(self.k_proj.lora_params());
1175        params.extend(self.v_proj.lora_params());
1176        params.extend(self.o_proj.lora_params());
1177        params
1178    }
1179
1180    /// Get all trainable LoRA parameters as mutable references
1181    pub fn lora_params_mut(&mut self) -> Vec<&mut Tensor> {
1182        let mut params = Vec::new();
1183        params.extend(self.q_proj.lora_params_mut());
1184        params.extend(self.k_proj.lora_params_mut());
1185        params.extend(self.v_proj.lora_params_mut());
1186        params.extend(self.o_proj.lora_params_mut());
1187        params
1188    }
1189
1190    /// Count total LoRA parameters
1191    pub fn lora_param_count(&self) -> usize {
1192        // Each projection has A (d_in × rank) + B (rank × d_out)
1193        let hidden = self.config.hidden_size;
1194        let kv_hidden = self.config.num_kv_heads * self.config.head_dim();
1195        let rank = self.q_proj.rank;
1196
1197        // Q: (hidden × rank) + (rank × hidden)
1198        // K: (hidden × rank) + (rank × kv_hidden)
1199        // V: (hidden × rank) + (rank × kv_hidden)
1200        // O: (hidden × rank) + (rank × hidden)
1201        (hidden * rank + rank * hidden)      // Q
1202            + (hidden * rank + rank * kv_hidden) // K
1203            + (hidden * rank + rank * kv_hidden) // V
1204            + (hidden * rank + rank * hidden) // O
1205    }
1206}
1207
1208#[cfg(test)]
1209mod tests {
1210    use super::*;
1211
1212    /// BEAT-QLORA-COMPOSED-FORWARD-EQUIVALENCE (FALSIFY-QLORA-COMPOSED-FORWARD-001).
1213    ///
1214    /// The composed on-the-fly QLoRA forward — base projection + `scale·(B@A)`
1215    /// LoRA delta + Q/K/V bias — must equal a forward on the model with the LoRA
1216    /// delta MERGED into the base weight. Gates the exact composition #2260's
1217    /// bias-drop corrupted, with NONZERO LoRA factors AND nonzero biases: the
1218    /// existing bias falsifier (FALSIFY-CPU-LORA-QKV-BIAS-001) uses zero-B so it
1219    /// only exercises the bias term, and `beat_lora_merge_forward_equivalence`
1220    /// uses no biases and hand-rolls the matmuls — neither drives all three terms
1221    /// through the real `forward_with_lora` code path.
1222    ///
1223    /// Independence: `forward_with_lora` computes `q = x@Wᵀ + scale·(x@Aᵀ)@Bᵀ + b`;
1224    /// the reference folds `W_merged = W + scale·(B@A)` and runs the plain
1225    /// `forward` — a different code path. A dropped bias, a wrong LoRA scale, or a
1226    /// transpose in either composition diverges. Self-contained, CPU, deterministic.
1227    #[test]
1228    fn beat_qlora_composed_forward_equivalence() {
1229        let mut config = TransformerConfig::tiny();
1230        config.use_bias = true;
1231        let hidden = config.hidden_size;
1232        let q_dim = config.q_dim();
1233        let kv_dim = config.num_kv_heads * config.head_dim();
1234        let seq = 3usize;
1235        let rank = 4usize;
1236        let scale = 8.0f32 / rank as f32; // alpha=8
1237
1238        let mut attn = MultiHeadAttention::new(&config);
1239
1240        // Deterministic NONZERO biases (new() zero-inits them — a dropped bias
1241        // term is invisible at zero bias).
1242        let mk_bias = |n: usize, amp: f32| {
1243            Tensor::from_vec((0..n).map(|i| amp * (((i % 7) as f32) - 3.0)).collect(), true)
1244        };
1245        attn.b_q = Some(mk_bias(q_dim, 0.05));
1246        attn.b_k = Some(mk_bias(kv_dim, 0.03));
1247        attn.b_v = Some(mk_bias(kv_dim, 0.04));
1248
1249        // Deterministic NONZERO LoRA factors — PEFT layout A:[rank,hidden],
1250        // B:[out,rank], both row-major (matches `apr finetune`).
1251        let mk = |rows: usize, cols: usize, amp: f32, ph: f32| -> Vec<f32> {
1252            (0..rows * cols).map(|i| amp * ((i as f32).mul_add(0.017, ph)).sin()).collect()
1253        };
1254        let a_q = mk(rank, hidden, 0.10, 0.0);
1255        let b_q = mk(q_dim, rank, 0.12, 1.0);
1256        let a_v = mk(rank, hidden, 0.09, 2.0);
1257        let b_v = mk(kv_dim, rank, 0.11, 3.0);
1258
1259        let x = Tensor::from_vec(
1260            (0..seq * hidden).map(|i| (i as f32).mul_add(0.023, -0.5).cos() * 0.4).collect(),
1261            true,
1262        );
1263
1264        // (1) The real composed forward.
1265        let out_lora = attn.forward_with_lora(
1266            &x,
1267            seq,
1268            &Tensor::from_vec(a_q.clone(), true),
1269            &Tensor::from_vec(b_q.clone(), true),
1270            &Tensor::from_vec(a_v.clone(), true),
1271            &Tensor::from_vec(b_v.clone(), true),
1272            rank,
1273            scale,
1274        );
1275
1276        // (2) Fold the LoRA delta into w_q, w_v IN PLACE (independent path), then
1277        // run the plain forward. W_merged[o,i] = W[o,i] + scale·Σ_k B[o,k]·A[k,i].
1278        let merge = |w: &Tensor, a: &[f32], b: &[f32], out: usize| -> Vec<f32> {
1279            let wd = w.data();
1280            let mut m = wd.as_slice().expect("contiguous w").to_vec();
1281            for o in 0..out {
1282                for i in 0..hidden {
1283                    let mut d = 0.0f32;
1284                    for k in 0..rank {
1285                        d += b[o * rank + k] * a[k * hidden + i];
1286                    }
1287                    m[o * hidden + i] += scale * d;
1288                }
1289            }
1290            m
1291        };
1292        attn.w_q = Tensor::from_vec(merge(&attn.w_q, &a_q, &b_q, q_dim), true);
1293        attn.w_v = Tensor::from_vec(merge(&attn.w_v, &a_v, &b_v, kv_dim), true);
1294        let out_merged = attn.forward(&x, seq);
1295
1296        let ld = out_lora.data();
1297        let ls = ld.as_slice().expect("contiguous lora out");
1298        let md = out_merged.data();
1299        let ms = md.as_slice().expect("contiguous merged out");
1300        assert_eq!(ls.len(), ms.len(), "output shape mismatch");
1301        let max_abs = ls.iter().zip(ms).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max);
1302        assert!(
1303            max_abs < 1e-4,
1304            "FALSIFY-QLORA-COMPOSED-FORWARD-001: composed forward_with_lora (base + \
1305             scale·B@A + bias) diverges from the LoRA-merged forward by max|Δ|={max_abs:.6} \
1306             — a dropped Q/K/V bias, a wrong LoRA scale, or a transpose in the composition \
1307             (the #2260 bias-drop class, now with nonzero LoRA + biases)."
1308        );
1309        println!(
1310            "BEAT-QLORA-COMPOSED-FORWARD: forward_with_lora ≡ merged forward — max|Δ|={max_abs:.2e}"
1311        );
1312    }
1313
1314    /// PMAT-805: RoPE must propagate gradients (it is no longer an autograd
1315    /// leaf). Validate `RopeBackward` against finite differences of a scalar
1316    /// loss `L = sum(rope(x) * w)` so dL/dx = rope_backward(w).
1317    #[test]
1318    fn falsify_pmat805_rope_backward_matches_finite_difference() {
1319        let seq_len = 3usize;
1320        let num_heads = 2usize;
1321        let head_dim = 4usize; // half_dim = 2
1322        let total = seq_len * num_heads * head_dim;
1323        let theta = 10000.0f32;
1324
1325        // Deterministic input + upstream gradient weights.
1326        let x_data: Vec<f32> = (0..total).map(|i| ((i as f32 * 0.37).sin() * 1.5) + 0.1).collect();
1327        let w: Vec<f32> = (0..total).map(|i| ((i as f32 * 0.21).cos() * 0.8) - 0.05).collect();
1328
1329        // Analytical gradient via RopeBackward.
1330        let x = Tensor::from_vec(x_data.clone(), true);
1331        let y = apply_rope(&x, seq_len, num_heads, head_dim, theta);
1332        y.set_grad(Array1::from(w.clone()));
1333        y.backward_op().expect("rope must have a backward op").backward();
1334        let analytical = x.grad().expect("x must have grad after rope backward").to_vec();
1335
1336        // Numerical gradient: dL/dx_j ≈ (L(x+h) - L(x-h)) / 2h, L = sum(rope(x) · w).
1337        let h = 1e-3f32;
1338        let loss = |xv: &[f32]| -> f32 {
1339            let t = Tensor::from_vec(xv.to_vec(), false);
1340            let r = apply_rope(&t, seq_len, num_heads, head_dim, theta);
1341            r.data().iter().zip(&w).map(|(a, b)| a * b).sum()
1342        };
1343        for j in 0..total {
1344            let mut xp = x_data.clone();
1345            let mut xm = x_data.clone();
1346            xp[j] += h;
1347            xm[j] -= h;
1348            let numerical = (loss(&xp) - loss(&xm)) / (2.0 * h);
1349            let diff = (analytical[j] - numerical).abs();
1350            assert!(
1351                diff < 1e-2,
1352                "FALSIFIED: RoPE grad[{j}] analytical={} numerical={} diff={diff}",
1353                analytical[j],
1354                numerical
1355            );
1356        }
1357    }
1358
1359    #[test]
1360    fn test_multi_head_attention_tiny() {
1361        let config = TransformerConfig::tiny();
1362        let attn = MultiHeadAttention::new(&config);
1363        let x = Tensor::from_vec(vec![0.1; 2 * config.hidden_size], true);
1364        let output = attn.forward(&x, 2);
1365        assert_eq!(output.len(), 2 * config.hidden_size);
1366    }
1367
1368    #[test]
1369    fn test_multi_head_attention_parameters() {
1370        let config = TransformerConfig::tiny();
1371        let attn = MultiHeadAttention::new(&config);
1372        let params = attn.parameters();
1373        assert_eq!(params.len(), 4); // w_q, w_k, w_v, w_o
1374    }
1375
1376    #[test]
1377    fn test_attention_longer_sequence() {
1378        let config = TransformerConfig::tiny();
1379        let attn = MultiHeadAttention::new(&config);
1380        let x = Tensor::from_vec(vec![0.1; 8 * config.hidden_size], true);
1381        let output = attn.forward(&x, 8);
1382        assert_eq!(output.len(), 8 * config.hidden_size);
1383    }
1384
1385    #[test]
1386    fn test_attention_weight_sizes() {
1387        let config = TransformerConfig::tiny();
1388        let attn = MultiHeadAttention::new(&config);
1389        let kv_hidden = config.num_kv_heads * config.head_dim();
1390        assert_eq!(attn.w_q.len(), config.hidden_size * config.hidden_size);
1391        assert_eq!(attn.w_k.len(), config.hidden_size * kv_hidden);
1392        assert_eq!(attn.w_v.len(), config.hidden_size * kv_hidden);
1393        assert_eq!(attn.w_o.len(), config.hidden_size * config.hidden_size);
1394    }
1395
1396    #[test]
1397    fn test_multi_head_attention_from_params_success() {
1398        let config = TransformerConfig::tiny();
1399        let hidden_size = config.hidden_size;
1400        let kv_hidden_size = config.num_kv_heads * config.head_dim();
1401
1402        let mut params = HashMap::new();
1403        params.insert(
1404            "attn.q_proj.weight".to_string(),
1405            Tensor::from_vec(vec![0.1; hidden_size * hidden_size], true),
1406        );
1407        params.insert(
1408            "attn.k_proj.weight".to_string(),
1409            Tensor::from_vec(vec![0.1; hidden_size * kv_hidden_size], true),
1410        );
1411        params.insert(
1412            "attn.v_proj.weight".to_string(),
1413            Tensor::from_vec(vec![0.1; hidden_size * kv_hidden_size], true),
1414        );
1415        params.insert(
1416            "attn.o_proj.weight".to_string(),
1417            Tensor::from_vec(vec![0.1; hidden_size * hidden_size], true),
1418        );
1419
1420        let attn = MultiHeadAttention::from_params(&config, &params, "attn");
1421        assert!(attn.is_some());
1422        let attn = attn.expect("operation should succeed");
1423        assert_eq!(attn.w_q.len(), hidden_size * hidden_size);
1424    }
1425
1426    #[test]
1427    fn test_multi_head_attention_from_params_missing_key() {
1428        let config = TransformerConfig::tiny();
1429        let hidden_size = config.hidden_size;
1430
1431        let mut params = HashMap::new();
1432        params.insert(
1433            "attn.q_proj.weight".to_string(),
1434            Tensor::from_vec(vec![0.1; hidden_size * hidden_size], true),
1435        );
1436        // Missing k_proj, v_proj, o_proj
1437
1438        let attn = MultiHeadAttention::from_params(&config, &params, "attn");
1439        assert!(attn.is_none());
1440    }
1441
1442    #[test]
1443    fn test_attention_projections_backward() {
1444        // Test that Q, K, V projection matmuls have gradients
1445        // (isolated from the full attention which has intermediate tensor issues)
1446        let config = TransformerConfig::tiny();
1447        let attn = MultiHeadAttention::new(&config);
1448        let hidden_size = config.hidden_size;
1449        let seq_len = 2;
1450
1451        let x = Tensor::from_vec(vec![0.1; seq_len * hidden_size], true);
1452
1453        // Test Q projection
1454        let mut q = crate::autograd::matmul(&x, &attn.w_q, seq_len, hidden_size, hidden_size);
1455        let grad_out = ndarray::Array1::ones(seq_len * hidden_size);
1456        crate::autograd::backward(&mut q, Some(grad_out));
1457
1458        assert!(attn.w_q.grad().is_some());
1459        let grad_q = attn.w_q.grad().expect("gradient should be available");
1460        assert!(grad_q.iter().all(|&v| v.is_finite()));
1461    }
1462
1463    #[test]
1464    fn test_output_projection_backward() {
1465        // Test output projection in isolation
1466        let config = TransformerConfig::tiny();
1467        let attn = MultiHeadAttention::new(&config);
1468        let hidden_size = config.hidden_size;
1469        let seq_len = 2;
1470
1471        // Simulate concatenated attention output
1472        let concat_out = Tensor::from_vec(vec![0.1; seq_len * hidden_size], true);
1473
1474        // Output projection
1475        let mut output =
1476            crate::autograd::matmul(&concat_out, &attn.w_o, seq_len, hidden_size, hidden_size);
1477
1478        let grad_out = ndarray::Array1::ones(seq_len * hidden_size);
1479        crate::autograd::backward(&mut output, Some(grad_out));
1480
1481        assert!(attn.w_o.grad().is_some());
1482        let grad_o = attn.w_o.grad().expect("gradient should be available");
1483        assert!(grad_o.iter().all(|&v| v.is_finite()));
1484        let sum: f32 = grad_o.iter().map(|v| v.abs()).sum();
1485        assert!(sum > 0.0, "Output projection gradient should not be all zero");
1486    }
1487
1488    /// ALB-038: Full attention forward must propagate gradients to Q/K/V weights
1489    ///
1490    /// NOTE: Currently fails because apply_rope() has no backward op — it severs
1491    /// the autograd chain for Q and K. Needs a proper RoPE backward implementation
1492    /// (ENT-272). Skipped until then.
1493    #[test]
1494    #[ignore = "apply_rope() severs autograd chain — needs backward op (ENT-272)"]
1495    fn test_attention_full_forward_qkv_gradients() {
1496        let config = TransformerConfig::tiny();
1497        let attn = MultiHeadAttention::new(&config);
1498        let hidden_size = config.hidden_size;
1499        let seq_len = 3;
1500
1501        // Non-uniform input: different positions must have different representations
1502        // so softmax produces non-uniform weights with non-zero score gradients
1503        let x_data: Vec<f32> =
1504            (0..seq_len * hidden_size).map(|i| ((i as f32) * 0.17).sin() * 0.5).collect();
1505        let x = Tensor::from_vec(x_data, true);
1506        let mut output = attn.forward(&x, seq_len);
1507
1508        let grad_out = ndarray::Array1::ones(seq_len * hidden_size);
1509        crate::autograd::backward(&mut output, Some(grad_out));
1510
1511        // All four projection weights must receive gradients
1512        for (name, param) in
1513            [("w_q", &attn.w_q), ("w_k", &attn.w_k), ("w_v", &attn.w_v), ("w_o", &attn.w_o)]
1514        {
1515            assert!(
1516                param.grad().is_some(),
1517                "ALB-038: {name} must have gradient after full attention forward"
1518            );
1519            let grad = param.grad().expect("gradient available");
1520            assert!(grad.iter().all(|&v| v.is_finite()), "ALB-038: {name} gradient must be finite");
1521            assert!(
1522                grad.iter().any(|&v| v.abs() > 1e-10),
1523                "ALB-038: {name} gradient must be non-zero"
1524            );
1525        }
1526
1527        // Input must also receive gradient (enables gradient flow through model)
1528        assert!(x.grad().is_some(), "ALB-038: input x must have gradient");
1529    }
1530
1531    // ============================================================================
1532    // LoRAProjection tests
1533    // ============================================================================
1534
1535    #[test]
1536    fn test_lora_projection_new() {
1537        let d_in = 32;
1538        let d_out = 16;
1539        let rank = 4;
1540        let alpha = 8.0;
1541
1542        let base_weight = Tensor::from_vec(vec![0.1; d_in * d_out], false);
1543        let lora = LoRAProjection::new(base_weight, d_in, d_out, rank, alpha);
1544
1545        assert_eq!(lora.d_in, d_in);
1546        assert_eq!(lora.d_out, d_out);
1547        assert_eq!(lora.rank, rank);
1548        assert!((lora.scale - 2.0).abs() < 1e-6); // alpha / rank = 8 / 4 = 2
1549        assert_eq!(lora.lora_a.len(), d_in * rank);
1550        assert_eq!(lora.lora_b.len(), rank * d_out);
1551    }
1552
1553    #[test]
1554    fn test_lora_projection_forward() {
1555        let d_in = 32;
1556        let d_out = 16;
1557        let rank = 4;
1558        let alpha = 8.0;
1559        let seq_len = 2;
1560
1561        let base_weight = Tensor::from_vec(vec![0.1; d_in * d_out], false);
1562        let lora = LoRAProjection::new(base_weight, d_in, d_out, rank, alpha);
1563
1564        let x = Tensor::from_vec(vec![0.1; seq_len * d_in], false);
1565        let output = lora.forward(&x, seq_len);
1566
1567        assert_eq!(output.len(), seq_len * d_out);
1568        // Check output is finite
1569        assert!(output.data().iter().all(|&v| v.is_finite()));
1570    }
1571
1572    #[test]
1573    fn test_lora_projection_params() {
1574        let d_in = 32;
1575        let d_out = 16;
1576        let rank = 4;
1577
1578        let base_weight = Tensor::from_vec(vec![0.1; d_in * d_out], false);
1579        let lora = LoRAProjection::new(base_weight, d_in, d_out, rank, 8.0);
1580
1581        let params = lora.lora_params();
1582        assert_eq!(params.len(), 2); // lora_a and lora_b
1583    }
1584
1585    #[test]
1586    fn test_lora_projection_params_mut() {
1587        let d_in = 32;
1588        let d_out = 16;
1589        let rank = 4;
1590
1591        let base_weight = Tensor::from_vec(vec![0.1; d_in * d_out], false);
1592        let mut lora = LoRAProjection::new(base_weight, d_in, d_out, rank, 8.0);
1593
1594        let params = lora.lora_params_mut();
1595        assert_eq!(params.len(), 2);
1596    }
1597
1598    #[test]
1599    #[should_panic(expected = "Base weight size mismatch")]
1600    fn test_lora_projection_size_mismatch() {
1601        let d_in = 32;
1602        let d_out = 16;
1603        let rank = 4;
1604
1605        // Wrong base weight size
1606        let base_weight = Tensor::from_vec(vec![0.1; d_in * d_out + 1], false);
1607        let _ = LoRAProjection::new(base_weight, d_in, d_out, rank, 8.0);
1608    }
1609
1610    // ============================================================================
1611    // MultiHeadAttentionWithLoRA tests
1612    // ============================================================================
1613
1614    #[test]
1615    fn test_mha_with_lora_creation() {
1616        let config = TransformerConfig::tiny();
1617        let attn = MultiHeadAttention::new(&config);
1618        let rank = 4;
1619        let alpha = 8.0;
1620
1621        let lora_attn = MultiHeadAttentionWithLoRA::from_attention(&attn, rank, alpha);
1622
1623        assert_eq!(lora_attn.q_proj.rank, rank);
1624        assert_eq!(lora_attn.k_proj.rank, rank);
1625        assert_eq!(lora_attn.v_proj.rank, rank);
1626        assert_eq!(lora_attn.o_proj.rank, rank);
1627    }
1628
1629    #[test]
1630    fn test_mha_with_lora_forward() {
1631        let config = TransformerConfig::tiny();
1632        let attn = MultiHeadAttention::new(&config);
1633        let lora_attn = MultiHeadAttentionWithLoRA::from_attention(&attn, 4, 8.0);
1634
1635        let seq_len = 2;
1636        let x = Tensor::from_vec(vec![0.1; seq_len * config.hidden_size], false);
1637        let output = lora_attn.forward(&x, seq_len);
1638
1639        assert_eq!(output.len(), seq_len * config.hidden_size);
1640        // Check output is finite and non-zero
1641        assert!(output.data().iter().all(|&v| v.is_finite()));
1642    }
1643
1644    #[test]
1645    fn test_mha_with_lora_params() {
1646        let config = TransformerConfig::tiny();
1647        let attn = MultiHeadAttention::new(&config);
1648        let lora_attn = MultiHeadAttentionWithLoRA::from_attention(&attn, 4, 8.0);
1649
1650        let params = lora_attn.lora_params();
1651        // 4 projections × 2 params each = 8
1652        assert_eq!(params.len(), 8);
1653    }
1654
1655    #[test]
1656    fn test_mha_with_lora_params_mut() {
1657        let config = TransformerConfig::tiny();
1658        let attn = MultiHeadAttention::new(&config);
1659        let mut lora_attn = MultiHeadAttentionWithLoRA::from_attention(&attn, 4, 8.0);
1660
1661        let params = lora_attn.lora_params_mut();
1662        assert_eq!(params.len(), 8);
1663    }
1664
1665    #[test]
1666    fn test_mha_with_lora_param_count() {
1667        let config = TransformerConfig::tiny();
1668        let attn = MultiHeadAttention::new(&config);
1669        let rank = 4;
1670        let lora_attn = MultiHeadAttentionWithLoRA::from_attention(&attn, rank, 8.0);
1671
1672        let param_count = lora_attn.lora_param_count();
1673
1674        // Calculate expected:
1675        let hidden = config.hidden_size;
1676        let kv_hidden = config.num_kv_heads * config.head_dim();
1677        let expected = (hidden * rank + rank * hidden)      // Q
1678            + (hidden * rank + rank * kv_hidden) // K
1679            + (hidden * rank + rank * kv_hidden) // V
1680            + (hidden * rank + rank * hidden); // O
1681
1682        assert_eq!(param_count, expected);
1683        assert!(param_count > 0);
1684    }
1685
1686    #[test]
1687    fn test_mha_with_lora_longer_sequence() {
1688        let config = TransformerConfig::tiny();
1689        let attn = MultiHeadAttention::new(&config);
1690        let lora_attn = MultiHeadAttentionWithLoRA::from_attention(&attn, 4, 8.0);
1691
1692        let seq_len = 8;
1693        let x = Tensor::from_vec(vec![0.1; seq_len * config.hidden_size], false);
1694        let output = lora_attn.forward(&x, seq_len);
1695
1696        assert_eq!(output.len(), seq_len * config.hidden_size);
1697    }
1698
1699    #[test]
1700    fn test_parameters_mut() {
1701        let config = TransformerConfig::tiny();
1702        let mut attn = MultiHeadAttention::new(&config);
1703
1704        let params = attn.parameters_mut();
1705        assert_eq!(params.len(), 4);
1706    }
1707
1708    // =========================================================================
1709    // FALSIFY-A: §2.1.3 Attention Projections — Five-Whys Gap Analysis (Refs PMAT-331)
1710    //
1711    // Contract: tensor-layout-v1.yaml §tensors.q_proj/k_proj/v_proj/o_proj
1712    //   q_proj: [num_heads*head_dim, hidden] (= [hidden, hidden] for MHA)
1713    //   k_proj: [num_kv_heads*head_dim, hidden] (smaller for GQA)
1714    //   v_proj: [num_kv_heads*head_dim, hidden] (smaller for GQA)
1715    //   o_proj: [hidden, num_heads*head_dim]
1716    //
1717    // Five-Whys:
1718    //   Why 1: Trained model's attention weights could be wrong shape
1719    //   Why 2: from_params accepts any tensor without shape validation
1720    //   Why 3: No ValidatedWeight in entrenar
1721    //   Why 4: entrenar predates the Poka-Yoke contract
1722    //   Why 5: No cross-crate contract enforcement for training weights
1723    //
1724    // Popper (1959): "These tests attempt to falsify the claim that
1725    // entrenar's attention weight handling prevents degenerate models."
1726    // =========================================================================
1727
1728    /// FALSIFY-A1e: from_params rejects wrong-shape Q weight (PMAT-331 fix)
1729    ///
1730    /// from_params now validates Q projection shape against config dimensions.
1731    /// A tensor of 50 elements is rejected when hidden*hidden is expected.
1732    #[test]
1733    fn falsify_a1e_from_params_rejects_wrong_shape_q_weight() {
1734        let config = TransformerConfig::tiny();
1735        let hidden_size = config.hidden_size;
1736        let kv_hidden_size = config.num_kv_heads * config.head_dim();
1737
1738        let mut params = HashMap::new();
1739        // WRONG-SHAPE q_proj: 50 elements instead of hidden*hidden
1740        params.insert("attn.q_proj.weight".to_string(), Tensor::from_vec(vec![0.1; 50], true));
1741        // Correct k, v, o
1742        params.insert(
1743            "attn.k_proj.weight".to_string(),
1744            Tensor::from_vec(vec![0.1; hidden_size * kv_hidden_size], true),
1745        );
1746        params.insert(
1747            "attn.v_proj.weight".to_string(),
1748            Tensor::from_vec(vec![0.1; hidden_size * kv_hidden_size], true),
1749        );
1750        params.insert(
1751            "attn.o_proj.weight".to_string(),
1752            Tensor::from_vec(vec![0.1; hidden_size * hidden_size], true),
1753        );
1754
1755        let attn = MultiHeadAttention::from_params(&config, &params, "attn");
1756        // FIXED (PMAT-331): now rejected
1757        assert!(
1758            attn.is_none(),
1759            "FALSIFY-A1e: PMAT-331 fix — from_params MUST reject wrong-shape q_proj"
1760        );
1761    }
1762
1763    /// FALSIFY-A2e: GQA init produces correct K/V dimensions
1764    ///
1765    /// For GQA (num_kv_heads < num_heads), K/V must be smaller than Q.
1766    /// If init uses num_heads for K/V, the shapes are wrong.
1767    #[test]
1768    fn falsify_a2e_gqa_init_correct_kv_dimensions() {
1769        let mut config = TransformerConfig::tiny();
1770        config.num_kv_heads = 1; // Force GQA: 1 KV head, but num_heads > 1
1771
1772        let attn = MultiHeadAttention::new(&config);
1773        let head_dim = config.head_dim();
1774        let kv_hidden = config.num_kv_heads * head_dim; // 1 * head_dim
1775
1776        // Q: hidden * hidden
1777        assert_eq!(
1778            attn.w_q.len(),
1779            config.hidden_size * config.hidden_size,
1780            "FALSIFY-A2e: Q projection must be hidden*hidden"
1781        );
1782
1783        // K: hidden * kv_hidden (smaller than Q for GQA)
1784        assert_eq!(
1785            attn.w_k.len(),
1786            config.hidden_size * kv_hidden,
1787            "FALSIFY-A2e: K projection must use num_kv_heads, not num_heads"
1788        );
1789
1790        // V: hidden * kv_hidden (same as K)
1791        assert_eq!(
1792            attn.w_v.len(),
1793            config.hidden_size * kv_hidden,
1794            "FALSIFY-A2e: V projection must use num_kv_heads, not num_heads"
1795        );
1796
1797        // O: hidden * hidden (matches Q output)
1798        assert_eq!(
1799            attn.w_o.len(),
1800            config.hidden_size * config.hidden_size,
1801            "FALSIFY-A2e: O projection must be hidden*hidden"
1802        );
1803
1804        // K/V must be SMALLER than Q for GQA
1805        assert!(
1806            attn.w_k.len() < attn.w_q.len(),
1807            "FALSIFY-A2e: For GQA, K weight must be smaller than Q weight"
1808        );
1809    }
1810
1811    /// FALSIFY-A3e: GQA forward produces correct output dimensions
1812    ///
1813    /// With num_kv_heads < num_heads, the forward pass must still produce
1814    /// [seq_len, hidden_size] output (not [seq_len, kv_hidden]).
1815    #[test]
1816    fn falsify_a3e_gqa_forward_correct_output_dims() {
1817        let mut config = TransformerConfig::tiny();
1818        config.num_kv_heads = 1; // Force GQA
1819
1820        let attn = MultiHeadAttention::new(&config);
1821        let seq_len = 3;
1822        let x = Tensor::from_vec(vec![0.1; seq_len * config.hidden_size], true);
1823        let output = attn.forward(&x, seq_len);
1824
1825        assert_eq!(
1826            output.len(),
1827            seq_len * config.hidden_size,
1828            "FALSIFY-A3e: GQA output must be seq_len * hidden_size, not seq_len * kv_hidden"
1829        );
1830    }
1831
1832    /// FALSIFY-A4e: Attention init produces non-degenerate values
1833    ///
1834    /// Like FALSIFY-E7a for embeddings: init must produce varied, finite values.
1835    #[test]
1836    fn falsify_a4e_init_produces_valid_attention_weights() {
1837        let config = TransformerConfig::tiny();
1838        let attn = MultiHeadAttention::new(&config);
1839
1840        for (name, w) in
1841            [("w_q", &attn.w_q), ("w_k", &attn.w_k), ("w_v", &attn.w_v), ("w_o", &attn.w_o)]
1842        {
1843            let data = w.data();
1844            let slice = data.as_slice().expect("data as slice");
1845
1846            // No NaN
1847            let nan_count = slice.iter().filter(|v| v.is_nan()).count();
1848            assert_eq!(nan_count, 0, "FALSIFY-A4e: {name} init must not contain NaN");
1849
1850            // No Inf
1851            let inf_count = slice.iter().filter(|v| v.is_infinite()).count();
1852            assert_eq!(inf_count, 0, "FALSIFY-A4e: {name} init must not contain Inf");
1853
1854            // Values vary
1855            let min = slice.iter().copied().fold(f32::INFINITY, f32::min);
1856            let max = slice.iter().copied().fold(f32::NEG_INFINITY, f32::max);
1857            assert!(
1858                (max - min).abs() > 1e-6,
1859                "FALSIFY-A4e: {name} init values are constant ({min}..{max}) — degenerate weight"
1860            );
1861        }
1862    }
1863
1864    /// FALSIFY-A5e: Attention forward produces finite outputs
1865    ///
1866    /// If any attention weight is degenerate, output should still be finite
1867    /// (the init is designed to prevent this).
1868    #[test]
1869    fn falsify_a5e_forward_produces_finite_output() {
1870        let config = TransformerConfig::tiny();
1871        let attn = MultiHeadAttention::new(&config);
1872        let seq_len = 4;
1873        let x = Tensor::from_vec(vec![0.1; seq_len * config.hidden_size], true);
1874        let output = attn.forward(&x, seq_len);
1875
1876        let data = output.data();
1877        let nan_count = data.iter().filter(|v| v.is_nan()).count();
1878        let inf_count = data.iter().filter(|v| v.is_infinite()).count();
1879        assert_eq!(nan_count, 0, "FALSIFY-A5e: Attention output must not contain NaN");
1880        assert_eq!(inf_count, 0, "FALSIFY-A5e: Attention output must not contain Inf");
1881    }
1882
1883    // =========================================================================
1884    // FALSIFY-GQ: gqa-kernel-v1.yaml contract (entrenar MultiHeadAttention GQA)
1885    //
1886    // Five-Whys (PMAT-354):
1887    //   Why 1: entrenar had FALSIFY-A tests but zero FALSIFY-GQ-* tests
1888    //   Why 2: FALSIFY-A tests verify projections/shapes, not GQA invariants
1889    //   Why 3: no mapping from gqa-kernel-v1.yaml to entrenar test names
1890    //   Why 4: entrenar's GQA support added after FALSIFY-A tests
1891    //   Why 5: GQA was "obviously correct" (just index K/V by h/heads_per_kv)
1892    //
1893    // References:
1894    //   - provable-contracts/contracts/gqa-kernel-v1.yaml
1895    //   - Ainslie et al. (2023) "GQA: Training Generalized MQT Models"
1896    // =========================================================================
1897
1898    /// FALSIFY-GQ-001e: GQA output shape correct for various head configs
1899    #[test]
1900    fn falsify_gq_001e_output_shape() {
1901        for (num_heads, num_kv_heads) in [(2, 2), (4, 2), (4, 1), (2, 1)] {
1902            let mut config = TransformerConfig::tiny();
1903            config.num_attention_heads = num_heads;
1904            config.num_kv_heads = num_kv_heads;
1905
1906            let attn = MultiHeadAttention::new(&config);
1907            let seq_len = 3;
1908            let x = Tensor::from_vec(vec![0.1; seq_len * config.hidden_size], true);
1909            let output = attn.forward(&x, seq_len);
1910
1911            assert_eq!(
1912                output.len(),
1913                seq_len * config.hidden_size,
1914                "FALSIFIED GQ-001e: output len mismatch for heads={num_heads},kv={num_kv_heads}"
1915            );
1916        }
1917    }
1918
1919    /// FALSIFY-GQ-002e: MHA degeneration — kv_heads == num_heads produces finite output
1920    #[test]
1921    fn falsify_gq_002e_mha_degeneration() {
1922        let config = TransformerConfig::tiny(); // num_heads == num_kv_heads == 2
1923        assert_eq!(config.num_attention_heads, config.num_kv_heads);
1924
1925        let attn = MultiHeadAttention::new(&config);
1926        let seq_len = 4;
1927        let x = Tensor::from_vec(
1928            (0..seq_len * config.hidden_size).map(|i| (i as f32 * 0.37).sin()).collect(),
1929            true,
1930        );
1931        let output = attn.forward(&x, seq_len);
1932
1933        let data = output.data();
1934        for (i, v) in data.iter().enumerate() {
1935            assert!(v.is_finite(), "FALSIFIED GQ-002e: MHA output[{i}] = {v} (not finite)");
1936        }
1937    }
1938
1939    /// FALSIFY-GQ-004e: Head divisibility — GQA requires num_heads % num_kv_heads == 0
1940    #[test]
1941    fn falsify_gq_004e_head_divisibility() {
1942        // Valid configurations should not panic
1943        for (nh, nkv) in [(2, 1), (2, 2), (4, 1), (4, 2), (4, 4), (8, 2), (8, 4)] {
1944            let mut config = TransformerConfig::tiny();
1945            config.num_attention_heads = nh;
1946            config.num_kv_heads = nkv;
1947            assert_eq!(nh % nkv, 0, "FALSIFIED GQ-004e: test config has invalid head ratio");
1948            // Should not panic during construction or forward
1949            let attn = MultiHeadAttention::new(&config);
1950            let x = Tensor::from_vec(vec![0.1; 2 * config.hidden_size], true);
1951            let _ = attn.forward(&x, 2);
1952        }
1953    }
1954
1955    /// FALSIFY-GQ-006e: MQA boundary — kv_heads=1 broadcasts single KV to all heads
1956    #[test]
1957    fn falsify_gq_006e_mqa_boundary() {
1958        let mut config = TransformerConfig::tiny();
1959        config.num_attention_heads = 4;
1960        config.num_kv_heads = 1;
1961        // Adjust hidden_size to be divisible by 4 heads
1962        config.hidden_size = 64;
1963
1964        let attn = MultiHeadAttention::new(&config);
1965        let seq_len = 3;
1966        let x = Tensor::from_vec(
1967            (0..seq_len * config.hidden_size).map(|i| (i as f32 * 0.73).cos()).collect(),
1968            true,
1969        );
1970        let output = attn.forward(&x, seq_len);
1971
1972        assert_eq!(
1973            output.len(),
1974            seq_len * config.hidden_size,
1975            "FALSIFIED GQ-006e: MQA output size wrong"
1976        );
1977
1978        // All finite
1979        let data = output.data();
1980        for (i, v) in data.iter().enumerate() {
1981            assert!(v.is_finite(), "FALSIFIED GQ-006e: MQA output[{i}] = {v} (not finite)");
1982        }
1983    }
1984
1985    mod gq_proptest_falsify {
1986        use super::*;
1987        use proptest::prelude::*;
1988
1989        // FALSIFY-GQ-001e-prop: GQA output shape for random configs
1990        proptest! {
1991            #![proptest_config(ProptestConfig::with_cases(50))]
1992
1993            #[test]
1994            fn falsify_gq_001e_prop_output_shape(
1995                config_idx in 0..4usize,
1996                seq_len in 2..=6usize,
1997                seed in 0..500u32,
1998            ) {
1999                let configs: [(usize, usize); 4] = [
2000                    (2, 2), (2, 1), (4, 2), (4, 1),
2001                ];
2002                let (num_heads, num_kv_heads) = configs[config_idx];
2003                let mut config = TransformerConfig::tiny();
2004                config.num_attention_heads = num_heads;
2005                config.num_kv_heads = num_kv_heads;
2006
2007                let attn = MultiHeadAttention::new(&config);
2008                let data: Vec<f32> = (0..seq_len * config.hidden_size)
2009                    .map(|i| ((i as f32 + seed as f32) * 0.37).sin())
2010                    .collect();
2011                let x = Tensor::from_vec(data, true);
2012                let output = attn.forward(&x, seq_len);
2013
2014                prop_assert_eq!(
2015                    output.len(),
2016                    seq_len * config.hidden_size,
2017                    "FALSIFIED GQ-001e-prop: output len mismatch"
2018                );
2019
2020                // All finite
2021                for v in output.data() {
2022                    prop_assert!(
2023                        v.is_finite(),
2024                        "FALSIFIED GQ-001e-prop: non-finite output"
2025                    );
2026                }
2027            }
2028        }
2029
2030        // FALSIFY-GQ-006e-prop: MQA boundary with random inputs
2031        proptest! {
2032            #![proptest_config(ProptestConfig::with_cases(30))]
2033
2034            #[test]
2035            fn falsify_gq_006e_prop_mqa_boundary(
2036                seed in 0..500u32,
2037                seq_len in 2..=5usize,
2038            ) {
2039                let mut config = TransformerConfig::tiny();
2040                config.num_attention_heads = 4;
2041                config.num_kv_heads = 1;
2042                config.hidden_size = 64;
2043
2044                let attn = MultiHeadAttention::new(&config);
2045                let data: Vec<f32> = (0..seq_len * config.hidden_size)
2046                    .map(|i| ((i as f32 + seed as f32) * 0.73).cos())
2047                    .collect();
2048                let x = Tensor::from_vec(data, true);
2049                let output = attn.forward(&x, seq_len);
2050
2051                prop_assert_eq!(
2052                    output.len(),
2053                    seq_len * config.hidden_size,
2054                    "FALSIFIED GQ-006e-prop: MQA output len mismatch"
2055                );
2056
2057                for v in output.data() {
2058                    prop_assert!(
2059                        v.is_finite(),
2060                        "FALSIFIED GQ-006e-prop: non-finite MQA output"
2061                    );
2062                }
2063            }
2064        }
2065    }
2066
2067    #[test]
2068    fn test_attention_from_params_with_biases() {
2069        let config = TransformerConfig::tiny();
2070        let hidden_size = config.hidden_size;
2071        let kv_hidden_size = config.num_kv_heads * config.head_dim();
2072
2073        let mut params = HashMap::new();
2074        params.insert(
2075            "attn.q_proj.weight".to_string(),
2076            Tensor::from_vec(vec![0.1; hidden_size * hidden_size], true),
2077        );
2078        params.insert(
2079            "attn.k_proj.weight".to_string(),
2080            Tensor::from_vec(vec![0.1; hidden_size * kv_hidden_size], true),
2081        );
2082        params.insert(
2083            "attn.v_proj.weight".to_string(),
2084            Tensor::from_vec(vec![0.1; hidden_size * kv_hidden_size], true),
2085        );
2086        params.insert(
2087            "attn.o_proj.weight".to_string(),
2088            Tensor::from_vec(vec![0.1; hidden_size * hidden_size], true),
2089        );
2090        params.insert(
2091            "attn.q_proj.bias".to_string(),
2092            Tensor::from_vec(vec![0.01; hidden_size], true),
2093        );
2094        params.insert(
2095            "attn.k_proj.bias".to_string(),
2096            Tensor::from_vec(vec![0.01; kv_hidden_size], true),
2097        );
2098        params.insert(
2099            "attn.v_proj.bias".to_string(),
2100            Tensor::from_vec(vec![0.01; kv_hidden_size], true),
2101        );
2102
2103        let attn = MultiHeadAttention::from_params(&config, &params, "attn");
2104        assert!(attn.is_some());
2105        let attn = attn.expect("should load with biases");
2106        assert!(attn.has_biases());
2107        assert_eq!(attn.parameters().len(), 7);
2108    }
2109
2110    #[test]
2111    fn test_attention_named_parameters_with_biases() {
2112        let config = TransformerConfig::tiny();
2113        let hidden_size = config.hidden_size;
2114        let kv_hidden_size = config.num_kv_heads * config.head_dim();
2115
2116        let mut params = HashMap::new();
2117        params.insert(
2118            "attn.q_proj.weight".to_string(),
2119            Tensor::from_vec(vec![0.1; hidden_size * hidden_size], true),
2120        );
2121        params.insert(
2122            "attn.k_proj.weight".to_string(),
2123            Tensor::from_vec(vec![0.1; hidden_size * kv_hidden_size], true),
2124        );
2125        params.insert(
2126            "attn.v_proj.weight".to_string(),
2127            Tensor::from_vec(vec![0.1; hidden_size * kv_hidden_size], true),
2128        );
2129        params.insert(
2130            "attn.o_proj.weight".to_string(),
2131            Tensor::from_vec(vec![0.1; hidden_size * hidden_size], true),
2132        );
2133        params.insert(
2134            "attn.q_proj.bias".to_string(),
2135            Tensor::from_vec(vec![0.01; hidden_size], true),
2136        );
2137        params.insert(
2138            "attn.k_proj.bias".to_string(),
2139            Tensor::from_vec(vec![0.01; kv_hidden_size], true),
2140        );
2141        params.insert(
2142            "attn.v_proj.bias".to_string(),
2143            Tensor::from_vec(vec![0.01; kv_hidden_size], true),
2144        );
2145
2146        let attn = MultiHeadAttention::from_params(&config, &params, "attn").expect("should load");
2147        let named = attn.named_parameters("attn");
2148        assert_eq!(named.len(), 7);
2149        let names: Vec<&str> = named.iter().map(|(n, _)| n.as_str()).collect();
2150        assert!(names.contains(&"attn.q_proj.bias"));
2151        assert!(names.contains(&"attn.k_proj.bias"));
2152        assert!(names.contains(&"attn.v_proj.bias"));
2153    }
2154
2155    #[test]
2156    fn test_attention_forward_with_biases() {
2157        let config = TransformerConfig::tiny();
2158        let hidden_size = config.hidden_size;
2159        let kv_hidden_size = config.num_kv_heads * config.head_dim();
2160
2161        let mut params = HashMap::new();
2162        params.insert(
2163            "attn.q_proj.weight".to_string(),
2164            Tensor::from_vec(vec![0.1; hidden_size * hidden_size], true),
2165        );
2166        params.insert(
2167            "attn.k_proj.weight".to_string(),
2168            Tensor::from_vec(vec![0.1; hidden_size * kv_hidden_size], true),
2169        );
2170        params.insert(
2171            "attn.v_proj.weight".to_string(),
2172            Tensor::from_vec(vec![0.1; hidden_size * kv_hidden_size], true),
2173        );
2174        params.insert(
2175            "attn.o_proj.weight".to_string(),
2176            Tensor::from_vec(vec![0.1; hidden_size * hidden_size], true),
2177        );
2178        params
2179            .insert("attn.q_proj.bias".to_string(), Tensor::from_vec(vec![0.5; hidden_size], true));
2180        params.insert(
2181            "attn.k_proj.bias".to_string(),
2182            Tensor::from_vec(vec![0.5; kv_hidden_size], true),
2183        );
2184        params.insert(
2185            "attn.v_proj.bias".to_string(),
2186            Tensor::from_vec(vec![0.5; kv_hidden_size], true),
2187        );
2188
2189        let attn = MultiHeadAttention::from_params(&config, &params, "attn").expect("should load");
2190        let x = Tensor::from_vec(vec![0.1; 2 * hidden_size], false);
2191        let output = attn.forward(&x, 2);
2192        assert_eq!(output.len(), 2 * hidden_size);
2193        assert!(output.data().iter().all(|v| v.is_finite()));
2194    }
2195}