Skip to main content

simular/config/
mod.rs

1//! Configuration system with YAML schema and validation.
2//!
3//! Implements Poka-Yoke (mistake-proofing) through:
4//! - Type-safe configuration structs
5//! - Compile-time validation via serde
6//! - Runtime semantic validation
7
8use serde::{Deserialize, Serialize};
9use std::path::Path;
10use validator::Validate;
11
12use crate::engine::jidoka::JidokaConfig;
13use crate::error::{SimError, SimResult};
14
15/// Top-level simulation configuration.
16///
17/// Loaded from YAML files with full schema validation.
18#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
19#[serde(deny_unknown_fields)]
20pub struct SimConfig {
21    /// Schema version for forward compatibility.
22    #[validate(length(min = 1))]
23    #[serde(default = "default_schema_version")]
24    pub schema_version: String,
25
26    /// Simulation metadata.
27    #[validate(nested)]
28    #[serde(default)]
29    pub simulation: SimulationMeta,
30
31    /// Reproducibility settings.
32    #[validate(nested)]
33    pub reproducibility: ReproducibilityConfig,
34
35    /// Domain-specific configurations.
36    #[validate(nested)]
37    #[serde(default)]
38    pub domains: DomainsConfig,
39
40    /// Jidoka (stop-on-error) configuration.
41    #[serde(default)]
42    pub jidoka: JidokaConfig,
43
44    /// Replay configuration.
45    #[validate(nested)]
46    #[serde(default)]
47    pub replay: ReplayConfig,
48
49    /// Visualization configuration.
50    #[serde(default)]
51    pub visualization: VisualizationConfig,
52
53    /// Falsification testing configuration.
54    #[serde(default)]
55    pub falsification: FalsificationConfig,
56}
57
58fn default_schema_version() -> String {
59    "1.0".to_string()
60}
61
62impl SimConfig {
63    /// Load configuration from a YAML file.
64    ///
65    /// # Errors
66    ///
67    /// Returns error if:
68    /// - File cannot be read
69    /// - YAML parsing fails
70    /// - Validation fails
71    pub fn load<P: AsRef<Path>>(path: P) -> SimResult<Self> {
72        let content = std::fs::read_to_string(path)?;
73        Self::from_yaml(&content)
74    }
75
76    /// Parse configuration from YAML string.
77    ///
78    /// # Errors
79    ///
80    /// Returns error if parsing or validation fails.
81    pub fn from_yaml(yaml: &str) -> SimResult<Self> {
82        let config: Self = serde_yaml::from_str(yaml)?;
83
84        // Poka-Yoke: validate all constraints
85        config.validate()?;
86
87        // Additional semantic validation
88        config.validate_semantic()?;
89
90        Ok(config)
91    }
92
93    /// Create a builder for configuration.
94    #[must_use]
95    pub fn builder() -> SimConfigBuilder {
96        SimConfigBuilder::default()
97    }
98
99    /// Validate semantic constraints beyond schema.
100    fn validate_semantic(&self) -> SimResult<()> {
101        // Ensure Monte Carlo has sufficient samples
102        if self.domains.monte_carlo.enabled && self.domains.monte_carlo.samples < 100 {
103            return Err(SimError::config(format!(
104                "Monte Carlo requires at least 100 samples, got {}",
105                self.domains.monte_carlo.samples
106            )));
107        }
108
109        // Ensure timestep is reasonable
110        let dt = self.domains.physics.timestep.dt;
111        if dt <= 0.0 {
112            return Err(SimError::config("Timestep must be positive"));
113        }
114        if dt > 1.0 {
115            return Err(SimError::config("Timestep should not exceed 1 second"));
116        }
117
118        Ok(())
119    }
120
121    /// Get the timestep in seconds.
122    #[must_use]
123    pub const fn get_timestep(&self) -> f64 {
124        self.domains.physics.timestep.dt
125    }
126}
127
128impl Default for SimConfig {
129    fn default() -> Self {
130        Self {
131            schema_version: default_schema_version(),
132            simulation: SimulationMeta::default(),
133            reproducibility: ReproducibilityConfig::default(),
134            domains: DomainsConfig::default(),
135            jidoka: JidokaConfig::default(),
136            replay: ReplayConfig::default(),
137            visualization: VisualizationConfig::default(),
138            falsification: FalsificationConfig::default(),
139        }
140    }
141}
142
143/// Configuration builder for programmatic construction.
144#[derive(Debug, Default)]
145pub struct SimConfigBuilder {
146    seed: Option<u64>,
147    timestep: Option<f64>,
148    jidoka: Option<JidokaConfig>,
149}
150
151impl SimConfigBuilder {
152    /// Set the random seed.
153    #[must_use]
154    pub const fn seed(mut self, seed: u64) -> Self {
155        self.seed = Some(seed);
156        self
157    }
158
159    /// Set the timestep in seconds.
160    #[must_use]
161    pub const fn timestep(mut self, dt: f64) -> Self {
162        self.timestep = Some(dt);
163        self
164    }
165
166    /// Set Jidoka configuration.
167    #[must_use]
168    #[allow(clippy::missing_const_for_fn)] // JidokaConfig doesn't impl Copy
169    pub fn jidoka(mut self, config: JidokaConfig) -> Self {
170        self.jidoka = Some(config);
171        self
172    }
173
174    /// Build the configuration.
175    #[must_use]
176    pub fn build(self) -> SimConfig {
177        let mut config = SimConfig::default();
178
179        if let Some(seed) = self.seed {
180            config.reproducibility.seed = seed;
181        }
182
183        if let Some(dt) = self.timestep {
184            config.domains.physics.timestep.dt = dt;
185        }
186
187        if let Some(jidoka) = self.jidoka {
188            config.jidoka = jidoka;
189        }
190
191        config
192    }
193}
194
195/// Simulation metadata.
196#[derive(Debug, Clone, Default, Serialize, Deserialize, Validate)]
197pub struct SimulationMeta {
198    /// Simulation name.
199    #[serde(default)]
200    pub name: String,
201    /// Description.
202    #[serde(default)]
203    pub description: String,
204    /// Version.
205    #[serde(default = "default_version")]
206    pub version: String,
207}
208
209fn default_version() -> String {
210    "0.1.0".to_string()
211}
212
213/// Reproducibility settings.
214#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
215pub struct ReproducibilityConfig {
216    /// Master seed for all RNG.
217    pub seed: u64,
218    /// IEEE 754 strict mode for cross-platform reproducibility.
219    #[serde(default = "default_true")]
220    pub ieee_strict: bool,
221    /// Record RNG state in journal for perfect replay.
222    #[serde(default = "default_true")]
223    pub record_rng_state: bool,
224}
225
226const fn default_true() -> bool {
227    true
228}
229
230impl Default for ReproducibilityConfig {
231    fn default() -> Self {
232        Self {
233            seed: 42,
234            ieee_strict: true,
235            record_rng_state: true,
236        }
237    }
238}
239
240/// Domain-specific configurations.
241#[derive(Debug, Clone, Default, Serialize, Deserialize, Validate)]
242pub struct DomainsConfig {
243    /// Physics domain configuration.
244    #[validate(nested)]
245    #[serde(default)]
246    pub physics: PhysicsConfig,
247    /// Monte Carlo domain configuration.
248    #[validate(nested)]
249    #[serde(default)]
250    pub monte_carlo: MonteCarloConfig,
251    /// Optimization domain configuration.
252    #[serde(default)]
253    pub optimization: OptimizationConfig,
254}
255
256/// Physics domain configuration.
257#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
258pub struct PhysicsConfig {
259    /// Whether physics is enabled.
260    #[serde(default = "default_true")]
261    pub enabled: bool,
262    /// Physics engine type.
263    #[serde(default)]
264    pub engine: PhysicsEngine,
265    /// Integrator configuration.
266    #[serde(default)]
267    pub integrator: IntegratorConfig,
268    /// Timestep configuration.
269    #[validate(nested)]
270    #[serde(default)]
271    pub timestep: TimestepConfig,
272}
273
274impl Default for PhysicsConfig {
275    fn default() -> Self {
276        Self {
277            enabled: true,
278            engine: PhysicsEngine::default(),
279            integrator: IntegratorConfig::default(),
280            timestep: TimestepConfig::default(),
281        }
282    }
283}
284
285/// Physics engine type.
286#[derive(Debug, Clone, Default, Serialize, Deserialize)]
287#[serde(rename_all = "kebab-case")]
288pub enum PhysicsEngine {
289    /// Rigid body dynamics.
290    #[default]
291    RigidBody,
292    /// Orbital mechanics.
293    Orbital,
294    /// Fluid dynamics.
295    Fluid,
296    /// Discrete event.
297    Discrete,
298}
299
300/// Integrator configuration.
301#[derive(Debug, Clone, Serialize, Deserialize)]
302pub struct IntegratorConfig {
303    /// Integrator type.
304    #[serde(default)]
305    pub integrator_type: IntegratorType,
306}
307
308impl Default for IntegratorConfig {
309    fn default() -> Self {
310        Self {
311            integrator_type: IntegratorType::Verlet,
312        }
313    }
314}
315
316/// Integrator type.
317#[derive(Debug, Clone, Default, Serialize, Deserialize)]
318#[serde(rename_all = "kebab-case")]
319pub enum IntegratorType {
320    /// Euler method (1st order).
321    Euler,
322    /// Störmer-Verlet (2nd order, symplectic).
323    #[default]
324    Verlet,
325    /// Runge-Kutta 4th order.
326    Rk4,
327    /// Dormand-Prince 7(8).
328    Rk78,
329    /// Symplectic Euler.
330    SymplecticEuler,
331}
332
333/// Timestep configuration.
334#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
335pub struct TimestepConfig {
336    /// Timestep mode.
337    #[serde(default)]
338    pub mode: TimestepMode,
339    /// Fixed timestep in seconds.
340    #[validate(range(min = 0.000_001, max = 1.0))]
341    #[serde(default = "default_timestep")]
342    pub dt: f64,
343    /// Minimum timestep for adaptive mode.
344    #[serde(default = "default_min_timestep")]
345    pub min_dt: f64,
346    /// Maximum timestep for adaptive mode.
347    #[serde(default = "default_max_timestep")]
348    pub max_dt: f64,
349    /// Tolerance for adaptive mode.
350    #[serde(default = "default_tolerance")]
351    pub tolerance: f64,
352}
353
354const fn default_timestep() -> f64 {
355    0.001
356}
357
358const fn default_min_timestep() -> f64 {
359    0.0001
360}
361
362const fn default_max_timestep() -> f64 {
363    0.01
364}
365
366const fn default_tolerance() -> f64 {
367    1e-9
368}
369
370impl Default for TimestepConfig {
371    fn default() -> Self {
372        Self {
373            mode: TimestepMode::Fixed,
374            dt: default_timestep(),
375            min_dt: default_min_timestep(),
376            max_dt: default_max_timestep(),
377            tolerance: default_tolerance(),
378        }
379    }
380}
381
382/// Timestep mode.
383#[derive(Debug, Clone, Default, Serialize, Deserialize)]
384#[serde(rename_all = "kebab-case")]
385pub enum TimestepMode {
386    /// Fixed timestep.
387    #[default]
388    Fixed,
389    /// Adaptive timestep.
390    Adaptive,
391}
392
393/// Monte Carlo configuration.
394#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
395pub struct MonteCarloConfig {
396    /// Whether Monte Carlo is enabled.
397    #[serde(default)]
398    pub enabled: bool,
399    /// Number of samples.
400    #[validate(range(min = 1))]
401    #[serde(default = "default_samples")]
402    pub samples: usize,
403    /// Variance reduction method.
404    #[serde(default)]
405    pub variance_reduction: VarianceReductionMethod,
406}
407
408const fn default_samples() -> usize {
409    10_000
410}
411
412impl Default for MonteCarloConfig {
413    fn default() -> Self {
414        Self {
415            enabled: false,
416            samples: default_samples(),
417            variance_reduction: VarianceReductionMethod::None,
418        }
419    }
420}
421
422/// Variance reduction method.
423#[derive(Debug, Clone, Default, Serialize, Deserialize)]
424#[serde(rename_all = "kebab-case")]
425pub enum VarianceReductionMethod {
426    /// No variance reduction.
427    #[default]
428    None,
429    /// Antithetic variates.
430    Antithetic,
431    /// Control variates.
432    ControlVariate,
433    /// Importance sampling.
434    Importance,
435    /// Stratified sampling.
436    Stratified,
437}
438
439/// Optimization configuration.
440#[derive(Debug, Clone, Default, Serialize, Deserialize)]
441pub struct OptimizationConfig {
442    /// Whether optimization is enabled.
443    #[serde(default)]
444    pub enabled: bool,
445    /// Optimization algorithm.
446    #[serde(default)]
447    pub algorithm: OptimizationAlgorithm,
448}
449
450/// Optimization algorithm.
451#[derive(Debug, Clone, Default, Serialize, Deserialize)]
452#[serde(rename_all = "kebab-case")]
453pub enum OptimizationAlgorithm {
454    /// Bayesian optimization with GP surrogate.
455    #[default]
456    Bayesian,
457    /// CMA-ES evolutionary strategy.
458    CmaEs,
459    /// Genetic algorithm.
460    Genetic,
461    /// Gradient-based optimization.
462    Gradient,
463}
464
465/// Replay configuration.
466#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
467pub struct ReplayConfig {
468    /// Whether replay is enabled.
469    #[serde(default = "default_true")]
470    pub enabled: bool,
471    /// Checkpoint interval in steps.
472    #[serde(default = "default_checkpoint_interval")]
473    pub checkpoint_interval: u64,
474    /// Maximum checkpoint storage in bytes.
475    #[serde(default = "default_max_storage")]
476    pub max_storage: usize,
477    /// Compression algorithm.
478    #[serde(default)]
479    pub compression: CompressionAlgorithm,
480    /// Compression level (1-22 for zstd).
481    #[validate(range(min = 1, max = 22))]
482    #[serde(default = "default_compression_level")]
483    pub compression_level: i32,
484}
485
486const fn default_checkpoint_interval() -> u64 {
487    1000
488}
489
490const fn default_max_storage() -> usize {
491    1024 * 1024 * 1024 // 1 GB
492}
493
494const fn default_compression_level() -> i32 {
495    3
496}
497
498impl Default for ReplayConfig {
499    fn default() -> Self {
500        Self {
501            enabled: true,
502            checkpoint_interval: default_checkpoint_interval(),
503            max_storage: default_max_storage(),
504            compression: CompressionAlgorithm::Zstd,
505            compression_level: default_compression_level(),
506        }
507    }
508}
509
510/// Compression algorithm.
511#[derive(Debug, Clone, Default, Serialize, Deserialize)]
512#[serde(rename_all = "kebab-case")]
513pub enum CompressionAlgorithm {
514    /// No compression.
515    None,
516    /// LZ4 fast compression.
517    Lz4,
518    /// Zstandard compression.
519    #[default]
520    Zstd,
521}
522
523/// Visualization configuration.
524#[derive(Debug, Clone, Default, Serialize, Deserialize)]
525pub struct VisualizationConfig {
526    /// TUI configuration.
527    #[serde(default)]
528    pub tui: TuiConfig,
529    /// Web visualization configuration.
530    #[serde(default)]
531    pub web: WebConfig,
532}
533
534/// TUI configuration.
535#[derive(Debug, Clone, Serialize, Deserialize)]
536pub struct TuiConfig {
537    /// Whether TUI is enabled.
538    #[serde(default)]
539    pub enabled: bool,
540    /// Refresh rate in Hz.
541    #[serde(default = "default_refresh_hz")]
542    pub refresh_hz: u32,
543}
544
545const fn default_refresh_hz() -> u32 {
546    30
547}
548
549impl Default for TuiConfig {
550    fn default() -> Self {
551        Self {
552            enabled: false,
553            refresh_hz: default_refresh_hz(),
554        }
555    }
556}
557
558/// Web visualization configuration.
559#[derive(Debug, Clone, Serialize, Deserialize)]
560pub struct WebConfig {
561    /// Whether web visualization is enabled.
562    #[serde(default)]
563    pub enabled: bool,
564    /// Web server port.
565    #[serde(default = "default_port")]
566    pub port: u16,
567}
568
569const fn default_port() -> u16 {
570    8080
571}
572
573impl Default for WebConfig {
574    fn default() -> Self {
575        Self {
576            enabled: false,
577            port: default_port(),
578        }
579    }
580}
581
582/// Falsification testing configuration.
583#[derive(Debug, Clone, Default, Serialize, Deserialize)]
584pub struct FalsificationConfig {
585    /// Null hypothesis description.
586    #[serde(default)]
587    pub null_hypothesis: String,
588    /// Significance level (alpha).
589    #[serde(default = "default_significance")]
590    pub significance: f64,
591}
592
593const fn default_significance() -> f64 {
594    0.05
595}
596
597// =============================================================================
598// Poka-Yoke: Explicit Units in Configuration (Section 4.3.6)
599// =============================================================================
600
601/// Poka-Yoke velocity with mandatory explicit units [56].
602///
603/// Prevents unit confusion by requiring explicit unit strings in YAML.
604///
605/// # YAML Examples
606///
607/// ```yaml
608/// separation_velocity: "10.0 m/s"
609/// orbital_velocity: "7.8 km/s"
610/// ```
611#[derive(Debug, Clone, Serialize)]
612pub struct Velocity {
613    /// Value in meters per second (canonical unit).
614    pub meters_per_second: f64,
615    /// Original unit string for display.
616    pub original_unit: String,
617}
618
619impl Velocity {
620    /// Create velocity from meters per second.
621    #[must_use]
622    pub fn from_mps(value: f64) -> Self {
623        Self {
624            meters_per_second: value,
625            original_unit: "m/s".to_string(),
626        }
627    }
628
629    /// Get value in meters per second.
630    #[must_use]
631    pub const fn as_mps(&self) -> f64 {
632        self.meters_per_second
633    }
634
635    /// Get value in kilometers per second.
636    #[must_use]
637    pub fn as_kps(&self) -> f64 {
638        self.meters_per_second / 1000.0
639    }
640
641    /// Get value in kilometers per hour.
642    #[must_use]
643    pub fn as_kph(&self) -> f64 {
644        self.meters_per_second * 3.6
645    }
646}
647
648impl<'de> Deserialize<'de> for Velocity {
649    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
650    where
651        D: serde::Deserializer<'de>,
652    {
653        let s = String::deserialize(deserializer)?;
654        parse_velocity(&s).ok_or_else(|| {
655            serde::de::Error::custom(format!(
656                "Invalid velocity '{s}'. Expected format: '<number> <unit>' \
657                 where unit is 'm/s', 'km/s', 'km/h', 'ft/s', or 'kn' (knots)"
658            ))
659        })
660    }
661}
662
663/// Parse velocity string with explicit units.
664fn parse_velocity(s: &str) -> Option<Velocity> {
665    let parts: Vec<&str> = s.split_whitespace().collect();
666    if parts.len() != 2 {
667        return None;
668    }
669
670    let value: f64 = parts[0].parse().ok()?;
671    let unit = parts[1].to_lowercase();
672
673    let meters_per_second = match unit.as_str() {
674        "m/s" => value,
675        "km/s" => value * 1000.0,
676        "km/h" | "kph" => value / 3.6,
677        "ft/s" => value * 0.3048,
678        "kn" | "knots" | "kt" => value * 0.514_444,
679        "mph" => value * 0.447_04,
680        _ => return None,
681    };
682
683    Some(Velocity {
684        meters_per_second,
685        original_unit: parts[1].to_string(),
686    })
687}
688
689/// Poka-Yoke distance/length with mandatory explicit units [56].
690#[derive(Debug, Clone, Serialize)]
691pub struct Length {
692    /// Value in meters (canonical unit).
693    pub meters: f64,
694    /// Original unit string for display.
695    pub original_unit: String,
696}
697
698impl Length {
699    /// Create length from meters.
700    #[must_use]
701    pub fn from_meters(value: f64) -> Self {
702        Self {
703            meters: value,
704            original_unit: "m".to_string(),
705        }
706    }
707
708    /// Get value in meters.
709    #[must_use]
710    pub const fn as_meters(&self) -> f64 {
711        self.meters
712    }
713
714    /// Get value in kilometers.
715    #[must_use]
716    pub fn as_km(&self) -> f64 {
717        self.meters / 1000.0
718    }
719}
720
721impl<'de> Deserialize<'de> for Length {
722    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
723    where
724        D: serde::Deserializer<'de>,
725    {
726        let s = String::deserialize(deserializer)?;
727        parse_length(&s).ok_or_else(|| {
728            serde::de::Error::custom(format!(
729                "Invalid length '{s}'. Expected format: '<number> <unit>' \
730                 where unit is 'm', 'km', 'cm', 'mm', 'ft', 'mi', or 'nm' (nautical miles)"
731            ))
732        })
733    }
734}
735
736/// Parse length string with explicit units.
737fn parse_length(s: &str) -> Option<Length> {
738    let parts: Vec<&str> = s.split_whitespace().collect();
739    if parts.len() != 2 {
740        return None;
741    }
742
743    let value: f64 = parts[0].parse().ok()?;
744    let unit = parts[1].to_lowercase();
745
746    let meters = match unit.as_str() {
747        "m" | "meters" => value,
748        "km" | "kilometers" => value * 1000.0,
749        "cm" | "centimeters" => value / 100.0,
750        "mm" | "millimeters" => value / 1000.0,
751        "ft" | "feet" => value * 0.3048,
752        "mi" | "miles" => value * 1609.344,
753        "nm" | "nmi" => value * 1852.0,    // Nautical miles
754        "au" => value * 149_597_870_700.0, // Astronomical units
755        _ => return None,
756    };
757
758    Some(Length {
759        meters,
760        original_unit: parts[1].to_string(),
761    })
762}
763
764/// Poka-Yoke mass with mandatory explicit units [56].
765#[derive(Debug, Clone, Serialize)]
766pub struct Mass {
767    /// Value in kilograms (canonical unit).
768    pub kilograms: f64,
769    /// Original unit string for display.
770    pub original_unit: String,
771}
772
773impl Mass {
774    /// Create mass from kilograms.
775    #[must_use]
776    pub fn from_kg(value: f64) -> Self {
777        Self {
778            kilograms: value,
779            original_unit: "kg".to_string(),
780        }
781    }
782
783    /// Get value in kilograms.
784    #[must_use]
785    pub const fn as_kg(&self) -> f64 {
786        self.kilograms
787    }
788
789    /// Get value in grams.
790    #[must_use]
791    pub fn as_grams(&self) -> f64 {
792        self.kilograms * 1000.0
793    }
794}
795
796impl<'de> Deserialize<'de> for Mass {
797    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
798    where
799        D: serde::Deserializer<'de>,
800    {
801        let s = String::deserialize(deserializer)?;
802        parse_mass(&s).ok_or_else(|| {
803            serde::de::Error::custom(format!(
804                "Invalid mass '{s}'. Expected format: '<number> <unit>' \
805                 where unit is 'kg', 'g', 'mg', 't' (metric ton), or 'lb'"
806            ))
807        })
808    }
809}
810
811/// Parse mass string with explicit units.
812fn parse_mass(s: &str) -> Option<Mass> {
813    let parts: Vec<&str> = s.split_whitespace().collect();
814    if parts.len() != 2 {
815        return None;
816    }
817
818    let value: f64 = parts[0].parse().ok()?;
819    let unit = parts[1].to_lowercase();
820
821    let kilograms = match unit.as_str() {
822        "kg" | "kilograms" => value,
823        "g" | "grams" => value / 1000.0,
824        "mg" | "milligrams" => value / 1_000_000.0,
825        "t" | "tonnes" | "metric_ton" => value * 1000.0,
826        "lb" | "lbs" | "pounds" => value * 0.453_592,
827        _ => return None,
828    };
829
830    Some(Mass {
831        kilograms,
832        original_unit: parts[1].to_string(),
833    })
834}
835
836/// Poka-Yoke time duration with mandatory explicit units [56].
837#[derive(Debug, Clone, Serialize)]
838pub struct Duration {
839    /// Value in seconds (canonical unit).
840    pub seconds: f64,
841    /// Original unit string for display.
842    pub original_unit: String,
843}
844
845impl Duration {
846    /// Create duration from seconds.
847    #[must_use]
848    pub fn from_seconds(value: f64) -> Self {
849        Self {
850            seconds: value,
851            original_unit: "s".to_string(),
852        }
853    }
854
855    /// Get value in seconds.
856    #[must_use]
857    pub const fn as_seconds(&self) -> f64 {
858        self.seconds
859    }
860
861    /// Get value in milliseconds.
862    #[must_use]
863    pub fn as_millis(&self) -> f64 {
864        self.seconds * 1000.0
865    }
866}
867
868impl<'de> Deserialize<'de> for Duration {
869    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
870    where
871        D: serde::Deserializer<'de>,
872    {
873        let s = String::deserialize(deserializer)?;
874        parse_duration(&s).ok_or_else(|| {
875            serde::de::Error::custom(format!(
876                "Invalid duration '{s}'. Expected format: '<number> <unit>' \
877                 where unit is 's', 'ms', 'us', 'ns', 'min', 'h', or 'd'"
878            ))
879        })
880    }
881}
882
883/// Parse duration string with explicit units.
884fn parse_duration(s: &str) -> Option<Duration> {
885    let parts: Vec<&str> = s.split_whitespace().collect();
886    if parts.len() != 2 {
887        return None;
888    }
889
890    let value: f64 = parts[0].parse().ok()?;
891    let unit = parts[1].to_lowercase();
892
893    let seconds = match unit.as_str() {
894        "s" | "sec" | "seconds" => value,
895        "ms" | "milliseconds" => value / 1000.0,
896        "us" | "microseconds" | "µs" => value / 1_000_000.0,
897        "ns" | "nanoseconds" => value / 1_000_000_000.0,
898        "min" | "minutes" => value * 60.0,
899        "h" | "hr" | "hours" => value * 3600.0,
900        "d" | "days" => value * 86400.0,
901        _ => return None,
902    };
903
904    Some(Duration {
905        seconds,
906        original_unit: parts[1].to_string(),
907    })
908}
909
910/// Poka-Yoke angle with mandatory explicit units [56].
911#[derive(Debug, Clone, Serialize)]
912pub struct Angle {
913    /// Value in radians (canonical unit).
914    pub radians: f64,
915    /// Original unit string for display.
916    pub original_unit: String,
917}
918
919impl Angle {
920    /// Create angle from radians.
921    #[must_use]
922    pub fn from_radians(value: f64) -> Self {
923        Self {
924            radians: value,
925            original_unit: "rad".to_string(),
926        }
927    }
928
929    /// Create angle from degrees.
930    #[must_use]
931    pub fn from_degrees(value: f64) -> Self {
932        Self {
933            radians: value.to_radians(),
934            original_unit: "deg".to_string(),
935        }
936    }
937
938    /// Get value in radians.
939    #[must_use]
940    pub const fn as_radians(&self) -> f64 {
941        self.radians
942    }
943
944    /// Get value in degrees.
945    #[must_use]
946    pub fn as_degrees(&self) -> f64 {
947        self.radians.to_degrees()
948    }
949}
950
951impl<'de> Deserialize<'de> for Angle {
952    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
953    where
954        D: serde::Deserializer<'de>,
955    {
956        let s = String::deserialize(deserializer)?;
957        parse_angle(&s).ok_or_else(|| {
958            serde::de::Error::custom(format!(
959                "Invalid angle '{s}'. Expected format: '<number> <unit>' \
960                 where unit is 'rad', 'deg', 'arcmin', or 'arcsec'"
961            ))
962        })
963    }
964}
965
966/// Parse angle string with explicit units.
967fn parse_angle(s: &str) -> Option<Angle> {
968    let parts: Vec<&str> = s.split_whitespace().collect();
969    if parts.len() != 2 {
970        return None;
971    }
972
973    let value: f64 = parts[0].parse().ok()?;
974    let unit = parts[1].to_lowercase();
975
976    let radians = match unit.as_str() {
977        "rad" | "radians" => value,
978        "deg" | "degrees" | "°" => value.to_radians(),
979        "arcmin" => (value / 60.0).to_radians(),
980        "arcsec" => (value / 3600.0).to_radians(),
981        _ => return None,
982    };
983
984    Some(Angle {
985        radians,
986        original_unit: parts[1].to_string(),
987    })
988}
989
990#[cfg(test)]
991mod tests {
992    use super::*;
993
994    #[test]
995    fn test_config_defaults() {
996        let config = SimConfig::default();
997
998        assert_eq!(config.schema_version, "1.0");
999        assert_eq!(config.reproducibility.seed, 42);
1000        assert!(config.reproducibility.ieee_strict);
1001        assert!((config.domains.physics.timestep.dt - 0.001).abs() < f64::EPSILON);
1002    }
1003
1004    #[test]
1005    fn test_config_builder() {
1006        let config = SimConfig::builder().seed(12345).timestep(0.01).build();
1007
1008        assert_eq!(config.reproducibility.seed, 12345);
1009        assert!((config.domains.physics.timestep.dt - 0.01).abs() < f64::EPSILON);
1010    }
1011
1012    #[test]
1013    fn test_config_yaml_parse() {
1014        let yaml = r"
1015reproducibility:
1016  seed: 42
1017domains:
1018  physics:
1019    enabled: true
1020    timestep:
1021      dt: 0.001
1022";
1023        let config = SimConfig::from_yaml(yaml);
1024        assert!(config.is_ok());
1025
1026        let config = config.ok();
1027        assert!(config.is_some());
1028        assert_eq!(config.as_ref().map(|c| c.reproducibility.seed), Some(42));
1029    }
1030
1031    #[test]
1032    fn test_config_validation_fails_invalid_samples() {
1033        let yaml = r"
1034reproducibility:
1035  seed: 42
1036domains:
1037  monte_carlo:
1038    enabled: true
1039    samples: 10
1040";
1041        let config = SimConfig::from_yaml(yaml);
1042        assert!(config.is_err());
1043    }
1044
1045    #[test]
1046    fn test_config_validation_fails_negative_timestep() {
1047        let yaml = r"
1048reproducibility:
1049  seed: 42
1050domains:
1051  physics:
1052    timestep:
1053      dt: -0.001
1054";
1055        // Negative timestep should fail semantic validation
1056        let config = SimConfig::from_yaml(yaml);
1057        assert!(config.is_err());
1058    }
1059
1060    #[test]
1061    fn test_integrator_types() {
1062        let yaml_verlet = r"
1063reproducibility:
1064  seed: 42
1065domains:
1066  physics:
1067    integrator:
1068      integrator_type: verlet
1069";
1070        let config = SimConfig::from_yaml(yaml_verlet);
1071        assert!(config.is_ok());
1072
1073        let yaml_rk4 = r"
1074reproducibility:
1075  seed: 42
1076domains:
1077  physics:
1078    integrator:
1079      integrator_type: rk4
1080";
1081        let config = SimConfig::from_yaml(yaml_rk4);
1082        assert!(config.is_ok());
1083    }
1084
1085    // === Poka-Yoke Unit Parsing Tests (Section 4.3.6) ===
1086
1087    #[test]
1088    fn test_velocity_parsing() {
1089        // m/s
1090        let v = parse_velocity("10.0 m/s");
1091        assert!(v.is_some());
1092        assert!((v.as_ref().unwrap().meters_per_second - 10.0).abs() < 0.01);
1093
1094        // km/s
1095        let v = parse_velocity("7.8 km/s");
1096        assert!(v.is_some());
1097        assert!((v.as_ref().unwrap().meters_per_second - 7800.0).abs() < 0.01);
1098
1099        // km/h
1100        let v = parse_velocity("100 km/h");
1101        assert!(v.is_some());
1102        assert!((v.as_ref().unwrap().meters_per_second - 27.778).abs() < 0.01);
1103
1104        // Invalid
1105        let v = parse_velocity("10.0");
1106        assert!(v.is_none());
1107
1108        let v = parse_velocity("10.0 furlongs/fortnight");
1109        assert!(v.is_none());
1110    }
1111
1112    #[test]
1113    fn test_velocity_conversions() {
1114        let v = Velocity::from_mps(1000.0);
1115        assert!((v.as_kps() - 1.0).abs() < f64::EPSILON);
1116        assert!((v.as_kph() - 3600.0).abs() < 0.01);
1117    }
1118
1119    #[test]
1120    fn test_length_parsing() {
1121        // meters
1122        let l = parse_length("100 m");
1123        assert!(l.is_some());
1124        assert!((l.as_ref().unwrap().meters - 100.0).abs() < 0.01);
1125
1126        // km
1127        let l = parse_length("1.5 km");
1128        assert!(l.is_some());
1129        assert!((l.as_ref().unwrap().meters - 1500.0).abs() < 0.01);
1130
1131        // astronomical units
1132        let l = parse_length("1 au");
1133        assert!(l.is_some());
1134        assert!((l.as_ref().unwrap().meters - 149_597_870_700.0).abs() < 1000.0);
1135
1136        // Invalid
1137        let l = parse_length("100");
1138        assert!(l.is_none());
1139    }
1140
1141    #[test]
1142    fn test_mass_parsing() {
1143        // kg
1144        let m = parse_mass("100 kg");
1145        assert!(m.is_some());
1146        assert!((m.as_ref().unwrap().kilograms - 100.0).abs() < 0.01);
1147
1148        // tonnes
1149        let m = parse_mass("1.5 t");
1150        assert!(m.is_some());
1151        assert!((m.as_ref().unwrap().kilograms - 1500.0).abs() < 0.01);
1152
1153        // pounds
1154        let m = parse_mass("100 lb");
1155        assert!(m.is_some());
1156        assert!((m.as_ref().unwrap().kilograms - 45.36).abs() < 0.01);
1157    }
1158
1159    #[test]
1160    fn test_duration_parsing() {
1161        // seconds
1162        let d = parse_duration("10 s");
1163        assert!(d.is_some());
1164        assert!((d.as_ref().unwrap().seconds - 10.0).abs() < f64::EPSILON);
1165
1166        // milliseconds
1167        let d = parse_duration("1000 ms");
1168        assert!(d.is_some());
1169        assert!((d.as_ref().unwrap().seconds - 1.0).abs() < 0.001);
1170
1171        // hours
1172        let d = parse_duration("2 h");
1173        assert!(d.is_some());
1174        assert!((d.as_ref().unwrap().seconds - 7200.0).abs() < f64::EPSILON);
1175    }
1176
1177    #[test]
1178    fn test_angle_parsing() {
1179        // radians
1180        let a = parse_angle("3.14159 rad");
1181        assert!(a.is_some());
1182        assert!((a.as_ref().unwrap().radians - 3.14159).abs() < 0.00001);
1183
1184        // degrees
1185        let a = parse_angle("180 deg");
1186        assert!(a.is_some());
1187        assert!((a.as_ref().unwrap().radians - std::f64::consts::PI).abs() < 0.0001);
1188
1189        // arcmin
1190        let a = parse_angle("60 arcmin");
1191        assert!(a.is_some());
1192        assert!((a.as_ref().unwrap().as_degrees() - 1.0).abs() < 0.001);
1193    }
1194
1195    #[test]
1196    fn test_poka_yoke_rejects_unitless() {
1197        // All parse functions should reject unitless values
1198        assert!(parse_velocity("100").is_none());
1199        assert!(parse_length("100").is_none());
1200        assert!(parse_mass("100").is_none());
1201        assert!(parse_duration("100").is_none());
1202        assert!(parse_angle("100").is_none());
1203    }
1204
1205    #[test]
1206    fn test_poka_yoke_yaml_deserialization() {
1207        #[derive(Debug, Deserialize)]
1208        struct TestConfig {
1209            velocity: Velocity,
1210            length: Length,
1211        }
1212
1213        let yaml = r#"
1214velocity: "100 m/s"
1215length: "10 km"
1216"#;
1217        let config: Result<TestConfig, _> = serde_yaml::from_str(yaml);
1218        assert!(config.is_ok());
1219
1220        let config = config.ok().unwrap();
1221        assert!((config.velocity.meters_per_second - 100.0).abs() < 0.01);
1222        assert!((config.length.meters - 10000.0).abs() < 0.01);
1223    }
1224
1225    #[test]
1226    fn test_poka_yoke_yaml_rejects_invalid() {
1227        #[derive(Debug, Deserialize)]
1228        #[allow(dead_code)]
1229        struct TestConfig {
1230            velocity: Velocity,
1231        }
1232
1233        // Unitless value should fail
1234        let yaml = r#"
1235velocity: "100"
1236"#;
1237        let config: Result<TestConfig, _> = serde_yaml::from_str(yaml);
1238        assert!(config.is_err());
1239
1240        // Invalid unit should fail
1241        let yaml = r#"
1242velocity: "100 parsecs"
1243"#;
1244        let config: Result<TestConfig, _> = serde_yaml::from_str(yaml);
1245        assert!(config.is_err());
1246    }
1247
1248    #[test]
1249    fn test_config_get_timestep() {
1250        let config = SimConfig::default();
1251        assert!((config.get_timestep() - 0.001).abs() < f64::EPSILON);
1252    }
1253
1254    #[test]
1255    fn test_config_builder_with_jidoka() {
1256        let jidoka = JidokaConfig::default();
1257        let config = SimConfig::builder().jidoka(jidoka).build();
1258        // JidokaConfig has energy_tolerance field
1259        assert!(config.jidoka.energy_tolerance > 0.0);
1260    }
1261
1262    #[test]
1263    fn test_config_validation_fails_large_timestep() {
1264        let yaml = r"
1265reproducibility:
1266  seed: 42
1267domains:
1268  physics:
1269    timestep:
1270      dt: 2.0
1271";
1272        let config = SimConfig::from_yaml(yaml);
1273        assert!(config.is_err());
1274    }
1275
1276    #[test]
1277    fn test_variance_reduction_methods() {
1278        let _none = VarianceReductionMethod::None;
1279        let _anti = VarianceReductionMethod::Antithetic;
1280        let _control = VarianceReductionMethod::ControlVariate;
1281        let _importance = VarianceReductionMethod::Importance;
1282        let _strat = VarianceReductionMethod::Stratified;
1283    }
1284
1285    #[test]
1286    fn test_optimization_algorithms() {
1287        let _bayesian = OptimizationAlgorithm::Bayesian;
1288        let _cmaes = OptimizationAlgorithm::CmaEs;
1289        let _genetic = OptimizationAlgorithm::Genetic;
1290        let _gradient = OptimizationAlgorithm::Gradient;
1291    }
1292
1293    #[test]
1294    fn test_compression_algorithms() {
1295        let _none = CompressionAlgorithm::None;
1296        let _lz4 = CompressionAlgorithm::Lz4;
1297        let _zstd = CompressionAlgorithm::Zstd;
1298    }
1299
1300    #[test]
1301    fn test_velocity_all_units() {
1302        // ft/s
1303        let v = parse_velocity("100 ft/s");
1304        assert!(v.is_some());
1305        assert!((v.as_ref().unwrap().meters_per_second - 30.48).abs() < 0.01);
1306
1307        // mph
1308        let v = parse_velocity("60 mph");
1309        assert!(v.is_some());
1310        assert!((v.as_ref().unwrap().meters_per_second - 26.82).abs() < 0.1);
1311
1312        // knots
1313        let v = parse_velocity("100 kn");
1314        assert!(v.is_some());
1315        assert!((v.as_ref().unwrap().meters_per_second - 51.44).abs() < 0.1);
1316
1317        // kph alias
1318        let v = parse_velocity("36 kph");
1319        assert!(v.is_some());
1320        assert!((v.as_ref().unwrap().meters_per_second - 10.0).abs() < 0.1);
1321    }
1322
1323    #[test]
1324    fn test_length_all_units() {
1325        // cm
1326        let l = parse_length("100 cm");
1327        assert!(l.is_some());
1328        assert!((l.as_ref().unwrap().meters - 1.0).abs() < 0.01);
1329
1330        // mm
1331        let l = parse_length("1000 mm");
1332        assert!(l.is_some());
1333        assert!((l.as_ref().unwrap().meters - 1.0).abs() < 0.01);
1334
1335        // ft
1336        let l = parse_length("100 ft");
1337        assert!(l.is_some());
1338        assert!((l.as_ref().unwrap().meters - 30.48).abs() < 0.01);
1339
1340        // mi
1341        let l = parse_length("1 mi");
1342        assert!(l.is_some());
1343        assert!((l.as_ref().unwrap().meters - 1609.344).abs() < 0.01);
1344
1345        // nm (nautical miles)
1346        let l = parse_length("1 nm");
1347        assert!(l.is_some());
1348        assert!((l.as_ref().unwrap().meters - 1852.0).abs() < 0.01);
1349
1350        // full word
1351        let l = parse_length("1 meters");
1352        assert!(l.is_some());
1353    }
1354
1355    #[test]
1356    fn test_length_conversions() {
1357        let l = Length::from_meters(1000.0);
1358        assert!((l.as_km() - 1.0).abs() < f64::EPSILON);
1359    }
1360
1361    #[test]
1362    fn test_mass_all_units() {
1363        // g
1364        let m = parse_mass("1000 g");
1365        assert!(m.is_some());
1366        assert!((m.as_ref().unwrap().kilograms - 1.0).abs() < 0.01);
1367
1368        // mg
1369        let m = parse_mass("1000000 mg");
1370        assert!(m.is_some());
1371        assert!((m.as_ref().unwrap().kilograms - 1.0).abs() < 0.01);
1372
1373        // lbs alias
1374        let m = parse_mass("2.2 lbs");
1375        assert!(m.is_some());
1376        assert!((m.as_ref().unwrap().kilograms - 1.0).abs() < 0.01);
1377    }
1378
1379    #[test]
1380    fn test_mass_conversions() {
1381        let m = Mass::from_kg(1.0);
1382        assert!((m.as_grams() - 1000.0).abs() < f64::EPSILON);
1383    }
1384
1385    #[test]
1386    fn test_duration_all_units() {
1387        // us
1388        let d = parse_duration("1000000 us");
1389        assert!(d.is_some());
1390        assert!((d.as_ref().unwrap().seconds - 1.0).abs() < 0.001);
1391
1392        // ns
1393        let d = parse_duration("1000000000 ns");
1394        assert!(d.is_some());
1395        assert!((d.as_ref().unwrap().seconds - 1.0).abs() < 0.001);
1396
1397        // min
1398        let d = parse_duration("1 min");
1399        assert!(d.is_some());
1400        assert!((d.as_ref().unwrap().seconds - 60.0).abs() < f64::EPSILON);
1401
1402        // d (days)
1403        let d = parse_duration("1 d");
1404        assert!(d.is_some());
1405        assert!((d.as_ref().unwrap().seconds - 86400.0).abs() < f64::EPSILON);
1406
1407        // sec alias
1408        let d = parse_duration("10 sec");
1409        assert!(d.is_some());
1410    }
1411
1412    #[test]
1413    fn test_duration_conversions() {
1414        let d = Duration::from_seconds(1.0);
1415        assert!((d.as_millis() - 1000.0).abs() < f64::EPSILON);
1416    }
1417
1418    #[test]
1419    fn test_angle_all_units() {
1420        // arcsec
1421        let a = parse_angle("3600 arcsec");
1422        assert!(a.is_some());
1423        assert!((a.as_ref().unwrap().as_degrees() - 1.0).abs() < 0.001);
1424
1425        // degrees alias
1426        let a = parse_angle("90 degrees");
1427        assert!(a.is_some());
1428
1429        // radians alias
1430        let a = parse_angle("1 radians");
1431        assert!(a.is_some());
1432    }
1433
1434    #[test]
1435    fn test_angle_conversions() {
1436        let a = Angle::from_degrees(180.0);
1437        assert!((a.as_radians() - std::f64::consts::PI).abs() < 0.0001);
1438        assert!((a.as_degrees() - 180.0).abs() < 0.0001);
1439
1440        let a2 = Angle::from_radians(std::f64::consts::PI);
1441        assert!((a2.as_degrees() - 180.0).abs() < 0.0001);
1442    }
1443
1444    #[test]
1445    fn test_parse_invalid_number() {
1446        assert!(parse_velocity("abc m/s").is_none());
1447        assert!(parse_length("abc m").is_none());
1448        assert!(parse_mass("abc kg").is_none());
1449        assert!(parse_duration("abc s").is_none());
1450        assert!(parse_angle("abc rad").is_none());
1451    }
1452
1453    #[test]
1454    fn test_parse_empty_string() {
1455        assert!(parse_velocity("").is_none());
1456        assert!(parse_length("").is_none());
1457        assert!(parse_mass("").is_none());
1458        assert!(parse_duration("").is_none());
1459        assert!(parse_angle("").is_none());
1460    }
1461
1462    #[test]
1463    fn test_parse_too_many_parts() {
1464        assert!(parse_velocity("100 m per second").is_none());
1465        assert!(parse_length("100 meters long").is_none());
1466    }
1467
1468    #[test]
1469    fn test_simulation_meta_default() {
1470        let meta = SimulationMeta::default();
1471        assert!(meta.name.is_empty());
1472    }
1473
1474    #[test]
1475    fn test_replay_config_default() {
1476        let config = ReplayConfig::default();
1477        assert!(config.enabled);
1478        assert_eq!(config.checkpoint_interval, 1000);
1479    }
1480
1481    #[test]
1482    fn test_tui_config_default() {
1483        let config = TuiConfig::default();
1484        assert!(!config.enabled);
1485        assert_eq!(config.refresh_hz, 30);
1486    }
1487
1488    #[test]
1489    fn test_web_config_default() {
1490        let config = WebConfig::default();
1491        assert!(!config.enabled);
1492        assert_eq!(config.port, 8080);
1493    }
1494
1495    #[test]
1496    fn test_falsification_config_default() {
1497        let config = FalsificationConfig::default();
1498        assert!(config.null_hypothesis.is_empty());
1499        // Rust Default for f64 is 0.0; serde default is 0.05 (via default_significance)
1500        assert!((config.significance - 0.0).abs() < f64::EPSILON);
1501    }
1502
1503    #[test]
1504    fn test_monte_carlo_config_default() {
1505        let config = MonteCarloConfig::default();
1506        assert!(!config.enabled);
1507        assert_eq!(config.samples, 10_000);
1508    }
1509
1510    #[test]
1511    fn test_optimization_config_default() {
1512        let config = OptimizationConfig::default();
1513        assert!(!config.enabled);
1514    }
1515
1516    #[test]
1517    fn test_domains_config_default() {
1518        let config = DomainsConfig::default();
1519        assert!(config.physics.enabled);
1520        assert!(!config.monte_carlo.enabled);
1521    }
1522
1523    #[test]
1524    fn test_reproducibility_config_default() {
1525        let config = ReproducibilityConfig::default();
1526        assert_eq!(config.seed, 42);
1527        assert!(config.ieee_strict);
1528    }
1529}