Skip to main content

optirs_core/research/
reproducibility.rs

1// Reproducibility tools for research experiments
2//
3// This module provides tools to ensure research experiments can be reproduced
4// exactly, including environment capture, dependency tracking, and result verification.
5
6use crate::error::{OptimError, Result};
7use chrono::{DateTime, Utc};
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10use std::path::PathBuf;
11
12/// Reproducibility manager for tracking experiment reproducibility
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct ReproducibilityManager {
15    /// Environment snapshots
16    pub environments: HashMap<String, EnvironmentSnapshot>,
17    /// Reproducibility reports
18    pub reports: Vec<ReproducibilityReport>,
19    /// Verification results
20    pub verifications: Vec<VerificationResult>,
21    /// Configuration
22    pub config: ReproducibilityConfig,
23}
24
25/// Complete environment snapshot for reproducibility
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct EnvironmentSnapshot {
28    /// Snapshot identifier
29    pub id: String,
30    /// Snapshot timestamp
31    pub timestamp: DateTime<Utc>,
32    /// System information
33    pub system_info: SystemInfo,
34    /// Software dependencies
35    pub dependencies: Vec<Dependency>,
36    /// Environment variables
37    pub environment_variables: HashMap<String, String>,
38    /// Hardware configuration
39    pub hardware_config: HardwareConfig,
40    /// Random seeds
41    pub random_seeds: Vec<u64>,
42    /// Data checksums
43    pub data_checksums: HashMap<String, String>,
44    /// Configuration hashes
45    pub config_hashes: HashMap<String, String>,
46}
47
48/// System information for reproducibility
49#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct SystemInfo {
51    /// Operating system
52    pub os: String,
53    /// OS version
54    pub os_version: String,
55    /// Kernel version
56    pub kernel_version: Option<String>,
57    /// Architecture
58    pub architecture: String,
59    /// Hostname
60    pub hostname: String,
61    /// Timezone
62    pub timezone: String,
63    /// Locale settings
64    pub locale: HashMap<String, String>,
65}
66
67/// Software dependency information
68#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct Dependency {
70    /// Package name
71    pub name: String,
72    /// Version
73    pub version: String,
74    /// Source/registry
75    pub source: String,
76    /// Checksum
77    pub checksum: Option<String>,
78    /// Installation path
79    pub install_path: Option<String>,
80}
81
82/// Hardware configuration for reproducibility
83#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct HardwareConfig {
85    /// CPU information
86    pub cpu: CpuSpec,
87    /// Memory information
88    pub memory: MemorySpec,
89    /// GPU information
90    pub gpu: Option<GpuSpec>,
91    /// Storage information
92    pub storage: Vec<StorageSpec>,
93}
94
95/// CPU specification
96#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct CpuSpec {
98    /// CPU model
99    pub model: String,
100    /// Number of cores
101    pub cores: usize,
102    /// Number of threads
103    pub threads: usize,
104    /// Base frequency (MHz)
105    pub base_frequency: u32,
106    /// Max frequency (MHz)
107    pub max_frequency: u32,
108    /// Cache information
109    pub cache: HashMap<String, String>,
110    /// CPU flags/features
111    pub flags: Vec<String>,
112}
113
114/// Memory specification
115#[derive(Debug, Clone, Serialize, Deserialize)]
116pub struct MemorySpec {
117    /// Total memory (bytes)
118    pub total_bytes: u64,
119    /// Available memory (bytes)
120    pub available_bytes: u64,
121    /// Memory type (DDR4, etc.)
122    pub memory_type: String,
123    /// Memory speed (MHz)
124    pub speed_mhz: u32,
125}
126
127/// GPU specification
128#[derive(Debug, Clone, Serialize, Deserialize)]
129pub struct GpuSpec {
130    /// GPU model
131    pub model: String,
132    /// GPU memory (bytes)
133    pub memory_bytes: u64,
134    /// Driver version
135    pub driver_version: String,
136    /// CUDA version (if applicable)
137    pub cuda_version: Option<String>,
138    /// Compute capability
139    pub compute_capability: Option<String>,
140}
141
142/// Storage specification
143#[derive(Debug, Clone, Serialize, Deserialize)]
144pub struct StorageSpec {
145    /// Device name
146    pub device: String,
147    /// Storage type (SSD, HDD, etc.)
148    pub storage_type: String,
149    /// Total size (bytes)
150    pub size_bytes: u64,
151    /// Available space (bytes)
152    pub available_bytes: u64,
153    /// File system
154    pub filesystem: String,
155}
156
157/// Reproducibility report
158#[derive(Debug, Clone, Serialize, Deserialize)]
159pub struct ReproducibilityReport {
160    /// Report ID
161    pub id: String,
162    /// Experiment ID
163    pub experiment_id: String,
164    /// Environment snapshot ID
165    pub environment_id: String,
166    /// Reproducibility score
167    pub reproducibility_score: f64,
168    /// Checklist results
169    pub checklist: ReproducibilityChecklist,
170    /// Issues found
171    pub issues: Vec<ReproducibilityIssue>,
172    /// Recommendations
173    pub recommendations: Vec<String>,
174    /// Generation timestamp
175    pub generated_at: DateTime<Utc>,
176}
177
178/// Reproducibility checklist
179#[derive(Debug, Clone, Serialize, Deserialize)]
180pub struct ReproducibilityChecklist {
181    /// Random seed documented
182    pub random_seed_documented: bool,
183    /// Dependencies pinned
184    pub dependencies_pinned: bool,
185    /// Environment captured
186    pub environment_captured: bool,
187    /// Data versioned
188    pub data_versioned: bool,
189    /// Code versioned
190    pub code_versioned: bool,
191    /// Hardware documented
192    pub hardware_documented: bool,
193    /// Configuration hashed
194    pub configuration_hashed: bool,
195    /// Results verified
196    pub results_verified: bool,
197}
198
199/// Reproducibility issue
200#[derive(Debug, Clone, Serialize, Deserialize)]
201pub struct ReproducibilityIssue {
202    /// Issue type
203    pub issue_type: IssueType,
204    /// Severity level
205    pub severity: IssueSeverity,
206    /// Description
207    pub description: String,
208    /// Affected component
209    pub component: String,
210    /// Suggested fix
211    pub suggested_fix: Option<String>,
212}
213
214/// Types of reproducibility issues
215#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
216pub enum IssueType {
217    /// Missing random seed
218    MissingRandomSeed,
219    /// Unpinned dependencies
220    UnpinnedDependencies,
221    /// Missing environment info
222    MissingEnvironment,
223    /// Data not versioned
224    DataNotVersioned,
225    /// Code not versioned
226    CodeNotVersioned,
227    /// Hardware not documented
228    HardwareNotDocumented,
229    /// Configuration not hashed
230    ConfigurationNotHashed,
231    /// Non-deterministic behavior
232    NonDeterministic,
233    /// Platform-specific code
234    PlatformSpecific,
235    /// External dependencies
236    ExternalDependencies,
237}
238
239/// Issue severity levels
240#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
241pub enum IssueSeverity {
242    /// Critical issue - prevents reproducibility
243    Critical,
244    /// High severity - likely to affect reproducibility
245    High,
246    /// Medium severity - may affect reproducibility
247    Medium,
248    /// Low severity - minor impact on reproducibility
249    Low,
250    /// Info only
251    Info,
252}
253
254/// Verification result for reproducibility
255#[derive(Debug, Clone, Serialize, Deserialize)]
256pub struct VerificationResult {
257    /// Verification ID
258    pub id: String,
259    /// Original experiment ID
260    pub original_experiment_id: String,
261    /// Reproduction experiment ID
262    pub reproduction_experiment_id: String,
263    /// Verification status
264    pub status: VerificationStatus,
265    /// Similarity metrics
266    pub similarity_metrics: SimilarityMetrics,
267    /// Differences found
268    pub differences: Vec<Difference>,
269    /// Verification timestamp
270    pub verified_at: DateTime<Utc>,
271}
272
273/// Verification status
274#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
275pub enum VerificationStatus {
276    /// Exact reproduction
277    ExactMatch,
278    /// Close reproduction (within tolerance)
279    CloseMatch,
280    /// Partial reproduction
281    PartialMatch,
282    /// No match
283    NoMatch,
284    /// Verification failed
285    VerificationFailed,
286}
287
288/// Similarity metrics between original and reproduction
289#[derive(Debug, Clone, Serialize, Deserialize)]
290pub struct SimilarityMetrics {
291    /// Overall similarity score (0.0 to 1.0), averaged from whichever of
292    /// the dimensions below were actually measured.
293    pub overall_similarity: f64,
294    /// Similarity of non-performance result metrics (accuracy, loss, final
295    /// objective, ...), computed from the metrics maps passed to
296    /// [`ReproducibilityManager::verify_reproducibility`].
297    pub result_similarity: f64,
298    /// Similarity of performance-labeled metrics (execution time, memory,
299    /// throughput, ...) within the same metrics maps.
300    pub performance_similarity: f64,
301    /// Similarity of experiment configuration. `None` when no
302    /// configuration comparison was performed, rather than a fabricated
303    /// number presented as a real measurement.
304    pub configuration_similarity: Option<f64>,
305    /// Similarity of the captured environment snapshots. `None` unless both
306    /// environment snapshot IDs were supplied to `verify_reproducibility`
307    /// and found.
308    pub environment_similarity: Option<f64>,
309}
310
311/// Difference between original and reproduction
312#[derive(Debug, Clone, Serialize, Deserialize)]
313pub struct Difference {
314    /// Category of difference
315    pub category: DifferenceCategory,
316    /// Field or metric name
317    pub field: String,
318    /// Original value
319    pub original_value: String,
320    /// Reproduction value
321    pub reproduction_value: String,
322    /// Difference magnitude
323    pub magnitude: f64,
324    /// Significance
325    pub significant: bool,
326}
327
328/// Categories of differences
329#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
330pub enum DifferenceCategory {
331    /// Difference in results
332    Results,
333    /// Difference in performance
334    Performance,
335    /// Difference in configuration
336    Configuration,
337    /// Difference in environment
338    Environment,
339    /// Difference in dependencies
340    Dependencies,
341    /// Difference in hardware
342    Hardware,
343}
344
345/// Reproducibility configuration
346#[derive(Debug, Clone, Serialize, Deserialize)]
347pub struct ReproducibilityConfig {
348    /// Tolerance for numerical comparisons
349    pub numerical_tolerance: f64,
350    /// Tolerance for performance comparisons
351    pub performance_tolerance: f64,
352    /// Minimum reproducibility score
353    pub min_reproducibility_score: f64,
354    /// Auto-capture environment
355    pub auto_capture_environment: bool,
356    /// Auto-verify results
357    pub auto_verify_results: bool,
358    /// Storage settings
359    pub storage: ReproducibilityStorage,
360}
361
362/// Storage settings for reproducibility data
363#[derive(Debug, Clone, Serialize, Deserialize)]
364pub struct ReproducibilityStorage {
365    /// Base storage directory
366    pub base_directory: PathBuf,
367    /// Compress snapshots
368    pub compress_snapshots: bool,
369    /// Retention period (days)
370    pub retention_days: u32,
371    /// Maximum storage size (bytes)
372    pub max_storage_bytes: u64,
373}
374
375impl ReproducibilityManager {
376    /// Create a new reproducibility manager
377    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    /// Capture current environment snapshot.
387    ///
388    /// `random_seeds` should be the actual seed(s) the experiment being
389    /// snapshotted used; pass an empty slice if none were recorded. This
390    /// used to be hardcoded to `vec![42]` regardless of what the experiment
391    /// actually did, which made the "random seed documented" checklist item
392    /// trivially true for every snapshot rather than reflecting reality.
393    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    /// Generate reproducibility report for experiment
412    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    /// Verify reproducibility between two experiment runs by comparing
438    /// their actual final metrics.
439    ///
440    /// Every key present in either `original_metrics` or
441    /// `reproduction_metrics` is compared (relative to
442    /// `config.numerical_tolerance`); a key named with a performance-ish
443    /// marker (time/memory/latency/throughput/duration/speed) is scored as
444    /// `performance_similarity`, everything else as `result_similarity`, so
445    /// wall-clock jitter between runs cannot mask (or be masked by) an
446    /// actual difference in the computed result, or vice versa.
447    ///
448    /// When `original_environment_id`/`reproduction_environment_id` are
449    /// both supplied and resolve to a captured [`EnvironmentSnapshot`],
450    /// `environment_similarity` is computed for real from OS/architecture
451    /// and pinned-dependency-set overlap; otherwise it is `None` rather
452    /// than a fabricated number. `configuration_similarity` is always
453    /// `None`: this function has no experiment-configuration data to
454    /// compare against.
455    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        // Nothing to compare in a bucket is vacuously "no difference found"
538        // (1.0), which is distinct from the old bug of a fixed similarity
539        // presented regardless of whether -- or how badly -- inputs
540        // actually differed.
541        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(), // Would use system APIs
605            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(), // Would detect actual timezone
609            locale: HashMap::new(),
610        })
611    }
612
613    /// Parse the workspace's `Cargo.lock` for the exact locked version of
614    /// every dependency (transitive included), which is what "pinned
615    /// dependencies" actually means for a Rust project. Returns an empty
616    /// list -- not a fabricated placeholder entry -- when no `Cargo.lock`
617    /// can be found (e.g. running outside a checked-out repository).
618    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    /// Capture the subset of environment variables relevant to reproducing
630    /// a run (toolchain/build configuration, locale, thread counts,
631    /// accelerator visibility, ...), never the full process environment.
632    ///
633    /// A `EnvironmentSnapshot` is `Serialize`/`Deserialize` and is intended
634    /// to be written to disk or shared between machines when debugging a
635    /// reproducibility gap, so capturing `std::env::vars()` unfiltered would
636    /// leak whatever secrets (API keys, tokens, cloud credentials, database
637    /// URLs, ...) happen to be set in the researcher's shell into that
638    /// artifact. Instead this uses an explicit allowlist of
639    /// reproducibility-relevant names, and additionally redacts the value
640    /// of any allowlisted variable whose name still looks secret-shaped (as
641    /// defense in depth against e.g. a CI variable named `RUSTC_WRAPPER`
642    /// being repurposed to smuggle a token).
643    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    /// Capture real hardware facts via portable, pure-Rust means (spawning
707    /// the OS's own introspection tools / reading its own `/proc` files --
708    /// no FFI, no linked C libraries). Previously this returned a fixed
709    /// "8GB / 6GB available" `MemorySpec` on every machine regardless of
710    /// its actual capacity; `0` now means "not detected" rather than a
711    /// specific, plausible-looking but wrong number being reported as fact.
712    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, // We have the snapshot
749            data_versioned: !environment.data_checksums.is_empty(),
750            code_versioned: is_code_versioned(),
751            // "Documented" means detection actually found real hardware
752            // facts, not merely that a (possibly all-unknown) HardwareConfig
753            // struct exists.
754            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            // True only if this specific experiment has actually been
758            // through `verify_reproducibility` with a non-failed outcome,
759            // not merely because *some* verification exists somewhere.
760            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    /// Score a run's reproducibility, cross-checking every *claim* on the
769    /// checklist against the *evidence* in the environment snapshot.
770    ///
771    /// # Why the snapshot matters
772    ///
773    /// Until 0.3.2 this function ignored `environment` entirely and scored the
774    /// checklist alone -- a caller could tick "random seed documented",
775    /// "dependencies pinned" and "configuration hashed" and receive a perfect
776    /// 1.0 while the captured environment recorded no seeds, no pinned
777    /// versions and no hashes. A checklist is a claim; the snapshot is what
778    /// substantiates it. An unsubstantiated claim now scores nothing and raises
779    /// an issue naming the contradiction, so the score cannot exceed the
780    /// evidence.
781    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        // Evidence contradicting a ticked box. Each entry costs the point the
791        // checklist would otherwise have earned.
792        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
955/// Locate `Cargo.lock` by walking up from this crate's own manifest
956/// directory (its build-time `CARGO_MANIFEST_DIR`) toward the filesystem
957/// root -- the same direction Cargo itself searches for a workspace root
958/// from a member crate.
959fn 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
971/// Extract every `[[package]]` entry's `name`/`version`/`source`/`checksum`
972/// from raw `Cargo.lock` content. `Cargo.lock` is machine-generated TOML
973/// with a very regular, single-line-per-field structure for these keys, so
974/// a full TOML parser is not needed to read them correctly.
975fn 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
1028/// Parse a single `key = "value"` TOML line for `key`, returning the value
1029/// (unquoted) if this line defines it. Only handles the plain-string form
1030/// `Cargo.lock` actually uses for `name`/`version`/`source`/`checksum`.
1031fn 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
1041/// Best-effort CPU model + (base, max) frequency in MHz, detected by
1042/// spawning the operating system's own introspection tools or reading its
1043/// own procfs -- no FFI, no linked C library. Returns `("Unknown CPU", 0,
1044/// 0)` when detection is unavailable rather than presenting a fabricated
1045/// model/speed as if it were measured.
1046fn 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
1084/// Best-effort (total, available) memory in bytes. Returns `(0, 0)` when
1085/// detection is unavailable rather than presenting a fabricated capacity as
1086/// if it were measured.
1087fn 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            // macOS has no single simple sysctl for "currently available";
1094            // report total for both rather than guessing at a fake split.
1095            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
1158/// Compare two environment snapshots and return a similarity in `[0, 1]`:
1159/// matching OS + architecture contributes half the score, and the Jaccard
1160/// similarity of the two snapshots' `name@version` dependency sets
1161/// contributes the other half.
1162fn 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
1193/// Whether the current working directory is inside a Git work tree, used
1194/// as a real (rather than hardcoded) signal for the "code versioned"
1195/// reproducibility checklist item.
1196fn 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,     // 10%
1209            min_reproducibility_score: 0.8, // 80%
1210            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, // 10GB
1217            },
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    // Regression test for F76: `capture_environment_variables` used to
1266    // return `std::env::vars()` unfiltered, which would capture and
1267    // persist (this snapshot is `Serialize`) any secret the researcher
1268    // happened to have set in their shell.
1269    #[test]
1270    fn test_environment_variables_are_allowlisted_and_redacted() {
1271        // SAFETY: test-only env mutation; no other test in this process
1272        // reads these specific names.
1273        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    // Regression test for F23: dependency capture used to always return a
1312    // single hardcoded fake `Dependency` regardless of the real
1313    // `Cargo.lock`; hardware capture used to always return a fixed "8GB /
1314    // 6GB available" `MemorySpec` regardless of the real machine.
1315    #[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        // This workspace has a real Cargo.lock with many real packages;
1325        // the old code always returned exactly one ("scirs2-optim").
1326        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        // The old code always reported exactly 8GiB / 6GiB regardless of
1351        // the real machine.
1352        assert_ne!(hardware.memory.total_bytes, 8 * 1024 * 1024 * 1024);
1353        assert_ne!(hardware.memory.available_bytes, 6 * 1024 * 1024 * 1024);
1354        // On macOS/Linux (this test's CI targets) real detection should
1355        // succeed; elsewhere `0` honestly means "not detected".
1356        #[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    // Regression test for F21: the reproducibility score used to always be
1364    // exactly 0.5 because half the checklist items were tautologically
1365    // fixed (a hardcoded non-empty seed list, a hardcoded non-empty fake
1366    // dependency list) and the other half were hardcoded constants
1367    // (`code_versioned: false`, `hardware_documented: true`).
1368    #[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        // A snapshot with no documented seed and no checksummed data
1374        // should score strictly lower than one with both, proving the
1375        // score is not a fixed constant.
1376        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        // Since we're running these tests inside a real git checkout,
1410        // `code_versioned` must be real (true) rather than the old
1411        // hardcoded `false`.
1412        assert!(is_code_versioned());
1413    }
1414
1415    // Regression test for F22: `verify_reproducibility` used to always
1416    // record `CloseMatch` with fixed similarity scores (0.95/0.98/0.92/...)
1417    // no matter what was being "verified" -- it never looked at the
1418    // experiments' actual results at all.
1419    #[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        // Configuration was never supplied, so it must be honestly `None`,
1479        // not a fabricated 1.0.
1480        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        // Both snapshots were captured on the same machine in the same
1512        // process, so they must be recognized as identical (1.0), not left
1513        // as `None` when the data to compare them was clearly available.
1514        assert_eq!(
1515            verification.similarity_metrics.environment_similarity,
1516            Some(1.0)
1517        );
1518    }
1519}