Skip to main content

entrenar/finetune/
instruct_trainer.rs

1//! Production training loop for instruction fine-tuning (GH-371)
2//!
3//! `InstructTrainer` wraps `InstructPipeline` with epoch management,
4//! validation, checkpointing, LR scheduling, and early stopping.
5//!
6//! # Contract Invariants
7//!
8//! - F-INST-003: Perplexity reported per epoch
9//! - F-LOOP-002: Validation computed every epoch
10//! - F-LOOP-007: Data shuffled per epoch
11//! - F-LOOP-008: Val split disjoint
12//! - F-LOOP-010: Early stopping respects patience
13
14use super::instruct_corpus::{format_chat_prompt, InstructSample};
15use super::instruct_pipeline::InstructPipeline;
16use sha2::{Digest, Sha256};
17use std::path::PathBuf;
18
19/// Training configuration for instruction trainer.
20#[derive(Debug, Clone)]
21pub struct InstructTrainingConfig {
22    /// Number of training epochs
23    pub epochs: usize,
24    /// Fraction of data reserved for validation (0.0, 0.5]
25    pub val_split: f32,
26    /// Save checkpoint every N epochs
27    pub save_every: usize,
28    /// Early stopping patience in epochs
29    pub early_stopping_patience: usize,
30    /// Directory for checkpoint files
31    pub checkpoint_dir: PathBuf,
32    /// Random seed for reproducibility
33    pub seed: u64,
34    /// Log metrics every N epochs
35    pub log_interval: usize,
36    /// Warmup steps as fraction of total steps
37    pub warmup_fraction: f32,
38    /// Minimum learning rate for cosine decay
39    pub lr_min: f32,
40}
41
42impl Default for InstructTrainingConfig {
43    fn default() -> Self {
44        Self {
45            epochs: 3,
46            val_split: 0.2,
47            save_every: 1,
48            early_stopping_patience: 5,
49            checkpoint_dir: PathBuf::from("checkpoints"),
50            seed: 42,
51            log_interval: 1,
52            warmup_fraction: 0.1,
53            lr_min: 1e-6,
54        }
55    }
56}
57
58/// Metrics for a single training epoch.
59#[derive(Debug, Clone)]
60pub struct InstructEpochMetrics {
61    /// Epoch number (0-indexed)
62    pub epoch: usize,
63    /// Average training loss (response tokens only)
64    pub train_loss: f32,
65    /// Training perplexity
66    pub train_perplexity: f32,
67    /// Average validation loss
68    pub val_loss: f32,
69    /// Validation perplexity
70    pub val_perplexity: f32,
71    /// Current learning rate
72    pub learning_rate: f32,
73    /// Epoch wall-clock time in milliseconds
74    pub epoch_time_ms: u64,
75    /// Training throughput (samples/second)
76    pub samples_per_sec: f32,
77}
78
79/// Result of the full training run.
80#[derive(Debug, Clone)]
81pub struct InstructTrainResult {
82    /// Per-epoch metrics
83    pub epoch_metrics: Vec<InstructEpochMetrics>,
84    /// Epoch with lowest validation loss
85    pub best_epoch: usize,
86    /// Lowest validation loss achieved
87    pub best_val_loss: f32,
88    /// Whether training stopped early
89    pub stopped_early: bool,
90    /// Total wall-clock training time in milliseconds
91    pub total_time_ms: u64,
92}
93
94/// Prepared token sequences for training.
95struct PreparedSample {
96    prompt_ids: Vec<u32>,
97    response_ids: Vec<u32>,
98}
99
100/// Production training loop for instruction fine-tuning.
101pub struct InstructTrainer {
102    /// The instruction pipeline (model + optimizer)
103    pipeline: InstructPipeline,
104    /// Training configuration
105    config: InstructTrainingConfig,
106    /// Training data (shuffled per epoch)
107    train_data: Vec<InstructSample>,
108    /// Validation data (frozen, never shuffled)
109    val_data: Vec<InstructSample>,
110    /// Base random seed
111    rng_seed: u64,
112    /// SHA-256 hash of training data for provenance
113    data_hash: String,
114}
115
116impl InstructTrainer {
117    /// Create a new trainer by splitting corpus into train/val sets.
118    ///
119    /// # Errors
120    /// Returns error if corpus is empty, val_split is out of range, or epochs is 0.
121    pub fn new(
122        pipeline: InstructPipeline,
123        corpus: Vec<InstructSample>,
124        config: InstructTrainingConfig,
125    ) -> crate::Result<Self> {
126        if corpus.is_empty() {
127            return Err(crate::Error::ConfigError("GH-371: corpus must not be empty".to_string()));
128        }
129        if config.val_split <= 0.0 || config.val_split > 0.5 {
130            return Err(crate::Error::ConfigError(format!(
131                "GH-371: val_split must be in (0.0, 0.5], got {}",
132                config.val_split,
133            )));
134        }
135        if config.epochs == 0 {
136            return Err(crate::Error::ConfigError("GH-371: epochs must be > 0".to_string()));
137        }
138
139        let (train_data, val_data) = Self::split_dataset(&corpus, config.val_split, config.seed);
140
141        if train_data.is_empty() || val_data.is_empty() {
142            return Err(crate::Error::ConfigError(format!(
143                "GH-371: split produced empty set (train={}, val={}). Need more samples.",
144                train_data.len(),
145                val_data.len(),
146            )));
147        }
148
149        let rng_seed = config.seed;
150        let data_hash = Self::compute_data_hash(&corpus);
151
152        Ok(Self { pipeline, config, train_data, val_data, rng_seed, data_hash })
153    }
154
155    /// Run the full training loop.
156    pub fn train(&mut self) -> InstructTrainResult {
157        use crate::optim::{LRScheduler, WarmupCosineDecayLR};
158
159        let total_start = std::time::Instant::now();
160        let base_lr = self.pipeline.learning_rate();
161        let total_steps = self.config.epochs * self.train_data.len();
162        let warmup_steps = (total_steps as f32 * self.config.warmup_fraction) as usize;
163
164        let mut scheduler =
165            WarmupCosineDecayLR::new(base_lr, self.config.lr_min, warmup_steps, total_steps);
166
167        let mut epoch_metrics = Vec::new();
168        let mut best_val_loss = f32::INFINITY;
169        let mut best_epoch = 0usize;
170        let mut patience_counter = 0usize;
171        let mut stopped_early = false;
172
173        // Pre-tokenize validation data (frozen across epochs)
174        // KAIZEN-046: Removed unnecessary .clone() — both borrows are immutable.
175        let val_prepared = self.prepare_samples(&self.val_data);
176
177        // KAIZEN-046: Pre-collect validation token IDs once (was: re-cloned every epoch).
178        let val_prompts: Vec<Vec<u32>> =
179            val_prepared.iter().map(|s| s.prompt_ids.clone()).collect();
180        let val_responses: Vec<Vec<u32>> =
181            val_prepared.iter().map(|s| s.response_ids.clone()).collect();
182
183        for epoch in 0..self.config.epochs {
184            let epoch_start = std::time::Instant::now();
185
186            // Shuffle training data
187            self.shuffle_train(epoch as u64);
188
189            // Pre-tokenize training data for this epoch (after shuffle)
190            // KAIZEN-046: Removed unnecessary .clone() — both borrows are immutable.
191            let train_prepared = self.prepare_samples(&self.train_data);
192
193            // ── Train ──
194            let mut epoch_loss = 0.0f32;
195            let mut epoch_tokens = 0usize;
196            let n_steps = train_prepared.len();
197
198            // Per-step progress so a slow CPU epoch does not look like a frozen
199            // hang. Low-noise: emit on every 10th step, on the final step, and
200            // at least once every ~10s. Pure stderr logging — the training math
201            // (loss/token accumulation, scheduler) is unchanged.
202            let mut last_step_log = std::time::Instant::now();
203
204            for (step, sample) in train_prepared.iter().enumerate() {
205                let lr = scheduler.get_lr();
206                self.pipeline.set_learning_rate(lr);
207
208                let result = self.pipeline.train_step(&sample.prompt_ids, &sample.response_ids);
209                epoch_loss += result.loss * result.num_response_tokens as f32;
210                epoch_tokens += result.num_response_tokens;
211                scheduler.step();
212
213                let is_last_step = step + 1 == n_steps;
214                if (step + 1) % 10 == 0 || is_last_step || last_step_log.elapsed().as_secs() >= 10 {
215                    eprintln!(
216                        "  Epoch {}/{} step {}/{}: loss={:.4} lr={:.2e}",
217                        epoch + 1,
218                        self.config.epochs,
219                        step + 1,
220                        n_steps,
221                        result.loss,
222                        lr,
223                    );
224                    last_step_log = std::time::Instant::now();
225                }
226            }
227
228            let train_loss = if epoch_tokens > 0 { epoch_loss / epoch_tokens as f32 } else { 0.0 };
229
230            // PMAT-512: Emit per-epoch loss to stderr so canary parser can extract it.
231            // Format matches WGPU path (finetune.rs:678) for parser compatibility.
232            eprintln!(
233                "  Epoch {} complete: avg_loss={:.4} tokens={} samples={} lr={:.2e}",
234                epoch + 1,
235                train_loss,
236                epoch_tokens,
237                train_prepared.len(),
238                self.pipeline.learning_rate(),
239            );
240
241            // ── Validate ──
242            // KAIZEN-046: val_prompts/val_responses hoisted outside epoch loop.
243            let val_result = self.pipeline.evaluate(&val_prompts, &val_responses);
244
245            let epoch_time_ms = epoch_start.elapsed().as_millis() as u64;
246            let samples_per_sec = if epoch_time_ms > 0 {
247                train_prepared.len() as f32 / (epoch_time_ms as f32 / 1000.0)
248            } else {
249                0.0
250            };
251
252            let metrics = InstructEpochMetrics {
253                epoch,
254                train_loss,
255                train_perplexity: train_loss.exp().min(1e6),
256                val_loss: val_result.avg_loss,
257                val_perplexity: val_result.perplexity,
258                learning_rate: self.pipeline.learning_rate(),
259                epoch_time_ms,
260                samples_per_sec,
261            };
262
263            // ── Checkpointing ──
264            if val_result.avg_loss < best_val_loss {
265                best_val_loss = val_result.avg_loss;
266                best_epoch = epoch;
267                patience_counter = 0;
268
269                // Save best checkpoint
270                let best_path = self.config.checkpoint_dir.join("best");
271                let _ = self.save_checkpoint(&best_path, epoch, &metrics);
272            } else {
273                patience_counter += 1;
274            }
275
276            // Periodic checkpoint
277            let effective_save_every = if self.config.epochs <= self.config.save_every {
278                1
279            } else {
280                self.config.save_every
281            };
282            if effective_save_every > 0 && (epoch + 1) % effective_save_every == 0 {
283                let epoch_path = self.config.checkpoint_dir.join(format!("epoch-{epoch}"));
284                let _ = self.save_checkpoint(&epoch_path, epoch, &metrics);
285            }
286
287            epoch_metrics.push(metrics);
288
289            // ── Early stopping ──
290            if patience_counter >= self.config.early_stopping_patience {
291                stopped_early = true;
292                break;
293            }
294        }
295
296        // PMAT-512: Print final training summary to stderr for canary parser.
297        if let Some(last) = epoch_metrics.last() {
298            eprintln!(
299                "[training] Training complete: final_loss={:.4} best_val_loss={:.4} best_epoch={} epochs={} time={}s{}",
300                last.train_loss,
301                best_val_loss,
302                best_epoch + 1,
303                epoch_metrics.len(),
304                total_start.elapsed().as_secs(),
305                if stopped_early { " (early stopped)" } else { "" },
306            );
307        }
308
309        // PMAT-483: Print profiler report at end of training (text + JSON)
310        if self.pipeline.profiler.is_enabled() {
311            self.pipeline.profiler.print_report();
312            self.pipeline.profiler.print_json_report();
313        }
314
315        InstructTrainResult {
316            epoch_metrics,
317            best_epoch,
318            best_val_loss,
319            stopped_early,
320            total_time_ms: total_start.elapsed().as_millis() as u64,
321        }
322    }
323
324    /// Prepare samples by tokenizing prompt and response.
325    fn prepare_samples(&self, samples: &[InstructSample]) -> Vec<PreparedSample> {
326        samples
327            .iter()
328            .map(|sample| {
329                let (prompt_text, response_text) = format_chat_prompt(sample);
330                PreparedSample {
331                    prompt_ids: self.pipeline.tokenize(&prompt_text),
332                    response_ids: self.pipeline.tokenize(&response_text),
333                }
334            })
335            .collect()
336    }
337
338    /// Split dataset into train/val with deterministic shuffling.
339    fn split_dataset(
340        corpus: &[InstructSample],
341        val_split: f32,
342        seed: u64,
343    ) -> (Vec<InstructSample>, Vec<InstructSample>) {
344        use std::collections::hash_map::DefaultHasher;
345        use std::hash::{Hash, Hasher};
346
347        let mut indices: Vec<usize> = (0..corpus.len()).collect();
348
349        // Fisher-Yates shuffle with deterministic seed
350        for i in (1..indices.len()).rev() {
351            let mut hasher = DefaultHasher::new();
352            seed.hash(&mut hasher);
353            i.hash(&mut hasher);
354            let j = (hasher.finish() as usize) % (i + 1);
355            indices.swap(i, j);
356        }
357
358        let val_size = (corpus.len() as f32 * val_split).ceil() as usize;
359        let val_size = val_size.max(1).min(corpus.len() - 1);
360
361        let val_data: Vec<InstructSample> =
362            indices[..val_size].iter().map(|&i| corpus[i].clone()).collect();
363        let train_data: Vec<InstructSample> =
364            indices[val_size..].iter().map(|&i| corpus[i].clone()).collect();
365
366        (train_data, val_data)
367    }
368
369    /// Shuffle training data with epoch-specific seed.
370    fn shuffle_train(&mut self, epoch: u64) {
371        use std::collections::hash_map::DefaultHasher;
372        use std::hash::{Hash, Hasher};
373
374        let n = self.train_data.len();
375        for i in (1..n).rev() {
376            let mut hasher = DefaultHasher::new();
377            self.rng_seed.hash(&mut hasher);
378            epoch.hash(&mut hasher);
379            i.hash(&mut hasher);
380            let j = (hasher.finish() as usize) % (i + 1);
381            self.train_data.swap(i, j);
382        }
383    }
384
385    /// Compute SHA-256 hash of corpus for provenance.
386    fn compute_data_hash(corpus: &[InstructSample]) -> String {
387        let mut hasher = Sha256::new();
388        for s in corpus {
389            hasher.update(s.instruction.as_bytes());
390            hasher.update([0u8]);
391            hasher.update(s.response.as_bytes());
392            hasher.update([0u8]);
393        }
394        format!("sha256:{:x}", hasher.finalize())
395    }
396
397    /// Get data hash for provenance tracking.
398    #[must_use]
399    pub fn data_hash(&self) -> &str {
400        &self.data_hash
401    }
402
403    /// Get training data size.
404    #[must_use]
405    pub fn train_size(&self) -> usize {
406        self.train_data.len()
407    }
408
409    /// Get validation data size.
410    #[must_use]
411    pub fn val_size(&self) -> usize {
412        self.val_data.len()
413    }
414
415    /// Save a checkpoint with LoRA adapter weights and training metadata.
416    ///
417    /// Creates a directory at `path` containing:
418    /// - `metadata.json`: training metrics for this checkpoint
419    /// - `model.safetensors`: LoRA adapter weights (Q/V projections per layer)
420    pub fn save_checkpoint(
421        &mut self,
422        path: &std::path::Path,
423        epoch: usize,
424        metrics: &InstructEpochMetrics,
425    ) -> crate::Result<()> {
426        contract_pre_save_checkpoint!();
427        // Sync GPU LoRA weights to CPU before saving
428        #[cfg(feature = "cuda")]
429        self.pipeline.sync_lora_to_cpu();
430
431        std::fs::create_dir_all(path).map_err(|e| {
432            crate::Error::Io(format!("Failed to create checkpoint dir {}: {e}", path.display()))
433        })?;
434
435        // Save metadata.json
436        let metadata = serde_json::json!({
437            "task": "instruct",
438            "epoch": epoch,
439            "train_loss": metrics.train_loss,
440            "val_loss": metrics.val_loss,
441            "train_perplexity": metrics.train_perplexity,
442            "val_perplexity": metrics.val_perplexity,
443            "learning_rate": metrics.learning_rate,
444            "epoch_time_ms": metrics.epoch_time_ms,
445            "samples_per_sec": metrics.samples_per_sec,
446            "lora_rank": self.pipeline.config.lora_rank,
447            "lora_alpha": self.pipeline.config.lora_alpha,
448            "data_hash": self.data_hash,
449        });
450
451        let meta_json = serde_json::to_string_pretty(&metadata).map_err(|e| {
452            crate::Error::Serialization(format!("Failed to serialize metadata: {e}"))
453        })?;
454        std::fs::write(path.join("metadata.json"), meta_json)?;
455
456        // Save LoRA adapter weights as SafeTensors
457        let mut tensor_data: Vec<(String, Vec<u8>, Vec<usize>)> = Vec::new();
458
459        for (idx, lora) in self.pipeline.lora_layers.iter().enumerate() {
460            let layer = idx / 2;
461            let proj = if idx % 2 == 0 { "q" } else { "v" };
462
463            // LoRA A: [rank, d_in]
464            let a_data = lora.lora_a().data();
465            let a_bytes: Vec<u8> =
466                bytemuck::cast_slice(a_data.as_slice().expect("contiguous lora_a")).to_vec();
467            let a_shape = vec![lora.rank(), lora.d_in()];
468            tensor_data.push((format!("lora.{layer}.{proj}_proj.lora_a"), a_bytes, a_shape));
469
470            // LoRA B: [d_out, rank]
471            let b_data = lora.lora_b().data();
472            let b_bytes: Vec<u8> =
473                bytemuck::cast_slice(b_data.as_slice().expect("contiguous lora_b")).to_vec();
474            let b_shape = vec![lora.d_out(), lora.rank()];
475            tensor_data.push((format!("lora.{layer}.{proj}_proj.lora_b"), b_bytes, b_shape));
476        }
477
478        let views: Vec<(&str, safetensors::tensor::TensorView<'_>)> = tensor_data
479            .iter()
480            .map(|(name, bytes, shape)| {
481                let view = safetensors::tensor::TensorView::new(
482                    safetensors::tensor::Dtype::F32,
483                    shape.clone(),
484                    bytes,
485                )
486                .expect("valid tensor view");
487                (name.as_str(), view)
488            })
489            .collect();
490
491        let mut st_metadata = std::collections::HashMap::new();
492        st_metadata.insert("epoch".to_string(), epoch.to_string());
493        st_metadata.insert("val_loss".to_string(), format!("{:.6}", metrics.val_loss));
494
495        let safetensor_bytes = safetensors::serialize(views, Some(st_metadata)).map_err(|e| {
496            crate::Error::Serialization(format!("SafeTensors serialization failed: {e}"))
497        })?;
498        std::fs::write(path.join("model.safetensors"), safetensor_bytes)?;
499
500        contract_post_save_checkpoint!(());
501        Ok(())
502    }
503}
504
505#[cfg(test)]
506mod tests {
507    use super::*;
508    use crate::finetune::instruct_pipeline::InstructConfig;
509    use crate::transformer::TransformerConfig;
510
511    fn make_corpus(n: usize) -> Vec<InstructSample> {
512        (0..n)
513            .map(|i| InstructSample {
514                instruction: format!("Write function {i}"),
515                response: format!("def func_{i}():\n    return {i}"),
516                system: None,
517                metadata: None,
518            })
519            .collect()
520    }
521
522    #[test]
523    fn test_trainer_creation() {
524        let model_config = TransformerConfig::tiny();
525        let instruct_config =
526            InstructConfig { lora_rank: 4, max_seq_len: 32, ..InstructConfig::default() };
527        let pipeline = InstructPipeline::new(&model_config, instruct_config);
528        let corpus = make_corpus(20);
529        let config = InstructTrainingConfig { epochs: 2, ..Default::default() };
530
531        let trainer = InstructTrainer::new(pipeline, corpus, config);
532        assert!(trainer.is_ok());
533
534        let trainer = trainer.unwrap();
535        assert!(trainer.train_size() > 0);
536        assert!(trainer.val_size() > 0);
537    }
538
539    #[test]
540    fn test_trainer_empty_corpus() {
541        let model_config = TransformerConfig::tiny();
542        let instruct_config = InstructConfig::default();
543        let pipeline = InstructPipeline::new(&model_config, instruct_config);
544        let config = InstructTrainingConfig::default();
545
546        let result = InstructTrainer::new(pipeline, vec![], config);
547        assert!(result.is_err());
548    }
549
550    #[test]
551    fn test_trainer_train() {
552        let model_config = TransformerConfig::tiny();
553        let instruct_config =
554            InstructConfig { lora_rank: 4, max_seq_len: 32, ..InstructConfig::default() };
555        let pipeline = InstructPipeline::new(&model_config, instruct_config);
556        let corpus = make_corpus(10);
557        let config = InstructTrainingConfig { epochs: 2, save_every: 1, ..Default::default() };
558
559        let mut trainer = InstructTrainer::new(pipeline, corpus, config).unwrap();
560        let result = trainer.train();
561
562        assert_eq!(result.epoch_metrics.len(), 2);
563        assert!(result.best_val_loss >= 0.0);
564        assert!(result.total_time_ms > 0);
565    }
566
567    #[test]
568    fn test_data_hash_deterministic() {
569        let corpus = make_corpus(5);
570        let hash1 = InstructTrainer::compute_data_hash(&corpus);
571        let hash2 = InstructTrainer::compute_data_hash(&corpus);
572        assert_eq!(hash1, hash2);
573        assert!(hash1.starts_with("sha256:"));
574    }
575
576    #[test]
577    fn test_split_disjoint() {
578        let corpus = make_corpus(20);
579        let (train, val) = InstructTrainer::split_dataset(&corpus, 0.2, 42);
580        assert_eq!(train.len() + val.len(), 20);
581        assert!(!train.is_empty());
582        assert!(!val.is_empty());
583    }
584}