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" if self.b_q.is_some() => {
928                self.b_q = Some(value);
929                true
930            }
931            "self_attn.k_proj.bias" if self.b_k.is_some() => {
932                self.b_k = Some(value);
933                true
934            }
935            "self_attn.v_proj.bias" if self.b_v.is_some() => {
936                self.b_v = Some(value);
937                true
938            }
939            _ => false,
940        }
941    }
942}
943
944/// LoRA-enabled linear projection
945///
946/// Computes: y = x @ W + scale * (x @ A) @ B
947/// Where W is frozen base weight, A and B are trainable LoRA adapters
948pub struct LoRAProjection {
949    /// Base weight (frozen), shape (d_in × d_out)
950    pub base_weight: Tensor,
951    /// LoRA A matrix (down-projection), shape (d_in × rank)
952    pub lora_a: Tensor,
953    /// LoRA B matrix (up-projection), shape (rank × d_out)
954    pub lora_b: Tensor,
955    /// Input dimension
956    pub d_in: usize,
957    /// Output dimension
958    pub d_out: usize,
959    /// LoRA rank
960    pub rank: usize,
961    /// Scaling factor (alpha / rank)
962    pub scale: f32,
963}
964
965impl LoRAProjection {
966    /// Create a new LoRA projection
967    ///
968    /// # Arguments
969    /// * `base_weight` - Frozen base weight [d_in × d_out]
970    /// * `d_in` - Input dimension
971    /// * `d_out` - Output dimension
972    /// * `rank` - LoRA rank (typically 4, 8, 16, 32, or 64)
973    /// * `alpha` - LoRA scaling parameter
974    pub fn new(base_weight: Tensor, d_in: usize, d_out: usize, rank: usize, alpha: f32) -> Self {
975        assert_eq!(base_weight.len(), d_in * d_out, "Base weight size mismatch");
976
977        // Freeze base weight — only LoRA adapters are trainable
978        let mut base_weight = base_weight;
979        base_weight.set_requires_grad(false);
980
981        // Initialize A with Kaiming uniform (standard LoRA paper)
982        let lora_a = Tensor::from_vec(
983            (0..d_in * rank).map(|i| (i as f32 * 0.123).sin() * 0.01).collect(),
984            true, // requires_grad
985        );
986
987        // Initialize B with zeros (LoRA invariant: ΔW = B @ A = 0 at init)
988        let lora_b = Tensor::zeros(rank * d_out, true);
989
990        Self { base_weight, lora_a, lora_b, d_in, d_out, rank, scale: alpha / rank as f32 }
991    }
992
993    /// Forward pass with LoRA
994    ///
995    /// Computes: y = x @ W + scale * (x @ A) @ B
996    ///
997    /// # Arguments
998    /// * `x` - Input tensor [seq_len × d_in]
999    /// * `seq_len` - Sequence length
1000    ///
1001    /// # Returns
1002    /// Output tensor [seq_len × d_out]
1003    pub fn forward(&self, x: &Tensor, seq_len: usize) -> Tensor {
1004        // Base projection: x @ W, (seq × d_in) @ (d_in × d_out) = (seq × d_out)
1005        let base_out = matmul(x, &self.base_weight, seq_len, self.d_in, self.d_out);
1006
1007        // LoRA path: scale * (x @ A) @ B
1008        // Step 1: x @ A, (seq × d_in) @ (d_in × rank) = (seq × rank)
1009        let lora_intermediate = matmul(x, &self.lora_a, seq_len, self.d_in, self.rank);
1010
1011        // Step 2: (x @ A) @ B, (seq × rank) @ (rank × d_out) = (seq × d_out)
1012        let lora_out = matmul(&lora_intermediate, &self.lora_b, seq_len, self.rank, self.d_out);
1013
1014        // Combine: base + scale * lora
1015        // Use autograd-compatible addition
1016        crate::autograd::add_scaled(&base_out, &lora_out, self.scale)
1017    }
1018
1019    /// Get trainable LoRA parameters
1020    pub fn lora_params(&self) -> Vec<&Tensor> {
1021        vec![&self.lora_a, &self.lora_b]
1022    }
1023
1024    /// Get mutable trainable LoRA parameters
1025    pub fn lora_params_mut(&mut self) -> Vec<&mut Tensor> {
1026        vec![&mut self.lora_a, &mut self.lora_b]
1027    }
1028}
1029
1030/// Multi-head attention with deep LoRA injection
1031///
1032/// LoRA adapters are applied to Q, K, V, O projections during forward pass
1033pub struct MultiHeadAttentionWithLoRA {
1034    /// Configuration
1035    pub config: TransformerConfig,
1036    /// Query projection with LoRA
1037    pub q_proj: LoRAProjection,
1038    /// Key projection with LoRA
1039    pub k_proj: LoRAProjection,
1040    /// Value projection with LoRA
1041    pub v_proj: LoRAProjection,
1042    /// Output projection with LoRA
1043    pub o_proj: LoRAProjection,
1044}
1045
1046impl MultiHeadAttentionWithLoRA {
1047    /// Create LoRA-enabled attention from existing attention weights
1048    ///
1049    /// # Arguments
1050    /// * `attn` - Base MultiHeadAttention with pretrained weights
1051    /// * `rank` - LoRA rank
1052    /// * `alpha` - LoRA alpha scaling factor
1053    pub fn from_attention(attn: &MultiHeadAttention, rank: usize, alpha: f32) -> Self {
1054        let hidden_size = attn.config.hidden_size;
1055        let q_dim = attn.config.q_dim();
1056        let kv_hidden_size = attn.config.num_kv_heads * attn.config.head_dim();
1057
1058        Self {
1059            config: attn.config.clone(),
1060            q_proj: LoRAProjection::new(attn.w_q.clone(), hidden_size, q_dim, rank, alpha),
1061            k_proj: LoRAProjection::new(attn.w_k.clone(), hidden_size, kv_hidden_size, rank, alpha),
1062            v_proj: LoRAProjection::new(attn.w_v.clone(), hidden_size, kv_hidden_size, rank, alpha),
1063            o_proj: LoRAProjection::new(attn.w_o.clone(), q_dim, hidden_size, rank, alpha),
1064        }
1065    }
1066
1067    /// Forward pass with deep LoRA injection
1068    ///
1069    /// LoRA is applied to all Q, K, V, O projections
1070    pub fn forward(&self, x: &Tensor, seq_len: usize) -> Tensor {
1071        let num_heads = self.config.num_attention_heads;
1072        let num_kv_heads = self.config.num_kv_heads;
1073        let head_dim = self.config.head_dim();
1074        let q_dim = self.config.q_dim();
1075        let kv_hidden_size = num_kv_heads * head_dim;
1076
1077        // Project Q, K, V with LoRA
1078        let q = self.q_proj.forward(x, seq_len);
1079        let k = self.k_proj.forward(x, seq_len);
1080        let v = self.v_proj.forward(x, seq_len);
1081
1082        // Multi-head attention with grouped-query attention support
1083        let mut attn_outputs = Vec::with_capacity(num_heads * seq_len * head_dim);
1084        let heads_per_kv = num_heads / num_kv_heads;
1085
1086        // KAIZEN-016: Hoist data borrows outside head loop
1087        let q_data = q.data();
1088        let q_slice = q_data.as_slice().expect("contiguous Q tensor");
1089        let k_data = k.data();
1090        let k_slice = k_data.as_slice().expect("contiguous K tensor");
1091        let v_data = v.data();
1092        let v_slice = v_data.as_slice().expect("contiguous V tensor");
1093
1094        for h in 0..num_heads {
1095            let kv_h = h / heads_per_kv;
1096
1097            // KAIZEN-016: extend_from_slice replaces flat_map+to_vec
1098            let mut q_head = Vec::with_capacity(seq_len * head_dim);
1099            for s in 0..seq_len {
1100                let start = s * q_dim + h * head_dim;
1101                q_head.extend_from_slice(&q_slice[start..start + head_dim]);
1102            }
1103
1104            let mut k_head = Vec::with_capacity(seq_len * head_dim);
1105            for s in 0..seq_len {
1106                let start = s * kv_hidden_size + kv_h * head_dim;
1107                k_head.extend_from_slice(&k_slice[start..start + head_dim]);
1108            }
1109
1110            let mut v_head = Vec::with_capacity(seq_len * head_dim);
1111            for s in 0..seq_len {
1112                let start = s * kv_hidden_size + kv_h * head_dim;
1113                v_head.extend_from_slice(&v_slice[start..start + head_dim]);
1114            }
1115
1116            // Scaled dot-product attention
1117            let q_tensor = Tensor::from_vec(q_head, false);
1118            let k_tensor = Tensor::from_vec(k_head, false);
1119            let v_tensor = Tensor::from_vec(v_head, false);
1120
1121            // FALSIFY-CUDA-NF4-TRAIN-LOSS-PARITY-001: decoder-only models
1122            // MUST use causal attention. The unmasked path let every position
1123            // attend to FUTURE tokens, leaking the training labels backwards
1124            // (deceptively low train/eval loss) and diverging from the
1125            // causal CUDA training forward. Encoders stay bidirectional.
1126            let attn_out = if self.config.architecture == ModelArchitecture::Decoder {
1127                crate::autograd::attention_causal(
1128                    &q_tensor, &k_tensor, &v_tensor, seq_len, head_dim, seq_len, head_dim,
1129                )
1130            } else {
1131                crate::autograd::attention(
1132                    &q_tensor, &k_tensor, &v_tensor, seq_len, head_dim, seq_len, head_dim,
1133                )
1134            };
1135
1136            attn_outputs.extend_from_slice(
1137                attn_out.data().as_slice().expect("contiguous attention output"),
1138            );
1139        }
1140
1141        // Concatenate heads and reorder: (seq_len, q_dim)
1142        let mut concat_output = vec![0.0; seq_len * q_dim];
1143        for h in 0..num_heads {
1144            for s in 0..seq_len {
1145                let src_idx = h * seq_len * head_dim + s * head_dim;
1146                let dst_idx = s * q_dim + h * head_dim;
1147                concat_output[dst_idx..dst_idx + head_dim]
1148                    .copy_from_slice(&attn_outputs[src_idx..src_idx + head_dim]);
1149            }
1150        }
1151
1152        let concat_tensor = Tensor::from_vec(concat_output, true);
1153
1154        // Output projection with LoRA: (seq_len, q_dim) -> (seq_len, hidden_size)
1155        self.o_proj.forward(&concat_tensor, seq_len)
1156    }
1157
1158    /// Get all trainable LoRA parameters
1159    pub fn lora_params(&self) -> Vec<&Tensor> {
1160        let mut params = Vec::new();
1161        params.extend(self.q_proj.lora_params());
1162        params.extend(self.k_proj.lora_params());
1163        params.extend(self.v_proj.lora_params());
1164        params.extend(self.o_proj.lora_params());
1165        params
1166    }
1167
1168    /// Get all trainable LoRA parameters as mutable references
1169    pub fn lora_params_mut(&mut self) -> Vec<&mut Tensor> {
1170        let mut params = Vec::new();
1171        params.extend(self.q_proj.lora_params_mut());
1172        params.extend(self.k_proj.lora_params_mut());
1173        params.extend(self.v_proj.lora_params_mut());
1174        params.extend(self.o_proj.lora_params_mut());
1175        params
1176    }
1177
1178    /// Count total LoRA parameters
1179    pub fn lora_param_count(&self) -> usize {
1180        // Each projection has A (d_in × rank) + B (rank × d_out)
1181        let hidden = self.config.hidden_size;
1182        let kv_hidden = self.config.num_kv_heads * self.config.head_dim();
1183        let rank = self.q_proj.rank;
1184
1185        // Q: (hidden × rank) + (rank × hidden)
1186        // K: (hidden × rank) + (rank × kv_hidden)
1187        // V: (hidden × rank) + (rank × kv_hidden)
1188        // O: (hidden × rank) + (rank × hidden)
1189        (hidden * rank + rank * hidden)      // Q
1190            + (hidden * rank + rank * kv_hidden) // K
1191            + (hidden * rank + rank * kv_hidden) // V
1192            + (hidden * rank + rank * hidden) // O
1193    }
1194}
1195
1196#[cfg(test)]
1197mod tests {
1198    use super::*;
1199
1200    /// BEAT-QLORA-COMPOSED-FORWARD-EQUIVALENCE (FALSIFY-QLORA-COMPOSED-FORWARD-001).
1201    ///
1202    /// The composed on-the-fly QLoRA forward — base projection + `scale·(B@A)`
1203    /// LoRA delta + Q/K/V bias — must equal a forward on the model with the LoRA
1204    /// delta MERGED into the base weight. Gates the exact composition #2260's
1205    /// bias-drop corrupted, with NONZERO LoRA factors AND nonzero biases: the
1206    /// existing bias falsifier (FALSIFY-CPU-LORA-QKV-BIAS-001) uses zero-B so it
1207    /// only exercises the bias term, and `beat_lora_merge_forward_equivalence`
1208    /// uses no biases and hand-rolls the matmuls — neither drives all three terms
1209    /// through the real `forward_with_lora` code path.
1210    ///
1211    /// Independence: `forward_with_lora` computes `q = x@Wᵀ + scale·(x@Aᵀ)@Bᵀ + b`;
1212    /// the reference folds `W_merged = W + scale·(B@A)` and runs the plain
1213    /// `forward` — a different code path. A dropped bias, a wrong LoRA scale, or a
1214    /// transpose in either composition diverges. Self-contained, CPU, deterministic.
1215    #[test]
1216    fn beat_qlora_composed_forward_equivalence() {
1217        let mut config = TransformerConfig::tiny();
1218        config.use_bias = true;
1219        let hidden = config.hidden_size;
1220        let q_dim = config.q_dim();
1221        let kv_dim = config.num_kv_heads * config.head_dim();
1222        let seq = 3usize;
1223        let rank = 4usize;
1224        let scale = 8.0f32 / rank as f32; // alpha=8
1225
1226        let mut attn = MultiHeadAttention::new(&config);
1227
1228        // Deterministic NONZERO biases (new() zero-inits them — a dropped bias
1229        // term is invisible at zero bias).
1230        let mk_bias = |n: usize, amp: f32| {
1231            Tensor::from_vec((0..n).map(|i| amp * (((i % 7) as f32) - 3.0)).collect(), true)
1232        };
1233        attn.b_q = Some(mk_bias(q_dim, 0.05));
1234        attn.b_k = Some(mk_bias(kv_dim, 0.03));
1235        attn.b_v = Some(mk_bias(kv_dim, 0.04));
1236
1237        // Deterministic NONZERO LoRA factors — PEFT layout A:[rank,hidden],
1238        // B:[out,rank], both row-major (matches `apr finetune`).
1239        let mk = |rows: usize, cols: usize, amp: f32, ph: f32| -> Vec<f32> {
1240            (0..rows * cols).map(|i| amp * ((i as f32).mul_add(0.017, ph)).sin()).collect()
1241        };
1242        let a_q = mk(rank, hidden, 0.10, 0.0);
1243        let b_q = mk(q_dim, rank, 0.12, 1.0);
1244        let a_v = mk(rank, hidden, 0.09, 2.0);
1245        let b_v = mk(kv_dim, rank, 0.11, 3.0);
1246
1247        let x = Tensor::from_vec(
1248            (0..seq * hidden).map(|i| (i as f32).mul_add(0.023, -0.5).cos() * 0.4).collect(),
1249            true,
1250        );
1251
1252        // (1) The real composed forward.
1253        let out_lora = attn.forward_with_lora(
1254            &x,
1255            seq,
1256            &Tensor::from_vec(a_q.clone(), true),
1257            &Tensor::from_vec(b_q.clone(), true),
1258            &Tensor::from_vec(a_v.clone(), true),
1259            &Tensor::from_vec(b_v.clone(), true),
1260            rank,
1261            scale,
1262        );
1263
1264        // (2) Fold the LoRA delta into w_q, w_v IN PLACE (independent path), then
1265        // run the plain forward. W_merged[o,i] = W[o,i] + scale·Σ_k B[o,k]·A[k,i].
1266        let merge = |w: &Tensor, a: &[f32], b: &[f32], out: usize| -> Vec<f32> {
1267            let wd = w.data();
1268            let mut m = wd.as_slice().expect("contiguous w").to_vec();
1269            for o in 0..out {
1270                for i in 0..hidden {
1271                    let mut d = 0.0f32;
1272                    for k in 0..rank {
1273                        d += b[o * rank + k] * a[k * hidden + i];
1274                    }
1275                    m[o * hidden + i] += scale * d;
1276                }
1277            }
1278            m
1279        };
1280        attn.w_q = Tensor::from_vec(merge(&attn.w_q, &a_q, &b_q, q_dim), true);
1281        attn.w_v = Tensor::from_vec(merge(&attn.w_v, &a_v, &b_v, kv_dim), true);
1282        let out_merged = attn.forward(&x, seq);
1283
1284        let ld = out_lora.data();
1285        let ls = ld.as_slice().expect("contiguous lora out");
1286        let md = out_merged.data();
1287        let ms = md.as_slice().expect("contiguous merged out");
1288        assert_eq!(ls.len(), ms.len(), "output shape mismatch");
1289        let max_abs = ls.iter().zip(ms).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max);
1290        assert!(
1291            max_abs < 1e-4,
1292            "FALSIFY-QLORA-COMPOSED-FORWARD-001: composed forward_with_lora (base + \
1293             scale·B@A + bias) diverges from the LoRA-merged forward by max|Δ|={max_abs:.6} \
1294             — a dropped Q/K/V bias, a wrong LoRA scale, or a transpose in the composition \
1295             (the #2260 bias-drop class, now with nonzero LoRA + biases)."
1296        );
1297        println!(
1298            "BEAT-QLORA-COMPOSED-FORWARD: forward_with_lora ≡ merged forward — max|Δ|={max_abs:.2e}"
1299        );
1300    }
1301
1302    /// PMAT-805: RoPE must propagate gradients (it is no longer an autograd
1303    /// leaf). Validate `RopeBackward` against finite differences of a scalar
1304    /// loss `L = sum(rope(x) * w)` so dL/dx = rope_backward(w).
1305    #[test]
1306    fn falsify_pmat805_rope_backward_matches_finite_difference() {
1307        let seq_len = 3usize;
1308        let num_heads = 2usize;
1309        let head_dim = 4usize; // half_dim = 2
1310        let total = seq_len * num_heads * head_dim;
1311        let theta = 10000.0f32;
1312
1313        // Deterministic input + upstream gradient weights.
1314        let x_data: Vec<f32> = (0..total).map(|i| ((i as f32 * 0.37).sin() * 1.5) + 0.1).collect();
1315        let w: Vec<f32> = (0..total).map(|i| ((i as f32 * 0.21).cos() * 0.8) - 0.05).collect();
1316
1317        // Analytical gradient via RopeBackward.
1318        let x = Tensor::from_vec(x_data.clone(), true);
1319        let y = apply_rope(&x, seq_len, num_heads, head_dim, theta);
1320        y.set_grad(Array1::from(w.clone()));
1321        y.backward_op().expect("rope must have a backward op").backward();
1322        let analytical = x.grad().expect("x must have grad after rope backward").to_vec();
1323
1324        // Numerical gradient: dL/dx_j ≈ (L(x+h) - L(x-h)) / 2h, L = sum(rope(x) · w).
1325        let h = 1e-3f32;
1326        let loss = |xv: &[f32]| -> f32 {
1327            let t = Tensor::from_vec(xv.to_vec(), false);
1328            let r = apply_rope(&t, seq_len, num_heads, head_dim, theta);
1329            r.data().iter().zip(&w).map(|(a, b)| a * b).sum()
1330        };
1331        for j in 0..total {
1332            let mut xp = x_data.clone();
1333            let mut xm = x_data.clone();
1334            xp[j] += h;
1335            xm[j] -= h;
1336            let numerical = (loss(&xp) - loss(&xm)) / (2.0 * h);
1337            let diff = (analytical[j] - numerical).abs();
1338            assert!(
1339                diff < 1e-2,
1340                "FALSIFIED: RoPE grad[{j}] analytical={} numerical={} diff={diff}",
1341                analytical[j],
1342                numerical
1343            );
1344        }
1345    }
1346
1347    #[test]
1348    fn test_multi_head_attention_tiny() {
1349        let config = TransformerConfig::tiny();
1350        let attn = MultiHeadAttention::new(&config);
1351        let x = Tensor::from_vec(vec![0.1; 2 * config.hidden_size], true);
1352        let output = attn.forward(&x, 2);
1353        assert_eq!(output.len(), 2 * config.hidden_size);
1354    }
1355
1356    #[test]
1357    fn test_multi_head_attention_parameters() {
1358        let config = TransformerConfig::tiny();
1359        let attn = MultiHeadAttention::new(&config);
1360        let params = attn.parameters();
1361        assert_eq!(params.len(), 4); // w_q, w_k, w_v, w_o
1362    }
1363
1364    #[test]
1365    fn test_attention_longer_sequence() {
1366        let config = TransformerConfig::tiny();
1367        let attn = MultiHeadAttention::new(&config);
1368        let x = Tensor::from_vec(vec![0.1; 8 * config.hidden_size], true);
1369        let output = attn.forward(&x, 8);
1370        assert_eq!(output.len(), 8 * config.hidden_size);
1371    }
1372
1373    #[test]
1374    fn test_attention_weight_sizes() {
1375        let config = TransformerConfig::tiny();
1376        let attn = MultiHeadAttention::new(&config);
1377        let kv_hidden = config.num_kv_heads * config.head_dim();
1378        assert_eq!(attn.w_q.len(), config.hidden_size * config.hidden_size);
1379        assert_eq!(attn.w_k.len(), config.hidden_size * kv_hidden);
1380        assert_eq!(attn.w_v.len(), config.hidden_size * kv_hidden);
1381        assert_eq!(attn.w_o.len(), config.hidden_size * config.hidden_size);
1382    }
1383
1384    #[test]
1385    fn test_multi_head_attention_from_params_success() {
1386        let config = TransformerConfig::tiny();
1387        let hidden_size = config.hidden_size;
1388        let kv_hidden_size = config.num_kv_heads * config.head_dim();
1389
1390        let mut params = HashMap::new();
1391        params.insert(
1392            "attn.q_proj.weight".to_string(),
1393            Tensor::from_vec(vec![0.1; hidden_size * hidden_size], true),
1394        );
1395        params.insert(
1396            "attn.k_proj.weight".to_string(),
1397            Tensor::from_vec(vec![0.1; hidden_size * kv_hidden_size], true),
1398        );
1399        params.insert(
1400            "attn.v_proj.weight".to_string(),
1401            Tensor::from_vec(vec![0.1; hidden_size * kv_hidden_size], true),
1402        );
1403        params.insert(
1404            "attn.o_proj.weight".to_string(),
1405            Tensor::from_vec(vec![0.1; hidden_size * hidden_size], true),
1406        );
1407
1408        let attn = MultiHeadAttention::from_params(&config, &params, "attn");
1409        assert!(attn.is_some());
1410        let attn = attn.expect("operation should succeed");
1411        assert_eq!(attn.w_q.len(), hidden_size * hidden_size);
1412    }
1413
1414    #[test]
1415    fn test_multi_head_attention_from_params_missing_key() {
1416        let config = TransformerConfig::tiny();
1417        let hidden_size = config.hidden_size;
1418
1419        let mut params = HashMap::new();
1420        params.insert(
1421            "attn.q_proj.weight".to_string(),
1422            Tensor::from_vec(vec![0.1; hidden_size * hidden_size], true),
1423        );
1424        // Missing k_proj, v_proj, o_proj
1425
1426        let attn = MultiHeadAttention::from_params(&config, &params, "attn");
1427        assert!(attn.is_none());
1428    }
1429
1430    #[test]
1431    fn test_attention_projections_backward() {
1432        // Test that Q, K, V projection matmuls have gradients
1433        // (isolated from the full attention which has intermediate tensor issues)
1434        let config = TransformerConfig::tiny();
1435        let attn = MultiHeadAttention::new(&config);
1436        let hidden_size = config.hidden_size;
1437        let seq_len = 2;
1438
1439        let x = Tensor::from_vec(vec![0.1; seq_len * hidden_size], true);
1440
1441        // Test Q projection
1442        let mut q = crate::autograd::matmul(&x, &attn.w_q, seq_len, hidden_size, hidden_size);
1443        let grad_out = ndarray::Array1::ones(seq_len * hidden_size);
1444        crate::autograd::backward(&mut q, Some(grad_out));
1445
1446        assert!(attn.w_q.grad().is_some());
1447        let grad_q = attn.w_q.grad().expect("gradient should be available");
1448        assert!(grad_q.iter().all(|&v| v.is_finite()));
1449    }
1450
1451    #[test]
1452    fn test_output_projection_backward() {
1453        // Test output projection in isolation
1454        let config = TransformerConfig::tiny();
1455        let attn = MultiHeadAttention::new(&config);
1456        let hidden_size = config.hidden_size;
1457        let seq_len = 2;
1458
1459        // Simulate concatenated attention output
1460        let concat_out = Tensor::from_vec(vec![0.1; seq_len * hidden_size], true);
1461
1462        // Output projection
1463        let mut output =
1464            crate::autograd::matmul(&concat_out, &attn.w_o, seq_len, hidden_size, hidden_size);
1465
1466        let grad_out = ndarray::Array1::ones(seq_len * hidden_size);
1467        crate::autograd::backward(&mut output, Some(grad_out));
1468
1469        assert!(attn.w_o.grad().is_some());
1470        let grad_o = attn.w_o.grad().expect("gradient should be available");
1471        assert!(grad_o.iter().all(|&v| v.is_finite()));
1472        let sum: f32 = grad_o.iter().map(|v| v.abs()).sum();
1473        assert!(sum > 0.0, "Output projection gradient should not be all zero");
1474    }
1475
1476    /// ALB-038: Full attention forward must propagate gradients to Q/K/V weights
1477    ///
1478    /// NOTE: Currently fails because apply_rope() has no backward op — it severs
1479    /// the autograd chain for Q and K. Needs a proper RoPE backward implementation
1480    /// (ENT-272). Skipped until then.
1481    #[test]
1482    #[ignore = "apply_rope() severs autograd chain — needs backward op (ENT-272)"]
1483    fn test_attention_full_forward_qkv_gradients() {
1484        let config = TransformerConfig::tiny();
1485        let attn = MultiHeadAttention::new(&config);
1486        let hidden_size = config.hidden_size;
1487        let seq_len = 3;
1488
1489        // Non-uniform input: different positions must have different representations
1490        // so softmax produces non-uniform weights with non-zero score gradients
1491        let x_data: Vec<f32> =
1492            (0..seq_len * hidden_size).map(|i| ((i as f32) * 0.17).sin() * 0.5).collect();
1493        let x = Tensor::from_vec(x_data, true);
1494        let mut output = attn.forward(&x, seq_len);
1495
1496        let grad_out = ndarray::Array1::ones(seq_len * hidden_size);
1497        crate::autograd::backward(&mut output, Some(grad_out));
1498
1499        // All four projection weights must receive gradients
1500        for (name, param) in
1501            [("w_q", &attn.w_q), ("w_k", &attn.w_k), ("w_v", &attn.w_v), ("w_o", &attn.w_o)]
1502        {
1503            assert!(
1504                param.grad().is_some(),
1505                "ALB-038: {name} must have gradient after full attention forward"
1506            );
1507            let grad = param.grad().expect("gradient available");
1508            assert!(grad.iter().all(|&v| v.is_finite()), "ALB-038: {name} gradient must be finite");
1509            assert!(
1510                grad.iter().any(|&v| v.abs() > 1e-10),
1511                "ALB-038: {name} gradient must be non-zero"
1512            );
1513        }
1514
1515        // Input must also receive gradient (enables gradient flow through model)
1516        assert!(x.grad().is_some(), "ALB-038: input x must have gradient");
1517    }
1518
1519    // ============================================================================
1520    // LoRAProjection tests
1521    // ============================================================================
1522
1523    #[test]
1524    fn test_lora_projection_new() {
1525        let d_in = 32;
1526        let d_out = 16;
1527        let rank = 4;
1528        let alpha = 8.0;
1529
1530        let base_weight = Tensor::from_vec(vec![0.1; d_in * d_out], false);
1531        let lora = LoRAProjection::new(base_weight, d_in, d_out, rank, alpha);
1532
1533        assert_eq!(lora.d_in, d_in);
1534        assert_eq!(lora.d_out, d_out);
1535        assert_eq!(lora.rank, rank);
1536        assert!((lora.scale - 2.0).abs() < 1e-6); // alpha / rank = 8 / 4 = 2
1537        assert_eq!(lora.lora_a.len(), d_in * rank);
1538        assert_eq!(lora.lora_b.len(), rank * d_out);
1539    }
1540
1541    #[test]
1542    fn test_lora_projection_forward() {
1543        let d_in = 32;
1544        let d_out = 16;
1545        let rank = 4;
1546        let alpha = 8.0;
1547        let seq_len = 2;
1548
1549        let base_weight = Tensor::from_vec(vec![0.1; d_in * d_out], false);
1550        let lora = LoRAProjection::new(base_weight, d_in, d_out, rank, alpha);
1551
1552        let x = Tensor::from_vec(vec![0.1; seq_len * d_in], false);
1553        let output = lora.forward(&x, seq_len);
1554
1555        assert_eq!(output.len(), seq_len * d_out);
1556        // Check output is finite
1557        assert!(output.data().iter().all(|&v| v.is_finite()));
1558    }
1559
1560    #[test]
1561    fn test_lora_projection_params() {
1562        let d_in = 32;
1563        let d_out = 16;
1564        let rank = 4;
1565
1566        let base_weight = Tensor::from_vec(vec![0.1; d_in * d_out], false);
1567        let lora = LoRAProjection::new(base_weight, d_in, d_out, rank, 8.0);
1568
1569        let params = lora.lora_params();
1570        assert_eq!(params.len(), 2); // lora_a and lora_b
1571    }
1572
1573    #[test]
1574    fn test_lora_projection_params_mut() {
1575        let d_in = 32;
1576        let d_out = 16;
1577        let rank = 4;
1578
1579        let base_weight = Tensor::from_vec(vec![0.1; d_in * d_out], false);
1580        let mut lora = LoRAProjection::new(base_weight, d_in, d_out, rank, 8.0);
1581
1582        let params = lora.lora_params_mut();
1583        assert_eq!(params.len(), 2);
1584    }
1585
1586    #[test]
1587    #[should_panic(expected = "Base weight size mismatch")]
1588    fn test_lora_projection_size_mismatch() {
1589        let d_in = 32;
1590        let d_out = 16;
1591        let rank = 4;
1592
1593        // Wrong base weight size
1594        let base_weight = Tensor::from_vec(vec![0.1; d_in * d_out + 1], false);
1595        let _ = LoRAProjection::new(base_weight, d_in, d_out, rank, 8.0);
1596    }
1597
1598    // ============================================================================
1599    // MultiHeadAttentionWithLoRA tests
1600    // ============================================================================
1601
1602    #[test]
1603    fn test_mha_with_lora_creation() {
1604        let config = TransformerConfig::tiny();
1605        let attn = MultiHeadAttention::new(&config);
1606        let rank = 4;
1607        let alpha = 8.0;
1608
1609        let lora_attn = MultiHeadAttentionWithLoRA::from_attention(&attn, rank, alpha);
1610
1611        assert_eq!(lora_attn.q_proj.rank, rank);
1612        assert_eq!(lora_attn.k_proj.rank, rank);
1613        assert_eq!(lora_attn.v_proj.rank, rank);
1614        assert_eq!(lora_attn.o_proj.rank, rank);
1615    }
1616
1617    #[test]
1618    fn test_mha_with_lora_forward() {
1619        let config = TransformerConfig::tiny();
1620        let attn = MultiHeadAttention::new(&config);
1621        let lora_attn = MultiHeadAttentionWithLoRA::from_attention(&attn, 4, 8.0);
1622
1623        let seq_len = 2;
1624        let x = Tensor::from_vec(vec![0.1; seq_len * config.hidden_size], false);
1625        let output = lora_attn.forward(&x, seq_len);
1626
1627        assert_eq!(output.len(), seq_len * config.hidden_size);
1628        // Check output is finite and non-zero
1629        assert!(output.data().iter().all(|&v| v.is_finite()));
1630    }
1631
1632    #[test]
1633    fn test_mha_with_lora_params() {
1634        let config = TransformerConfig::tiny();
1635        let attn = MultiHeadAttention::new(&config);
1636        let lora_attn = MultiHeadAttentionWithLoRA::from_attention(&attn, 4, 8.0);
1637
1638        let params = lora_attn.lora_params();
1639        // 4 projections × 2 params each = 8
1640        assert_eq!(params.len(), 8);
1641    }
1642
1643    #[test]
1644    fn test_mha_with_lora_params_mut() {
1645        let config = TransformerConfig::tiny();
1646        let attn = MultiHeadAttention::new(&config);
1647        let mut lora_attn = MultiHeadAttentionWithLoRA::from_attention(&attn, 4, 8.0);
1648
1649        let params = lora_attn.lora_params_mut();
1650        assert_eq!(params.len(), 8);
1651    }
1652
1653    #[test]
1654    fn test_mha_with_lora_param_count() {
1655        let config = TransformerConfig::tiny();
1656        let attn = MultiHeadAttention::new(&config);
1657        let rank = 4;
1658        let lora_attn = MultiHeadAttentionWithLoRA::from_attention(&attn, rank, 8.0);
1659
1660        let param_count = lora_attn.lora_param_count();
1661
1662        // Calculate expected:
1663        let hidden = config.hidden_size;
1664        let kv_hidden = config.num_kv_heads * config.head_dim();
1665        let expected = (hidden * rank + rank * hidden)      // Q
1666            + (hidden * rank + rank * kv_hidden) // K
1667            + (hidden * rank + rank * kv_hidden) // V
1668            + (hidden * rank + rank * hidden); // O
1669
1670        assert_eq!(param_count, expected);
1671        assert!(param_count > 0);
1672    }
1673
1674    #[test]
1675    fn test_mha_with_lora_longer_sequence() {
1676        let config = TransformerConfig::tiny();
1677        let attn = MultiHeadAttention::new(&config);
1678        let lora_attn = MultiHeadAttentionWithLoRA::from_attention(&attn, 4, 8.0);
1679
1680        let seq_len = 8;
1681        let x = Tensor::from_vec(vec![0.1; seq_len * config.hidden_size], false);
1682        let output = lora_attn.forward(&x, seq_len);
1683
1684        assert_eq!(output.len(), seq_len * config.hidden_size);
1685    }
1686
1687    #[test]
1688    fn test_parameters_mut() {
1689        let config = TransformerConfig::tiny();
1690        let mut attn = MultiHeadAttention::new(&config);
1691
1692        let params = attn.parameters_mut();
1693        assert_eq!(params.len(), 4);
1694    }
1695
1696    // =========================================================================
1697    // FALSIFY-A: §2.1.3 Attention Projections — Five-Whys Gap Analysis (Refs PMAT-331)
1698    //
1699    // Contract: tensor-layout-v1.yaml §tensors.q_proj/k_proj/v_proj/o_proj
1700    //   q_proj: [num_heads*head_dim, hidden] (= [hidden, hidden] for MHA)
1701    //   k_proj: [num_kv_heads*head_dim, hidden] (smaller for GQA)
1702    //   v_proj: [num_kv_heads*head_dim, hidden] (smaller for GQA)
1703    //   o_proj: [hidden, num_heads*head_dim]
1704    //
1705    // Five-Whys:
1706    //   Why 1: Trained model's attention weights could be wrong shape
1707    //   Why 2: from_params accepts any tensor without shape validation
1708    //   Why 3: No ValidatedWeight in entrenar
1709    //   Why 4: entrenar predates the Poka-Yoke contract
1710    //   Why 5: No cross-crate contract enforcement for training weights
1711    //
1712    // Popper (1959): "These tests attempt to falsify the claim that
1713    // entrenar's attention weight handling prevents degenerate models."
1714    // =========================================================================
1715
1716    /// FALSIFY-A1e: from_params rejects wrong-shape Q weight (PMAT-331 fix)
1717    ///
1718    /// from_params now validates Q projection shape against config dimensions.
1719    /// A tensor of 50 elements is rejected when hidden*hidden is expected.
1720    #[test]
1721    fn falsify_a1e_from_params_rejects_wrong_shape_q_weight() {
1722        let config = TransformerConfig::tiny();
1723        let hidden_size = config.hidden_size;
1724        let kv_hidden_size = config.num_kv_heads * config.head_dim();
1725
1726        let mut params = HashMap::new();
1727        // WRONG-SHAPE q_proj: 50 elements instead of hidden*hidden
1728        params.insert("attn.q_proj.weight".to_string(), Tensor::from_vec(vec![0.1; 50], true));
1729        // Correct k, v, o
1730        params.insert(
1731            "attn.k_proj.weight".to_string(),
1732            Tensor::from_vec(vec![0.1; hidden_size * kv_hidden_size], true),
1733        );
1734        params.insert(
1735            "attn.v_proj.weight".to_string(),
1736            Tensor::from_vec(vec![0.1; hidden_size * kv_hidden_size], true),
1737        );
1738        params.insert(
1739            "attn.o_proj.weight".to_string(),
1740            Tensor::from_vec(vec![0.1; hidden_size * hidden_size], true),
1741        );
1742
1743        let attn = MultiHeadAttention::from_params(&config, &params, "attn");
1744        // FIXED (PMAT-331): now rejected
1745        assert!(
1746            attn.is_none(),
1747            "FALSIFY-A1e: PMAT-331 fix — from_params MUST reject wrong-shape q_proj"
1748        );
1749    }
1750
1751    /// FALSIFY-A2e: GQA init produces correct K/V dimensions
1752    ///
1753    /// For GQA (num_kv_heads < num_heads), K/V must be smaller than Q.
1754    /// If init uses num_heads for K/V, the shapes are wrong.
1755    #[test]
1756    fn falsify_a2e_gqa_init_correct_kv_dimensions() {
1757        let mut config = TransformerConfig::tiny();
1758        config.num_kv_heads = 1; // Force GQA: 1 KV head, but num_heads > 1
1759
1760        let attn = MultiHeadAttention::new(&config);
1761        let head_dim = config.head_dim();
1762        let kv_hidden = config.num_kv_heads * head_dim; // 1 * head_dim
1763
1764        // Q: hidden * hidden
1765        assert_eq!(
1766            attn.w_q.len(),
1767            config.hidden_size * config.hidden_size,
1768            "FALSIFY-A2e: Q projection must be hidden*hidden"
1769        );
1770
1771        // K: hidden * kv_hidden (smaller than Q for GQA)
1772        assert_eq!(
1773            attn.w_k.len(),
1774            config.hidden_size * kv_hidden,
1775            "FALSIFY-A2e: K projection must use num_kv_heads, not num_heads"
1776        );
1777
1778        // V: hidden * kv_hidden (same as K)
1779        assert_eq!(
1780            attn.w_v.len(),
1781            config.hidden_size * kv_hidden,
1782            "FALSIFY-A2e: V projection must use num_kv_heads, not num_heads"
1783        );
1784
1785        // O: hidden * hidden (matches Q output)
1786        assert_eq!(
1787            attn.w_o.len(),
1788            config.hidden_size * config.hidden_size,
1789            "FALSIFY-A2e: O projection must be hidden*hidden"
1790        );
1791
1792        // K/V must be SMALLER than Q for GQA
1793        assert!(
1794            attn.w_k.len() < attn.w_q.len(),
1795            "FALSIFY-A2e: For GQA, K weight must be smaller than Q weight"
1796        );
1797    }
1798
1799    /// FALSIFY-A3e: GQA forward produces correct output dimensions
1800    ///
1801    /// With num_kv_heads < num_heads, the forward pass must still produce
1802    /// [seq_len, hidden_size] output (not [seq_len, kv_hidden]).
1803    #[test]
1804    fn falsify_a3e_gqa_forward_correct_output_dims() {
1805        let mut config = TransformerConfig::tiny();
1806        config.num_kv_heads = 1; // Force GQA
1807
1808        let attn = MultiHeadAttention::new(&config);
1809        let seq_len = 3;
1810        let x = Tensor::from_vec(vec![0.1; seq_len * config.hidden_size], true);
1811        let output = attn.forward(&x, seq_len);
1812
1813        assert_eq!(
1814            output.len(),
1815            seq_len * config.hidden_size,
1816            "FALSIFY-A3e: GQA output must be seq_len * hidden_size, not seq_len * kv_hidden"
1817        );
1818    }
1819
1820    /// FALSIFY-A4e: Attention init produces non-degenerate values
1821    ///
1822    /// Like FALSIFY-E7a for embeddings: init must produce varied, finite values.
1823    #[test]
1824    fn falsify_a4e_init_produces_valid_attention_weights() {
1825        let config = TransformerConfig::tiny();
1826        let attn = MultiHeadAttention::new(&config);
1827
1828        for (name, w) in
1829            [("w_q", &attn.w_q), ("w_k", &attn.w_k), ("w_v", &attn.w_v), ("w_o", &attn.w_o)]
1830        {
1831            let data = w.data();
1832            let slice = data.as_slice().expect("data as slice");
1833
1834            // No NaN
1835            let nan_count = slice.iter().filter(|v| v.is_nan()).count();
1836            assert_eq!(nan_count, 0, "FALSIFY-A4e: {name} init must not contain NaN");
1837
1838            // No Inf
1839            let inf_count = slice.iter().filter(|v| v.is_infinite()).count();
1840            assert_eq!(inf_count, 0, "FALSIFY-A4e: {name} init must not contain Inf");
1841
1842            // Values vary
1843            let min = slice.iter().copied().fold(f32::INFINITY, f32::min);
1844            let max = slice.iter().copied().fold(f32::NEG_INFINITY, f32::max);
1845            assert!(
1846                (max - min).abs() > 1e-6,
1847                "FALSIFY-A4e: {name} init values are constant ({min}..{max}) — degenerate weight"
1848            );
1849        }
1850    }
1851
1852    /// FALSIFY-A5e: Attention forward produces finite outputs
1853    ///
1854    /// If any attention weight is degenerate, output should still be finite
1855    /// (the init is designed to prevent this).
1856    #[test]
1857    fn falsify_a5e_forward_produces_finite_output() {
1858        let config = TransformerConfig::tiny();
1859        let attn = MultiHeadAttention::new(&config);
1860        let seq_len = 4;
1861        let x = Tensor::from_vec(vec![0.1; seq_len * config.hidden_size], true);
1862        let output = attn.forward(&x, seq_len);
1863
1864        let data = output.data();
1865        let nan_count = data.iter().filter(|v| v.is_nan()).count();
1866        let inf_count = data.iter().filter(|v| v.is_infinite()).count();
1867        assert_eq!(nan_count, 0, "FALSIFY-A5e: Attention output must not contain NaN");
1868        assert_eq!(inf_count, 0, "FALSIFY-A5e: Attention output must not contain Inf");
1869    }
1870
1871    // =========================================================================
1872    // FALSIFY-GQ: gqa-kernel-v1.yaml contract (entrenar MultiHeadAttention GQA)
1873    //
1874    // Five-Whys (PMAT-354):
1875    //   Why 1: entrenar had FALSIFY-A tests but zero FALSIFY-GQ-* tests
1876    //   Why 2: FALSIFY-A tests verify projections/shapes, not GQA invariants
1877    //   Why 3: no mapping from gqa-kernel-v1.yaml to entrenar test names
1878    //   Why 4: entrenar's GQA support added after FALSIFY-A tests
1879    //   Why 5: GQA was "obviously correct" (just index K/V by h/heads_per_kv)
1880    //
1881    // References:
1882    //   - provable-contracts/contracts/gqa-kernel-v1.yaml
1883    //   - Ainslie et al. (2023) "GQA: Training Generalized MQT Models"
1884    // =========================================================================
1885
1886    /// FALSIFY-GQ-001e: GQA output shape correct for various head configs
1887    #[test]
1888    fn falsify_gq_001e_output_shape() {
1889        for (num_heads, num_kv_heads) in [(2, 2), (4, 2), (4, 1), (2, 1)] {
1890            let mut config = TransformerConfig::tiny();
1891            config.num_attention_heads = num_heads;
1892            config.num_kv_heads = num_kv_heads;
1893
1894            let attn = MultiHeadAttention::new(&config);
1895            let seq_len = 3;
1896            let x = Tensor::from_vec(vec![0.1; seq_len * config.hidden_size], true);
1897            let output = attn.forward(&x, seq_len);
1898
1899            assert_eq!(
1900                output.len(),
1901                seq_len * config.hidden_size,
1902                "FALSIFIED GQ-001e: output len mismatch for heads={num_heads},kv={num_kv_heads}"
1903            );
1904        }
1905    }
1906
1907    /// FALSIFY-GQ-002e: MHA degeneration — kv_heads == num_heads produces finite output
1908    #[test]
1909    fn falsify_gq_002e_mha_degeneration() {
1910        let config = TransformerConfig::tiny(); // num_heads == num_kv_heads == 2
1911        assert_eq!(config.num_attention_heads, config.num_kv_heads);
1912
1913        let attn = MultiHeadAttention::new(&config);
1914        let seq_len = 4;
1915        let x = Tensor::from_vec(
1916            (0..seq_len * config.hidden_size).map(|i| (i as f32 * 0.37).sin()).collect(),
1917            true,
1918        );
1919        let output = attn.forward(&x, seq_len);
1920
1921        let data = output.data();
1922        for (i, v) in data.iter().enumerate() {
1923            assert!(v.is_finite(), "FALSIFIED GQ-002e: MHA output[{i}] = {v} (not finite)");
1924        }
1925    }
1926
1927    /// FALSIFY-GQ-004e: Head divisibility — GQA requires num_heads % num_kv_heads == 0
1928    #[test]
1929    fn falsify_gq_004e_head_divisibility() {
1930        // Valid configurations should not panic
1931        for (nh, nkv) in [(2, 1), (2, 2), (4, 1), (4, 2), (4, 4), (8, 2), (8, 4)] {
1932            let mut config = TransformerConfig::tiny();
1933            config.num_attention_heads = nh;
1934            config.num_kv_heads = nkv;
1935            assert_eq!(nh % nkv, 0, "FALSIFIED GQ-004e: test config has invalid head ratio");
1936            // Should not panic during construction or forward
1937            let attn = MultiHeadAttention::new(&config);
1938            let x = Tensor::from_vec(vec![0.1; 2 * config.hidden_size], true);
1939            let _ = attn.forward(&x, 2);
1940        }
1941    }
1942
1943    /// FALSIFY-GQ-006e: MQA boundary — kv_heads=1 broadcasts single KV to all heads
1944    #[test]
1945    fn falsify_gq_006e_mqa_boundary() {
1946        let mut config = TransformerConfig::tiny();
1947        config.num_attention_heads = 4;
1948        config.num_kv_heads = 1;
1949        // Adjust hidden_size to be divisible by 4 heads
1950        config.hidden_size = 64;
1951
1952        let attn = MultiHeadAttention::new(&config);
1953        let seq_len = 3;
1954        let x = Tensor::from_vec(
1955            (0..seq_len * config.hidden_size).map(|i| (i as f32 * 0.73).cos()).collect(),
1956            true,
1957        );
1958        let output = attn.forward(&x, seq_len);
1959
1960        assert_eq!(
1961            output.len(),
1962            seq_len * config.hidden_size,
1963            "FALSIFIED GQ-006e: MQA output size wrong"
1964        );
1965
1966        // All finite
1967        let data = output.data();
1968        for (i, v) in data.iter().enumerate() {
1969            assert!(v.is_finite(), "FALSIFIED GQ-006e: MQA output[{i}] = {v} (not finite)");
1970        }
1971    }
1972
1973    mod gq_proptest_falsify {
1974        use super::*;
1975        use proptest::prelude::*;
1976
1977        // FALSIFY-GQ-001e-prop: GQA output shape for random configs
1978        proptest! {
1979            #![proptest_config(ProptestConfig::with_cases(50))]
1980
1981            #[test]
1982            fn falsify_gq_001e_prop_output_shape(
1983                config_idx in 0..4usize,
1984                seq_len in 2..=6usize,
1985                seed in 0..500u32,
1986            ) {
1987                let configs: [(usize, usize); 4] = [
1988                    (2, 2), (2, 1), (4, 2), (4, 1),
1989                ];
1990                let (num_heads, num_kv_heads) = configs[config_idx];
1991                let mut config = TransformerConfig::tiny();
1992                config.num_attention_heads = num_heads;
1993                config.num_kv_heads = num_kv_heads;
1994
1995                let attn = MultiHeadAttention::new(&config);
1996                let data: Vec<f32> = (0..seq_len * config.hidden_size)
1997                    .map(|i| ((i as f32 + seed as f32) * 0.37).sin())
1998                    .collect();
1999                let x = Tensor::from_vec(data, true);
2000                let output = attn.forward(&x, seq_len);
2001
2002                prop_assert_eq!(
2003                    output.len(),
2004                    seq_len * config.hidden_size,
2005                    "FALSIFIED GQ-001e-prop: output len mismatch"
2006                );
2007
2008                // All finite
2009                for v in output.data() {
2010                    prop_assert!(
2011                        v.is_finite(),
2012                        "FALSIFIED GQ-001e-prop: non-finite output"
2013                    );
2014                }
2015            }
2016        }
2017
2018        // FALSIFY-GQ-006e-prop: MQA boundary with random inputs
2019        proptest! {
2020            #![proptest_config(ProptestConfig::with_cases(30))]
2021
2022            #[test]
2023            fn falsify_gq_006e_prop_mqa_boundary(
2024                seed in 0..500u32,
2025                seq_len in 2..=5usize,
2026            ) {
2027                let mut config = TransformerConfig::tiny();
2028                config.num_attention_heads = 4;
2029                config.num_kv_heads = 1;
2030                config.hidden_size = 64;
2031
2032                let attn = MultiHeadAttention::new(&config);
2033                let data: Vec<f32> = (0..seq_len * config.hidden_size)
2034                    .map(|i| ((i as f32 + seed as f32) * 0.73).cos())
2035                    .collect();
2036                let x = Tensor::from_vec(data, true);
2037                let output = attn.forward(&x, seq_len);
2038
2039                prop_assert_eq!(
2040                    output.len(),
2041                    seq_len * config.hidden_size,
2042                    "FALSIFIED GQ-006e-prop: MQA output len mismatch"
2043                );
2044
2045                for v in output.data() {
2046                    prop_assert!(
2047                        v.is_finite(),
2048                        "FALSIFIED GQ-006e-prop: non-finite MQA output"
2049                    );
2050                }
2051            }
2052        }
2053    }
2054
2055    #[test]
2056    fn test_attention_from_params_with_biases() {
2057        let config = TransformerConfig::tiny();
2058        let hidden_size = config.hidden_size;
2059        let kv_hidden_size = config.num_kv_heads * config.head_dim();
2060
2061        let mut params = HashMap::new();
2062        params.insert(
2063            "attn.q_proj.weight".to_string(),
2064            Tensor::from_vec(vec![0.1; hidden_size * hidden_size], true),
2065        );
2066        params.insert(
2067            "attn.k_proj.weight".to_string(),
2068            Tensor::from_vec(vec![0.1; hidden_size * kv_hidden_size], true),
2069        );
2070        params.insert(
2071            "attn.v_proj.weight".to_string(),
2072            Tensor::from_vec(vec![0.1; hidden_size * kv_hidden_size], true),
2073        );
2074        params.insert(
2075            "attn.o_proj.weight".to_string(),
2076            Tensor::from_vec(vec![0.1; hidden_size * hidden_size], true),
2077        );
2078        params.insert(
2079            "attn.q_proj.bias".to_string(),
2080            Tensor::from_vec(vec![0.01; hidden_size], true),
2081        );
2082        params.insert(
2083            "attn.k_proj.bias".to_string(),
2084            Tensor::from_vec(vec![0.01; kv_hidden_size], true),
2085        );
2086        params.insert(
2087            "attn.v_proj.bias".to_string(),
2088            Tensor::from_vec(vec![0.01; kv_hidden_size], true),
2089        );
2090
2091        let attn = MultiHeadAttention::from_params(&config, &params, "attn");
2092        assert!(attn.is_some());
2093        let attn = attn.expect("should load with biases");
2094        assert!(attn.has_biases());
2095        assert_eq!(attn.parameters().len(), 7);
2096    }
2097
2098    #[test]
2099    fn test_attention_named_parameters_with_biases() {
2100        let config = TransformerConfig::tiny();
2101        let hidden_size = config.hidden_size;
2102        let kv_hidden_size = config.num_kv_heads * config.head_dim();
2103
2104        let mut params = HashMap::new();
2105        params.insert(
2106            "attn.q_proj.weight".to_string(),
2107            Tensor::from_vec(vec![0.1; hidden_size * hidden_size], true),
2108        );
2109        params.insert(
2110            "attn.k_proj.weight".to_string(),
2111            Tensor::from_vec(vec![0.1; hidden_size * kv_hidden_size], true),
2112        );
2113        params.insert(
2114            "attn.v_proj.weight".to_string(),
2115            Tensor::from_vec(vec![0.1; hidden_size * kv_hidden_size], true),
2116        );
2117        params.insert(
2118            "attn.o_proj.weight".to_string(),
2119            Tensor::from_vec(vec![0.1; hidden_size * hidden_size], true),
2120        );
2121        params.insert(
2122            "attn.q_proj.bias".to_string(),
2123            Tensor::from_vec(vec![0.01; hidden_size], true),
2124        );
2125        params.insert(
2126            "attn.k_proj.bias".to_string(),
2127            Tensor::from_vec(vec![0.01; kv_hidden_size], true),
2128        );
2129        params.insert(
2130            "attn.v_proj.bias".to_string(),
2131            Tensor::from_vec(vec![0.01; kv_hidden_size], true),
2132        );
2133
2134        let attn = MultiHeadAttention::from_params(&config, &params, "attn").expect("should load");
2135        let named = attn.named_parameters("attn");
2136        assert_eq!(named.len(), 7);
2137        let names: Vec<&str> = named.iter().map(|(n, _)| n.as_str()).collect();
2138        assert!(names.contains(&"attn.q_proj.bias"));
2139        assert!(names.contains(&"attn.k_proj.bias"));
2140        assert!(names.contains(&"attn.v_proj.bias"));
2141    }
2142
2143    #[test]
2144    fn test_attention_forward_with_biases() {
2145        let config = TransformerConfig::tiny();
2146        let hidden_size = config.hidden_size;
2147        let kv_hidden_size = config.num_kv_heads * config.head_dim();
2148
2149        let mut params = HashMap::new();
2150        params.insert(
2151            "attn.q_proj.weight".to_string(),
2152            Tensor::from_vec(vec![0.1; hidden_size * hidden_size], true),
2153        );
2154        params.insert(
2155            "attn.k_proj.weight".to_string(),
2156            Tensor::from_vec(vec![0.1; hidden_size * kv_hidden_size], true),
2157        );
2158        params.insert(
2159            "attn.v_proj.weight".to_string(),
2160            Tensor::from_vec(vec![0.1; hidden_size * kv_hidden_size], true),
2161        );
2162        params.insert(
2163            "attn.o_proj.weight".to_string(),
2164            Tensor::from_vec(vec![0.1; hidden_size * hidden_size], true),
2165        );
2166        params
2167            .insert("attn.q_proj.bias".to_string(), Tensor::from_vec(vec![0.5; hidden_size], true));
2168        params.insert(
2169            "attn.k_proj.bias".to_string(),
2170            Tensor::from_vec(vec![0.5; kv_hidden_size], true),
2171        );
2172        params.insert(
2173            "attn.v_proj.bias".to_string(),
2174            Tensor::from_vec(vec![0.5; kv_hidden_size], true),
2175        );
2176
2177        let attn = MultiHeadAttention::from_params(&config, &params, "attn").expect("should load");
2178        let x = Tensor::from_vec(vec![0.1; 2 * hidden_size], false);
2179        let output = attn.forward(&x, 2);
2180        assert_eq!(output.len(), 2 * hidden_size);
2181        assert!(output.data().iter().all(|v| v.is_finite()));
2182    }
2183}