1use super::instruct_corpus::{format_chat_prompt, InstructSample};
15use super::instruct_pipeline::InstructPipeline;
16use sha2::{Digest, Sha256};
17use std::path::PathBuf;
18
19#[derive(Debug, Clone)]
21pub struct InstructTrainingConfig {
22 pub epochs: usize,
24 pub val_split: f32,
26 pub save_every: usize,
28 pub early_stopping_patience: usize,
30 pub checkpoint_dir: PathBuf,
32 pub seed: u64,
34 pub log_interval: usize,
36 pub warmup_fraction: f32,
38 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#[derive(Debug, Clone)]
60pub struct InstructEpochMetrics {
61 pub epoch: usize,
63 pub train_loss: f32,
65 pub train_perplexity: f32,
67 pub val_loss: f32,
69 pub val_perplexity: f32,
71 pub learning_rate: f32,
73 pub epoch_time_ms: u64,
75 pub samples_per_sec: f32,
77}
78
79#[derive(Debug, Clone)]
81pub struct InstructTrainResult {
82 pub epoch_metrics: Vec<InstructEpochMetrics>,
84 pub best_epoch: usize,
86 pub best_val_loss: f32,
88 pub stopped_early: bool,
90 pub total_time_ms: u64,
92}
93
94struct PreparedSample {
96 prompt_ids: Vec<u32>,
97 response_ids: Vec<u32>,
98}
99
100pub struct InstructTrainer {
102 pipeline: InstructPipeline,
104 config: InstructTrainingConfig,
106 train_data: Vec<InstructSample>,
108 val_data: Vec<InstructSample>,
110 rng_seed: u64,
112 data_hash: String,
114}
115
116impl InstructTrainer {
117 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 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 let val_prepared = self.prepare_samples(&self.val_data);
176
177 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 self.shuffle_train(epoch as u64);
188
189 let train_prepared = self.prepare_samples(&self.train_data);
192
193 let mut epoch_loss = 0.0f32;
195 let mut epoch_tokens = 0usize;
196 let n_steps = train_prepared.len();
197
198 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 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 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 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 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 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 if patience_counter >= self.config.early_stopping_patience {
291 stopped_early = true;
292 break;
293 }
294 }
295
296 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 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 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 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 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 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 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 #[must_use]
399 pub fn data_hash(&self) -> &str {
400 &self.data_hash
401 }
402
403 #[must_use]
405 pub fn train_size(&self) -> usize {
406 self.train_data.len()
407 }
408
409 #[must_use]
411 pub fn val_size(&self) -> usize {
412 self.val_data.len()
413 }
414
415 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 #[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 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 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 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 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}