1mod bars;
6mod charts;
7mod epoch;
8mod format;
9
10pub use bars::{
11 build_block_bar, build_colored_block_bar, pct_color, render_sparkline, trend_arrow,
12};
13pub use charts::{
14 render_braille_chart, render_config_panel, render_gauge, render_history_table,
15 render_sample_panel, BrailleChart,
16};
17pub use epoch::{compute_epoch_summaries, EpochSummary};
18pub use format::{format_bytes, format_duration, format_lr};
19
20use super::color::{ColorMode, Styled, TrainingPalette};
21use super::state::{TrainingSnapshot, TrainingStatus};
22
23pub fn render_layout(snapshot: &TrainingSnapshot, width: usize) -> String {
28 render_layout_colored(snapshot, width, ColorMode::detect())
29}
30
31pub fn render_layout_colored(
32 snapshot: &TrainingSnapshot,
33 width: usize,
34 color_mode: ColorMode,
35) -> String {
36 let mut lines: Vec<String> = Vec::new();
37 let w = width.max(80);
38
39 render_header(&mut lines, snapshot, w, color_mode);
40 render_progress(&mut lines, snapshot, w, color_mode);
41 render_metrics(&mut lines, snapshot, color_mode);
42 render_loss_sparkline(&mut lines, snapshot, w, color_mode);
43 render_gpu_section(&mut lines, snapshot, w, color_mode);
44 render_epoch_table(&mut lines, snapshot, w, color_mode);
45 render_config_footer(&mut lines, snapshot, w, color_mode);
46
47 lines.join("\n")
48}
49
50fn render_header(
51 lines: &mut Vec<String>,
52 snapshot: &TrainingSnapshot,
53 w: usize,
54 color_mode: ColorMode,
55) {
56 let (status_icon, status_text, status_color) = match &snapshot.status {
57 TrainingStatus::Initializing => ("\u{25D0}", "Init", TrainingPalette::INFO),
58 TrainingStatus::Running => ("\u{25CF}", "Running", TrainingPalette::SUCCESS),
59 TrainingStatus::Paused => ("\u{25D0}", "Paused", TrainingPalette::WARNING),
60 TrainingStatus::Completed => ("\u{25CB}", "Done", TrainingPalette::PRIMARY),
61 TrainingStatus::Failed(_) => ("\u{25CF}", "FAIL", TrainingPalette::ERROR),
62 };
63
64 let elapsed = format_duration(snapshot.elapsed());
65 let tps = snapshot.tokens_per_second.max(0.0);
66
67 let model_display = if snapshot.model_name.is_empty() {
68 "N/A".to_string()
69 } else if snapshot.model_name.len() > 30 {
70 format!("{}...", &snapshot.model_name[..27])
71 } else {
72 snapshot.model_name.clone()
73 };
74
75 let header = format!(
76 "ENTRENAR {} {} {} {:.0} tok/s",
77 Styled::new(&format!("{status_icon} {status_text}"), color_mode).fg(status_color),
78 Styled::new(&elapsed, color_mode).fg((150, 150, 150)),
79 Styled::new(&model_display, color_mode).fg((180, 180, 220)),
80 tps
81 );
82 lines.push(format!("\u{2550}{:\u{2550}<w$}\u{2550}", "", w = w - 2));
83 lines.push(header);
84 lines.push(format!("\u{2500}{:\u{2500}<w$}\u{2500}", "", w = w - 2));
85}
86
87fn render_progress(
88 lines: &mut Vec<String>,
89 snapshot: &TrainingSnapshot,
90 _w: usize,
91 color_mode: ColorMode,
92) {
93 let epoch = snapshot.epoch.min(snapshot.total_epochs);
94 let epoch_pct = if snapshot.total_epochs > 0 {
95 let pct = (epoch as f64 / snapshot.total_epochs as f64 * 100.0).clamp(0.0, 100.0);
97 pct as f32
98 } else {
99 0.0
100 };
101
102 let step = snapshot.step.min(snapshot.steps_per_epoch);
103 let step_pct = if snapshot.steps_per_epoch > 0 {
104 let pct = (step as f64 / snapshot.steps_per_epoch as f64 * 100.0).clamp(0.0, 100.0);
105 pct as f32
106 } else {
107 0.0
108 };
109
110 let bar_w = 20;
111 let epoch_bar = build_colored_block_bar(epoch_pct, bar_w, color_mode);
112 let step_bar = build_colored_block_bar(step_pct, bar_w, color_mode);
113
114 lines.push(format!(
115 "Epoch {:>2}/{:<2} {} {:>3.0}% Step {:>2}/{:<2} {} {:>3.0}%",
116 epoch,
117 snapshot.total_epochs,
118 epoch_bar,
119 epoch_pct,
120 step,
121 snapshot.steps_per_epoch,
122 step_bar,
123 step_pct
124 ));
125}
126
127fn render_metrics(lines: &mut Vec<String>, snapshot: &TrainingSnapshot, color_mode: ColorMode) {
128 let loss_str =
129 if snapshot.loss.is_finite() { format!("{:.4}", snapshot.loss) } else { "???".to_string() };
130 let loss_color = if snapshot.loss.is_finite() {
131 pct_color((snapshot.loss * 10.0).min(100.0))
132 } else {
133 (255, 64, 64)
134 };
135
136 let best = snapshot
137 .loss_history
138 .iter()
139 .copied()
140 .filter(|v| v.is_finite())
141 .fold(f32::INFINITY, f32::min);
142 let best_str = if best.is_finite() { format!("{best:.4}") } else { "---".to_string() };
143
144 let grad = snapshot.gradient_norm.max(0.0);
145 let eta = snapshot.estimated_remaining().map_or("--:--:--".to_string(), format_duration);
146
147 lines.push(format!(
148 "Loss {} {} Best {} LR {} Grad {:.2} ETA {}",
149 Styled::new(&loss_str, color_mode).fg(loss_color),
150 trend_arrow(&snapshot.loss_history),
151 Styled::new(&best_str, color_mode).fg((100, 200, 100)),
152 format_lr(snapshot.learning_rate),
153 grad,
154 eta
155 ));
156}
157
158fn render_loss_sparkline(
159 lines: &mut Vec<String>,
160 snapshot: &TrainingSnapshot,
161 w: usize,
162 color_mode: ColorMode,
163) {
164 if snapshot.loss_history.is_empty() {
165 return;
166 }
167
168 let spark_w = w.saturating_sub(20);
169 let sparkline = render_sparkline(&snapshot.loss_history, spark_w, color_mode);
170
171 let valid: Vec<f32> = snapshot.loss_history.iter().copied().filter(|v| v.is_finite()).collect();
172 let (min_l, max_l) = if valid.is_empty() {
173 (0.0, 0.0)
174 } else {
175 (
176 valid.iter().copied().fold(f32::INFINITY, f32::min),
177 valid.iter().copied().fold(f32::NEG_INFINITY, f32::max),
178 )
179 };
180
181 lines.push(format!("\u{2500}{:\u{2500}<w$}\u{2500}", "", w = w - 2));
182 lines.push(format!("Loss History: {sparkline} [{min_l:.2} - {max_l:.2}]"));
183}
184
185fn render_gpu_section(
186 lines: &mut Vec<String>,
187 snapshot: &TrainingSnapshot,
188 w: usize,
189 color_mode: ColorMode,
190) {
191 lines.push(format!("\u{2500}{:\u{2500}<w$}\u{2500}", "", w = w - 2));
192 if let Some(gpu) = &snapshot.gpu {
193 let util_bar = build_colored_block_bar(gpu.utilization_percent, 15, color_mode);
194 let vram_pct = gpu.vram_percent().min(100.0);
195 let vram_bar = build_colored_block_bar(vram_pct, 15, color_mode);
196
197 let temp_color = if gpu.temperature_celsius > 80.0 {
198 TrainingPalette::ERROR
199 } else if gpu.temperature_celsius > 70.0 {
200 TrainingPalette::WARNING
201 } else {
202 TrainingPalette::SUCCESS
203 };
204
205 lines.push(format!(
206 "GPU: {} Util {} {:>3.0}% Temp {} Power {:.0}W",
207 gpu.device_name.chars().take(20).collect::<String>(),
208 util_bar,
209 gpu.utilization_percent,
210 Styled::new(&format!("{:.0}\u{00B0}C", gpu.temperature_celsius), color_mode)
211 .fg(temp_color),
212 gpu.power_watts
213 ));
214
215 lines.push(format!(
216 "VRAM: {} {:>3.0}% {:.1}G / {:.0}G",
217 vram_bar,
218 vram_pct,
219 gpu.vram_used_gb.min(gpu.vram_total_gb),
220 gpu.vram_total_gb
221 ));
222 } else {
223 lines.push("GPU: N/A".to_string());
224 }
225}
226
227fn render_epoch_table(
228 lines: &mut Vec<String>,
229 snapshot: &TrainingSnapshot,
230 w: usize,
231 color_mode: ColorMode,
232) {
233 lines.push(format!("\u{2500}{:\u{2500}<w$}\u{2500}", "", w = w - 2));
234 lines.push(format!(
235 "{:>5} {:>8} {:>8} {:>8} {:>10} {:>5}",
236 Styled::new("Epoch", color_mode).fg((150, 150, 150)),
237 Styled::new("Loss", color_mode).fg((150, 150, 150)),
238 Styled::new("Min", color_mode).fg((150, 150, 150)),
239 Styled::new("Max", color_mode).fg((150, 150, 150)),
240 Styled::new("LR", color_mode).fg((150, 150, 150)),
241 Styled::new("Trend", color_mode).fg((150, 150, 150)),
242 ));
243
244 let summaries = compute_epoch_summaries(snapshot);
245 if summaries.is_empty() {
246 lines.push(" (waiting for epoch data...)".to_string());
247 return;
248 }
249
250 let max_rows = 6;
251 let start_idx = summaries.len().saturating_sub(max_rows);
252
253 for (i, summary) in summaries.iter().skip(start_idx).enumerate() {
254 let trend = epoch_trend_arrow(i, start_idx, &summaries, color_mode);
255 let loss_color = pct_color((summary.avg_loss * 8.0).min(100.0));
256
257 lines.push(format!(
258 "{:>5} {} {:>8.4} {:>8.4} {:>10} {:>5}",
259 summary.epoch,
260 Styled::new(&format!("{:>8.4}", summary.avg_loss), color_mode).fg(loss_color),
261 summary.min_loss,
262 summary.max_loss,
263 format_lr(summary.lr),
264 trend
265 ));
266 }
267
268 if start_idx > 0 {
269 lines.push(format!(
270 " ... {} earlier epochs",
271 Styled::new(&format!("{start_idx}"), color_mode).fg((100, 100, 100))
272 ));
273 }
274}
275
276fn epoch_trend_arrow(
277 i: usize,
278 start_idx: usize,
279 summaries: &[EpochSummary],
280 color_mode: ColorMode,
281) -> String {
282 if i == 0 && start_idx == 0 {
283 return " ".to_string();
284 }
285 let prev_idx = if i > 0 { start_idx + i - 1 } else { start_idx.saturating_sub(1) };
286 if let Some(prev) = summaries.get(prev_idx) {
287 let current = &summaries[start_idx + i];
288 let change = (current.avg_loss - prev.avg_loss) / prev.avg_loss.abs().max(0.001);
289 if change < -0.02 {
290 Styled::new("\u{2193}", color_mode).fg((100, 255, 100)).to_string()
291 } else if change > 0.02 {
292 Styled::new("\u{2191}", color_mode).fg((255, 100, 100)).to_string()
293 } else {
294 Styled::new("\u{2192}", color_mode).fg((150, 150, 150)).to_string()
295 }
296 } else {
297 " ".to_string()
298 }
299}
300
301fn render_config_footer(
302 lines: &mut Vec<String>,
303 snapshot: &TrainingSnapshot,
304 w: usize,
305 color_mode: ColorMode,
306) {
307 lines.push(format!("\u{2500}{:\u{2500}<w$}\u{2500}", "", w = w - 2));
308
309 let opt = if snapshot.optimizer_name.is_empty() { "N/A" } else { &snapshot.optimizer_name };
310 let batch = if snapshot.batch_size > 0 {
311 format!("{}", snapshot.batch_size)
312 } else {
313 "N/A".to_string()
314 };
315
316 lines.push(format!(
317 "Config: {} Batch: {} Checkpoint: {}",
318 Styled::new(opt, color_mode).fg((150, 255, 150)),
319 batch,
320 if snapshot.checkpoint_path.is_empty() { "N/A" } else { &snapshot.checkpoint_path }
321 ));
322
323 lines.push(format!("\u{2550}{:\u{2550}<w$}\u{2550}", "", w = w - 2));
324}
325
326#[cfg(test)]
331mod tests {
332 use super::*;
333
334 #[test]
335 fn test_layout_renders() {
336 let snapshot = TrainingSnapshot {
337 epoch: 5,
338 total_epochs: 10,
339 step: 8,
340 steps_per_epoch: 16,
341 loss: 2.5,
342 loss_history: vec![5.0, 4.5, 4.0, 3.5, 3.0, 2.8, 2.6, 2.5],
343 learning_rate: 0.0001,
344 gradient_norm: 1.5,
345 tokens_per_second: 100.0,
346 model_name: "TestModel".to_string(),
347 optimizer_name: "AdamW".to_string(),
348 batch_size: 4,
349 ..Default::default()
350 };
351
352 let layout = render_layout(&snapshot, 80);
353 assert!(layout.contains("ENTRENAR"));
354 assert!(layout.contains("Epoch"));
355 assert!(layout.contains("Loss"));
356 assert!(layout.contains("Step"));
357 }
358
359 #[test]
360 fn test_render_header_initializing() {
361 let snapshot = TrainingSnapshot {
362 status: TrainingStatus::Initializing,
363 model_name: "TestModel".to_string(),
364 ..Default::default()
365 };
366 let layout = render_layout_colored(&snapshot, 80, ColorMode::Mono);
367 assert!(layout.contains("Init"));
368 }
369
370 #[test]
371 fn test_render_header_running() {
372 let snapshot = TrainingSnapshot {
373 status: TrainingStatus::Running,
374 model_name: "TestModel".to_string(),
375 ..Default::default()
376 };
377 let layout = render_layout_colored(&snapshot, 80, ColorMode::Mono);
378 assert!(layout.contains("Running"));
379 }
380
381 #[test]
382 fn test_render_header_paused() {
383 let snapshot = TrainingSnapshot {
384 status: TrainingStatus::Paused,
385 model_name: "TestModel".to_string(),
386 ..Default::default()
387 };
388 let layout = render_layout_colored(&snapshot, 80, ColorMode::Mono);
389 assert!(layout.contains("Paused"));
390 }
391
392 #[test]
393 fn test_render_header_completed() {
394 let snapshot = TrainingSnapshot {
395 status: TrainingStatus::Completed,
396 model_name: "TestModel".to_string(),
397 ..Default::default()
398 };
399 let layout = render_layout_colored(&snapshot, 80, ColorMode::Mono);
400 assert!(layout.contains("Done"));
401 }
402
403 #[test]
404 fn test_render_header_failed_arm() {
405 let status = TrainingStatus::Failed("out of memory".to_string());
406 match &status {
407 TrainingStatus::Failed(_) => {}
408 _ => unreachable!(),
409 }
410 let snapshot =
411 TrainingSnapshot { status, model_name: "TestModel".to_string(), ..Default::default() };
412 let layout = render_layout_colored(&snapshot, 80, ColorMode::Mono);
413 assert!(layout.contains("FAIL"));
414 }
415
416 #[test]
419 fn test_render_layout_colored_mono() {
420 let snapshot = TrainingSnapshot {
421 epoch: 2,
422 total_epochs: 5,
423 step: 3,
424 steps_per_epoch: 10,
425 loss: 1.5,
426 loss_history: vec![3.0, 2.5, 2.0, 1.5],
427 learning_rate: 0.0001,
428 gradient_norm: 0.5,
429 tokens_per_second: 50.0,
430 model_name: "TestModel".to_string(),
431 optimizer_name: "SGD".to_string(),
432 batch_size: 8,
433 status: TrainingStatus::Running,
434 ..Default::default()
435 };
436 let layout = render_layout_colored(&snapshot, 100, ColorMode::Mono);
437 assert!(layout.contains("ENTRENAR"));
438 assert!(layout.contains("Running"));
439 assert!(layout.contains("Epoch"));
440 assert!(layout.contains("Step"));
441 assert!(layout.contains("Loss"));
442 assert!(layout.contains("Config"));
443 }
444
445 #[test]
446 fn test_render_layout_with_gpu() {
447 let snapshot = TrainingSnapshot {
448 epoch: 1,
449 total_epochs: 3,
450 step: 5,
451 steps_per_epoch: 20,
452 loss: 2.0,
453 loss_history: vec![3.0, 2.5, 2.0],
454 learning_rate: 0.001,
455 gradient_norm: 1.0,
456 tokens_per_second: 200.0,
457 model_name: "GPUModel".to_string(),
458 status: TrainingStatus::Running,
459 gpu: Some(super::super::state::GpuTelemetry {
460 device_name: "RTX 4090".to_string(),
461 utilization_percent: 95.0,
462 vram_used_gb: 20.0,
463 vram_total_gb: 24.0,
464 temperature_celsius: 72.0,
465 power_watts: 350.0,
466 power_limit_watts: 400.0,
467 processes: vec![],
468 }),
469 ..Default::default()
470 };
471 let layout = render_layout_colored(&snapshot, 100, ColorMode::Mono);
472 assert!(layout.contains("GPU:"));
473 assert!(layout.contains("RTX 4090"));
474 assert!(layout.contains("VRAM:"));
475 }
476
477 #[test]
478 fn test_render_layout_no_gpu() {
479 let snapshot = TrainingSnapshot {
480 epoch: 1,
481 total_epochs: 1,
482 step: 1,
483 steps_per_epoch: 1,
484 loss: 1.0,
485 model_name: "NoGPU".to_string(),
486 status: TrainingStatus::Running,
487 gpu: None,
488 ..Default::default()
489 };
490 let layout = render_layout_colored(&snapshot, 80, ColorMode::Mono);
491 assert!(layout.contains("GPU: N/A"));
492 }
493
494 #[test]
495 fn test_render_layout_empty_model_name() {
496 let snapshot = TrainingSnapshot {
497 model_name: String::new(),
498 status: TrainingStatus::Running,
499 ..Default::default()
500 };
501 let layout = render_layout_colored(&snapshot, 80, ColorMode::Mono);
502 assert!(layout.contains("N/A"));
503 }
504
505 #[test]
506 fn test_render_layout_long_model_name() {
507 let snapshot = TrainingSnapshot {
508 model_name: "A".repeat(50),
509 status: TrainingStatus::Running,
510 ..Default::default()
511 };
512 let layout = render_layout_colored(&snapshot, 80, ColorMode::Mono);
513 assert!(layout.contains("..."));
515 }
516
517 #[test]
518 fn test_render_progress_zero_epochs() {
519 let snapshot = TrainingSnapshot {
520 epoch: 0,
521 total_epochs: 0,
522 step: 0,
523 steps_per_epoch: 0,
524 ..Default::default()
525 };
526 let layout = render_layout_colored(&snapshot, 80, ColorMode::Mono);
527 assert!(layout.contains("Epoch"));
528 }
529
530 #[test]
531 fn test_render_metrics_nan_loss() {
532 let snapshot = TrainingSnapshot {
533 loss: f32::NAN,
534 learning_rate: 0.001,
535 status: TrainingStatus::Running,
536 model_name: "test".to_string(),
537 ..Default::default()
538 };
539 let layout = render_layout_colored(&snapshot, 80, ColorMode::Mono);
540 assert!(layout.contains("???"));
541 }
542
543 #[test]
544 fn test_render_metrics_infinite_loss() {
545 let snapshot = TrainingSnapshot {
546 loss: f32::INFINITY,
547 learning_rate: 0.001,
548 status: TrainingStatus::Running,
549 model_name: "test".to_string(),
550 ..Default::default()
551 };
552 let layout = render_layout_colored(&snapshot, 80, ColorMode::Mono);
553 assert!(layout.contains("???"));
554 }
555
556 #[test]
557 fn test_render_loss_sparkline_empty() {
558 let snapshot = TrainingSnapshot {
559 loss_history: vec![],
560 status: TrainingStatus::Running,
561 model_name: "test".to_string(),
562 ..Default::default()
563 };
564 let layout = render_layout_colored(&snapshot, 80, ColorMode::Mono);
565 assert!(!layout.contains("Loss History"));
567 }
568
569 #[test]
570 fn test_render_epoch_table_no_data() {
571 let snapshot = TrainingSnapshot {
572 loss_history: vec![],
573 steps_per_epoch: 0,
574 status: TrainingStatus::Running,
575 model_name: "test".to_string(),
576 ..Default::default()
577 };
578 let layout = render_layout_colored(&snapshot, 80, ColorMode::Mono);
579 assert!(layout.contains("waiting for epoch data"));
580 }
581
582 #[test]
583 fn test_render_epoch_table_with_epochs() {
584 let snapshot = TrainingSnapshot {
585 steps_per_epoch: 3,
586 loss_history: vec![5.0, 4.0, 3.0, 2.5, 2.0, 1.5],
587 learning_rate: 0.001,
588 tokens_per_second: 100.0,
589 status: TrainingStatus::Running,
590 model_name: "test".to_string(),
591 ..Default::default()
592 };
593 let layout = render_layout_colored(&snapshot, 80, ColorMode::Mono);
594 assert!(layout.contains("Epoch"));
595 assert!(layout.contains("Loss"));
596 }
597
598 #[test]
599 fn test_render_epoch_table_many_epochs_truncation() {
600 let snapshot = TrainingSnapshot {
602 steps_per_epoch: 1,
603 loss_history: (0..20).map(|i| 10.0 - (i as f32 * 0.5)).collect(),
604 learning_rate: 0.001,
605 tokens_per_second: 100.0,
606 status: TrainingStatus::Running,
607 model_name: "test".to_string(),
608 ..Default::default()
609 };
610 let layout = render_layout_colored(&snapshot, 80, ColorMode::Mono);
611 assert!(layout.contains("earlier epochs"));
612 }
613
614 #[test]
615 fn test_render_config_footer_empty_optimizer() {
616 let snapshot = TrainingSnapshot {
617 optimizer_name: String::new(),
618 batch_size: 0,
619 checkpoint_path: String::new(),
620 status: TrainingStatus::Running,
621 model_name: "test".to_string(),
622 ..Default::default()
623 };
624 let layout = render_layout_colored(&snapshot, 80, ColorMode::Mono);
625 assert!(layout.contains("Config:"));
626 }
627
628 #[test]
629 fn test_render_config_footer_with_values() {
630 let snapshot = TrainingSnapshot {
631 optimizer_name: "AdamW".to_string(),
632 batch_size: 16,
633 checkpoint_path: "/tmp/checkpoints".to_string(),
634 status: TrainingStatus::Running,
635 model_name: "test".to_string(),
636 ..Default::default()
637 };
638 let layout = render_layout_colored(&snapshot, 100, ColorMode::Mono);
639 assert!(layout.contains("AdamW"));
640 assert!(layout.contains("16"));
641 assert!(layout.contains("/tmp/checkpoints"));
642 }
643
644 #[test]
645 fn test_render_gpu_temp_warning() {
646 let snapshot = TrainingSnapshot {
647 gpu: Some(super::super::state::GpuTelemetry {
648 device_name: "RTX 4090".to_string(),
649 utilization_percent: 90.0,
650 vram_used_gb: 20.0,
651 vram_total_gb: 24.0,
652 temperature_celsius: 75.0, power_watts: 300.0,
654 power_limit_watts: 400.0,
655 processes: vec![],
656 }),
657 status: TrainingStatus::Running,
658 model_name: "test".to_string(),
659 ..Default::default()
660 };
661 let layout = render_layout_colored(&snapshot, 100, ColorMode::Mono);
662 assert!(layout.contains("75"));
663 }
664
665 #[test]
666 fn test_render_gpu_temp_error() {
667 let snapshot = TrainingSnapshot {
668 gpu: Some(super::super::state::GpuTelemetry {
669 device_name: "RTX 4090".to_string(),
670 utilization_percent: 90.0,
671 vram_used_gb: 20.0,
672 vram_total_gb: 24.0,
673 temperature_celsius: 85.0, power_watts: 300.0,
675 power_limit_watts: 400.0,
676 processes: vec![],
677 }),
678 status: TrainingStatus::Running,
679 model_name: "test".to_string(),
680 ..Default::default()
681 };
682 let layout = render_layout_colored(&snapshot, 100, ColorMode::Mono);
683 assert!(layout.contains("85"));
684 }
685
686 #[test]
687 fn test_epoch_trend_arrow_first_epoch_no_start_idx() {
688 let result = epoch_trend_arrow(0, 0, &[], ColorMode::Mono);
689 assert_eq!(result, " ");
690 }
691
692 #[test]
693 fn test_epoch_trend_arrow_with_start_idx() {
694 let summaries = vec![
695 EpochSummary {
696 epoch: 1,
697 avg_loss: 5.0,
698 min_loss: 4.0,
699 max_loss: 6.0,
700 end_loss: 4.5,
701 avg_grad: 1.0,
702 lr: 0.001,
703 tokens_per_sec: 100.0,
704 },
705 EpochSummary {
706 epoch: 2,
707 avg_loss: 3.0,
708 min_loss: 2.5,
709 max_loss: 3.5,
710 end_loss: 2.8,
711 avg_grad: 0.8,
712 lr: 0.001,
713 tokens_per_sec: 100.0,
714 },
715 ];
716 let result = epoch_trend_arrow(0, 1, &summaries, ColorMode::Mono);
718 assert!(result.contains("\u{2193}")); }
721
722 #[test]
723 fn test_epoch_trend_arrow_no_prev() {
724 let summaries: Vec<EpochSummary> = vec![];
725 let result = epoch_trend_arrow(0, 1, &summaries, ColorMode::Mono);
727 assert_eq!(result, " ");
728 }
729
730 #[test]
731 fn test_render_layout_minimum_width() {
732 let snapshot = TrainingSnapshot {
734 model_name: "test".to_string(),
735 status: TrainingStatus::Running,
736 ..Default::default()
737 };
738 let layout = render_layout_colored(&snapshot, 40, ColorMode::Mono);
739 assert!(layout.contains("ENTRENAR"));
741 }
742
743 #[test]
744 fn test_render_with_truecolor() {
745 let snapshot = TrainingSnapshot {
746 epoch: 2,
747 total_epochs: 5,
748 step: 3,
749 steps_per_epoch: 10,
750 loss: 1.5,
751 loss_history: vec![3.0, 2.5, 2.0, 1.5],
752 learning_rate: 0.0001,
753 gradient_norm: 0.5,
754 tokens_per_second: 50.0,
755 model_name: "TestModel".to_string(),
756 status: TrainingStatus::Running,
757 ..Default::default()
758 };
759 let layout = render_layout_colored(&snapshot, 100, ColorMode::TrueColor);
760 assert!(layout.contains("\x1b["));
762 }
763
764 #[test]
765 fn test_render_best_loss_all_nan() {
766 let snapshot = TrainingSnapshot {
767 loss: 1.0,
768 loss_history: vec![f32::NAN, f32::NAN, f32::NAN],
769 learning_rate: 0.001,
770 model_name: "test".to_string(),
771 status: TrainingStatus::Running,
772 ..Default::default()
773 };
774 let layout = render_layout_colored(&snapshot, 80, ColorMode::Mono);
775 assert!(layout.contains("Best ---"));
776 }
777
778 #[test]
779 fn test_render_eta_no_tps() {
780 let snapshot = TrainingSnapshot {
781 tokens_per_second: 0.0,
782 learning_rate: 0.001,
783 model_name: "test".to_string(),
784 status: TrainingStatus::Running,
785 ..Default::default()
786 };
787 let layout = render_layout_colored(&snapshot, 80, ColorMode::Mono);
788 assert!(layout.contains("ETA --:--:--"));
789 }
790}