Skip to main content

simular/edd/
falsifiable.rs

1//! Falsifiable simulation traits for EDD.
2//!
3//! Every simulation in the EDD framework must be falsifiable. This module
4//! provides the `FalsifiableSimulation` trait that defines how simulations
5//! can be actively tested for falsification.
6//!
7//! # EDD-04: Falsification Criteria
8//!
9//! > **Claim:** Every model has explicit conditions for refutation.
10//! > **Rejection Criteria:** Model without falsification tests.
11//!
12//! # References
13//!
14//! - [1] Popper, K. (1959). The Logic of Scientific Discovery
15//! - [11] Donzé, A. & Maler, O. (2010). Robust Satisfaction of Temporal Logic (STL)
16
17use super::experiment::FalsificationCriterion;
18use std::collections::HashMap;
19
20/// Result of a falsification search.
21#[derive(Debug, Clone)]
22pub struct FalsificationResult {
23    /// Whether any falsifying condition was found
24    pub falsified: bool,
25    /// The parameter values that caused falsification (if any)
26    pub falsifying_params: Option<HashMap<String, f64>>,
27    /// Which criterion was violated (if any)
28    pub violated_criterion: Option<String>,
29    /// Robustness degree (positive = satisfied, negative = violated)
30    pub robustness: f64,
31    /// Number of parameter combinations tested
32    pub tests_performed: usize,
33    /// Human-readable summary
34    pub summary: String,
35}
36
37impl FalsificationResult {
38    /// Create a result indicating no falsification found.
39    #[must_use]
40    pub fn not_falsified(tests_performed: usize, robustness: f64) -> Self {
41        Self {
42            falsified: false,
43            falsifying_params: None,
44            violated_criterion: None,
45            robustness,
46            tests_performed,
47            summary: format!(
48                "Model not falsified after {tests_performed} tests (robustness: {robustness:.4})"
49            ),
50        }
51    }
52
53    /// Create a result indicating falsification was found.
54    #[must_use]
55    pub fn falsified(
56        params: HashMap<String, f64>,
57        criterion: &str,
58        robustness: f64,
59        tests_performed: usize,
60    ) -> Self {
61        Self {
62            falsified: true,
63            falsifying_params: Some(params),
64            violated_criterion: Some(criterion.to_string()),
65            robustness,
66            tests_performed,
67            summary: format!(
68                "Model FALSIFIED at test {tests_performed}: criterion '{criterion}' violated (robustness: {robustness:.4})"
69            ),
70        }
71    }
72}
73
74/// A trajectory of simulation states over time.
75#[derive(Debug, Clone)]
76pub struct Trajectory {
77    /// Time points
78    pub times: Vec<f64>,
79    /// State values at each time point (column-major: `state_dim × n_times`)
80    pub states: Vec<Vec<f64>>,
81    /// State variable names
82    pub state_names: Vec<String>,
83}
84
85impl Trajectory {
86    /// Create a new empty trajectory.
87    #[must_use]
88    pub fn new(state_names: Vec<String>) -> Self {
89        Self {
90            times: Vec::new(),
91            states: vec![Vec::new(); state_names.len()],
92            state_names,
93        }
94    }
95
96    /// Add a time point and state.
97    pub fn push(&mut self, time: f64, state: &[f64]) {
98        self.times.push(time);
99        for (i, &val) in state.iter().enumerate() {
100            if i < self.states.len() {
101                self.states[i].push(val);
102            }
103        }
104    }
105
106    /// Get the number of time points.
107    #[must_use]
108    pub fn len(&self) -> usize {
109        self.times.len()
110    }
111
112    /// Check if the trajectory is empty.
113    #[must_use]
114    pub fn is_empty(&self) -> bool {
115        self.times.is_empty()
116    }
117
118    /// Get a state variable by name at a specific time index.
119    #[must_use]
120    pub fn get_state(&self, name: &str, time_idx: usize) -> Option<f64> {
121        let state_idx = self.state_names.iter().position(|n| n == name)?;
122        self.states.get(state_idx)?.get(time_idx).copied()
123    }
124}
125
126/// Parameter space for falsification search.
127#[derive(Debug, Clone)]
128pub struct ParamSpace {
129    /// Parameter bounds: name -> (min, max)
130    pub bounds: HashMap<String, (f64, f64)>,
131    /// Number of samples per dimension
132    pub samples_per_dim: usize,
133    /// Whether to use Latin Hypercube Sampling
134    pub use_lhs: bool,
135}
136
137impl ParamSpace {
138    /// Create a new parameter space.
139    #[must_use]
140    pub fn new() -> Self {
141        Self {
142            bounds: HashMap::new(),
143            samples_per_dim: 10,
144            use_lhs: true,
145        }
146    }
147
148    /// Add a parameter with bounds.
149    #[must_use]
150    pub fn with_param(mut self, name: &str, min: f64, max: f64) -> Self {
151        self.bounds.insert(name.to_string(), (min, max));
152        self
153    }
154
155    /// Set samples per dimension.
156    #[must_use]
157    pub fn with_samples(mut self, n: usize) -> Self {
158        self.samples_per_dim = n;
159        self
160    }
161
162    /// Generate grid points for exhaustive search.
163    #[must_use]
164    pub fn grid_points(&self) -> Vec<HashMap<String, f64>> {
165        let params: Vec<(&String, &(f64, f64))> = self.bounds.iter().collect();
166        if params.is_empty() {
167            return vec![HashMap::new()];
168        }
169
170        let mut points = Vec::new();
171        let n = self.samples_per_dim;
172
173        // Generate all combinations (simple grid)
174        let total_points = n.pow(params.len() as u32);
175        for i in 0..total_points {
176            let mut point = HashMap::new();
177            let mut idx = i;
178            for (name, (min, max)) in &params {
179                let dim_idx = idx % n;
180                idx /= n;
181                let t = if n > 1 {
182                    dim_idx as f64 / (n - 1) as f64
183                } else {
184                    0.5
185                };
186                let value = min + t * (max - min);
187                point.insert((*name).clone(), value);
188            }
189            points.push(point);
190        }
191
192        points
193    }
194}
195
196impl Default for ParamSpace {
197    fn default() -> Self {
198        Self::new()
199    }
200}
201
202/// Trait for simulations that can be falsified.
203///
204/// Every simulation in the EDD framework must implement this trait to
205/// provide explicit falsification criteria and support active search
206/// for falsifying conditions.
207///
208/// # Example
209///
210/// ```ignore
211/// impl FalsifiableSimulation for MySimulation {
212///     fn falsification_criteria(&self) -> Vec<FalsificationCriterion> {
213///         vec![
214///             FalsificationCriterion::new(
215///                 "energy_conservation",
216///                 "|E(t) - E(0)| / E(0) > 1e-6",
217///                 FalsificationAction::RejectModel,
218///             ),
219///         ]
220///     }
221///
222///     fn evaluate(&self, params: &HashMap<String, f64>) -> Trajectory {
223///         // Run simulation with given parameters
224///     }
225///
226///     fn check_criterion(&self, criterion: &str, trajectory: &Trajectory) -> f64 {
227///         // Return robustness: positive = satisfied, negative = violated
228///     }
229/// }
230/// ```
231pub trait FalsifiableSimulation {
232    /// Define conditions that would falsify this simulation.
233    fn falsification_criteria(&self) -> Vec<FalsificationCriterion>;
234
235    /// Evaluate the simulation with given parameters.
236    fn evaluate(&self, params: &HashMap<String, f64>) -> Trajectory;
237
238    /// Check a specific criterion against a trajectory.
239    ///
240    /// Returns robustness degree:
241    /// - ρ > 0 → satisfies criterion with margin ρ
242    /// - ρ < 0 → violates criterion by margin |ρ|
243    fn check_criterion(&self, criterion: &str, trajectory: &Trajectory) -> f64;
244
245    /// Actively search for falsifying conditions.
246    ///
247    /// Explores the parameter space looking for conditions that would
248    /// falsify the model (violate any falsification criterion).
249    fn seek_falsification(&self, params: &ParamSpace) -> FalsificationResult {
250        let criteria = self.falsification_criteria();
251        let points = params.grid_points();
252        let mut min_robustness = f64::INFINITY;
253
254        for (test_idx, point) in points.iter().enumerate() {
255            let trajectory = self.evaluate(point);
256
257            for criterion in &criteria {
258                let robustness = self.check_criterion(&criterion.name, &trajectory);
259                min_robustness = min_robustness.min(robustness);
260
261                if robustness < 0.0 {
262                    // Found falsifying condition
263                    return FalsificationResult::falsified(
264                        point.clone(),
265                        &criterion.name,
266                        robustness,
267                        test_idx + 1,
268                    );
269                }
270            }
271        }
272
273        FalsificationResult::not_falsified(points.len(), min_robustness)
274    }
275
276    /// Compute overall robustness for a trajectory.
277    ///
278    /// Returns the minimum robustness across all criteria.
279    fn robustness(&self, trajectory: &Trajectory) -> f64 {
280        let criteria = self.falsification_criteria();
281        criteria
282            .iter()
283            .map(|c| self.check_criterion(&c.name, trajectory))
284            .fold(f64::INFINITY, f64::min)
285    }
286}
287
288/// Seed management for reproducible experiments.
289///
290/// Implements EDD-03: Deterministic Reproducibility.
291///
292/// ```ignore
293/// let seed = ExperimentSeed::new(42);
294/// let arrival_rng = seed.derive_rng("arrivals");
295/// let service_rng = seed.derive_rng("service");
296/// ```
297#[derive(Debug, Clone)]
298pub struct ExperimentSeed {
299    /// Master seed for all RNG operations
300    pub master_seed: u64,
301    /// Per-component seeds derived from master
302    pub component_seeds: HashMap<String, u64>,
303    /// IEEE 754 strict mode for floating-point reproducibility
304    pub ieee_strict: bool,
305}
306
307impl ExperimentSeed {
308    /// Create a new experiment seed.
309    #[must_use]
310    pub fn new(master_seed: u64) -> Self {
311        Self {
312            master_seed,
313            component_seeds: HashMap::new(),
314            ieee_strict: true,
315        }
316    }
317
318    /// Disable IEEE strict mode (for performance).
319    #[must_use]
320    pub fn relaxed(mut self) -> Self {
321        self.ieee_strict = false;
322        self
323    }
324
325    /// Pre-register a component seed.
326    #[must_use]
327    pub fn with_component(mut self, component: &str, seed: u64) -> Self {
328        self.component_seeds.insert(component.to_string(), seed);
329        self
330    }
331
332    /// Derive a seed for a specific component.
333    ///
334    /// Uses deterministic derivation from master seed if not pre-registered.
335    #[must_use]
336    pub fn derive_seed(&self, component: &str) -> u64 {
337        if let Some(&seed) = self.component_seeds.get(component) {
338            return seed;
339        }
340
341        // Deterministic derivation using simple hash mixing
342        // This is a simplified version - production would use BLAKE3
343        let mut hash = self.master_seed;
344        for byte in component.as_bytes() {
345            hash = hash.wrapping_mul(0x517c_c1b7_2722_0a95);
346            hash = hash.wrapping_add(u64::from(*byte));
347            hash ^= hash >> 33;
348        }
349        hash
350    }
351}
352
353#[cfg(test)]
354mod tests {
355    use super::*;
356    use crate::edd::experiment::FalsificationAction;
357
358    #[test]
359    fn test_falsification_result_not_falsified() {
360        let result = FalsificationResult::not_falsified(100, 0.5);
361        assert!(!result.falsified);
362        assert_eq!(result.tests_performed, 100);
363        assert!((result.robustness - 0.5).abs() < f64::EPSILON);
364    }
365
366    #[test]
367    fn test_falsification_result_falsified() {
368        let mut params = HashMap::new();
369        params.insert("x".to_string(), 1.0);
370        let result = FalsificationResult::falsified(params, "energy_drift", -0.1, 42);
371        assert!(result.falsified);
372        assert!(result.falsifying_params.is_some());
373        assert_eq!(result.violated_criterion, Some("energy_drift".to_string()));
374    }
375
376    #[test]
377    fn test_trajectory() {
378        let mut traj = Trajectory::new(vec!["x".to_string(), "v".to_string()]);
379        traj.push(0.0, &[1.0, 0.0]);
380        traj.push(1.0, &[0.0, -1.0]);
381
382        assert_eq!(traj.len(), 2);
383        assert!(!traj.is_empty());
384        assert!((traj.get_state("x", 0).unwrap() - 1.0).abs() < f64::EPSILON);
385        assert!((traj.get_state("v", 1).unwrap() - (-1.0)).abs() < f64::EPSILON);
386    }
387
388    #[test]
389    fn test_param_space_grid() {
390        let space = ParamSpace::new().with_param("x", 0.0, 1.0).with_samples(3);
391
392        let points = space.grid_points();
393        assert_eq!(points.len(), 3);
394        assert!((points[0]["x"] - 0.0).abs() < f64::EPSILON);
395        assert!((points[1]["x"] - 0.5).abs() < f64::EPSILON);
396        assert!((points[2]["x"] - 1.0).abs() < f64::EPSILON);
397    }
398
399    #[test]
400    fn test_param_space_2d_grid() {
401        let space = ParamSpace::new()
402            .with_param("x", 0.0, 1.0)
403            .with_param("y", 0.0, 1.0)
404            .with_samples(2);
405
406        let points = space.grid_points();
407        assert_eq!(points.len(), 4); // 2^2
408    }
409
410    #[test]
411    fn test_experiment_seed_derivation() {
412        let seed = ExperimentSeed::new(42);
413
414        let seed1 = seed.derive_seed("arrivals");
415        let seed2 = seed.derive_seed("service");
416        let seed3 = seed.derive_seed("arrivals");
417
418        // Same component should give same seed
419        assert_eq!(seed1, seed3);
420        // Different components should give different seeds
421        assert_ne!(seed1, seed2);
422    }
423
424    #[test]
425    fn test_experiment_seed_preregistered() {
426        let seed = ExperimentSeed::new(42).with_component("arrivals", 12345);
427
428        assert_eq!(seed.derive_seed("arrivals"), 12345);
429    }
430
431    // Mock simulation for testing FalsifiableSimulation trait
432    struct MockSimulation {
433        fail_on: Option<String>,
434    }
435
436    impl FalsifiableSimulation for MockSimulation {
437        fn falsification_criteria(&self) -> Vec<FalsificationCriterion> {
438            vec![FalsificationCriterion::new(
439                "bounds_check",
440                "x < 10",
441                FalsificationAction::RejectModel,
442            )]
443        }
444
445        fn evaluate(&self, params: &HashMap<String, f64>) -> Trajectory {
446            let mut traj = Trajectory::new(vec!["x".to_string()]);
447            let x = params.get("x").copied().unwrap_or(0.0);
448            traj.push(0.0, &[x]);
449            traj
450        }
451
452        fn check_criterion(&self, criterion: &str, trajectory: &Trajectory) -> f64 {
453            if Some(criterion.to_string()) == self.fail_on {
454                return -1.0;
455            }
456            let x = trajectory.get_state("x", 0).unwrap_or(0.0);
457            10.0 - x // robustness: positive if x < 10
458        }
459    }
460
461    #[test]
462    fn test_falsifiable_simulation_not_falsified() {
463        let sim = MockSimulation { fail_on: None };
464        let params = ParamSpace::new().with_param("x", 0.0, 5.0).with_samples(3);
465
466        let result = sim.seek_falsification(&params);
467        assert!(!result.falsified);
468    }
469
470    #[test]
471    fn test_falsifiable_simulation_robustness() {
472        let sim = MockSimulation { fail_on: None };
473        let mut traj = Trajectory::new(vec!["x".to_string()]);
474        traj.push(0.0, &[3.0]);
475
476        let robustness = sim.robustness(&traj);
477        assert!((robustness - 7.0).abs() < f64::EPSILON); // 10 - 3 = 7
478    }
479}