Skip to main content

optirs_core/research/
experiments.rs

1// Experiment tracking and management for research projects
2//
3// This module provides comprehensive tools for designing, executing, and tracking
4// machine learning optimization experiments with full reproducibility support.
5
6use crate::error::{OptimError, Result};
7use crate::unified_api::OptimizerConfig;
8use chrono::{DateTime, Utc};
9use scirs2_core::ndarray::Array2;
10use serde::{Deserialize, Serialize};
11use std::collections::HashMap;
12
13/// Comprehensive experiment definition and tracking
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct Experiment {
16    /// Experiment identifier
17    pub id: String,
18    /// Experiment name
19    pub name: String,
20    /// Research hypothesis
21    pub hypothesis: String,
22    /// Experiment description
23    pub description: String,
24    /// Experiment status
25    pub status: ExperimentStatus,
26    /// Experiment configuration
27    pub config: ExperimentConfig,
28    /// Optimizer configurations being tested
29    pub optimizer_configs: HashMap<String, OptimizerConfig<f64>>,
30    /// Dataset information
31    pub dataset_info: DatasetInfo,
32    /// Metrics to track
33    pub metrics: Vec<String>,
34    /// Experiment results
35    pub results: Vec<ExperimentResult>,
36    /// Reproducibility information
37    pub reproducibility: ReproducibilityInfo,
38    /// Experiment timeline
39    pub timeline: ExperimentTimeline,
40    /// Analysis notes
41    pub notes: Vec<ExperimentNote>,
42    /// Experiment metadata
43    pub metadata: ExperimentMetadata,
44}
45
46/// Experiment status
47#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
48pub enum ExperimentStatus {
49    /// Planning phase
50    Planning,
51    /// Ready to run
52    Ready,
53    /// Currently running
54    Running,
55    /// Completed successfully
56    Completed,
57    /// Failed with errors
58    Failed,
59    /// Paused/suspended
60    Paused,
61    /// Cancelled
62    Cancelled,
63    /// Under analysis
64    Analyzing,
65    /// Results published
66    Published,
67}
68
69/// Experiment configuration
70#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct ExperimentConfig {
72    /// Random seed for reproducibility
73    pub random_seed: u64,
74    /// Number of runs/repetitions
75    pub num_runs: usize,
76    /// Maximum training epochs
77    pub max_epochs: usize,
78    /// Early stopping criteria
79    pub early_stopping: Option<EarlyStoppingConfig>,
80    /// Hardware configuration
81    pub hardware_config: HardwareConfig,
82    /// Environment variables
83    pub environment: HashMap<String, String>,
84    /// Validation split
85    pub validation_split: f64,
86    /// Test split
87    pub test_split: f64,
88    /// Cross-validation folds
89    pub cv_folds: Option<usize>,
90}
91
92/// Early stopping configuration
93#[derive(Debug, Clone, Serialize, Deserialize)]
94pub struct EarlyStoppingConfig {
95    /// Metric to monitor
96    pub monitor_metric: String,
97    /// Patience (epochs without improvement)
98    pub patience: usize,
99    /// Minimum improvement threshold
100    pub min_improvement: f64,
101    /// Mode (minimize or maximize)
102    pub mode: OptimizationMode,
103}
104
105/// Optimization mode
106#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
107pub enum OptimizationMode {
108    /// Minimize the metric
109    Minimize,
110    /// Maximize the metric
111    Maximize,
112}
113
114/// Hardware configuration
115#[derive(Debug, Clone, Serialize, Deserialize, Default)]
116pub struct HardwareConfig {
117    /// CPU information
118    pub cpu_info: CpuInfo,
119    /// GPU information
120    pub gpu_info: Option<GpuInfo>,
121    /// Memory configuration
122    pub memory_config: MemoryConfig,
123    /// Parallel processing settings
124    pub parallel_config: ParallelConfig,
125}
126
127/// CPU information
128#[derive(Debug, Clone, Serialize, Deserialize)]
129pub struct CpuInfo {
130    /// CPU model
131    pub model: String,
132    /// Number of cores
133    pub cores: usize,
134    /// Number of threads
135    pub threads: usize,
136    /// CPU frequency (MHz)
137    pub frequency_mhz: u32,
138    /// Cache sizes
139    pub cache_sizes: Vec<String>,
140    /// SIMD capabilities
141    pub simd_capabilities: Vec<String>,
142}
143
144/// GPU information
145#[derive(Debug, Clone, Serialize, Deserialize)]
146pub struct GpuInfo {
147    /// GPU model
148    pub model: String,
149    /// Memory size (MB)
150    pub memory_mb: usize,
151    /// Compute capability
152    pub compute_capability: String,
153    /// CUDA version
154    pub cuda_version: Option<String>,
155    /// Driver version
156    pub driver_version: String,
157}
158
159/// Memory configuration
160#[derive(Debug, Clone, Serialize, Deserialize)]
161pub struct MemoryConfig {
162    /// Total system memory (MB)
163    pub total_memory_mb: usize,
164    /// Available memory (MB)
165    pub available_memory_mb: usize,
166    /// Memory allocation strategy
167    pub allocation_strategy: MemoryAllocationStrategy,
168    /// Memory pool size (MB)
169    pub pool_size_mb: Option<usize>,
170}
171
172/// Memory allocation strategies
173#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
174pub enum MemoryAllocationStrategy {
175    /// Standard allocation
176    Standard,
177    /// Pool-based allocation
178    Pooled,
179    /// Memory-mapped allocation
180    MemoryMapped,
181    /// Compressed allocation
182    Compressed,
183}
184
185/// Parallel processing configuration
186#[derive(Debug, Clone, Serialize, Deserialize)]
187pub struct ParallelConfig {
188    /// Number of threads
189    pub num_threads: usize,
190    /// Thread affinity
191    pub thread_affinity: Option<Vec<usize>>,
192    /// Work stealing enabled
193    pub work_stealing: bool,
194    /// Chunk size for parallel operations
195    pub chunk_size: Option<usize>,
196}
197
198/// Dataset information
199#[derive(Debug, Clone, Serialize, Deserialize)]
200pub struct DatasetInfo {
201    /// Dataset name
202    pub name: String,
203    /// Dataset description
204    pub description: String,
205    /// Dataset source/URL
206    pub source: String,
207    /// Dataset version
208    pub version: String,
209    /// Number of samples
210    pub num_samples: usize,
211    /// Number of features
212    pub num_features: usize,
213    /// Number of classes (for classification)
214    pub num_classes: Option<usize>,
215    /// Data type
216    pub data_type: DataType,
217    /// Dataset statistics
218    pub statistics: DatasetStatistics,
219    /// Data preprocessing steps
220    pub preprocessing: Vec<PreprocessingStep>,
221}
222
223/// Data types
224#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
225pub enum DataType {
226    /// Tabular data
227    Tabular,
228    /// Image data
229    Image,
230    /// Text data
231    Text,
232    /// Audio data
233    Audio,
234    /// Video data
235    Video,
236    /// Time series data
237    TimeSeries,
238    /// Graph data
239    Graph,
240    /// Multi-modal data
241    MultiModal,
242}
243
244/// Dataset statistics
245#[derive(Debug, Clone, Serialize, Deserialize, Default)]
246pub struct DatasetStatistics {
247    /// Feature means
248    pub feature_means: Vec<f64>,
249    /// Feature standard deviations
250    pub feature_stds: Vec<f64>,
251    /// Feature ranges
252    pub feature_ranges: Vec<(f64, f64)>,
253    /// Class distribution (for classification)
254    pub class_distribution: Option<HashMap<String, usize>>,
255    /// Missing value counts
256    pub missing_values: Vec<usize>,
257    /// Correlation matrix
258    pub correlation_matrix: Option<Array2<f64>>,
259}
260
261/// Preprocessing step
262#[derive(Debug, Clone, Serialize, Deserialize)]
263pub struct PreprocessingStep {
264    /// Step name
265    pub name: String,
266    /// Step description
267    pub description: String,
268    /// Parameters
269    pub parameters: HashMap<String, serde_json::Value>,
270    /// Order in preprocessing pipeline
271    pub order: usize,
272}
273
274/// Experiment result for a single run
275#[derive(Debug, Clone, Serialize, Deserialize)]
276pub struct ExperimentResult {
277    /// Run identifier
278    pub run_id: String,
279    /// Optimizer name
280    pub optimizer_name: String,
281    /// Start timestamp
282    pub start_time: DateTime<Utc>,
283    /// End timestamp
284    pub end_time: Option<DateTime<Utc>>,
285    /// Run status
286    pub status: RunStatus,
287    /// Final metrics
288    pub final_metrics: HashMap<String, f64>,
289    /// Training history
290    pub training_history: TrainingHistory,
291    /// Resource usage
292    pub resource_usage: ResourceUsage,
293    /// Error information (if failed)
294    pub error_info: Option<String>,
295    /// Additional metadata
296    pub metadata: HashMap<String, serde_json::Value>,
297}
298
299/// Run status
300#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
301pub enum RunStatus {
302    /// Run completed successfully
303    Success,
304    /// Run failed with error
305    Failed,
306    /// Run was terminated early
307    Terminated,
308    /// Run timed out
309    Timeout,
310    /// Run was cancelled
311    Cancelled,
312}
313
314/// Training history tracking
315#[derive(Debug, Clone, Serialize, Deserialize)]
316pub struct TrainingHistory {
317    /// Epoch numbers
318    pub epochs: Vec<usize>,
319    /// Training metrics by epoch
320    pub train_metrics: HashMap<String, Vec<f64>>,
321    /// Validation metrics by epoch
322    pub val_metrics: HashMap<String, Vec<f64>>,
323    /// Learning rates by epoch
324    pub learning_rates: Vec<f64>,
325    /// Gradient norms by epoch
326    pub gradient_norms: Vec<f64>,
327    /// Parameter norms by epoch
328    pub parameter_norms: Vec<f64>,
329    /// Step times by epoch
330    pub step_times: Vec<f64>,
331}
332
333/// Resource usage tracking
334#[derive(Debug, Clone, Default, Serialize, Deserialize)]
335pub struct ResourceUsage {
336    /// Peak CPU usage (%)
337    pub peak_cpu_usage: f64,
338    /// Average CPU usage (%)
339    pub avg_cpu_usage: f64,
340    /// Peak memory usage (MB)
341    pub peak_memory_mb: usize,
342    /// Average memory usage (MB)
343    pub avg_memory_mb: usize,
344    /// Peak GPU memory usage (MB)
345    pub peak_gpu_memory_mb: Option<usize>,
346    /// Total training time (seconds)
347    pub total_time_seconds: f64,
348    /// Energy consumption (Joules)
349    pub energy_consumption_joules: Option<f64>,
350}
351
352/// Reproducibility information
353#[derive(Debug, Clone, Serialize, Deserialize, Default)]
354pub struct ReproducibilityInfo {
355    /// Environment hash for reproducibility
356    pub environment_hash: String,
357    /// Git commit hash
358    pub git_commit: Option<String>,
359    /// Code checksum
360    pub code_checksum: String,
361    /// Dependency versions
362    pub dependency_versions: HashMap<String, String>,
363    /// System information
364    pub system_info: SystemInfo,
365    /// Reproducibility checklist
366    pub checklist: ReproducibilityChecklist,
367}
368
369/// System information
370#[derive(Debug, Clone, Serialize, Deserialize)]
371pub struct SystemInfo {
372    /// Operating system
373    pub os: String,
374    /// OS version
375    pub os_version: String,
376    /// Architecture
377    pub architecture: String,
378    /// Hostname
379    pub hostname: String,
380    /// Username
381    pub username: String,
382    /// Timezone
383    pub timezone: String,
384}
385
386/// Reproducibility checklist
387#[derive(Debug, Clone, Serialize, Deserialize, Default)]
388pub struct ReproducibilityChecklist {
389    /// Random seed set
390    pub random_seed_set: bool,
391    /// Dependencies pinned
392    pub dependencies_pinned: bool,
393    /// Data version controlled
394    pub data_version_controlled: bool,
395    /// Code version controlled
396    pub code_version_controlled: bool,
397    /// Environment documented
398    pub environment_documented: bool,
399    /// Hardware documented
400    pub hardware_documented: bool,
401    /// Results archived
402    pub results_archived: bool,
403}
404
405/// Experiment timeline
406#[derive(Debug, Clone, Serialize, Deserialize)]
407pub struct ExperimentTimeline {
408    /// Created timestamp
409    pub created_at: DateTime<Utc>,
410    /// Started timestamp
411    pub started_at: Option<DateTime<Utc>>,
412    /// Completed timestamp
413    pub completed_at: Option<DateTime<Utc>>,
414    /// Estimated duration
415    pub estimated_duration: Option<chrono::Duration>,
416    /// Actual duration
417    pub actual_duration: Option<chrono::Duration>,
418}
419
420/// Experiment note
421#[derive(Debug, Clone, Serialize, Deserialize)]
422pub struct ExperimentNote {
423    /// Note timestamp
424    pub timestamp: DateTime<Utc>,
425    /// Note author
426    pub author: String,
427    /// Note content
428    pub content: String,
429    /// Note type
430    pub note_type: NoteType,
431    /// Associated run ID
432    pub run_id: Option<String>,
433}
434
435/// Note types
436#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
437pub enum NoteType {
438    /// General observation
439    Observation,
440    /// Issue or problem
441    Issue,
442    /// Solution or fix
443    Solution,
444    /// Hypothesis
445    Hypothesis,
446    /// Conclusion
447    Conclusion,
448    /// Question
449    Question,
450    /// Reminder
451    Reminder,
452}
453
454/// Experiment metadata
455#[derive(Debug, Clone, Serialize, Deserialize, Default)]
456pub struct ExperimentMetadata {
457    /// Experiment tags
458    pub tags: Vec<String>,
459    /// Research question
460    pub research_question: String,
461    /// Expected outcomes
462    pub expected_outcomes: Vec<String>,
463    /// Success criteria
464    pub success_criteria: Vec<String>,
465    /// Related experiments
466    pub related_experiments: Vec<String>,
467    /// References/citations
468    pub references: Vec<String>,
469}
470
471/// Experiment runner for executing experiments
472pub struct ExperimentRunner {
473    /// Current experiment
474    experiment: Experiment,
475    /// Resource monitor
476    resource_monitor: ResourceMonitor,
477    /// Progress callback
478    progress_callback: Option<Box<dyn Fn(f64) + Send + Sync>>,
479}
480
481impl std::fmt::Debug for ExperimentRunner {
482    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
483        f.debug_struct("ExperimentRunner")
484            .field("experiment", &self.experiment)
485            .field("resource_monitor", &self.resource_monitor)
486            .field("progress_callback", &self.progress_callback.is_some())
487            .finish()
488    }
489}
490
491/// Resource monitoring
492#[derive(Debug)]
493pub struct ResourceMonitor {
494    /// CPU usage history
495    cpu_usage: Vec<f64>,
496    /// Memory usage history  
497    memory_usage: Vec<usize>,
498    /// GPU memory usage history
499    gpu_memory_usage: Vec<Option<usize>>,
500    /// Monitoring interval (seconds)
501    interval_seconds: u64,
502}
503
504impl Experiment {
505    /// Create a new experiment
506    pub fn new(name: &str) -> Self {
507        let now = Utc::now();
508        Self {
509            id: uuid::Uuid::new_v4().to_string(),
510            name: name.to_string(),
511            hypothesis: String::new(),
512            description: String::new(),
513            status: ExperimentStatus::Planning,
514            config: ExperimentConfig::default(),
515            optimizer_configs: HashMap::new(),
516            dataset_info: DatasetInfo::default(),
517            metrics: Vec::new(),
518            results: Vec::new(),
519            reproducibility: ReproducibilityInfo::default(),
520            timeline: ExperimentTimeline {
521                created_at: now,
522                started_at: None,
523                completed_at: None,
524                estimated_duration: None,
525                actual_duration: None,
526            },
527            notes: Vec::new(),
528            metadata: ExperimentMetadata::default(),
529        }
530    }
531
532    /// Set experiment hypothesis
533    pub fn hypothesis(mut self, hypothesis: &str) -> Self {
534        self.hypothesis = hypothesis.to_string();
535        self
536    }
537
538    /// Set experiment description
539    pub fn description(mut self, description: &str) -> Self {
540        self.description = description.to_string();
541        self
542    }
543
544    /// Add optimizer configuration
545    pub fn add_optimizer_config(mut self, name: &str, config: OptimizerConfig<f64>) -> Self {
546        self.optimizer_configs.insert(name.to_string(), config);
547        self
548    }
549
550    /// Set dataset information
551    pub fn dataset(mut self, datasetinfo: DatasetInfo) -> Self {
552        self.dataset_info = datasetinfo;
553        self
554    }
555
556    /// Set metrics to track
557    pub fn metrics(mut self, metrics: Vec<String>) -> Self {
558        self.metrics = metrics;
559        self
560    }
561
562    /// Add a note to the experiment
563    pub fn add_note(&mut self, author: &str, content: &str, notetype: NoteType) {
564        let note = ExperimentNote {
565            timestamp: Utc::now(),
566            author: author.to_string(),
567            content: content.to_string(),
568            note_type: notetype,
569            run_id: None,
570        };
571        self.notes.push(note);
572    }
573
574    /// Start the experiment
575    pub fn start(&mut self) -> Result<()> {
576        if self.status != ExperimentStatus::Ready && self.status != ExperimentStatus::Planning {
577            return Err(OptimError::InvalidConfig(format!(
578                "Cannot start experiment in status {:?}",
579                self.status
580            )));
581        }
582
583        self.status = ExperimentStatus::Running;
584        self.timeline.started_at = Some(Utc::now());
585
586        Ok(())
587    }
588
589    /// Complete the experiment
590    pub fn complete(&mut self) -> Result<()> {
591        if self.status != ExperimentStatus::Running {
592            return Err(OptimError::InvalidConfig(format!(
593                "Cannot complete experiment in status {:?}",
594                self.status
595            )));
596        }
597
598        self.status = ExperimentStatus::Completed;
599        self.timeline.completed_at = Some(Utc::now());
600
601        if let (Some(start), Some(end)) = (self.timeline.started_at, self.timeline.completed_at) {
602            self.timeline.actual_duration = Some(end - start);
603        }
604
605        Ok(())
606    }
607
608    /// Generate experiment report
609    pub fn generate_report(&self) -> String {
610        let mut report = String::new();
611
612        report.push_str(&format!("# Experiment Report: {}\n\n", self.name));
613        report.push_str(&format!("**ID**: {}\n", self.id));
614        report.push_str(&format!("**Status**: {:?}\n", self.status));
615        report.push_str(&format!("**Hypothesis**: {}\n\n", self.hypothesis));
616
617        if !self.description.is_empty() {
618            report.push_str(&format!("## Description\n\n{}\n\n", self.description));
619        }
620
621        report.push_str("## Configuration\n\n");
622        report.push_str(&format!("- **Random Seed**: {}\n", self.config.random_seed));
623        report.push_str(&format!("- **Number of Runs**: {}\n", self.config.num_runs));
624        report.push_str(&format!("- **Max Epochs**: {}\n", self.config.max_epochs));
625
626        report.push_str("\n## Optimizers\n\n");
627        for name in self.optimizer_configs.keys() {
628            report.push_str(&format!("- {}\n", name));
629        }
630
631        report.push_str("\n## Dataset\n\n");
632        report.push_str(&format!("- **Name**: {}\n", self.dataset_info.name));
633        report.push_str(&format!(
634            "- **Samples**: {}\n",
635            self.dataset_info.num_samples
636        ));
637        report.push_str(&format!(
638            "- **Features**: {}\n",
639            self.dataset_info.num_features
640        ));
641
642        report.push_str("\n## Results\n\n");
643        report.push_str(&format!("**Total Runs**: {}\n\n", self.results.len()));
644
645        // Group results by optimizer
646        let mut optimizer_results: HashMap<String, Vec<&ExperimentResult>> = HashMap::new();
647        for result in &self.results {
648            optimizer_results
649                .entry(result.optimizer_name.clone())
650                .or_default()
651                .push(result);
652        }
653
654        for (optimizer, results) in optimizer_results {
655            report.push_str(&format!("### {}\n\n", optimizer));
656
657            if !results.is_empty() {
658                // Calculate statistics
659                let successful_runs: Vec<&ExperimentResult> = results
660                    .iter()
661                    .filter(|r| r.status == RunStatus::Success)
662                    .copied()
663                    .collect();
664
665                report.push_str(&format!(
666                    "- **Successful Runs**: {}/{}\n",
667                    successful_runs.len(),
668                    results.len()
669                ));
670
671                if !successful_runs.is_empty() {
672                    for metric in &self.metrics {
673                        if let Some(values) = self.get_metric_values(&successful_runs, metric) {
674                            let mean = values.iter().sum::<f64>() / values.len() as f64;
675                            let std = (values.iter().map(|v| (v - mean).powi(2)).sum::<f64>()
676                                / values.len() as f64)
677                                .sqrt();
678                            report
679                                .push_str(&format!("- **{}**: {:.4} ± {:.4}\n", metric, mean, std));
680                        }
681                    }
682                }
683            }
684            report.push('\n');
685        }
686
687        if !self.notes.is_empty() {
688            report.push_str("## Notes\n\n");
689            for note in &self.notes {
690                report.push_str(&format!(
691                    "**{}** ({}): {}\n\n",
692                    note.author,
693                    note.timestamp.format("%Y-%m-%d %H:%M"),
694                    note.content
695                ));
696            }
697        }
698
699        report
700    }
701
702    fn get_metric_values(&self, results: &[&ExperimentResult], metric: &str) -> Option<Vec<f64>> {
703        let mut values = Vec::new();
704        for result in results {
705            if let Some(&value) = result.final_metrics.get(metric) {
706                values.push(value);
707            }
708        }
709        if values.is_empty() {
710            None
711        } else {
712            Some(values)
713        }
714    }
715}
716
717impl Default for ExperimentConfig {
718    fn default() -> Self {
719        Self {
720            random_seed: 42,
721            num_runs: 1,
722            max_epochs: 100,
723            early_stopping: None,
724            hardware_config: HardwareConfig::default(),
725            environment: HashMap::new(),
726            validation_split: 0.2,
727            test_split: 0.1,
728            cv_folds: None,
729        }
730    }
731}
732
733impl Default for CpuInfo {
734    fn default() -> Self {
735        Self {
736            model: "Unknown".to_string(),
737            cores: std::thread::available_parallelism()
738                .map(|p| p.get())
739                .unwrap_or(1),
740            threads: std::thread::available_parallelism()
741                .map(|p| p.get())
742                .unwrap_or(1),
743            frequency_mhz: 0,
744            cache_sizes: Vec::new(),
745            simd_capabilities: Vec::new(),
746        }
747    }
748}
749
750impl Default for MemoryConfig {
751    fn default() -> Self {
752        Self {
753            total_memory_mb: 8192,     // 8GB default
754            available_memory_mb: 6144, // 6GB default
755            allocation_strategy: MemoryAllocationStrategy::Standard,
756            pool_size_mb: None,
757        }
758    }
759}
760
761impl Default for ParallelConfig {
762    fn default() -> Self {
763        Self {
764            num_threads: std::thread::available_parallelism()
765                .map(|p| p.get())
766                .unwrap_or(1),
767            thread_affinity: None,
768            work_stealing: true,
769            chunk_size: None,
770        }
771    }
772}
773
774impl Default for DatasetInfo {
775    fn default() -> Self {
776        Self {
777            name: "Unknown".to_string(),
778            description: String::new(),
779            source: String::new(),
780            version: "1.0".to_string(),
781            num_samples: 0,
782            num_features: 0,
783            num_classes: None,
784            data_type: DataType::Tabular,
785            statistics: DatasetStatistics::default(),
786            preprocessing: Vec::new(),
787        }
788    }
789}
790
791impl Default for SystemInfo {
792    fn default() -> Self {
793        Self {
794            os: std::env::consts::OS.to_string(),
795            os_version: String::new(),
796            architecture: std::env::consts::ARCH.to_string(),
797            hostname: String::new(),
798            username: std::env::var("USER").unwrap_or_else(|_| "unknown".to_string()),
799            timezone: String::new(),
800        }
801    }
802}
803
804impl ExperimentRunner {
805    /// Create a new runner wrapping `experiment`, sampling resource usage
806    /// every `monitor_interval_seconds` seconds while a run is active.
807    pub fn new(experiment: Experiment, monitor_interval_seconds: u64) -> Self {
808        Self {
809            experiment,
810            resource_monitor: ResourceMonitor::new(monitor_interval_seconds),
811            progress_callback: None,
812        }
813    }
814
815    /// Register a callback invoked with a completion fraction in `[0.0, 1.0]`
816    /// every time [`Self::record_result`] appends a new run result.
817    pub fn set_progress_callback<F>(&mut self, callback: F)
818    where
819        F: Fn(f64) + Send + Sync + 'static,
820    {
821        self.progress_callback = Some(Box::new(callback));
822    }
823
824    /// Borrow the experiment under management.
825    pub fn experiment(&self) -> &Experiment {
826        &self.experiment
827    }
828
829    /// Mutably borrow the experiment under management.
830    pub fn experiment_mut(&mut self) -> &mut Experiment {
831        &mut self.experiment
832    }
833
834    /// Borrow the resource monitor.
835    pub fn resource_monitor(&self) -> &ResourceMonitor {
836        &self.resource_monitor
837    }
838
839    /// Transition the underlying experiment to `Running` and start resource
840    /// monitoring. Mirrors [`Experiment::start`]'s status-transition rules.
841    pub fn start(&mut self) -> Result<()> {
842        self.experiment.start()?;
843        self.resource_monitor.start_monitoring();
844        Ok(())
845    }
846
847    /// Record the outcome of one run against the experiment and report
848    /// progress (as `results.len() / config.num_runs`) to the progress
849    /// callback, if one is registered.
850    pub fn record_result(&mut self, mut result: ExperimentResult) {
851        if result.end_time.is_none() {
852            result.end_time = Some(Utc::now());
853        }
854        self.experiment.results.push(result);
855
856        if let Some(ref callback) = self.progress_callback {
857            let target = self.experiment.config.num_runs.max(1);
858            let completed = self.experiment.results.len().min(target);
859            callback(completed as f64 / target as f64);
860        }
861    }
862
863    /// Stop resource monitoring and mark the experiment `Completed`,
864    /// returning the aggregate resource usage for the run.
865    pub fn finish(&mut self) -> Result<ResourceUsage> {
866        let usage = self.resource_monitor.stop_monitoring();
867        self.experiment.complete()?;
868        Ok(usage)
869    }
870}
871
872impl ResourceMonitor {
873    /// Create a new resource monitor
874    pub fn new(_intervalseconds: u64) -> Self {
875        Self {
876            cpu_usage: Vec::new(),
877            memory_usage: Vec::new(),
878            gpu_memory_usage: Vec::new(),
879            interval_seconds: _intervalseconds,
880        }
881    }
882
883    /// Discard any samples collected so far and begin a fresh window.
884    ///
885    /// This crate takes no measurements of its own: reading CPU and memory
886    /// counters requires platform-specific system interfaces, and the project's
887    /// pure-Rust policy rules out the FFI they need. Until 0.3.2 this method
888    /// had an empty body with the comment "Implementation would use system
889    /// monitoring libraries", so `stop_monitoring` reported a summary over an
890    /// empty sample set as though it had measured something. Feed measurements
891    /// in with [`Self::record_sample`]; the summary then describes real data or
892    /// honestly reports none.
893    pub fn start_monitoring(&mut self) {
894        self.cpu_usage.clear();
895        self.memory_usage.clear();
896        self.gpu_memory_usage.clear();
897    }
898
899    /// Record one observation. `gpu_memory_mb` is `None` when no GPU is in use.
900    pub fn record_sample(
901        &mut self,
902        cpu_percent: f64,
903        memory_mb: usize,
904        gpu_memory_mb: Option<usize>,
905    ) {
906        self.cpu_usage.push(cpu_percent);
907        self.memory_usage.push(memory_mb);
908        self.gpu_memory_usage.push(gpu_memory_mb);
909    }
910
911    /// Number of samples recorded in the current window.
912    pub fn sample_count(&self) -> usize {
913        self.cpu_usage.len()
914    }
915
916    /// The sampling interval the monitor was configured with, in seconds.
917    pub fn interval_seconds(&self) -> u64 {
918        self.interval_seconds
919    }
920
921    /// Stop monitoring and return resource usage summary
922    pub fn stop_monitoring(&self) -> ResourceUsage {
923        let peak_cpu = self.cpu_usage.iter().fold(0.0f64, |a, &b| a.max(b));
924        let avg_cpu = if self.cpu_usage.is_empty() {
925            0.0
926        } else {
927            self.cpu_usage.iter().sum::<f64>() / self.cpu_usage.len() as f64
928        };
929
930        let peak_memory = self.memory_usage.iter().fold(0usize, |a, &b| a.max(b));
931        let avg_memory = if self.memory_usage.is_empty() {
932            0
933        } else {
934            self.memory_usage.iter().sum::<usize>() / self.memory_usage.len()
935        };
936
937        ResourceUsage {
938            peak_cpu_usage: peak_cpu,
939            avg_cpu_usage: avg_cpu,
940            peak_memory_mb: peak_memory,
941            avg_memory_mb: avg_memory,
942            // Real values derived from the recorded samples: the peak of the
943            // GPU series, and the wall-clock span the samples cover at the
944            // configured interval. Both were hardcoded to `None` / `0.0` with a
945            // "would be calculated" comment.
946            peak_gpu_memory_mb: self.gpu_memory_usage.iter().flatten().copied().max(),
947            total_time_seconds: self.cpu_usage.len().saturating_sub(1) as f64
948                * self.interval_seconds as f64,
949            // Energy draw needs a hardware power counter this crate cannot read.
950            energy_consumption_joules: None,
951        }
952    }
953}
954
955#[cfg(test)]
956mod tests {
957    use super::*;
958
959    #[test]
960    fn test_experiment_creation() {
961        let experiment = Experiment::new("Test Experiment")
962            .hypothesis("Test hypothesis")
963            .description("Test description")
964            .metrics(vec!["accuracy".to_string(), "loss".to_string()]);
965
966        assert_eq!(experiment.name, "Test Experiment");
967        assert_eq!(experiment.hypothesis, "Test hypothesis");
968        assert_eq!(experiment.description, "Test description");
969        assert_eq!(experiment.metrics.len(), 2);
970        assert_eq!(experiment.status, ExperimentStatus::Planning);
971    }
972
973    #[test]
974    fn test_experiment_lifecycle() {
975        let mut experiment = Experiment::new("Lifecycle Test");
976
977        // Start experiment
978        experiment.status = ExperimentStatus::Ready;
979        assert!(experiment.start().is_ok());
980        assert_eq!(experiment.status, ExperimentStatus::Running);
981        assert!(experiment.timeline.started_at.is_some());
982
983        // Complete experiment
984        assert!(experiment.complete().is_ok());
985        assert_eq!(experiment.status, ExperimentStatus::Completed);
986        assert!(experiment.timeline.completed_at.is_some());
987        assert!(experiment.timeline.actual_duration.is_some());
988    }
989
990    #[test]
991    fn test_experiment_notes() {
992        let mut experiment = Experiment::new("Notes Test");
993
994        experiment.add_note("Researcher", "Initial observation", NoteType::Observation);
995        experiment.add_note("Researcher", "Found an issue", NoteType::Issue);
996
997        assert_eq!(experiment.notes.len(), 2);
998        assert_eq!(experiment.notes[0].note_type, NoteType::Observation);
999        assert_eq!(experiment.notes[1].note_type, NoteType::Issue);
1000    }
1001
1002    // Regression test for F24: `ExperimentRunner` was exported with fully
1003    // private fields and no constructor at all, making it impossible to
1004    // build despite being part of the public API.
1005    #[test]
1006    fn test_experiment_runner_is_constructible_and_functional() {
1007        let mut experiment = Experiment::new("Runner Test");
1008        experiment.config.num_runs = 2;
1009        experiment.status = ExperimentStatus::Ready;
1010
1011        let mut runner = ExperimentRunner::new(experiment, 1);
1012        assert!(runner.start().is_ok());
1013        assert_eq!(runner.experiment().status, ExperimentStatus::Running);
1014
1015        let progress = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
1016        let progress_clone = progress.clone();
1017        runner.set_progress_callback(move |fraction| {
1018            progress_clone.lock().expect("lock").push(fraction);
1019        });
1020
1021        let make_result = |run_id: &str| ExperimentResult {
1022            run_id: run_id.to_string(),
1023            optimizer_name: "adam".to_string(),
1024            start_time: Utc::now(),
1025            end_time: None,
1026            status: RunStatus::Success,
1027            final_metrics: HashMap::new(),
1028            training_history: TrainingHistory {
1029                epochs: vec![],
1030                train_metrics: HashMap::new(),
1031                val_metrics: HashMap::new(),
1032                learning_rates: vec![],
1033                gradient_norms: vec![],
1034                parameter_norms: vec![],
1035                step_times: vec![],
1036            },
1037            resource_usage: ResourceUsage::default(),
1038            error_info: None,
1039            metadata: HashMap::new(),
1040        };
1041
1042        runner.record_result(make_result("run-1"));
1043        runner.record_result(make_result("run-2"));
1044
1045        assert_eq!(runner.experiment().results.len(), 2);
1046        assert!(runner.experiment().results[0].end_time.is_some());
1047        let recorded_progress = progress.lock().expect("lock").clone();
1048        assert_eq!(recorded_progress, vec![0.5, 1.0]);
1049
1050        let usage = runner.finish().expect("finish should succeed");
1051        assert_eq!(usage.peak_cpu_usage, 0.0);
1052        assert_eq!(runner.experiment().status, ExperimentStatus::Completed);
1053    }
1054}