entrenar/lora/layer/core.rs
1//! LoRA (Low-Rank Adaptation) layer implementation
2//!
3//! LoRA enables parameter-efficient fine-tuning by adding trainable low-rank
4//! decomposition matrices to frozen pretrained weights.
5//!
6//! For a frozen weight matrix W ∈ ℝ^(d_out × d_in), LoRA adds:
7//! ΔW = B @ A where A ∈ ℝ^(r × d_in) and B ∈ ℝ^(d_out × r)
8//!
9//! Forward pass: y = (W + α·B·A) @ x = W@x + α·(B@(A@x))
10//! where α is a scaling factor (typically alpha/r)
11//!
12//! Dropout placement (PMAT-879): matching HF PEFT `lora.Linear.forward`, dropout
13//! is applied to the INPUT `x` *before* the down-projection `A`:
14//!
15//! `y = W@x + scale · B(A(dropout(x)))`
16//!
17//! Dropout is active only in training mode; in eval mode (the default) it is the
18//! identity, so inference output is unchanged. Inverted dropout scales the
19//! surviving activations by `1/(1-p)` so the expected value is preserved.
20
21use crate::autograd::matmul;
22use crate::autograd::ops::{add, scale};
23use crate::Tensor;
24use std::cell::Cell;
25
26/// LoRA scaling mode (ENT-LoRA-004)
27#[derive(Debug, Clone, Copy, PartialEq)]
28pub enum LoRAScaling {
29 /// Standard: scale = alpha / rank
30 Standard,
31 /// rsLoRA: scale = alpha / sqrt(rank) — rank-stable, default for rank > 16
32 RsLoRA,
33}
34
35impl LoRAScaling {
36 /// Compute the scaling factor
37 ///
38 /// # Panics
39 /// Panics if rank is zero
40 pub fn compute(self, alpha: f32, rank: usize) -> f32 {
41 assert!(rank > 0, "LoRA rank must be > 0");
42 match self {
43 Self::Standard => alpha / rank as f32,
44 Self::RsLoRA => alpha / (rank as f32).sqrt(),
45 }
46 }
47}
48
49/// LoRA layer: adds trainable low-rank adaptation to a frozen base weight
50#[derive(Clone)]
51pub struct LoRALayer {
52 /// Frozen base weight matrix stored as 1D [d_out * d_in]
53 base_weight: Tensor,
54 /// LoRA matrix A stored as 1D [r * d_in] - downprojection
55 lora_a: Tensor,
56 /// LoRA matrix B stored as 1D [d_out * r] - upprojection
57 lora_b: Tensor,
58 /// Output dimension
59 d_out: usize,
60 /// Input dimension
61 d_in: usize,
62 /// LoRA rank
63 rank: usize,
64 /// Scaling factor (alpha/rank)
65 scale: f32,
66 /// Whether the adapter is merged into base_weight
67 merged: bool,
68 /// LoRA dropout probability applied to the input `x` in the LoRA branch
69 /// (PMAT-879). `0.0` disables dropout (identity). Active only in training mode.
70 dropout: f32,
71 /// Training mode flag. `false` (eval) is the default so inference output is
72 /// deterministic and dropout-free, matching PEFT (`nn.Dropout` is identity in eval).
73 training: bool,
74 /// Base seed for the deterministic dropout RNG (PMAT-879).
75 dropout_seed: u64,
76 /// Per-forward counter that advances the dropout RNG so successive training
77 /// steps draw fresh, but reproducible, masks. Interior mutability keeps
78 /// `forward(&self)` (shared borrow) while remaining deterministic for a seed.
79 dropout_step: Cell<u64>,
80}
81
82impl LoRALayer {
83 /// Create a new LoRA layer
84 ///
85 /// # Arguments
86 /// * `base_weight` - Frozen pretrained weight [d_out * d_in]
87 /// * `d_out` - Output dimension
88 /// * `d_in` - Input dimension
89 /// * `rank` - LoRA rank (typically 4, 8, 16, 32, or 64)
90 /// * `alpha` - LoRA scaling parameter (often same as rank)
91 ///
92 /// # Returns
93 /// LoRA layer with randomly initialized A (Gaussian) and zero-initialized B
94 pub fn new(base_weight: Tensor, d_out: usize, d_in: usize, rank: usize, alpha: f32) -> Self {
95 assert!(rank > 0, "LoRA rank must be > 0");
96 assert_eq!(base_weight.len(), d_out * d_in, "Base weight size must match d_out * d_in");
97
98 // Initialize A with small Gaussian noise, B with zeros (standard LoRA init)
99 // This ensures that initially ΔW = B·A = 0
100 let lora_a_data: Vec<f32> = (0..rank * d_in)
101 .map(|i| {
102 // Simple deterministic "random" init for reproducibility in tests
103 let x = (i as f32 * 0.1).sin();
104 x * 0.01 // Small values
105 })
106 .collect();
107 let lora_a = Tensor::from_vec(lora_a_data, true);
108
109 let lora_b = Tensor::zeros(d_out * rank, true);
110
111 let scale = alpha / rank as f32;
112
113 Self {
114 base_weight,
115 lora_a,
116 lora_b,
117 d_out,
118 d_in,
119 rank,
120 scale,
121 merged: false,
122 dropout: 0.0,
123 training: false,
124 dropout_seed: 0,
125 dropout_step: Cell::new(0),
126 }
127 }
128
129 /// Create a new LoRA layer with explicit scaling mode (ENT-LoRA-004)
130 ///
131 /// Use `LoRAScaling::RsLoRA` for rank-stable training (recommended for rank > 16).
132 pub fn new_with_scaling(
133 base_weight: Tensor,
134 d_out: usize,
135 d_in: usize,
136 rank: usize,
137 alpha: f32,
138 scaling: LoRAScaling,
139 ) -> Self {
140 let mut layer = Self::new(base_weight, d_out, d_in, rank, alpha);
141 layer.scale = scaling.compute(alpha, rank);
142 layer
143 }
144
145 /// Override the LoRA scaling factor.
146 ///
147 /// Used when restoring a serialized adapter whose `scale` was produced by a
148 /// non-Standard mode (e.g. rsLoRA, where `scale = alpha / sqrt(rank)` rather than
149 /// the `alpha / rank` that [`LoRALayer::new`] recomputes). The adapter stores the
150 /// resulting scale *value*, not the scaling mode, so restoration sets it directly.
151 #[must_use]
152 pub fn with_scale(mut self, scale: f32) -> Self {
153 self.scale = scale;
154 self
155 }
156
157 /// Set the LoRA dropout probability (PMAT-879).
158 ///
159 /// Matches HF PEFT `lora_dropout`: dropout is applied to the input `x` before
160 /// the down-projection `A`. `p` is clamped to `[0.0, 1.0)`; `0.0` disables
161 /// dropout. Dropout is only active in training mode (see [`LoRALayer::train`]).
162 #[must_use]
163 pub fn with_dropout(mut self, p: f32) -> Self {
164 // Clamp to [0, 1): p == 1.0 would zero everything and divide by zero in
165 // the inverted-dropout scale, which is never a valid configuration.
166 self.dropout = p.clamp(0.0, 0.999_999);
167 self
168 }
169
170 /// Set the deterministic dropout RNG seed (PMAT-879).
171 ///
172 /// With a fixed seed, dropout masks are fully reproducible, which makes the
173 /// training-mode forward path testable.
174 #[must_use]
175 pub fn with_dropout_seed(mut self, seed: u64) -> Self {
176 self.dropout_seed = seed;
177 self
178 }
179
180 /// Switch the layer to training mode. Dropout is active when `dropout > 0.0`.
181 pub fn train(&mut self) {
182 self.training = true;
183 }
184
185 /// Switch the layer to evaluation mode (the default). Dropout is the identity,
186 /// so inference output is unchanged — matching PEFT `nn.Dropout` in eval.
187 pub fn eval(&mut self) {
188 self.training = false;
189 }
190
191 /// Set training/eval mode explicitly.
192 pub fn set_training(&mut self, training: bool) {
193 self.training = training;
194 }
195
196 /// Whether the layer is in training mode.
197 pub fn is_training(&self) -> bool {
198 self.training
199 }
200
201 /// LoRA dropout probability.
202 pub fn dropout(&self) -> f32 {
203 self.dropout
204 }
205
206 /// Apply inverted dropout to the LoRA-branch input (PMAT-879).
207 ///
208 /// Returns `x` unchanged when not in training mode or when `p == 0.0` (the
209 /// identity), exactly mirroring PEFT's `nn.Dropout`/`nn.Identity` placement.
210 /// Otherwise each element is independently zeroed with probability `p` and the
211 /// survivors are scaled by `1/(1-p)` so the expectation is preserved.
212 ///
213 /// Uses a deterministic RNG seeded from `(dropout_seed, dropout_step)` so the
214 /// mask is reproducible for a given seed while advancing per forward call.
215 fn apply_input_dropout(&self, x: &Tensor) -> Tensor {
216 if !self.training || self.dropout <= 0.0 {
217 return x.clone();
218 }
219
220 use rand::rngs::StdRng;
221 use rand::{Rng, SeedableRng};
222
223 let step = self.dropout_step.get();
224 self.dropout_step.set(step.wrapping_add(1));
225
226 // Mix seed and step so each forward draws a fresh, reproducible mask.
227 let mixed = self.dropout_seed ^ step.wrapping_mul(0x9E37_79B9_7F4A_7C15);
228 let mut rng = StdRng::seed_from_u64(mixed);
229
230 let keep = 1.0 - self.dropout;
231 let inv_keep = 1.0 / keep;
232
233 let dropped: Vec<f32> = x
234 .data()
235 .iter()
236 .map(|&v| if rng.random::<f32>() < self.dropout { 0.0 } else { v * inv_keep })
237 .collect();
238
239 Tensor::from_vec(dropped, x.requires_grad())
240 }
241
242 /// Forward pass: y = W@x + scale * (B @ (A @ dropout(x)))
243 ///
244 /// # Arguments
245 /// * `x` - Input tensor `[d_in]`
246 ///
247 /// # Returns
248 /// Output tensor `[d_out]`
249 pub fn forward(&self, x: &Tensor) -> Tensor {
250 assert_eq!(x.len(), self.d_in, "Input size must match d_in");
251
252 // Base forward: W @ x [d_out, d_in] @ [d_in, 1] -> [d_out, 1]
253 let base_output = matmul(&self.base_weight, x, self.d_out, self.d_in, 1);
254
255 if self.merged {
256 // If merged, W already includes LoRA adaptation
257 base_output
258 } else {
259 // LoRA forward: scale * (B @ (A @ dropout(x)))
260 // PEFT placement: dropout is applied to the INPUT x before A.
261 // In eval mode (default) or with dropout == 0.0 this is the identity.
262 let dropped_x = self.apply_input_dropout(x);
263
264 // Step 1: A @ dropout(x) [r, d_in] @ [d_in, 1] -> [r, 1]
265 let lora_out_a = matmul(&self.lora_a, &dropped_x, self.rank, self.d_in, 1);
266
267 // Step 2: B @ (A @ x) [d_out, r] @ [r, 1] -> [d_out, 1]
268 let lora_out_b = matmul(&self.lora_b, &lora_out_a, self.d_out, self.rank, 1);
269
270 // Step 3: scale * LoRA output.
271 //
272 // PMAT-931: route the scale through the autograd-aware `scale` op
273 // instead of rebuilding the tensor with `Tensor::new(.., false)`,
274 // which SEVERS the backward edge to lora_a/lora_b (the same
275 // graph-severing class as the PMAT-921/922 sweep). Without this the
276 // adapter receives no gradient and LoRA fine-tuning silently fails
277 // to train.
278 let scaled_lora = scale(&lora_out_b, self.scale);
279
280 // Step 4: base + LoRA, again through the autograd-aware `add` op so
281 // the result keeps a live backward op reaching both the frozen base
282 // matmul (no-grad, dropped) AND the trainable LoRA branch.
283 add(&base_output, &scaled_lora)
284 }
285 }
286
287 /// Merge LoRA weights into base weight: W' = W + scale * (B @ A)
288 ///
289 /// After merging, forward pass only uses W' (more efficient).
290 /// This is typically done for inference.
291 pub fn merge(&mut self) {
292 if self.merged {
293 return; // Already merged
294 }
295
296 // Compute B @ A [d_out, r] @ [r, d_in] -> [d_out, d_in]
297 let ba = matmul(&self.lora_b, &self.lora_a, self.d_out, self.rank, self.d_in);
298
299 // Scale and add to base weight: W' = W + scale * B @ A
300 for (i, val) in self.base_weight.data_mut().iter_mut().enumerate() {
301 *val += self.scale * ba.data()[i];
302 }
303
304 self.merged = true;
305 }
306
307 /// Unmerge LoRA weights from base weight: W = W' - scale * (B @ A)
308 ///
309 /// Reverses the merge operation. Useful for continuing training or
310 /// switching adapters.
311 pub fn unmerge(&mut self) {
312 if !self.merged {
313 return; // Not merged
314 }
315
316 // Compute B @ A
317 let ba = matmul(&self.lora_b, &self.lora_a, self.d_out, self.rank, self.d_in);
318
319 // Subtract from base weight: W = W' - scale * B @ A
320 for (i, val) in self.base_weight.data_mut().iter_mut().enumerate() {
321 *val -= self.scale * ba.data()[i];
322 }
323
324 self.merged = false;
325 }
326
327 /// Get reference to base weight matrix
328 pub fn base_weight(&self) -> &Tensor {
329 &self.base_weight
330 }
331
332 /// Get reference to LoRA A matrix
333 pub fn lora_a(&self) -> &Tensor {
334 &self.lora_a
335 }
336
337 /// Get mutable reference to LoRA A matrix
338 pub fn lora_a_mut(&mut self) -> &mut Tensor {
339 &mut self.lora_a
340 }
341
342 /// Get reference to LoRA B matrix
343 pub fn lora_b(&self) -> &Tensor {
344 &self.lora_b
345 }
346
347 /// Get mutable reference to LoRA B matrix
348 pub fn lora_b_mut(&mut self) -> &mut Tensor {
349 &mut self.lora_b
350 }
351
352 /// Get trainable parameters (A and B)
353 pub fn trainable_params(&mut self) -> Vec<&mut Tensor> {
354 vec![&mut self.lora_a, &mut self.lora_b]
355 }
356
357 /// Check if LoRA is merged
358 pub fn is_merged(&self) -> bool {
359 self.merged
360 }
361
362 /// Get rank
363 pub fn rank(&self) -> usize {
364 self.rank
365 }
366
367 /// Get scale factor
368 pub fn scale(&self) -> f32 {
369 self.scale
370 }
371
372 /// Get output dimension
373 pub fn d_out(&self) -> usize {
374 self.d_out
375 }
376
377 /// Get input dimension
378 pub fn d_in(&self) -> usize {
379 self.d_in
380 }
381}