1use crate::error::{OptimError, Result};
7use chrono::{DateTime, Utc};
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10use std::path::PathBuf;
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct ReproducibilityManager {
15 pub environments: HashMap<String, EnvironmentSnapshot>,
17 pub reports: Vec<ReproducibilityReport>,
19 pub verifications: Vec<VerificationResult>,
21 pub config: ReproducibilityConfig,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct EnvironmentSnapshot {
28 pub id: String,
30 pub timestamp: DateTime<Utc>,
32 pub system_info: SystemInfo,
34 pub dependencies: Vec<Dependency>,
36 pub environment_variables: HashMap<String, String>,
38 pub hardware_config: HardwareConfig,
40 pub random_seeds: Vec<u64>,
42 pub data_checksums: HashMap<String, String>,
44 pub config_hashes: HashMap<String, String>,
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct SystemInfo {
51 pub os: String,
53 pub os_version: String,
55 pub kernel_version: Option<String>,
57 pub architecture: String,
59 pub hostname: String,
61 pub timezone: String,
63 pub locale: HashMap<String, String>,
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct Dependency {
70 pub name: String,
72 pub version: String,
74 pub source: String,
76 pub checksum: Option<String>,
78 pub install_path: Option<String>,
80}
81
82#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct HardwareConfig {
85 pub cpu: CpuSpec,
87 pub memory: MemorySpec,
89 pub gpu: Option<GpuSpec>,
91 pub storage: Vec<StorageSpec>,
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct CpuSpec {
98 pub model: String,
100 pub cores: usize,
102 pub threads: usize,
104 pub base_frequency: u32,
106 pub max_frequency: u32,
108 pub cache: HashMap<String, String>,
110 pub flags: Vec<String>,
112}
113
114#[derive(Debug, Clone, Serialize, Deserialize)]
116pub struct MemorySpec {
117 pub total_bytes: u64,
119 pub available_bytes: u64,
121 pub memory_type: String,
123 pub speed_mhz: u32,
125}
126
127#[derive(Debug, Clone, Serialize, Deserialize)]
129pub struct GpuSpec {
130 pub model: String,
132 pub memory_bytes: u64,
134 pub driver_version: String,
136 pub cuda_version: Option<String>,
138 pub compute_capability: Option<String>,
140}
141
142#[derive(Debug, Clone, Serialize, Deserialize)]
144pub struct StorageSpec {
145 pub device: String,
147 pub storage_type: String,
149 pub size_bytes: u64,
151 pub available_bytes: u64,
153 pub filesystem: String,
155}
156
157#[derive(Debug, Clone, Serialize, Deserialize)]
159pub struct ReproducibilityReport {
160 pub id: String,
162 pub experiment_id: String,
164 pub environment_id: String,
166 pub reproducibility_score: f64,
168 pub checklist: ReproducibilityChecklist,
170 pub issues: Vec<ReproducibilityIssue>,
172 pub recommendations: Vec<String>,
174 pub generated_at: DateTime<Utc>,
176}
177
178#[derive(Debug, Clone, Serialize, Deserialize)]
180pub struct ReproducibilityChecklist {
181 pub random_seed_documented: bool,
183 pub dependencies_pinned: bool,
185 pub environment_captured: bool,
187 pub data_versioned: bool,
189 pub code_versioned: bool,
191 pub hardware_documented: bool,
193 pub configuration_hashed: bool,
195 pub results_verified: bool,
197}
198
199#[derive(Debug, Clone, Serialize, Deserialize)]
201pub struct ReproducibilityIssue {
202 pub issue_type: IssueType,
204 pub severity: IssueSeverity,
206 pub description: String,
208 pub component: String,
210 pub suggested_fix: Option<String>,
212}
213
214#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
216pub enum IssueType {
217 MissingRandomSeed,
219 UnpinnedDependencies,
221 MissingEnvironment,
223 DataNotVersioned,
225 CodeNotVersioned,
227 HardwareNotDocumented,
229 ConfigurationNotHashed,
231 NonDeterministic,
233 PlatformSpecific,
235 ExternalDependencies,
237}
238
239#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
241pub enum IssueSeverity {
242 Critical,
244 High,
246 Medium,
248 Low,
250 Info,
252}
253
254#[derive(Debug, Clone, Serialize, Deserialize)]
256pub struct VerificationResult {
257 pub id: String,
259 pub original_experiment_id: String,
261 pub reproduction_experiment_id: String,
263 pub status: VerificationStatus,
265 pub similarity_metrics: SimilarityMetrics,
267 pub differences: Vec<Difference>,
269 pub verified_at: DateTime<Utc>,
271}
272
273#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
275pub enum VerificationStatus {
276 ExactMatch,
278 CloseMatch,
280 PartialMatch,
282 NoMatch,
284 VerificationFailed,
286}
287
288#[derive(Debug, Clone, Serialize, Deserialize)]
290pub struct SimilarityMetrics {
291 pub overall_similarity: f64,
294 pub result_similarity: f64,
298 pub performance_similarity: f64,
301 pub configuration_similarity: Option<f64>,
305 pub environment_similarity: Option<f64>,
309}
310
311#[derive(Debug, Clone, Serialize, Deserialize)]
313pub struct Difference {
314 pub category: DifferenceCategory,
316 pub field: String,
318 pub original_value: String,
320 pub reproduction_value: String,
322 pub magnitude: f64,
324 pub significant: bool,
326}
327
328#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
330pub enum DifferenceCategory {
331 Results,
333 Performance,
335 Configuration,
337 Environment,
339 Dependencies,
341 Hardware,
343}
344
345#[derive(Debug, Clone, Serialize, Deserialize)]
347pub struct ReproducibilityConfig {
348 pub numerical_tolerance: f64,
350 pub performance_tolerance: f64,
352 pub min_reproducibility_score: f64,
354 pub auto_capture_environment: bool,
356 pub auto_verify_results: bool,
358 pub storage: ReproducibilityStorage,
360}
361
362#[derive(Debug, Clone, Serialize, Deserialize)]
364pub struct ReproducibilityStorage {
365 pub base_directory: PathBuf,
367 pub compress_snapshots: bool,
369 pub retention_days: u32,
371 pub max_storage_bytes: u64,
373}
374
375impl ReproducibilityManager {
376 pub fn new(config: ReproducibilityConfig) -> Self {
378 Self {
379 environments: HashMap::new(),
380 reports: Vec::new(),
381 verifications: Vec::new(),
382 config,
383 }
384 }
385
386 pub fn capture_environment(&mut self, random_seeds: &[u64]) -> Result<String> {
394 let snapshot_id = uuid::Uuid::new_v4().to_string();
395 let snapshot = EnvironmentSnapshot {
396 id: snapshot_id.clone(),
397 timestamp: Utc::now(),
398 system_info: self.capture_system_info()?,
399 dependencies: self.capture_dependencies()?,
400 environment_variables: self.capture_environment_variables(),
401 hardware_config: self.capture_hardware_config()?,
402 random_seeds: random_seeds.to_vec(),
403 data_checksums: HashMap::new(),
404 config_hashes: HashMap::new(),
405 };
406
407 self.environments.insert(snapshot_id.clone(), snapshot);
408 Ok(snapshot_id)
409 }
410
411 pub fn generate_report(&mut self, experiment_id: &str, environment_id: &str) -> Result<String> {
413 let environment = self.environments.get(environment_id).ok_or_else(|| {
414 OptimError::InvalidConfig("Environment snapshot not found".to_string())
415 })?;
416
417 let checklist = self.evaluate_checklist(environment, experiment_id);
418 let (score, issues) = self.calculate_reproducibility_score(&checklist, environment);
419 let recommendations = self.generate_recommendations(&issues);
420
421 let report_id = uuid::Uuid::new_v4().to_string();
422 let report = ReproducibilityReport {
423 id: report_id.clone(),
424 experiment_id: experiment_id.to_string(),
425 environment_id: environment_id.to_string(),
426 reproducibility_score: score,
427 checklist,
428 issues,
429 recommendations,
430 generated_at: Utc::now(),
431 };
432
433 self.reports.push(report);
434 Ok(report_id)
435 }
436
437 pub fn verify_reproducibility(
456 &mut self,
457 original_experiment_id: &str,
458 reproduction_experiment_id: &str,
459 original_metrics: &HashMap<String, f64>,
460 reproduction_metrics: &HashMap<String, f64>,
461 original_environment_id: Option<&str>,
462 reproduction_environment_id: Option<&str>,
463 ) -> Result<String> {
464 const PERFORMANCE_MARKERS: &[&str] = &[
465 "time",
466 "memory",
467 "latency",
468 "throughput",
469 "duration",
470 "speed",
471 ];
472
473 let mut keys: Vec<&String> = original_metrics
474 .keys()
475 .chain(reproduction_metrics.keys())
476 .collect();
477 keys.sort();
478 keys.dedup();
479
480 let mut differences = Vec::new();
481 let mut result_diffs = Vec::new();
482 let mut performance_diffs = Vec::new();
483
484 for key in keys {
485 let is_performance = PERFORMANCE_MARKERS
486 .iter()
487 .any(|marker| key.to_lowercase().contains(marker));
488 let category = if is_performance {
489 DifferenceCategory::Performance
490 } else {
491 DifferenceCategory::Results
492 };
493
494 let relative = match (original_metrics.get(key), reproduction_metrics.get(key)) {
495 (Some(&orig), Some(&repro)) => {
496 let magnitude = orig
497 .abs()
498 .max(repro.abs())
499 .max(self.config.numerical_tolerance);
500 let relative = ((orig - repro).abs() / magnitude).min(1.0);
501 if relative > self.config.numerical_tolerance {
502 differences.push(Difference {
503 category,
504 field: key.clone(),
505 original_value: orig.to_string(),
506 reproduction_value: repro.to_string(),
507 magnitude: relative,
508 significant: relative > self.config.performance_tolerance,
509 });
510 }
511 relative
512 }
513 (orig, repro) => {
514 differences.push(Difference {
515 category,
516 field: key.clone(),
517 original_value: orig
518 .map(|v| v.to_string())
519 .unwrap_or_else(|| "<missing>".to_string()),
520 reproduction_value: repro
521 .map(|v| v.to_string())
522 .unwrap_or_else(|| "<missing>".to_string()),
523 magnitude: 1.0,
524 significant: true,
525 });
526 1.0
527 }
528 };
529
530 if is_performance {
531 performance_diffs.push(relative);
532 } else {
533 result_diffs.push(relative);
534 }
535 }
536
537 let mean_similarity = |diffs: &[f64]| -> f64 {
542 if diffs.is_empty() {
543 1.0
544 } else {
545 1.0 - (diffs.iter().sum::<f64>() / diffs.len() as f64)
546 }
547 };
548 let result_similarity = mean_similarity(&result_diffs);
549 let performance_similarity = mean_similarity(&performance_diffs);
550
551 let environment_similarity = match (original_environment_id, reproduction_environment_id) {
552 (Some(orig_id), Some(repro_id)) => match (
553 self.environments.get(orig_id),
554 self.environments.get(repro_id),
555 ) {
556 (Some(orig_env), Some(repro_env)) => {
557 Some(environment_similarity(orig_env, repro_env))
558 }
559 _ => None,
560 },
561 _ => None,
562 };
563
564 let overall_similarity = {
565 let mut parts = vec![result_similarity, performance_similarity];
566 parts.extend(environment_similarity);
567 parts.iter().sum::<f64>() / parts.len() as f64
568 };
569
570 let status = if differences.is_empty() {
571 VerificationStatus::ExactMatch
572 } else if overall_similarity >= 1.0 - self.config.performance_tolerance {
573 VerificationStatus::CloseMatch
574 } else if overall_similarity >= self.config.min_reproducibility_score {
575 VerificationStatus::PartialMatch
576 } else {
577 VerificationStatus::NoMatch
578 };
579
580 let verification_id = uuid::Uuid::new_v4().to_string();
581 let verification = VerificationResult {
582 id: verification_id.clone(),
583 original_experiment_id: original_experiment_id.to_string(),
584 reproduction_experiment_id: reproduction_experiment_id.to_string(),
585 status,
586 similarity_metrics: SimilarityMetrics {
587 overall_similarity,
588 result_similarity,
589 performance_similarity,
590 configuration_similarity: None,
591 environment_similarity,
592 },
593 differences,
594 verified_at: Utc::now(),
595 };
596
597 self.verifications.push(verification);
598 Ok(verification_id)
599 }
600
601 fn capture_system_info(&self) -> Result<SystemInfo> {
602 Ok(SystemInfo {
603 os: std::env::consts::OS.to_string(),
604 os_version: "Unknown".to_string(), kernel_version: None,
606 architecture: std::env::consts::ARCH.to_string(),
607 hostname: std::env::var("HOSTNAME").unwrap_or_else(|_| "unknown".to_string()),
608 timezone: "UTC".to_string(), locale: HashMap::new(),
610 })
611 }
612
613 fn capture_dependencies(&self) -> Result<Vec<Dependency>> {
619 let Some(lock_path) = find_cargo_lock() else {
620 return Ok(Vec::new());
621 };
622 let content = std::fs::read_to_string(&lock_path).map_err(|e| {
623 OptimError::InvalidConfig(format!("failed to read {}: {e}", lock_path.display()))
624 })?;
625
626 Ok(parse_cargo_lock_dependencies(&content))
627 }
628
629 fn capture_environment_variables(&self) -> HashMap<String, String> {
644 const ALLOWED_EXACT: &[&str] = &[
645 "LANG",
646 "LC_ALL",
647 "LC_CTYPE",
648 "LC_NUMERIC",
649 "TZ",
650 "PATH",
651 "HOSTNAME",
652 "USER",
653 "SHELL",
654 "PWD",
655 "OS",
656 "OSTYPE",
657 "HOSTTYPE",
658 "RUSTC_VERSION",
659 "RUSTFLAGS",
660 "RUST_BACKTRACE",
661 "RUST_LOG",
662 "CARGO_HOME",
663 "RUSTUP_HOME",
664 "RUSTUP_TOOLCHAIN",
665 "OMP_NUM_THREADS",
666 "RAYON_NUM_THREADS",
667 "MKL_NUM_THREADS",
668 "OPENBLAS_NUM_THREADS",
669 "CUDA_VISIBLE_DEVICES",
670 "HIP_VISIBLE_DEVICES",
671 "ROCR_VISIBLE_DEVICES",
672 ];
673 const ALLOWED_PREFIXES: &[&str] = &["CARGO_", "RUSTC_"];
674 const SECRET_MARKERS: &[&str] = &[
675 "KEY",
676 "TOKEN",
677 "SECRET",
678 "PASSWORD",
679 "PASSWD",
680 "CREDENTIAL",
681 "AUTH",
682 "PRIVATE",
683 "APIKEY",
684 "ACCESS",
685 "COOKIE",
686 "SESSION",
687 ];
688
689 std::env::vars()
690 .filter(|(name, _)| {
691 let upper = name.to_uppercase();
692 ALLOWED_EXACT.contains(&upper.as_str())
693 || ALLOWED_PREFIXES.iter().any(|p| upper.starts_with(p))
694 })
695 .map(|(name, value)| {
696 let upper = name.to_uppercase();
697 if SECRET_MARKERS.iter().any(|marker| upper.contains(marker)) {
698 (name, "<redacted>".to_string())
699 } else {
700 (name, value)
701 }
702 })
703 .collect()
704 }
705
706 fn capture_hardware_config(&self) -> Result<HardwareConfig> {
713 let cores = std::thread::available_parallelism()
714 .map(|p| p.get())
715 .unwrap_or(1);
716 let (model, base_frequency, max_frequency) = detect_cpu_info();
717 let (total_bytes, available_bytes) = detect_memory_info();
718
719 Ok(HardwareConfig {
720 cpu: CpuSpec {
721 model,
722 cores,
723 threads: cores,
724 base_frequency,
725 max_frequency,
726 cache: HashMap::new(),
727 flags: Vec::new(),
728 },
729 memory: MemorySpec {
730 total_bytes,
731 available_bytes,
732 memory_type: "Unknown".to_string(),
733 speed_mhz: 0,
734 },
735 gpu: None,
736 storage: Vec::new(),
737 })
738 }
739
740 fn evaluate_checklist(
741 &self,
742 environment: &EnvironmentSnapshot,
743 experiment_id: &str,
744 ) -> ReproducibilityChecklist {
745 ReproducibilityChecklist {
746 random_seed_documented: !environment.random_seeds.is_empty(),
747 dependencies_pinned: !environment.dependencies.is_empty(),
748 environment_captured: true, data_versioned: !environment.data_checksums.is_empty(),
750 code_versioned: is_code_versioned(),
751 hardware_documented: environment.hardware_config.cpu.model != "Unknown CPU"
755 || environment.hardware_config.memory.total_bytes > 0,
756 configuration_hashed: !environment.config_hashes.is_empty(),
757 results_verified: self.verifications.iter().any(|v| {
761 (v.original_experiment_id == experiment_id
762 || v.reproduction_experiment_id == experiment_id)
763 && v.status != VerificationStatus::VerificationFailed
764 }),
765 }
766 }
767
768 fn calculate_reproducibility_score(
782 &self,
783 checklist: &ReproducibilityChecklist,
784 environment: &EnvironmentSnapshot,
785 ) -> (f64, Vec<ReproducibilityIssue>) {
786 let mut score = 0.0;
787 let mut issues = Vec::new();
788 let total_checks = 8.0;
789
790 let contradictions: [(bool, IssueType, &str, &str); 5] = [
793 (
794 checklist.random_seed_documented && environment.random_seeds.is_empty(),
795 IssueType::MissingRandomSeed,
796 "the checklist claims the random seed is documented, but the environment snapshot \
797 recorded no seeds",
798 "record every seed in EnvironmentSnapshot::random_seeds",
799 ),
800 (
801 checklist.dependencies_pinned
802 && environment
803 .dependencies
804 .iter()
805 .any(|dependency| dependency.version.trim().is_empty()),
806 IssueType::UnpinnedDependencies,
807 "the checklist claims dependencies are pinned, but the snapshot contains a \
808 dependency with no version",
809 "pin every dependency to an exact version",
810 ),
811 (
812 checklist.environment_captured
813 && environment.dependencies.is_empty()
814 && environment.environment_variables.is_empty(),
815 IssueType::MissingEnvironment,
816 "the checklist claims the environment is captured, but the snapshot records \
817 neither dependencies nor environment variables",
818 "capture the dependency set and the relevant environment variables",
819 ),
820 (
821 checklist.data_versioned && environment.data_checksums.is_empty(),
822 IssueType::DataNotVersioned,
823 "the checklist claims the data is versioned, but the snapshot records no data \
824 checksums",
825 "record a checksum per dataset in EnvironmentSnapshot::data_checksums",
826 ),
827 (
828 checklist.configuration_hashed && environment.config_hashes.is_empty(),
829 IssueType::ConfigurationNotHashed,
830 "the checklist claims the configuration is hashed, but the snapshot records no \
831 configuration hashes",
832 "record a hash per configuration file in EnvironmentSnapshot::config_hashes",
833 ),
834 ];
835
836 let mut unsubstantiated = 0.0_f64;
837 for (contradicted, issue_type, description, fix) in contradictions {
838 if contradicted {
839 unsubstantiated += 1.0;
840 issues.push(ReproducibilityIssue {
841 issue_type,
842 severity: IssueSeverity::High,
843 description: description.to_string(),
844 component: format!("environment snapshot {}", environment.id),
845 suggested_fix: Some(fix.to_string()),
846 });
847 }
848 }
849
850 if checklist.random_seed_documented {
851 score += 1.0;
852 } else {
853 issues.push(ReproducibilityIssue {
854 issue_type: IssueType::MissingRandomSeed,
855 severity: IssueSeverity::High,
856 description: "Random seed not documented".to_string(),
857 component: "Random Number Generation".to_string(),
858 suggested_fix: Some("Set and document random seeds for all RNGs".to_string()),
859 });
860 }
861
862 if checklist.dependencies_pinned {
863 score += 1.0;
864 } else {
865 issues.push(ReproducibilityIssue {
866 issue_type: IssueType::UnpinnedDependencies,
867 severity: IssueSeverity::Critical,
868 description: "Dependencies not pinned to specific versions".to_string(),
869 component: "Dependencies".to_string(),
870 suggested_fix: Some("Pin all dependencies to exact versions".to_string()),
871 });
872 }
873
874 if checklist.environment_captured {
875 score += 1.0;
876 }
877
878 if checklist.data_versioned {
879 score += 1.0;
880 } else {
881 issues.push(ReproducibilityIssue {
882 issue_type: IssueType::DataNotVersioned,
883 severity: IssueSeverity::High,
884 description: "Data not versioned or checksummed".to_string(),
885 component: "Data Management".to_string(),
886 suggested_fix: Some("Version control data or provide checksums".to_string()),
887 });
888 }
889
890 if checklist.code_versioned {
891 score += 1.0;
892 } else {
893 issues.push(ReproducibilityIssue {
894 issue_type: IssueType::CodeNotVersioned,
895 severity: IssueSeverity::Critical,
896 description: "Code not under version control".to_string(),
897 component: "Source Code".to_string(),
898 suggested_fix: Some("Use Git or other version control system".to_string()),
899 });
900 }
901
902 if checklist.hardware_documented {
903 score += 1.0;
904 }
905
906 if checklist.configuration_hashed {
907 score += 1.0;
908 } else {
909 issues.push(ReproducibilityIssue {
910 issue_type: IssueType::ConfigurationNotHashed,
911 severity: IssueSeverity::Medium,
912 description: "Configuration not hashed for integrity".to_string(),
913 component: "Configuration".to_string(),
914 suggested_fix: Some("Generate and store configuration hashes".to_string()),
915 });
916 }
917
918 if checklist.results_verified {
919 score += 1.0;
920 }
921
922 ((score - unsubstantiated).max(0.0) / total_checks, issues)
923 }
924
925 fn generate_recommendations(&self, issues: &[ReproducibilityIssue]) -> Vec<String> {
926 let mut recommendations = Vec::new();
927
928 for issue in issues {
929 if let Some(fix) = &issue.suggested_fix {
930 recommendations.push(format!("{}: {}", issue.component, fix));
931 }
932 }
933
934 if issues
935 .iter()
936 .any(|i| i.issue_type == IssueType::MissingRandomSeed)
937 {
938 recommendations.push("Use consistent random seeds across all components".to_string());
939 }
940
941 if issues
942 .iter()
943 .any(|i| i.issue_type == IssueType::UnpinnedDependencies)
944 {
945 recommendations.push("Create a lockfile with exact dependency versions".to_string());
946 }
947
948 recommendations.push("Document the complete experimental procedure".to_string());
949 recommendations.push("Provide clear instructions for reproduction".to_string());
950
951 recommendations
952 }
953}
954
955fn find_cargo_lock() -> Option<PathBuf> {
960 let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
961 let mut dir: &std::path::Path = &manifest_dir;
962 loop {
963 let candidate = dir.join("Cargo.lock");
964 if candidate.is_file() {
965 return Some(candidate);
966 }
967 dir = dir.parent()?;
968 }
969}
970
971fn parse_cargo_lock_dependencies(content: &str) -> Vec<Dependency> {
976 let mut dependencies = Vec::new();
977 let mut current: Option<(String, String, Option<String>, Option<String>)> = None;
978
979 for raw_line in content.lines() {
980 let line = raw_line.trim();
981
982 if line == "[[package]]" {
983 if let Some((name, version, source, checksum)) = current.take() {
984 if !name.is_empty() {
985 dependencies.push(Dependency {
986 name,
987 version,
988 source: source.unwrap_or_else(|| "local".to_string()),
989 checksum,
990 install_path: None,
991 });
992 }
993 }
994 current = Some((String::new(), String::new(), None, None));
995 continue;
996 }
997
998 let Some(entry) = current.as_mut() else {
999 continue;
1000 };
1001
1002 if let Some(value) = parse_toml_string_field(line, "name") {
1003 entry.0 = value;
1004 } else if let Some(value) = parse_toml_string_field(line, "version") {
1005 entry.1 = value;
1006 } else if let Some(value) = parse_toml_string_field(line, "source") {
1007 entry.2 = Some(value);
1008 } else if let Some(value) = parse_toml_string_field(line, "checksum") {
1009 entry.3 = Some(value);
1010 }
1011 }
1012
1013 if let Some((name, version, source, checksum)) = current {
1014 if !name.is_empty() {
1015 dependencies.push(Dependency {
1016 name,
1017 version,
1018 source: source.unwrap_or_else(|| "local".to_string()),
1019 checksum,
1020 install_path: None,
1021 });
1022 }
1023 }
1024
1025 dependencies
1026}
1027
1028fn parse_toml_string_field(line: &str, key: &str) -> Option<String> {
1032 let rest = line.strip_prefix(key)?;
1033 let rest = rest.trim_start();
1034 let rest = rest.strip_prefix('=')?;
1035 let rest = rest.trim();
1036 let rest = rest.strip_prefix('"')?;
1037 let value = rest.strip_suffix('"')?;
1038 Some(value.to_string())
1039}
1040
1041fn detect_cpu_info() -> (String, u32, u32) {
1047 #[cfg(target_os = "macos")]
1048 {
1049 if let Some(brand) = run_system_command("sysctl", &["-n", "machdep.cpu.brand_string"]) {
1050 let brand = brand.trim();
1051 if !brand.is_empty() {
1052 return (brand.to_string(), 0, 0);
1053 }
1054 }
1055 }
1056 #[cfg(target_os = "linux")]
1057 {
1058 if let Ok(cpuinfo) = std::fs::read_to_string("/proc/cpuinfo") {
1059 let model = cpuinfo
1060 .lines()
1061 .find(|line| line.starts_with("model name"))
1062 .and_then(|line| line.split_once(':'))
1063 .map(|(_, value)| value.trim().to_string());
1064 if let Some(model) = model {
1065 if !model.is_empty() {
1066 return (model, 0, 0);
1067 }
1068 }
1069 }
1070 }
1071 #[cfg(target_os = "windows")]
1072 {
1073 if let Some(output) = run_system_command("wmic", &["cpu", "get", "name"]) {
1074 if let Some(model) = output.lines().nth(1).map(str::trim) {
1075 if !model.is_empty() {
1076 return (model.to_string(), 0, 0);
1077 }
1078 }
1079 }
1080 }
1081 ("Unknown CPU".to_string(), 0, 0)
1082}
1083
1084fn detect_memory_info() -> (u64, u64) {
1088 #[cfg(target_os = "macos")]
1089 {
1090 if let Some(total) = run_system_command("sysctl", &["-n", "hw.memsize"])
1091 .and_then(|s| s.trim().parse::<u64>().ok())
1092 {
1093 return (total, total);
1096 }
1097 }
1098 #[cfg(target_os = "linux")]
1099 {
1100 if let Ok(meminfo) = std::fs::read_to_string("/proc/meminfo") {
1101 let total = parse_meminfo_kb(&meminfo, "MemTotal:");
1102 let available = parse_meminfo_kb(&meminfo, "MemAvailable:");
1103 if total > 0 {
1104 return (total * 1024, available * 1024);
1105 }
1106 }
1107 }
1108 #[cfg(target_os = "windows")]
1109 {
1110 if let Some(output) = run_system_command(
1111 "wmic",
1112 &[
1113 "OS",
1114 "get",
1115 "TotalVisibleMemorySize,FreePhysicalMemory",
1116 "/value",
1117 ],
1118 ) {
1119 let total = parse_wmic_kb_field(&output, "TotalVisibleMemorySize");
1120 let available = parse_wmic_kb_field(&output, "FreePhysicalMemory");
1121 if total > 0 {
1122 return (total * 1024, available * 1024);
1123 }
1124 }
1125 }
1126 (0, 0)
1127}
1128
1129#[cfg(any(target_os = "macos", target_os = "windows"))]
1130fn run_system_command(program: &str, args: &[&str]) -> Option<String> {
1131 std::process::Command::new(program)
1132 .args(args)
1133 .output()
1134 .ok()
1135 .filter(|output| output.status.success())
1136 .map(|output| String::from_utf8_lossy(&output.stdout).to_string())
1137}
1138
1139#[cfg(target_os = "linux")]
1140fn parse_meminfo_kb(meminfo: &str, key: &str) -> u64 {
1141 meminfo
1142 .lines()
1143 .find(|line| line.starts_with(key))
1144 .and_then(|line| line.split_whitespace().nth(1))
1145 .and_then(|value| value.parse::<u64>().ok())
1146 .unwrap_or(0)
1147}
1148
1149#[cfg(target_os = "windows")]
1150fn parse_wmic_kb_field(output: &str, key: &str) -> u64 {
1151 output
1152 .lines()
1153 .find_map(|line| line.trim().strip_prefix(&format!("{key}=")))
1154 .and_then(|value| value.trim().parse::<u64>().ok())
1155 .unwrap_or(0)
1156}
1157
1158fn environment_similarity(a: &EnvironmentSnapshot, b: &EnvironmentSnapshot) -> f64 {
1163 let os_match = if a.system_info.os == b.system_info.os
1164 && a.system_info.architecture == b.system_info.architecture
1165 {
1166 1.0
1167 } else {
1168 0.0
1169 };
1170
1171 let deps_a: std::collections::HashSet<String> = a
1172 .dependencies
1173 .iter()
1174 .map(|d| format!("{}@{}", d.name, d.version))
1175 .collect();
1176 let deps_b: std::collections::HashSet<String> = b
1177 .dependencies
1178 .iter()
1179 .map(|d| format!("{}@{}", d.name, d.version))
1180 .collect();
1181
1182 let dependency_similarity = if deps_a.is_empty() && deps_b.is_empty() {
1183 1.0
1184 } else {
1185 let intersection = deps_a.intersection(&deps_b).count() as f64;
1186 let union = deps_a.union(&deps_b).count().max(1) as f64;
1187 intersection / union
1188 };
1189
1190 0.5 * os_match + 0.5 * dependency_similarity
1191}
1192
1193fn is_code_versioned() -> bool {
1197 std::process::Command::new("git")
1198 .args(["rev-parse", "--is-inside-work-tree"])
1199 .output()
1200 .map(|output| output.status.success())
1201 .unwrap_or(false)
1202}
1203
1204impl Default for ReproducibilityConfig {
1205 fn default() -> Self {
1206 Self {
1207 numerical_tolerance: 1e-6,
1208 performance_tolerance: 0.1, min_reproducibility_score: 0.8, auto_capture_environment: true,
1211 auto_verify_results: false,
1212 storage: ReproducibilityStorage {
1213 base_directory: PathBuf::from("./reproducibility"),
1214 compress_snapshots: true,
1215 retention_days: 365,
1216 max_storage_bytes: 10 * 1024 * 1024 * 1024, },
1218 }
1219 }
1220}
1221
1222#[cfg(test)]
1223mod tests {
1224 use super::*;
1225
1226 #[test]
1227 fn test_reproducibility_manager_creation() {
1228 let config = ReproducibilityConfig::default();
1229 let manager = ReproducibilityManager::new(config);
1230
1231 assert!(manager.environments.is_empty());
1232 assert!(manager.reports.is_empty());
1233 assert!(manager.verifications.is_empty());
1234 }
1235
1236 #[test]
1237 fn test_environment_capture() {
1238 let config = ReproducibilityConfig::default();
1239 let mut manager = ReproducibilityManager::new(config);
1240
1241 let snapshot_id = manager.capture_environment(&[123]).expect("unwrap failed");
1242
1243 assert!(manager.environments.contains_key(&snapshot_id));
1244 let snapshot = &manager.environments[&snapshot_id];
1245 assert_eq!(snapshot.system_info.os, std::env::consts::OS);
1246 assert_eq!(snapshot.random_seeds, vec![123]);
1247 }
1248
1249 #[test]
1250 fn test_reproducibility_report() {
1251 let config = ReproducibilityConfig::default();
1252 let mut manager = ReproducibilityManager::new(config);
1253
1254 let env_id = manager.capture_environment(&[42]).expect("unwrap failed");
1255 let report_id = manager
1256 .generate_report("test_experiment", &env_id)
1257 .expect("unwrap failed");
1258
1259 assert!(!manager.reports.is_empty());
1260 let report = &manager.reports[0];
1261 assert_eq!(report.id, report_id);
1262 assert_eq!(report.experiment_id, "test_experiment");
1263 }
1264
1265 #[test]
1270 fn test_environment_variables_are_allowlisted_and_redacted() {
1271 unsafe {
1274 std::env::set_var("OPTIRS_TEST_SECRET_API_KEY", "super-secret-value");
1275 std::env::set_var("LANG", "en_US.UTF-8");
1276 }
1277
1278 let config = ReproducibilityConfig::default();
1279 let manager = ReproducibilityManager::new(config);
1280 let captured = manager.capture_environment_variables();
1281
1282 assert!(
1283 !captured.contains_key("OPTIRS_TEST_SECRET_API_KEY"),
1284 "a variable outside the allowlist must not be captured at all"
1285 );
1286
1287 unsafe {
1288 std::env::set_var("CARGO_TEST_SECRET_KEY", "another-secret");
1289 }
1290 let captured = manager.capture_environment_variables();
1291 if let Some(value) = captured.get("CARGO_TEST_SECRET_KEY") {
1292 assert_eq!(
1293 value, "<redacted>",
1294 "an allowlisted-by-prefix variable whose name looks secret-shaped must be redacted"
1295 );
1296 }
1297
1298 if let Some(lang) = captured.get("LANG") {
1299 assert_eq!(
1300 lang, "en_US.UTF-8",
1301 "ordinary allowlisted values pass through"
1302 );
1303 }
1304
1305 unsafe {
1306 std::env::remove_var("OPTIRS_TEST_SECRET_API_KEY");
1307 std::env::remove_var("CARGO_TEST_SECRET_KEY");
1308 }
1309 }
1310
1311 #[test]
1316 fn test_capture_dependencies_reads_real_cargo_lock() {
1317 let config = ReproducibilityConfig::default();
1318 let manager = ReproducibilityManager::new(config);
1319
1320 let dependencies = manager
1321 .capture_dependencies()
1322 .expect("dependency capture should not error");
1323
1324 assert!(
1327 dependencies.len() > 1,
1328 "expected real Cargo.lock contents, got {} entries",
1329 dependencies.len()
1330 );
1331 assert!(
1332 dependencies.iter().any(|d| d.name == "serde"),
1333 "expected to find a real, well-known dependency (serde) in the parsed lockfile"
1334 );
1335 assert!(
1336 !dependencies.iter().any(|d| d.name == "scirs2-optim"),
1337 "must not still contain the old fabricated placeholder entry"
1338 );
1339 }
1340
1341 #[test]
1342 fn test_capture_hardware_config_is_not_the_old_fixed_placeholder() {
1343 let config = ReproducibilityConfig::default();
1344 let manager = ReproducibilityManager::new(config);
1345
1346 let hardware = manager
1347 .capture_hardware_config()
1348 .expect("hardware capture should not error");
1349
1350 assert_ne!(hardware.memory.total_bytes, 8 * 1024 * 1024 * 1024);
1353 assert_ne!(hardware.memory.available_bytes, 6 * 1024 * 1024 * 1024);
1354 #[cfg(any(target_os = "macos", target_os = "linux"))]
1357 {
1358 assert!(hardware.memory.total_bytes > 0);
1359 assert_ne!(hardware.cpu.model, "Unknown CPU");
1360 }
1361 }
1362
1363 #[test]
1369 fn test_reproducibility_score_reflects_real_environment_not_a_fixed_constant() {
1370 let config = ReproducibilityConfig::default();
1371 let mut manager = ReproducibilityManager::new(config);
1372
1373 let sparse_env_id = manager.capture_environment(&[]).expect("capture");
1377 let rich_env_id = manager.capture_environment(&[7]).expect("capture");
1378 if let Some(env) = manager.environments.get_mut(&rich_env_id) {
1379 env.data_checksums
1380 .insert("dataset.csv".to_string(), "deadbeef".to_string());
1381 env.config_hashes
1382 .insert("config.json".to_string(), "cafebabe".to_string());
1383 }
1384
1385 let sparse_report_id = manager
1386 .generate_report("exp_sparse", &sparse_env_id)
1387 .expect("report");
1388 let rich_report_id = manager
1389 .generate_report("exp_rich", &rich_env_id)
1390 .expect("report");
1391
1392 let sparse_score = manager
1393 .reports
1394 .iter()
1395 .find(|r| r.id == sparse_report_id)
1396 .unwrap()
1397 .reproducibility_score;
1398 let rich_score = manager
1399 .reports
1400 .iter()
1401 .find(|r| r.id == rich_report_id)
1402 .unwrap()
1403 .reproducibility_score;
1404
1405 assert!(
1406 rich_score > sparse_score,
1407 "richer environment ({rich_score}) should score higher than sparse ({sparse_score})"
1408 );
1409 assert!(is_code_versioned());
1413 }
1414
1415 #[test]
1420 fn test_verify_reproducibility_reflects_real_metric_differences() {
1421 let config = ReproducibilityConfig::default();
1422 let mut manager = ReproducibilityManager::new(config);
1423
1424 let mut identical_metrics = HashMap::new();
1425 identical_metrics.insert("accuracy".to_string(), 0.95);
1426 identical_metrics.insert("execution_time_seconds".to_string(), 12.0);
1427
1428 let exact_id = manager
1429 .verify_reproducibility(
1430 "orig",
1431 "repro_exact",
1432 &identical_metrics,
1433 &identical_metrics.clone(),
1434 None,
1435 None,
1436 )
1437 .expect("verification should succeed");
1438 let exact = manager
1439 .verifications
1440 .iter()
1441 .find(|v| v.id == exact_id)
1442 .unwrap();
1443 assert_eq!(exact.status, VerificationStatus::ExactMatch);
1444 assert_eq!(exact.similarity_metrics.result_similarity, 1.0);
1445 assert!(exact.differences.is_empty());
1446 let exact_overall_similarity = exact.similarity_metrics.overall_similarity;
1447
1448 let mut divergent_metrics = HashMap::new();
1449 divergent_metrics.insert("accuracy".to_string(), 0.10);
1450 divergent_metrics.insert("execution_time_seconds".to_string(), 999.0);
1451
1452 let divergent_id = manager
1453 .verify_reproducibility(
1454 "orig",
1455 "repro_divergent",
1456 &identical_metrics,
1457 &divergent_metrics,
1458 None,
1459 None,
1460 )
1461 .expect("verification should succeed");
1462 let divergent = manager
1463 .verifications
1464 .iter()
1465 .find(|v| v.id == divergent_id)
1466 .unwrap();
1467
1468 assert_ne!(
1469 divergent.status,
1470 VerificationStatus::ExactMatch,
1471 "a run with wildly different metrics must not be reported as an exact match"
1472 );
1473 assert!(!divergent.differences.is_empty());
1474 assert!(
1475 divergent.similarity_metrics.overall_similarity < exact_overall_similarity,
1476 "the divergent run must score lower than the identical run, not a fixed constant"
1477 );
1478 assert!(divergent
1481 .similarity_metrics
1482 .configuration_similarity
1483 .is_none());
1484 }
1485
1486 #[test]
1487 fn test_verify_reproducibility_computes_real_environment_similarity() {
1488 let config = ReproducibilityConfig::default();
1489 let mut manager = ReproducibilityManager::new(config);
1490
1491 let env_a = manager.capture_environment(&[1]).expect("capture");
1492 let env_b = manager.capture_environment(&[2]).expect("capture");
1493
1494 let metrics = HashMap::new();
1495 let verification_id = manager
1496 .verify_reproducibility(
1497 "orig",
1498 "repro",
1499 &metrics,
1500 &metrics,
1501 Some(env_a.as_str()),
1502 Some(env_b.as_str()),
1503 )
1504 .expect("verification should succeed");
1505
1506 let verification = manager
1507 .verifications
1508 .iter()
1509 .find(|v| v.id == verification_id)
1510 .unwrap();
1511 assert_eq!(
1515 verification.similarity_metrics.environment_similarity,
1516 Some(1.0)
1517 );
1518 }
1519}