Skip to main content

optirs_core/
visualization.rs

1// Visualization tools for optimization metrics
2//
3// This module provides comprehensive visualization capabilities for tracking
4// optimization progress, comparing optimizers, and analyzing training dynamics.
5
6use crate::error::{OptimError, Result};
7use std::collections::{HashMap, VecDeque};
8use std::io::Write;
9use std::path::Path;
10use std::time::{Duration, SystemTime, UNIX_EPOCH};
11
12/// Configuration for visualization output
13#[derive(Debug, Clone)]
14pub struct VisualizationConfig {
15    /// Output directory for saved plots
16    pub output_dir: String,
17
18    /// Maximum number of data points to display
19    pub max_points: usize,
20
21    /// Update frequency for real-time plots (in steps)
22    pub update_frequency: usize,
23
24    /// Enable interactive HTML output
25    pub interactive_html: bool,
26
27    /// Enable SVG output for publication
28    pub svg_output: bool,
29
30    /// Color scheme for plots
31    pub color_scheme: ColorScheme,
32
33    /// Default figure size (width, height)
34    pub figure_size: (u32, u32),
35
36    /// DPI for raster outputs
37    pub dpi: u32,
38
39    /// Enable grid lines
40    pub show_grid: bool,
41
42    /// Enable legends
43    pub show_legend: bool,
44}
45
46impl Default for VisualizationConfig {
47    fn default() -> Self {
48        Self {
49            output_dir: "optimization_plots".to_string(),
50            max_points: 10000,
51            update_frequency: 100,
52            interactive_html: true,
53            svg_output: false,
54            color_scheme: ColorScheme::Default,
55            figure_size: (800, 600),
56            dpi: 300,
57            show_grid: true,
58            show_legend: true,
59        }
60    }
61}
62
63/// Color schemes for plots
64#[derive(Debug, Clone, Copy)]
65pub enum ColorScheme {
66    Default,
67    Dark,
68    Colorblind,
69    Publication,
70    Vibrant,
71}
72
73/// Optimization metric for tracking
74#[derive(Debug, Clone)]
75pub struct OptimizationMetric {
76    /// Metric name
77    pub name: String,
78
79    /// Metric values over time
80    pub values: VecDeque<f64>,
81
82    /// Timestamps for each value
83    pub timestamps: VecDeque<u64>,
84
85    /// Step numbers
86    pub steps: VecDeque<usize>,
87
88    /// Target value (if any)
89    pub target: Option<f64>,
90
91    /// Whether higher values are better
92    pub higher_isbetter: bool,
93
94    /// Units for display
95    pub units: String,
96
97    /// Smoothing window size
98    pub smoothing_window: usize,
99}
100
101impl OptimizationMetric {
102    /// Create new metric tracker
103    pub fn new(name: String, higher_isbetter: bool, units: String) -> Self {
104        Self {
105            name,
106            values: VecDeque::new(),
107            timestamps: VecDeque::new(),
108            steps: VecDeque::new(),
109            target: None,
110            higher_isbetter,
111            units,
112            smoothing_window: 10,
113        }
114    }
115
116    /// Add a new value
117    pub fn add_value(&mut self, value: f64, step: usize) {
118        // A system clock set before the epoch is not a reason to panic a
119        // metrics recorder: such a reading is clamped to the epoch, matching
120        // `streaming_metrics::unix_timestamp_*`.
121        let timestamp = SystemTime::now()
122            .duration_since(UNIX_EPOCH)
123            .unwrap_or_default()
124            .as_secs();
125
126        self.values.push_back(value);
127        self.timestamps.push_back(timestamp);
128        self.steps.push_back(step);
129
130        // Keep only recent values to avoid memory issues
131        while self.values.len() > 50000 {
132            self.values.pop_front();
133            self.timestamps.pop_front();
134            self.steps.pop_front();
135        }
136    }
137
138    /// Get smoothed values
139    pub fn get_smoothed_values(&self) -> Vec<f64> {
140        if self.values.len() < self.smoothing_window {
141            return self.values.iter().copied().collect();
142        }
143
144        let mut smoothed = Vec::new();
145        let window = self.smoothing_window.min(self.values.len());
146
147        for i in 0..self.values.len() {
148            let start = i.saturating_sub(window / 2);
149            let end = (i + window / 2 + 1).min(self.values.len());
150
151            let sum: f64 = self.values.range(start..end).sum();
152            let avg = sum / (end - start) as f64;
153            smoothed.push(avg);
154        }
155
156        smoothed
157    }
158
159    /// Get recent improvement
160    pub fn get_recent_improvement(&self, windowsize: usize) -> Option<f64> {
161        if self.values.len() < windowsize * 2 {
162            return None;
163        }
164
165        let recent_avg: f64 =
166            self.values.iter().rev().take(windowsize).sum::<f64>() / windowsize as f64;
167        let older_avg: f64 = self
168            .values
169            .iter()
170            .rev()
171            .skip(windowsize)
172            .take(windowsize)
173            .sum::<f64>()
174            / windowsize as f64;
175
176        Some(if self.higher_isbetter {
177            recent_avg - older_avg
178        } else {
179            older_avg - recent_avg
180        })
181    }
182}
183
184/// Optimizer comparison data
185#[derive(Debug, Clone)]
186pub struct OptimizerComparison {
187    /// Optimizer name
188    pub name: String,
189
190    /// Performance metrics
191    pub metrics: HashMap<String, Vec<f64>>,
192
193    /// Hyperparameters used
194    pub hyperparameters: HashMap<String, f64>,
195
196    /// Total training time
197    pub training_time: Duration,
198
199    /// Memory usage statistics
200    pub memory_stats: MemoryStats,
201
202    /// Convergence information
203    pub convergence_info: ConvergenceInfo,
204}
205
206/// Memory usage statistics
207#[derive(Debug, Clone)]
208pub struct MemoryStats {
209    /// Peak memory usage (MB)
210    pub peak_memory_mb: f64,
211
212    /// Average memory usage (MB)
213    pub avg_memory_mb: f64,
214
215    /// Memory efficiency (ops per MB)
216    pub memory_efficiency: f64,
217}
218
219/// Convergence information
220#[derive(Debug, Clone)]
221pub struct ConvergenceInfo {
222    /// Whether convergence was achieved
223    pub converged: bool,
224
225    /// Step at which convergence was achieved
226    pub convergence_step: Option<usize>,
227
228    /// Final metric value
229    pub final_value: f64,
230
231    /// Best metric value achieved
232    pub best_value: f64,
233
234    /// Convergence rate (improvement per step)
235    pub convergence_rate: f64,
236}
237
238/// Main visualization engine
239pub struct OptimizationVisualizer {
240    /// Configuration
241    config: VisualizationConfig,
242
243    /// Tracked metrics
244    metrics: HashMap<String, OptimizationMetric>,
245
246    /// Optimizer comparisons
247    comparisons: Vec<OptimizerComparison>,
248
249    /// Real-time dashboard state
250    dashboard_state: DashboardState,
251
252    /// Step counter
253    current_step: usize,
254
255    /// Last update step
256    last_update_step: usize,
257}
258
259/// Dashboard state for real-time visualization.
260///
261/// `update_dashboard` regenerates the whole static dashboard from `metrics` on
262/// every refresh; it never consulted the per-plot `active_plots` map or the
263/// stored `DashboardLayout`, so those were removed rather than left as a
264/// second, never-read description of the same dashboard.
265#[derive(Debug)]
266struct DashboardState {
267    /// Update timestamps
268    last_update: SystemTime,
269}
270
271/// Types of plots available
272#[derive(Debug, Clone, Copy)]
273pub enum PlotType {
274    Line,
275    Scatter,
276    Histogram,
277    Heatmap,
278    Bar,
279    Box,
280    Violin,
281    Surface3D,
282}
283
284/// Data series for plotting
285#[derive(Debug, Clone)]
286pub struct DataSeries {
287    /// Series name
288    pub name: String,
289
290    /// X values
291    pub x_values: Vec<f64>,
292
293    /// Y values
294    pub y_values: Vec<f64>,
295
296    /// Z values (for 3D plots)
297    pub z_values: Option<Vec<f64>>,
298
299    /// Color
300    pub color: String,
301
302    /// Line style
303    pub line_style: LineStyle,
304
305    /// Marker style
306    pub marker_style: MarkerStyle,
307}
308
309/// Line styles
310#[derive(Debug, Clone, Copy)]
311pub enum LineStyle {
312    Solid,
313    Dashed,
314    Dotted,
315    DashDot,
316    None,
317}
318
319/// Marker styles
320#[derive(Debug, Clone, Copy)]
321pub enum MarkerStyle {
322    Circle,
323    Square,
324    Triangle,
325    Diamond,
326    Plus,
327    Cross,
328    None,
329}
330
331/// Axis configuration
332#[derive(Debug, Clone)]
333pub struct AxisConfig {
334    /// Axis label
335    pub label: String,
336
337    /// Scale type
338    pub scale: AxisScale,
339
340    /// Range (min, max)
341    pub range: Option<(f64, f64)>,
342
343    /// Tick configuration
344    pub ticks: TickConfig,
345}
346
347/// Axis scale types
348#[derive(Debug, Clone, Copy)]
349pub enum AxisScale {
350    Linear,
351    Log,
352    Symlog,
353}
354
355/// Tick configuration
356#[derive(Debug, Clone)]
357pub struct TickConfig {
358    /// Major tick spacing
359    pub major_spacing: Option<f64>,
360
361    /// Minor tick count
362    pub minor_count: usize,
363
364    /// Show tick labels
365    pub show_labels: bool,
366}
367
368/// Dashboard layout
369#[derive(Debug, Clone)]
370pub struct DashboardLayout {
371    /// Number of rows
372    pub rows: usize,
373
374    /// Number of columns
375    pub cols: usize,
376
377    /// Plot positions
378    pub plot_positions: HashMap<String, (usize, usize)>,
379}
380
381impl OptimizationVisualizer {
382    /// Create new visualization engine
383    pub fn new(config: VisualizationConfig) -> Result<Self> {
384        // Create output directory if it doesn't exist
385        std::fs::create_dir_all(&config.output_dir).map_err(|e| {
386            OptimError::InvalidConfig(format!("Failed to create output directory: {e}"))
387        })?;
388
389        let dashboard_state = DashboardState {
390            last_update: SystemTime::now(),
391        };
392
393        Ok(Self {
394            config,
395            metrics: HashMap::new(),
396            comparisons: Vec::new(),
397            dashboard_state,
398            current_step: 0,
399            last_update_step: 0,
400        })
401    }
402
403    /// Add or update a metric
404    pub fn add_metric(&mut self, name: String, value: f64, higher_isbetter: bool, units: String) {
405        let metric = self
406            .metrics
407            .entry(name.clone())
408            .or_insert_with(|| OptimizationMetric::new(name, higher_isbetter, units));
409
410        metric.add_value(value, self.current_step);
411    }
412
413    /// Set target value for a metric
414    pub fn set_target(&mut self, metricname: &str, target: f64) {
415        if let Some(metric) = self.metrics.get_mut(metricname) {
416            metric.target = Some(target);
417        }
418    }
419
420    /// Update step counter
421    pub fn step(&mut self) {
422        self.current_step += 1;
423
424        // `saturating_sub` rather than `-`: `current_step` is only ever
425        // incremented here and `last_update_step` only ever assigned from it,
426        // so today the difference cannot go negative -- but a plain `-` makes
427        // that invariant load-bearing for panic-freedom, and any future
428        // rewind of the counter would turn this into a debug panic /
429        // release wrap-around. Saturating keeps it total.
430        if self.current_step.saturating_sub(self.last_update_step) >= self.config.update_frequency {
431            if let Err(e) = self.update_dashboard() {
432                eprintln!("Failed to update dashboard: {e}");
433            }
434            self.last_update_step = self.current_step;
435        }
436    }
437
438    /// Create loss curve plot
439    pub fn plot_loss_curve(&self, metricname: &str) -> Result<String> {
440        let metric = self
441            .metrics
442            .get(metricname)
443            .ok_or_else(|| OptimError::InvalidConfig(format!("Metric '{metricname}' not found")))?;
444
445        let steps: Vec<f64> = metric.steps.iter().map(|&s| s as f64).collect();
446        let values = metric.get_smoothed_values();
447
448        let plotdata = self.create_line_plot(
449            &steps,
450            &values,
451            &format!("{} over Training Steps", metric.name),
452            "Training Steps",
453            &format!("{} ({})", metric.name, metric.units),
454        )?;
455
456        self.save_plot(&plotdata, &format!("{metricname}_curve"))
457    }
458
459    /// Create learning rate schedule plot
460    pub fn plot_learning_rate_schedule(&self) -> Result<String> {
461        if let Some(lr_metric) = self.metrics.get("learning_rate") {
462            let steps: Vec<f64> = lr_metric.steps.iter().map(|&s| s as f64).collect();
463            let values: Vec<f64> = lr_metric.values.iter().copied().collect();
464
465            let plotdata = self.create_line_plot(
466                &steps,
467                &values,
468                "Learning Rate Schedule",
469                "Training Steps",
470                "Learning Rate",
471            )?;
472
473            self.save_plot(&plotdata, "learning_rate_schedule")
474        } else {
475            Err(OptimError::InvalidConfig(
476                "Learning rate metric not found".to_string(),
477            ))
478        }
479    }
480
481    /// Create optimizer comparison plot
482    pub fn plot_optimizer_comparison(&self, metricname: &str) -> Result<String> {
483        if self.comparisons.is_empty() {
484            return Err(OptimError::InvalidConfig(
485                "No optimizer comparisons available".to_string(),
486            ));
487        }
488
489        let mut plotdata = String::new();
490
491        // HTML header for interactive plot
492        if self.config.interactive_html {
493            plotdata.push_str(&self.create_html_header("Optimizer Comparison")?);
494            plotdata.push_str("<div id='comparison-plot'></div>\n");
495            plotdata.push_str("<script>\n");
496            plotdata.push_str("const traces = [];\n");
497
498            for (series_index, comparison) in self.comparisons.iter().enumerate() {
499                if let Some(values) = comparison.metrics.get(metricname) {
500                    let x_values: Vec<String> = (0..values.len()).map(|i| i.to_string()).collect();
501                    plotdata.push_str(&format!("traces.push({{x: {:?}, y: {:?}, name: '{}', type: 'scatter', mode: 'lines', line: {{color: '{}'}}}});\n",
502                        x_values, values, comparison.name, self.get_color(series_index)));
503                }
504            }
505
506            plotdata.push_str("Plotly.newPlot('comparison-plot', traces, {\n");
507            plotdata.push_str("  title: 'Optimizer Comparison',\n");
508            plotdata.push_str("  xaxis: {title: 'Training Steps'},\n");
509            plotdata.push_str(&format!("  yaxis: {{title: '{metricname}'}}\n"));
510            plotdata.push_str("});\n");
511            plotdata.push_str("</script>\n");
512            plotdata.push_str("</body></html>\n");
513        }
514
515        self.save_plot(&plotdata, &format!("{metricname}_comparison"))
516    }
517
518    /// Create gradient norm visualization
519    pub fn plot_gradient_norm(&self) -> Result<String> {
520        if let Some(grad_metric) = self.metrics.get("gradient_norm") {
521            let steps: Vec<f64> = grad_metric.steps.iter().map(|&s| s as f64).collect();
522            let values: Vec<f64> = grad_metric.values.iter().copied().collect();
523
524            let mut plotdata = self.create_line_plot(
525                &steps,
526                &values,
527                "Gradient Norm",
528                "Training Steps",
529                "Gradient Norm",
530            )?;
531
532            // Add log scale for y-axis if values span multiple orders of magnitude
533            let max_val = values.iter().fold(0.0f64, |a, &b| a.max(b));
534            let min_val = values.iter().fold(f64::INFINITY, |a, &b| a.min(b));
535
536            if max_val / min_val > 100.0 {
537                plotdata = plotdata.replace("yaxis: {", "yaxis: {type: 'log', ");
538            }
539
540            self.save_plot(&plotdata, "gradient_norm")
541        } else {
542            Err(OptimError::InvalidConfig(
543                "Gradient norm metric not found".to_string(),
544            ))
545        }
546    }
547
548    /// Create training throughput plot
549    pub fn plot_throughput(&self) -> Result<String> {
550        if let Some(throughput_metric) = self.metrics.get("throughput") {
551            let steps: Vec<f64> = throughput_metric.steps.iter().map(|&s| s as f64).collect();
552            let values: Vec<f64> = throughput_metric.values.iter().copied().collect();
553
554            let plotdata = self.create_line_plot(
555                &steps,
556                &values,
557                "Training Throughput",
558                "Training Steps",
559                "Samples/Second",
560            )?;
561
562            self.save_plot(&plotdata, "throughput")
563        } else {
564            Err(OptimError::InvalidConfig(
565                "Throughput metric not found".to_string(),
566            ))
567        }
568    }
569
570    /// Create memory usage visualization
571    pub fn plot_memory_usage(&self) -> Result<String> {
572        if let Some(memory_metric) = self.metrics.get("memory_usage") {
573            let steps: Vec<f64> = memory_metric.steps.iter().map(|&s| s as f64).collect();
574            let values: Vec<f64> = memory_metric.values.iter().copied().collect();
575
576            let plotdata = self.create_line_plot(
577                &steps,
578                &values,
579                "Memory Usage",
580                "Training Steps",
581                "Memory (MB)",
582            )?;
583
584            self.save_plot(&plotdata, "memory_usage")
585        } else {
586            Err(OptimError::InvalidConfig(
587                "Memory usage metric not found".to_string(),
588            ))
589        }
590    }
591
592    /// Create hyperparameter sensitivity analysis
593    pub fn plot_hyperparameter_sensitivity(
594        &self,
595        param_name: &str,
596        metricname: &str,
597    ) -> Result<String> {
598        let mut param_values = Vec::new();
599        let mut metric_values = Vec::new();
600
601        for comparison in &self.comparisons {
602            if let (Some(&param_val), Some(metric_vals)) = (
603                comparison.hyperparameters.get(param_name),
604                comparison.metrics.get(metricname),
605            ) {
606                if let Some(&final_metric) = metric_vals.last() {
607                    param_values.push(param_val);
608                    metric_values.push(final_metric);
609                }
610            }
611        }
612
613        if param_values.is_empty() {
614            return Err(OptimError::InvalidConfig(format!(
615                "No data available for hyperparameter '{}' and metric '{}'",
616                param_name, metricname
617            )));
618        }
619
620        let plotdata = self.create_scatter_plot(
621            &param_values,
622            &metric_values,
623            &format!("Sensitivity of {} to {}", metricname, param_name),
624            param_name,
625            metricname,
626        )?;
627
628        self.save_plot(
629            &plotdata,
630            &format!("sensitivity_{}_{}", param_name, metricname),
631        )
632    }
633
634    /// Create comprehensive dashboard
635    pub fn create_dashboard(&self) -> Result<String> {
636        let mut dashboard = String::new();
637
638        if self.config.interactive_html {
639            dashboard.push_str(&self.create_html_header("Optimization Dashboard")?);
640
641            // Add CSS for layout
642            dashboard.push_str(
643                r#"
644<style>
645.dashboard-container {
646    display: grid;
647    grid-template-columns: 1fr 1fr;
648    grid-template-rows: 1fr 1fr;
649    gap: 20px;
650    height: 100vh;
651    padding: 20px;
652}
653.plot-container {
654    border: 1px solid #ddd;
655    border-radius: 8px;
656    padding: 10px;
657}
658.metrics-summary {
659    grid-column: span 2;
660    padding: 20px;
661    background-color: #f8f9fa;
662    border-radius: 8px;
663    margin-bottom: 20px;
664}
665</style>
666"#,
667            );
668
669            // Metrics summary
670            dashboard.push_str("<div class='metrics-summary'>\n");
671            dashboard.push_str("<h2>Current Metrics</h2>\n");
672            dashboard.push_str("<div style='display: flex; gap: 20px;'>\n");
673
674            for (name, metric) in &self.metrics {
675                if let Some(&latest_value) = metric.values.back() {
676                    dashboard.push_str(&format!(
677                        "<div><strong>{}:</strong> {:.4} {}</div>\n",
678                        name, latest_value, metric.units
679                    ));
680                }
681            }
682
683            dashboard.push_str("</div></div>\n");
684
685            // Plot containers
686            dashboard.push_str("<div class='dashboard-container'>\n");
687
688            let mut plot_id = 0;
689            for _ in &self.metrics {
690                if plot_id >= 4 {
691                    break;
692                } // Limit to 4 plots in 2x2 grid
693
694                dashboard.push_str(&format!(
695                    "<div class='plot-container'><div id='plot-{}'></div></div>\n",
696                    plot_id
697                ));
698
699                plot_id += 1;
700            }
701
702            dashboard.push_str("</div>\n");
703
704            // JavaScript for plots
705            dashboard.push_str("<script>\n");
706
707            plot_id = 0;
708            for (name, metric) in &self.metrics {
709                if plot_id >= 4 {
710                    break;
711                }
712
713                let steps: Vec<String> = metric.steps.iter().map(|&s| s.to_string()).collect();
714                let values: Vec<f64> = metric.values.iter().copied().collect();
715
716                dashboard.push_str(&format!("Plotly.newPlot('plot-{}', [{{x: {:?}, y: {:?}, type: 'scatter', mode: 'lines', name: '{}', line: {{color: '{}'}}}}], {{title: '{}', xaxis: {{title: 'Steps'}}, yaxis: {{title: '{}'}}}});\n",
717                    plot_id, steps, values, name, self.get_color(plot_id), name, metric.units));
718
719                plot_id += 1;
720            }
721
722            dashboard.push_str("</script>\n");
723            dashboard.push_str("</body></html>\n");
724        }
725
726        self.save_plot(&dashboard, "dashboard")
727    }
728
729    /// Update real-time dashboard
730    fn update_dashboard(&mut self) -> Result<()> {
731        self.dashboard_state.last_update = SystemTime::now();
732
733        // In a real implementation, this would update the live dashboard
734        // For now, we'll just regenerate static files
735        self.create_dashboard()?;
736
737        Ok(())
738    }
739
740    /// Add optimizer comparison data
741    pub fn add_optimizer_comparison(&mut self, comparison: OptimizerComparison) {
742        self.comparisons.push(comparison);
743    }
744
745    /// Export all visualizations
746    pub fn export_all(&self) -> Result<Vec<String>> {
747        let mut exported_files = Vec::new();
748
749        // Export individual metric plots
750        for metricname in self.metrics.keys() {
751            if let Ok(filename) = self.plot_loss_curve(metricname) {
752                exported_files.push(filename);
753            }
754        }
755
756        // Export comparisons
757        for metricname in ["loss", "accuracy", "throughput"] {
758            if let Ok(filename) = self.plot_optimizer_comparison(metricname) {
759                exported_files.push(filename);
760            }
761        }
762
763        // Export specialized plots
764        if let Ok(filename) = self.plot_gradient_norm() {
765            exported_files.push(filename);
766        }
767
768        if let Ok(filename) = self.plot_throughput() {
769            exported_files.push(filename);
770        }
771
772        if let Ok(filename) = self.plot_memory_usage() {
773            exported_files.push(filename);
774        }
775
776        // Export dashboard
777        if let Ok(filename) = self.create_dashboard() {
778            exported_files.push(filename);
779        }
780
781        Ok(exported_files)
782    }
783
784    /// Helper function to create line plot
785    fn create_line_plot(
786        &self,
787        x_values: &[f64],
788        y_values: &[f64],
789        title: &str,
790        x_label: &str,
791        y_label: &str,
792    ) -> Result<String> {
793        if !self.config.interactive_html {
794            return Ok(format!("# {}\nX: {:?}\nY: {:?}", title, x_values, y_values));
795        }
796
797        let mut plot = String::new();
798        plot.push_str(&self.create_html_header(title)?);
799        plot.push_str("<div id='plot'></div>\n");
800        plot.push_str("<script>\n");
801
802        plot.push_str(&format!("const trace = {{x: {:?}, y: {:?}, type: 'scatter', mode: 'lines', name: '{}', line: {{color: '{}'}}}};\n",
803            x_values, y_values, title, self.get_color(0)));
804
805        plot.push_str(&format!("Plotly.newPlot('plot', [trace], {{title: '{}', xaxis: {{title: '{}'}}, yaxis: {{title: '{}'}}}});\n",
806            title, x_label, y_label));
807
808        plot.push_str("</script></body></html>");
809
810        Ok(plot)
811    }
812
813    /// Helper function to create scatter plot
814    fn create_scatter_plot(
815        &self,
816        x_values: &[f64],
817        y_values: &[f64],
818        title: &str,
819        x_label: &str,
820        y_label: &str,
821    ) -> Result<String> {
822        if !self.config.interactive_html {
823            return Ok(format!("# {}\nX: {:?}\nY: {:?}", title, x_values, y_values));
824        }
825
826        let mut plot = String::new();
827        plot.push_str(&self.create_html_header(title)?);
828        plot.push_str("<div id='plot'></div>\n");
829        plot.push_str("<script>\n");
830
831        plot.push_str(&format!("const trace = {{x: {:?}, y: {:?}, type: 'scatter', mode: 'markers', name: '{}', marker: {{color: '{}'}}}};\n",
832            x_values, y_values, title, self.get_color(0)));
833
834        plot.push_str(&format!("Plotly.newPlot('plot', [trace], {{title: '{}', xaxis: {{title: '{}'}}, yaxis: {{title: '{}'}}}});\n",
835            title, x_label, y_label));
836
837        plot.push_str("</script></body></html>");
838
839        Ok(plot)
840    }
841
842    /// Create HTML header for interactive plots
843    fn create_html_header(&self, title: &str) -> Result<String> {
844        Ok(format!(
845            r#"
846<!DOCTYPE html>
847<html>
848<head>
849    <title>{}</title>
850    <script src="https://cdn.plot.ly/plotly-latest.min.js"></script>
851    <style>
852        body {{ font-family: Arial, sans-serif; margin: 20px; }}
853        #plot {{ width: 100%; height: 500px; }}
854    </style>
855</head>
856<body>
857    <h1>{}</h1>
858"#,
859            title, title
860        ))
861    }
862
863    /// Save plot to file
864    fn save_plot(&self, plotdata: &str, filename: &str) -> Result<String> {
865        let extension = if self.config.interactive_html {
866            "html"
867        } else {
868            "txt"
869        };
870        let full_filename = format!("{}.{}", filename, extension);
871        let filepath = Path::new(&self.config.output_dir).join(&full_filename);
872
873        let mut file = std::fs::File::create(&filepath).map_err(|e| {
874            OptimError::InvalidConfig(format!(
875                "Failed to create file {}: {}",
876                filepath.display(),
877                e
878            ))
879        })?;
880
881        file.write_all(plotdata.as_bytes()).map_err(|e| {
882            OptimError::InvalidConfig(format!(
883                "Failed to write to file {}: {}",
884                filepath.display(),
885                e
886            ))
887        })?;
888
889        Ok(full_filename)
890    }
891
892    /// Get color for plot series
893    fn get_color(&self, index: usize) -> String {
894        let colors = match self.config.color_scheme {
895            ColorScheme::Default => vec![
896                "#1f77b4", "#ff7f0e", "#2ca02c", "#d62728", "#9467bd", "#8c564b", "#e377c2",
897                "#7f7f7f", "#bcbd22", "#17becf",
898            ],
899            ColorScheme::Dark => vec![
900                "#8dd3c7", "#ffffb3", "#bebada", "#fb8072", "#80b1d3", "#fdb462", "#b3de69",
901                "#fccde5", "#d9d9d9", "#bc80bd",
902            ],
903            ColorScheme::Colorblind => vec![
904                "#000000", "#E69F00", "#56B4E9", "#009E73", "#F0E442", "#0072B2", "#D55E00",
905                "#CC79A7",
906            ],
907            ColorScheme::Publication => vec!["#000000", "#333333", "#666666", "#999999", "#CCCCCC"],
908            ColorScheme::Vibrant => vec![
909                "#FF6B6B", "#4ECDC4", "#45B7D1", "#96CEB4", "#FFEAA7", "#DDA0DD", "#98D8C8",
910                "#F7DC6F", "#BB8FCE", "#85C1E9",
911            ],
912        };
913
914        colors[index % colors.len()].to_string()
915    }
916}
917
918#[cfg(test)]
919mod tests {
920    use super::*;
921    use std::time::Duration;
922
923    #[test]
924    fn test_visualization_config_default() {
925        let config = VisualizationConfig::default();
926        assert_eq!(config.max_points, 10000);
927        assert!(config.interactive_html);
928        assert!(config.show_grid);
929    }
930
931    #[test]
932    fn test_optimization_metric() {
933        let mut metric = OptimizationMetric::new("loss".to_string(), false, "nats".to_string());
934
935        metric.add_value(1.0, 0);
936        metric.add_value(0.8, 1);
937        metric.add_value(0.6, 2);
938        metric.add_value(0.4, 3); // Add 4th value to meet windowsize * 2 requirement
939
940        assert_eq!(metric.values.len(), 4);
941        assert_eq!(metric.steps.len(), 4);
942
943        let improvement = metric.get_recent_improvement(2);
944        assert!(improvement.is_some());
945    }
946
947    #[test]
948    fn test_visualizer_creation() {
949        let config = VisualizationConfig {
950            output_dir: "/tmp/test_plots".to_string(),
951            ..Default::default()
952        };
953
954        let visualizer = OptimizationVisualizer::new(config);
955        assert!(visualizer.is_ok());
956    }
957
958    #[test]
959    fn test_add_metric() {
960        let config = VisualizationConfig {
961            output_dir: "/tmp/test_plots".to_string(),
962            ..Default::default()
963        };
964
965        let mut visualizer = OptimizationVisualizer::new(config).expect("unwrap failed");
966
967        visualizer.add_metric("loss".to_string(), 1.0, false, "nats".to_string());
968        visualizer.step();
969        visualizer.add_metric("loss".to_string(), 0.8, false, "nats".to_string());
970
971        assert!(visualizer.metrics.contains_key("loss"));
972        assert_eq!(visualizer.metrics["loss"].values.len(), 2);
973    }
974
975    #[test]
976    fn test_optimizer_comparison() {
977        let comparison = OptimizerComparison {
978            name: "Adam".to_string(),
979            metrics: {
980                let mut map = HashMap::new();
981                map.insert("loss".to_string(), vec![1.0, 0.8, 0.6]);
982                map
983            },
984            hyperparameters: {
985                let mut map = HashMap::new();
986                map.insert("learning_rate".to_string(), 0.001);
987                map
988            },
989            training_time: Duration::from_secs(120),
990            memory_stats: MemoryStats {
991                peak_memory_mb: 1024.0,
992                avg_memory_mb: 512.0,
993                memory_efficiency: 100.0,
994            },
995            convergence_info: ConvergenceInfo {
996                converged: true,
997                convergence_step: Some(100),
998                final_value: 0.6,
999                best_value: 0.6,
1000                convergence_rate: 0.004,
1001            },
1002        };
1003
1004        assert_eq!(comparison.name, "Adam");
1005        assert!(comparison.convergence_info.converged);
1006    }
1007
1008    #[test]
1009    fn test_color_schemes() {
1010        let config = VisualizationConfig {
1011            color_scheme: ColorScheme::Colorblind,
1012            output_dir: "/tmp/test_plots".to_string(),
1013            ..Default::default()
1014        };
1015
1016        let visualizer = OptimizationVisualizer::new(config).expect("unwrap failed");
1017        let color = visualizer.get_color(0);
1018        assert_eq!(color, "#000000");
1019    }
1020}