Skip to main content

simular/edd/
v2.rs

1//! EDD v2: YAML-Only, probar-First Simulation Framework
2//!
3//! Key changes from EDD v1:
4//! - YAML-ONLY: No JavaScript/HTML/custom code
5//! - probar-FIRST: Foundation of testing pyramid
6//! - 95% mutation coverage (hard requirement)
7//! - Replayable simulations (WASM/TUI/.mp4)
8
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11use std::path::Path;
12
13// ============================================================================
14// Core Types
15// ============================================================================
16
17/// EDD v2 Experiment loaded from YAML only
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct YamlExperiment {
20    /// Unique experiment identifier
21    pub id: String,
22    /// Random seed (MANDATORY)
23    pub seed: u64,
24    /// Reference to EMC (MANDATORY)
25    pub emc_ref: String,
26    /// Simulation configuration
27    pub simulation: SimulationConfig,
28    /// Falsification criteria (MANDATORY)
29    pub falsification: FalsificationConfig,
30}
31
32/// Simulation configuration - all from YAML
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct SimulationConfig {
35    /// Simulation type (must be supported by core engine)
36    #[serde(rename = "type")]
37    pub sim_type: String,
38    /// Parameters as key-value pairs
39    #[serde(default)]
40    pub parameters: HashMap<String, serde_yaml::Value>,
41}
42
43/// Falsification configuration
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct FalsificationConfig {
46    /// List of falsification criteria
47    pub criteria: Vec<FalsificationCriterionV2>,
48}
49
50/// Single falsification criterion
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct FalsificationCriterionV2 {
53    /// Criterion identifier
54    pub id: String,
55    /// Metric to evaluate
56    #[serde(default)]
57    pub metric: Option<String>,
58    /// Threshold value
59    pub threshold: f64,
60    /// Condition expression
61    pub condition: String,
62    /// Severity level
63    #[serde(default = "default_severity")]
64    pub severity: String,
65}
66
67fn default_severity() -> String {
68    "major".to_string()
69}
70
71/// Replay file format for shareable simulations
72#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct ReplayFile {
74    /// Format version
75    pub version: String,
76    /// Original seed
77    pub seed: u64,
78    /// Reference to experiment YAML
79    pub experiment_ref: String,
80    /// Timeline of simulation steps
81    pub timeline: Vec<ReplayStep>,
82    /// Available export outputs
83    #[serde(default)]
84    pub outputs: ReplayOutputs,
85}
86
87/// Single step in replay timeline
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct ReplayStep {
90    /// Step number
91    pub step: u64,
92    /// State snapshot
93    pub state: HashMap<String, serde_yaml::Value>,
94    /// Equation evaluations at this step
95    #[serde(default)]
96    pub equations: Vec<EquationEvaluation>,
97}
98
99/// Equation evaluation record
100#[derive(Debug, Clone, Serialize, Deserialize)]
101pub struct EquationEvaluation {
102    /// Equation identifier
103    pub id: String,
104    /// Computed value
105    pub value: f64,
106}
107
108/// Available replay outputs
109#[derive(Debug, Clone, Default, Serialize, Deserialize)]
110pub struct ReplayOutputs {
111    /// WASM bundle path
112    #[serde(default)]
113    pub wasm: Option<String>,
114    /// MP4 video path
115    #[serde(default)]
116    pub mp4: Option<String>,
117    /// TUI session path
118    #[serde(default)]
119    pub tui_session: Option<String>,
120}
121
122// ============================================================================
123// YAML-Only Loader
124// ============================================================================
125
126/// Errors for YAML loading
127#[derive(Debug, Clone, PartialEq, Eq)]
128pub enum YamlLoadError {
129    /// File not found
130    FileNotFound(String),
131    /// Parse error
132    ParseError(String),
133    /// Missing required field
134    MissingField(String),
135    /// Invalid configuration
136    InvalidConfig(String),
137    /// Custom code detected (PROHIBITED)
138    CustomCodeDetected(String),
139}
140
141impl std::fmt::Display for YamlLoadError {
142    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
143        match self {
144            Self::FileNotFound(p) => write!(f, "File not found: {p}"),
145            Self::ParseError(e) => write!(f, "YAML parse error: {e}"),
146            Self::MissingField(field) => write!(f, "Missing required field: {field}"),
147            Self::InvalidConfig(msg) => write!(f, "Invalid configuration: {msg}"),
148            Self::CustomCodeDetected(msg) => {
149                write!(f, "PROHIBITED: Custom code detected - {msg}")
150            }
151        }
152    }
153}
154
155impl std::error::Error for YamlLoadError {}
156
157/// Load experiment from YAML file
158///
159/// # Errors
160/// Returns error if file doesn't exist, has parse errors, or contains custom code
161pub fn load_yaml_experiment(path: &Path) -> Result<YamlExperiment, YamlLoadError> {
162    // Check file exists
163    if !path.exists() {
164        return Err(YamlLoadError::FileNotFound(path.display().to_string()));
165    }
166
167    // Read file contents
168    let contents =
169        std::fs::read_to_string(path).map_err(|e| YamlLoadError::ParseError(e.to_string()))?;
170
171    // Check for prohibited patterns (custom code)
172    check_for_custom_code(&contents)?;
173
174    // Parse YAML
175    let experiment: YamlExperiment =
176        serde_yaml::from_str(&contents).map_err(|e| YamlLoadError::ParseError(e.to_string()))?;
177
178    // Validate required fields
179    validate_experiment(&experiment)?;
180
181    Ok(experiment)
182}
183
184/// Check for prohibited custom code patterns
185fn check_for_custom_code(contents: &str) -> Result<(), YamlLoadError> {
186    let prohibited_patterns = [
187        ("javascript:", "JavaScript code"),
188        ("script:", "Script code"),
189        ("<script", "HTML script tag"),
190        ("function(", "JavaScript function"),
191        ("() =>", "Arrow function"),
192        ("eval(", "Eval expression"),
193        ("new Function", "Function constructor"),
194    ];
195
196    for (pattern, description) in prohibited_patterns {
197        if contents.to_lowercase().contains(&pattern.to_lowercase()) {
198            return Err(YamlLoadError::CustomCodeDetected(description.to_string()));
199        }
200    }
201
202    Ok(())
203}
204
205/// Validate experiment has all required fields
206fn validate_experiment(exp: &YamlExperiment) -> Result<(), YamlLoadError> {
207    if exp.id.is_empty() {
208        return Err(YamlLoadError::MissingField("id".to_string()));
209    }
210    if exp.emc_ref.is_empty() {
211        return Err(YamlLoadError::MissingField("emc_ref".to_string()));
212    }
213    if exp.falsification.criteria.is_empty() {
214        return Err(YamlLoadError::MissingField(
215            "falsification.criteria".to_string(),
216        ));
217    }
218
219    Ok(())
220}
221
222// ============================================================================
223// Replay System
224// ============================================================================
225
226/// Replay recorder for capturing simulation state
227#[derive(Debug)]
228pub struct ReplayRecorder {
229    seed: u64,
230    experiment_ref: String,
231    timeline: Vec<ReplayStep>,
232}
233
234impl ReplayRecorder {
235    /// Create new replay recorder
236    #[must_use]
237    pub fn new(seed: u64, experiment_ref: &str) -> Self {
238        Self {
239            seed,
240            experiment_ref: experiment_ref.to_string(),
241            timeline: Vec::new(),
242        }
243    }
244
245    /// Record a step
246    pub fn record_step(&mut self, step: u64, state: HashMap<String, serde_yaml::Value>) {
247        contract_pre_iterator!();
248        self.timeline.push(ReplayStep {
249            step,
250            state,
251            equations: Vec::new(),
252        });
253    }
254
255    /// Record step with equation evaluations
256    pub fn record_step_with_equations(
257        &mut self,
258        step: u64,
259        state: HashMap<String, serde_yaml::Value>,
260        equations: Vec<EquationEvaluation>,
261    ) {
262        contract_pre_iterator!();
263        self.timeline.push(ReplayStep {
264            step,
265            state,
266            equations,
267        });
268    }
269
270    /// Finalize and create replay file
271    #[must_use]
272    pub fn finalize(self) -> ReplayFile {
273        ReplayFile {
274            version: "1.0".to_string(),
275            seed: self.seed,
276            experiment_ref: self.experiment_ref,
277            timeline: self.timeline,
278            outputs: ReplayOutputs::default(),
279        }
280    }
281
282    /// Get number of recorded steps
283    #[must_use]
284    pub fn step_count(&self) -> usize {
285        contract_pre_iterator!();
286        self.timeline.len()
287    }
288}
289
290/// Export replay to different formats
291pub struct ReplayExporter;
292
293impl ReplayExporter {
294    /// Export replay to YAML file
295    ///
296    /// # Errors
297    /// Returns error if serialization or file write fails
298    pub fn export_yaml(replay: &ReplayFile, path: &Path) -> Result<(), std::io::Error> {
299        let yaml = serde_yaml::to_string(replay)
300            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
301        std::fs::write(path, yaml)
302    }
303
304    /// Export replay to JSON (for WASM consumption)
305    ///
306    /// # Errors
307    /// Returns error if serialization fails
308    pub fn export_json(replay: &ReplayFile) -> Result<String, serde_json::Error> {
309        serde_json::to_string_pretty(replay)
310    }
311}
312
313// ============================================================================
314// Falsification Evaluator
315// ============================================================================
316
317/// Result of falsification evaluation
318#[derive(Debug, Clone)]
319pub struct FalsificationEvalResult {
320    /// Criterion ID
321    pub criterion_id: String,
322    /// Whether criterion passed
323    pub passed: bool,
324    /// Actual value measured
325    pub actual_value: f64,
326    /// Threshold from criterion
327    pub threshold: f64,
328    /// Message explaining result
329    pub message: String,
330}
331
332/// Evaluate falsification criteria against simulation results
333pub struct FalsificationEvaluator;
334
335impl FalsificationEvaluator {
336    /// Evaluate a single criterion
337    #[must_use]
338    pub fn evaluate_criterion(
339        criterion: &FalsificationCriterionV2,
340        actual_value: f64,
341    ) -> FalsificationEvalResult {
342        let passed =
343            Self::evaluate_condition(&criterion.condition, actual_value, criterion.threshold);
344
345        let message = if passed {
346            format!(
347                "PASSED: {} = {:.6} satisfies '{}'",
348                criterion.id, actual_value, criterion.condition
349            )
350        } else {
351            format!(
352                "FAILED: {} = {:.6} violates '{}' (threshold: {})",
353                criterion.id, actual_value, criterion.condition, criterion.threshold
354            )
355        };
356
357        FalsificationEvalResult {
358            criterion_id: criterion.id.clone(),
359            passed,
360            actual_value,
361            threshold: criterion.threshold,
362            message,
363        }
364    }
365
366    /// Evaluate condition expression
367    fn evaluate_condition(condition: &str, value: f64, threshold: f64) -> bool {
368        // Simple condition evaluator
369        // Supports: "< threshold", "> threshold", "<= threshold", ">= threshold",
370        // "value < threshold", "gap < threshold", etc.
371        let condition_lower = condition.to_lowercase();
372
373        if condition_lower.contains("< threshold") || condition_lower.contains("<= threshold") {
374            value <= threshold
375        } else if condition_lower.contains("> threshold")
376            || condition_lower.contains(">= threshold")
377        {
378            value >= threshold
379        } else if condition_lower.contains('<') {
380            value < threshold
381        } else if condition_lower.contains('>') {
382            value > threshold
383        } else {
384            // Default: check if value is within threshold of expected
385            (value - threshold).abs() < threshold * 0.01
386        }
387    }
388
389    /// Evaluate all criteria
390    #[must_use]
391    pub fn evaluate_all(
392        criteria: &[FalsificationCriterionV2],
393        values: &HashMap<String, f64>,
394    ) -> Vec<FalsificationEvalResult> {
395        criteria
396            .iter()
397            .filter_map(|c| {
398                let metric_key = c.metric.as_ref().unwrap_or(&c.id);
399                values
400                    .get(metric_key)
401                    .map(|&v| Self::evaluate_criterion(c, v))
402            })
403            .collect()
404    }
405
406    /// Check if all criteria passed
407    #[must_use]
408    pub fn all_passed(results: &[FalsificationEvalResult]) -> bool {
409        results.iter().all(|r| r.passed)
410    }
411}
412
413// ============================================================================
414// JSON Schema Validation
415// ============================================================================
416
417/// Schema validation errors
418#[derive(Debug, Clone, PartialEq, Eq)]
419pub enum SchemaValidationError {
420    /// Schema file not found
421    SchemaNotFound(String),
422    /// Schema parse error
423    SchemaParseError(String),
424    /// Validation failed
425    ValidationFailed(Vec<String>),
426    /// YAML parse error
427    YamlParseError(String),
428}
429
430impl std::fmt::Display for SchemaValidationError {
431    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
432        match self {
433            Self::SchemaNotFound(p) => write!(f, "Schema not found: {p}"),
434            Self::SchemaParseError(e) => write!(f, "Schema parse error: {e}"),
435            Self::ValidationFailed(errors) => {
436                write!(f, "Validation failed: {}", errors.join("; "))
437            }
438            Self::YamlParseError(e) => write!(f, "YAML parse error: {e}"),
439        }
440    }
441}
442
443impl std::error::Error for SchemaValidationError {}
444
445/// Schema validator for experiments and EMCs
446pub struct SchemaValidator {
447    experiment_schema: serde_json::Value,
448    emc_schema: serde_json::Value,
449}
450
451impl SchemaValidator {
452    /// Create new validator from schema files
453    ///
454    /// # Errors
455    /// Returns error if schema files cannot be read or parsed
456    pub fn from_files(
457        experiment_schema_path: &Path,
458        emc_schema_path: &Path,
459    ) -> Result<Self, SchemaValidationError> {
460        let experiment_schema = Self::load_schema(experiment_schema_path)?;
461        let emc_schema = Self::load_schema(emc_schema_path)?;
462
463        Ok(Self {
464            experiment_schema,
465            emc_schema,
466        })
467    }
468
469    /// Create validator from embedded schemas (for use without file access).
470    ///
471    /// # Panics
472    ///
473    /// Panics if embedded schema files are invalid JSON. This is a compile-time
474    /// guarantee and indicates a build system error, not a runtime condition.
475    #[must_use]
476    #[allow(clippy::expect_used)]
477    pub fn from_embedded() -> Self {
478        let experiment_schema: serde_json::Value =
479            serde_json::from_str(include_str!("../../schemas/experiment.schema.json"))
480                .expect("Embedded experiment schema must be valid JSON");
481        let emc_schema: serde_json::Value =
482            serde_json::from_str(include_str!("../../schemas/emc.schema.json"))
483                .expect("Embedded EMC schema must be valid JSON");
484
485        Self {
486            experiment_schema,
487            emc_schema,
488        }
489    }
490
491    fn load_schema(path: &Path) -> Result<serde_json::Value, SchemaValidationError> {
492        if !path.exists() {
493            return Err(SchemaValidationError::SchemaNotFound(
494                path.display().to_string(),
495            ));
496        }
497
498        let contents = std::fs::read_to_string(path)
499            .map_err(|e| SchemaValidationError::SchemaParseError(e.to_string()))?;
500
501        serde_json::from_str(&contents)
502            .map_err(|e| SchemaValidationError::SchemaParseError(e.to_string()))
503    }
504
505    /// Validate experiment YAML against schema
506    ///
507    /// # Errors
508    /// Returns error if YAML is invalid or doesn't conform to schema
509    pub fn validate_experiment(&self, yaml_content: &str) -> Result<(), SchemaValidationError> {
510        // Parse YAML to JSON Value
511        let yaml_value: serde_json::Value = serde_yaml::from_str(yaml_content)
512            .map_err(|e| SchemaValidationError::YamlParseError(e.to_string()))?;
513
514        self.validate_against_schema(&yaml_value, &self.experiment_schema)
515    }
516
517    /// Validate EMC YAML against schema
518    ///
519    /// # Errors
520    /// Returns error if YAML is invalid or doesn't conform to schema
521    pub fn validate_emc(&self, yaml_content: &str) -> Result<(), SchemaValidationError> {
522        let yaml_value: serde_json::Value = serde_yaml::from_str(yaml_content)
523            .map_err(|e| SchemaValidationError::YamlParseError(e.to_string()))?;
524
525        self.validate_against_schema(&yaml_value, &self.emc_schema)
526    }
527
528    #[cfg(feature = "schema-validation")]
529    #[allow(clippy::unused_self)]
530    fn validate_against_schema(
531        &self,
532        instance: &serde_json::Value,
533        schema: &serde_json::Value,
534    ) -> Result<(), SchemaValidationError> {
535        let compiled = jsonschema::validator_for(schema)
536            .map_err(|e| SchemaValidationError::SchemaParseError(e.to_string()))?;
537
538        let result = compiled.validate(instance);
539
540        if let Err(error) = result {
541            // Single error case - collect into vec
542            return Err(SchemaValidationError::ValidationFailed(vec![
543                error.to_string()
544            ]));
545        }
546
547        // Also check iter_errors for additional validation errors
548        let errors: Vec<String> = compiled
549            .iter_errors(instance)
550            .map(|e| e.to_string())
551            .collect();
552        if !errors.is_empty() {
553            return Err(SchemaValidationError::ValidationFailed(errors));
554        }
555
556        Ok(())
557    }
558
559    #[cfg(not(feature = "schema-validation"))]
560    #[allow(clippy::unused_self, clippy::unnecessary_wraps)]
561    fn validate_against_schema(
562        &self,
563        _instance: &serde_json::Value,
564        _schema: &serde_json::Value,
565    ) -> Result<(), SchemaValidationError> {
566        // Schema validation disabled (WASM build)
567        Ok(())
568    }
569
570    /// Validate experiment file
571    ///
572    /// # Errors
573    /// Returns error if file cannot be read or validation fails
574    pub fn validate_experiment_file(&self, path: &Path) -> Result<(), SchemaValidationError> {
575        let contents = std::fs::read_to_string(path)
576            .map_err(|e| SchemaValidationError::YamlParseError(e.to_string()))?;
577        self.validate_experiment(&contents)
578    }
579
580    /// Validate EMC file
581    ///
582    /// # Errors
583    /// Returns error if file cannot be read or validation fails
584    pub fn validate_emc_file(&self, path: &Path) -> Result<(), SchemaValidationError> {
585        let contents = std::fs::read_to_string(path)
586            .map_err(|e| SchemaValidationError::YamlParseError(e.to_string()))?;
587        self.validate_emc(&contents)
588    }
589}
590
591/// Validate experiment YAML with embedded schema (convenience function)
592///
593/// # Errors
594/// Returns error if validation fails
595pub fn validate_experiment_yaml(yaml_content: &str) -> Result<(), SchemaValidationError> {
596    let validator = SchemaValidator::from_embedded();
597    validator.validate_experiment(yaml_content)
598}
599
600/// Validate EMC YAML with embedded schema (convenience function)
601///
602/// # Errors
603/// Returns error if validation fails
604pub fn validate_emc_yaml(yaml_content: &str) -> Result<(), SchemaValidationError> {
605    let validator = SchemaValidator::from_embedded();
606    validator.validate_emc(yaml_content)
607}
608
609// ============================================================================
610// Unit tests — red-green-refactor cycle
611// ============================================================================
612
613#[cfg(test)]
614mod tests {
615    use super::*;
616    use std::io::Write;
617    use tempfile::NamedTempFile;
618
619    // =========================================================================
620    // RED PHASE: YAML-Only Loading Tests
621    // =========================================================================
622
623    #[test]
624    fn test_yaml_loader_rejects_javascript() {
625        let yaml_with_js = r#"
626experiment:
627  id: "BAD-001"
628  seed: 42
629  emc_ref: "test/emc"
630  simulation:
631    type: "custom"
632    javascript: "function() { alert('bad'); }"
633  falsification:
634    criteria:
635      - id: "test"
636        threshold: 0.1
637        condition: "value < threshold"
638"#;
639
640        let mut file = NamedTempFile::new().unwrap();
641        file.write_all(yaml_with_js.as_bytes()).unwrap();
642
643        let result = load_yaml_experiment(file.path());
644        assert!(
645            matches!(result, Err(YamlLoadError::CustomCodeDetected(_))),
646            "Should reject YAML with JavaScript"
647        );
648    }
649
650    #[test]
651    fn test_yaml_loader_rejects_script_tags() {
652        let yaml_with_html = r#"
653experiment:
654  id: "BAD-002"
655  seed: 42
656  emc_ref: "test/emc"
657  simulation:
658    type: "custom"
659    html: "<script>alert('bad')</script>"
660  falsification:
661    criteria:
662      - id: "test"
663        threshold: 0.1
664        condition: "value < threshold"
665"#;
666
667        let mut file = NamedTempFile::new().unwrap();
668        file.write_all(yaml_with_html.as_bytes()).unwrap();
669
670        let result = load_yaml_experiment(file.path());
671        assert!(
672            matches!(result, Err(YamlLoadError::CustomCodeDetected(_))),
673            "Should reject YAML with HTML script tags"
674        );
675    }
676
677    #[test]
678    fn test_yaml_loader_rejects_arrow_functions() {
679        let yaml_with_arrow = r#"
680experiment:
681  id: "BAD-003"
682  seed: 42
683  emc_ref: "test/emc"
684  simulation:
685    type: "custom"
686    callback: "() => console.log('bad')"
687  falsification:
688    criteria:
689      - id: "test"
690        threshold: 0.1
691        condition: "value < threshold"
692"#;
693
694        let mut file = NamedTempFile::new().unwrap();
695        file.write_all(yaml_with_arrow.as_bytes()).unwrap();
696
697        let result = load_yaml_experiment(file.path());
698        assert!(
699            matches!(result, Err(YamlLoadError::CustomCodeDetected(_))),
700            "Should reject YAML with arrow functions"
701        );
702    }
703
704    #[test]
705    fn test_yaml_loader_accepts_valid_yaml() {
706        let valid_yaml = r#"
707id: "GOOD-001"
708seed: 42
709emc_ref: "optimization/tsp_grasp"
710simulation:
711  type: "tsp_grasp"
712  parameters:
713    n_cities: 25
714    rcl_size: 5
715falsification:
716  criteria:
717    - id: "optimality_gap"
718      threshold: 0.25
719      condition: "gap < threshold"
720"#;
721
722        let mut file = NamedTempFile::new().unwrap();
723        file.write_all(valid_yaml.as_bytes()).unwrap();
724
725        let result = load_yaml_experiment(file.path());
726        assert!(result.is_ok(), "Should accept valid YAML-only experiment");
727
728        let exp = result.unwrap();
729        assert_eq!(exp.id, "GOOD-001");
730        assert_eq!(exp.seed, 42);
731        assert_eq!(exp.emc_ref, "optimization/tsp_grasp");
732    }
733
734    #[test]
735    fn test_yaml_loader_requires_seed() {
736        let yaml_no_seed = r#"
737id: "NO-SEED"
738emc_ref: "test/emc"
739simulation:
740  type: "test"
741falsification:
742  criteria:
743    - id: "test"
744      threshold: 0.1
745      condition: "value < threshold"
746"#;
747
748        let mut file = NamedTempFile::new().unwrap();
749        file.write_all(yaml_no_seed.as_bytes()).unwrap();
750
751        let result = load_yaml_experiment(file.path());
752        // Should fail parsing because seed is required
753        assert!(result.is_err(), "Should reject YAML without seed");
754    }
755
756    #[test]
757    fn test_yaml_loader_requires_emc_ref() {
758        let yaml_no_emc = r#"
759id: "NO-EMC"
760seed: 42
761emc_ref: ""
762simulation:
763  type: "test"
764falsification:
765  criteria:
766    - id: "test"
767      threshold: 0.1
768      condition: "value < threshold"
769"#;
770
771        let mut file = NamedTempFile::new().unwrap();
772        file.write_all(yaml_no_emc.as_bytes()).unwrap();
773
774        let result = load_yaml_experiment(file.path());
775        assert!(
776            matches!(result, Err(YamlLoadError::MissingField(_))),
777            "Should reject YAML without emc_ref"
778        );
779    }
780
781    #[test]
782    fn test_yaml_loader_requires_falsification_criteria() {
783        let yaml_no_falsification = r#"
784id: "NO-FALSIFICATION"
785seed: 42
786emc_ref: "test/emc"
787simulation:
788  type: "test"
789falsification:
790  criteria: []
791"#;
792
793        let mut file = NamedTempFile::new().unwrap();
794        file.write_all(yaml_no_falsification.as_bytes()).unwrap();
795
796        let result = load_yaml_experiment(file.path());
797        assert!(
798            matches!(result, Err(YamlLoadError::MissingField(_))),
799            "Should reject YAML without falsification criteria"
800        );
801    }
802
803    // =========================================================================
804    // RED PHASE: Replay System Tests
805    // =========================================================================
806
807    #[test]
808    fn test_replay_recorder_captures_steps() {
809        let mut recorder = ReplayRecorder::new(42, "experiments/test.yaml");
810
811        let mut state1 = HashMap::new();
812        state1.insert(
813            "tour_length".to_string(),
814            serde_yaml::Value::Number(1234.into()),
815        );
816        recorder.record_step(0, state1);
817
818        let mut state2 = HashMap::new();
819        state2.insert(
820            "tour_length".to_string(),
821            serde_yaml::Value::Number(1198.into()),
822        );
823        recorder.record_step(1, state2);
824
825        assert_eq!(recorder.step_count(), 2, "Should record 2 steps");
826    }
827
828    #[test]
829    fn test_replay_recorder_captures_equations() {
830        let mut recorder = ReplayRecorder::new(42, "experiments/test.yaml");
831
832        let state = HashMap::new();
833        let equations = vec![
834            EquationEvaluation {
835                id: "tour_length".to_string(),
836                value: 1234.5,
837            },
838            EquationEvaluation {
839                id: "two_opt_delta".to_string(),
840                value: 36.3,
841            },
842        ];
843
844        recorder.record_step_with_equations(0, state, equations);
845
846        let replay = recorder.finalize();
847        assert_eq!(replay.timeline[0].equations.len(), 2);
848    }
849
850    #[test]
851    fn test_replay_finalize_creates_valid_file() {
852        let mut recorder = ReplayRecorder::new(42, "experiments/tsp.yaml");
853        recorder.record_step(0, HashMap::new());
854
855        let replay = recorder.finalize();
856
857        assert_eq!(replay.version, "1.0");
858        assert_eq!(replay.seed, 42);
859        assert_eq!(replay.experiment_ref, "experiments/tsp.yaml");
860        assert_eq!(replay.timeline.len(), 1);
861    }
862
863    #[test]
864    fn test_replay_export_yaml() {
865        let replay = ReplayFile {
866            version: "1.0".to_string(),
867            seed: 42,
868            experiment_ref: "test.yaml".to_string(),
869            timeline: vec![ReplayStep {
870                step: 0,
871                state: HashMap::new(),
872                equations: vec![],
873            }],
874            outputs: ReplayOutputs::default(),
875        };
876
877        let file = NamedTempFile::new().unwrap();
878        let result = ReplayExporter::export_yaml(&replay, file.path());
879        assert!(result.is_ok(), "Should export to YAML");
880
881        // Verify file exists and has content
882        let contents = std::fs::read_to_string(file.path()).unwrap();
883        assert!(contents.contains("version: '1.0'") || contents.contains("version: \"1.0\""));
884        assert!(contents.contains("seed: 42"));
885    }
886
887    #[test]
888    fn test_replay_export_json() {
889        let replay = ReplayFile {
890            version: "1.0".to_string(),
891            seed: 42,
892            experiment_ref: "test.yaml".to_string(),
893            timeline: vec![],
894            outputs: ReplayOutputs::default(),
895        };
896
897        let result = ReplayExporter::export_json(&replay);
898        assert!(result.is_ok(), "Should export to JSON");
899
900        let json = result.unwrap();
901        assert!(json.contains("\"version\": \"1.0\""));
902        assert!(json.contains("\"seed\": 42"));
903    }
904
905    // =========================================================================
906    // RED PHASE: Falsification Evaluator Tests
907    // =========================================================================
908
909    #[test]
910    fn test_falsification_evaluator_passes_when_under_threshold() {
911        let criterion = FalsificationCriterionV2 {
912            id: "optimality_gap".to_string(),
913            metric: None,
914            threshold: 0.25,
915            condition: "gap < threshold".to_string(),
916            severity: "critical".to_string(),
917        };
918
919        let result = FalsificationEvaluator::evaluate_criterion(&criterion, 0.18);
920        assert!(
921            result.passed,
922            "Should pass when gap (0.18) < threshold (0.25)"
923        );
924    }
925
926    #[test]
927    fn test_falsification_evaluator_fails_when_over_threshold() {
928        let criterion = FalsificationCriterionV2 {
929            id: "optimality_gap".to_string(),
930            metric: None,
931            threshold: 0.25,
932            condition: "gap < threshold".to_string(),
933            severity: "critical".to_string(),
934        };
935
936        let result = FalsificationEvaluator::evaluate_criterion(&criterion, 0.30);
937        assert!(
938            !result.passed,
939            "Should fail when gap (0.30) > threshold (0.25)"
940        );
941    }
942
943    #[test]
944    fn test_falsification_evaluator_handles_greater_than() {
945        let criterion = FalsificationCriterionV2 {
946            id: "accuracy".to_string(),
947            metric: None,
948            threshold: 0.95,
949            condition: "accuracy > threshold".to_string(),
950            severity: "major".to_string(),
951        };
952
953        let result = FalsificationEvaluator::evaluate_criterion(&criterion, 0.97);
954        assert!(
955            result.passed,
956            "Should pass when accuracy (0.97) > threshold (0.95)"
957        );
958    }
959
960    #[test]
961    fn test_falsification_evaluator_all_criteria() {
962        let criteria = vec![
963            FalsificationCriterionV2 {
964                id: "gap".to_string(),
965                metric: Some("gap".to_string()),
966                threshold: 0.25,
967                condition: "gap < threshold".to_string(),
968                severity: "critical".to_string(),
969            },
970            FalsificationCriterionV2 {
971                id: "energy".to_string(),
972                metric: Some("energy_drift".to_string()),
973                threshold: 1e-9,
974                condition: "drift < threshold".to_string(),
975                severity: "critical".to_string(),
976            },
977        ];
978
979        let mut values = HashMap::new();
980        values.insert("gap".to_string(), 0.18);
981        values.insert("energy_drift".to_string(), 1e-10);
982
983        let results = FalsificationEvaluator::evaluate_all(&criteria, &values);
984        assert_eq!(results.len(), 2);
985        assert!(FalsificationEvaluator::all_passed(&results));
986    }
987
988    #[test]
989    fn test_falsification_evaluator_detects_failure() {
990        let criteria = vec![FalsificationCriterionV2 {
991            id: "gap".to_string(),
992            metric: Some("gap".to_string()),
993            threshold: 0.25,
994            condition: "gap < threshold".to_string(),
995            severity: "critical".to_string(),
996        }];
997
998        let mut values = HashMap::new();
999        values.insert("gap".to_string(), 0.30); // Over threshold!
1000
1001        let results = FalsificationEvaluator::evaluate_all(&criteria, &values);
1002        assert!(!FalsificationEvaluator::all_passed(&results));
1003    }
1004
1005    // =========================================================================
1006    // Integration Tests
1007    // =========================================================================
1008
1009    #[test]
1010    fn test_full_edd_v2_workflow() {
1011        // 1. Create valid YAML experiment
1012        let yaml = r#"
1013id: "TSP-GRASP-001"
1014seed: 42
1015emc_ref: "optimization/tsp_grasp"
1016simulation:
1017  type: "tsp_grasp"
1018  parameters:
1019    n_cities: 25
1020    rcl_size: 5
1021    max_iterations: 100
1022falsification:
1023  criteria:
1024    - id: "optimality_gap"
1025      metric: "gap"
1026      threshold: 0.25
1027      condition: "gap < threshold"
1028      severity: "critical"
1029"#;
1030
1031        let mut file = NamedTempFile::new().unwrap();
1032        file.write_all(yaml.as_bytes()).unwrap();
1033
1034        // 2. Load experiment
1035        let experiment = load_yaml_experiment(file.path()).expect("Should load valid experiment");
1036        assert_eq!(experiment.id, "TSP-GRASP-001");
1037
1038        // 3. Create replay recorder
1039        let mut recorder = ReplayRecorder::new(experiment.seed, &experiment.id);
1040
1041        // 4. Simulate steps (mock)
1042        for step in 0..5 {
1043            let mut state = HashMap::new();
1044            state.insert(
1045                "tour_length".to_string(),
1046                serde_yaml::Value::Number((1000 - step * 50).into()),
1047            );
1048            recorder.record_step(step, state);
1049        }
1050
1051        // 5. Finalize replay
1052        let replay = recorder.finalize();
1053        assert_eq!(replay.timeline.len(), 5);
1054
1055        // 6. Evaluate falsification
1056        let mut values = HashMap::new();
1057        values.insert("gap".to_string(), 0.18);
1058
1059        let results =
1060            FalsificationEvaluator::evaluate_all(&experiment.falsification.criteria, &values);
1061        assert!(FalsificationEvaluator::all_passed(&results));
1062    }
1063
1064    // =========================================================================
1065    // RED PHASE: Schema Validation Tests
1066    // =========================================================================
1067
1068    #[test]
1069    fn test_schema_validator_from_embedded() {
1070        // Should load embedded schemas without error
1071        let validator = SchemaValidator::from_embedded();
1072        // If we get here without panic, schemas are valid JSON
1073        assert!(!validator.experiment_schema.is_null());
1074        assert!(!validator.emc_schema.is_null());
1075    }
1076
1077    #[test]
1078    fn test_schema_validates_valid_experiment() {
1079        let valid_yaml = r#"
1080id: "TSP-GRASP-001"
1081seed: 42
1082emc_ref: "optimization/tsp_grasp"
1083simulation:
1084  type: "tsp_grasp"
1085  parameters:
1086    n_cities: 25
1087    rcl_size: 5
1088falsification:
1089  criteria:
1090    - id: "optimality_gap"
1091      threshold: 0.25
1092      condition: "gap < threshold"
1093"#;
1094
1095        let result = validate_experiment_yaml(valid_yaml);
1096        assert!(
1097            result.is_ok(),
1098            "Valid experiment YAML should pass: {:?}",
1099            result
1100        );
1101    }
1102
1103    #[test]
1104    #[cfg(feature = "schema-validation")]
1105    fn test_schema_rejects_missing_seed() {
1106        let invalid_yaml = r#"
1107id: "TSP-001"
1108emc_ref: "optimization/tsp"
1109simulation:
1110  type: "tsp"
1111falsification:
1112  criteria:
1113    - id: "gap"
1114      threshold: 0.25
1115      condition: "gap < threshold"
1116"#;
1117
1118        let result = validate_experiment_yaml(invalid_yaml);
1119        assert!(
1120            matches!(result, Err(SchemaValidationError::ValidationFailed(_))),
1121            "Should reject YAML without seed"
1122        );
1123    }
1124
1125    #[test]
1126    #[cfg(feature = "schema-validation")]
1127    fn test_schema_rejects_missing_falsification() {
1128        let invalid_yaml = r#"
1129id: "TSP-001"
1130seed: 42
1131emc_ref: "optimization/tsp"
1132simulation:
1133  type: "tsp"
1134"#;
1135
1136        let result = validate_experiment_yaml(invalid_yaml);
1137        assert!(
1138            matches!(result, Err(SchemaValidationError::ValidationFailed(_))),
1139            "Should reject YAML without falsification"
1140        );
1141    }
1142
1143    #[test]
1144    #[cfg(feature = "schema-validation")]
1145    fn test_schema_rejects_empty_falsification_criteria() {
1146        let invalid_yaml = r#"
1147id: "TSP-001"
1148seed: 42
1149emc_ref: "optimization/tsp"
1150simulation:
1151  type: "tsp"
1152falsification:
1153  criteria: []
1154"#;
1155
1156        let result = validate_experiment_yaml(invalid_yaml);
1157        assert!(
1158            matches!(result, Err(SchemaValidationError::ValidationFailed(_))),
1159            "Should reject YAML with empty falsification criteria"
1160        );
1161    }
1162
1163    #[test]
1164    #[cfg(feature = "schema-validation")]
1165    fn test_schema_rejects_javascript_field() {
1166        let invalid_yaml = r#"
1167id: "TSP-001"
1168seed: 42
1169emc_ref: "optimization/tsp"
1170simulation:
1171  type: "tsp"
1172  javascript: "alert('bad')"
1173falsification:
1174  criteria:
1175    - id: "gap"
1176      threshold: 0.25
1177      condition: "gap < threshold"
1178"#;
1179
1180        let result = validate_experiment_yaml(invalid_yaml);
1181        assert!(
1182            matches!(result, Err(SchemaValidationError::ValidationFailed(_))),
1183            "Should reject YAML with javascript field"
1184        );
1185    }
1186
1187    #[test]
1188    fn test_schema_validates_valid_emc() {
1189        let valid_emc = r#"
1190emc_version: "1.0"
1191emc_id: "optimization/tsp_grasp"
1192identity:
1193  name: "TSP GRASP"
1194  version: "1.0.0"
1195governing_equation:
1196  latex: "L(\\pi) = \\sum d(\\pi_i, \\pi_{i+1})"
1197  plain_text: "L(π) = Σ d(πᵢ, πᵢ₊₁)"
1198  description: "Tour length is sum of edge distances"
1199domain_of_validity:
1200  parameters:
1201    n_cities:
1202      min: 3
1203      max: 1000
1204  assumptions:
1205    - "Euclidean distance"
1206falsification:
1207  criteria:
1208    - id: "optimality_gap"
1209      condition: "gap <= 0.25"
1210"#;
1211
1212        let result = validate_emc_yaml(valid_emc);
1213        assert!(result.is_ok(), "Valid EMC YAML should pass: {:?}", result);
1214    }
1215
1216    #[test]
1217    #[cfg(feature = "schema-validation")]
1218    fn test_schema_rejects_emc_without_governing_equation() {
1219        let invalid_emc = r#"
1220emc_version: "1.0"
1221emc_id: "optimization/tsp"
1222identity:
1223  name: "TSP"
1224  version: "1.0.0"
1225domain_of_validity:
1226  assumptions:
1227    - "Test"
1228falsification:
1229  criteria:
1230    - id: "gap"
1231      condition: "gap < 0.25"
1232"#;
1233
1234        let result = validate_emc_yaml(invalid_emc);
1235        assert!(
1236            matches!(result, Err(SchemaValidationError::ValidationFailed(_))),
1237            "Should reject EMC without governing_equation"
1238        );
1239    }
1240
1241    #[test]
1242    #[cfg(feature = "schema-validation")]
1243    fn test_schema_rejects_emc_invalid_version() {
1244        let invalid_emc = r#"
1245emc_version: "invalid"
1246emc_id: "optimization/tsp"
1247identity:
1248  name: "TSP"
1249  version: "1.0.0"
1250governing_equation:
1251  latex: "L = sum"
1252  plain_text: "L = sum"
1253  description: "Tour length"
1254domain_of_validity:
1255  assumptions:
1256    - "Test"
1257falsification:
1258  criteria:
1259    - id: "gap"
1260      condition: "gap < 0.25"
1261"#;
1262
1263        let result = validate_emc_yaml(invalid_emc);
1264        assert!(
1265            matches!(result, Err(SchemaValidationError::ValidationFailed(_))),
1266            "Should reject EMC with invalid version format"
1267        );
1268    }
1269
1270    #[test]
1271    fn test_schema_validation_error_display() {
1272        let errors = vec!["error1".to_string(), "error2".to_string()];
1273        let err = SchemaValidationError::ValidationFailed(errors);
1274        let display = format!("{err}");
1275        assert!(display.contains("error1"));
1276        assert!(display.contains("error2"));
1277
1278        let not_found = SchemaValidationError::SchemaNotFound("test.json".to_string());
1279        assert!(format!("{not_found}").contains("test.json"));
1280    }
1281
1282    #[test]
1283    fn test_validate_experiment_file() {
1284        let valid_yaml = r#"
1285id: "TSP-001"
1286seed: 42
1287emc_ref: "optimization/tsp"
1288simulation:
1289  type: "tsp"
1290falsification:
1291  criteria:
1292    - id: "gap"
1293      threshold: 0.25
1294      condition: "gap < threshold"
1295"#;
1296
1297        let mut file = NamedTempFile::new().unwrap();
1298        file.write_all(valid_yaml.as_bytes()).unwrap();
1299
1300        let validator = SchemaValidator::from_embedded();
1301        let result = validator.validate_experiment_file(file.path());
1302        assert!(result.is_ok());
1303    }
1304
1305    #[test]
1306    fn test_validate_emc_file() {
1307        let valid_emc = r#"
1308emc_version: "1.0"
1309emc_id: "test/emc"
1310identity:
1311  name: "Test EMC"
1312  version: "1.0.0"
1313governing_equation:
1314  latex: "x = y"
1315"#;
1316
1317        let mut file = NamedTempFile::new().unwrap();
1318        file.write_all(valid_emc.as_bytes()).unwrap();
1319
1320        let validator = SchemaValidator::from_embedded();
1321        let result = validator.validate_emc_file(file.path());
1322        assert!(result.is_ok());
1323    }
1324
1325    #[test]
1326    fn test_validate_experiment_file_not_found() {
1327        let validator = SchemaValidator::from_embedded();
1328        let result = validator.validate_experiment_file(Path::new("/nonexistent/file.yaml"));
1329        assert!(matches!(
1330            result,
1331            Err(SchemaValidationError::YamlParseError(_))
1332        ));
1333    }
1334
1335    #[test]
1336    fn test_validate_emc_file_not_found() {
1337        let validator = SchemaValidator::from_embedded();
1338        let result = validator.validate_emc_file(Path::new("/nonexistent/emc.yaml"));
1339        assert!(matches!(
1340            result,
1341            Err(SchemaValidationError::YamlParseError(_))
1342        ));
1343    }
1344
1345    #[test]
1346    fn test_schema_validation_error_yaml_parse() {
1347        let err = SchemaValidationError::YamlParseError("invalid yaml".to_string());
1348        let display = format!("{err}");
1349        assert!(display.contains("YAML parse error"));
1350        assert!(display.contains("invalid yaml"));
1351    }
1352
1353    #[test]
1354    fn test_schema_validation_error_schema_parse() {
1355        let err = SchemaValidationError::SchemaParseError("bad schema".to_string());
1356        let display = format!("{err}");
1357        assert!(display.contains("Schema parse error"));
1358        assert!(display.contains("bad schema"));
1359    }
1360
1361    #[test]
1362    fn test_yaml_load_error_display() {
1363        let file_not_found = YamlLoadError::FileNotFound("/path/to/file".to_string());
1364        assert!(format!("{file_not_found}").contains("File not found"));
1365
1366        let parse_error = YamlLoadError::ParseError("bad syntax".to_string());
1367        assert!(format!("{parse_error}").contains("YAML parse error"));
1368
1369        let missing_field = YamlLoadError::MissingField("seed".to_string());
1370        assert!(format!("{missing_field}").contains("Missing required field"));
1371
1372        let invalid_config = YamlLoadError::InvalidConfig("bad config".to_string());
1373        assert!(format!("{invalid_config}").contains("Invalid configuration"));
1374
1375        let custom_code = YamlLoadError::CustomCodeDetected("javascript".to_string());
1376        assert!(format!("{custom_code}").contains("PROHIBITED"));
1377    }
1378
1379    #[test]
1380    fn test_falsification_evaluator_default_condition() {
1381        // Test condition that doesn't contain < or > - uses default path
1382        let criterion = FalsificationCriterionV2 {
1383            id: "equality".to_string(),
1384            metric: None,
1385            threshold: 100.0,
1386            condition: "value equals threshold".to_string(),
1387            severity: "major".to_string(),
1388        };
1389
1390        // Default behavior: check if value is within 1% of threshold
1391        let result = FalsificationEvaluator::evaluate_criterion(&criterion, 100.5);
1392        assert!(
1393            result.passed,
1394            "Should pass when value is within 1% of threshold"
1395        );
1396
1397        let result_fail = FalsificationEvaluator::evaluate_criterion(&criterion, 110.0);
1398        assert!(
1399            !result_fail.passed,
1400            "Should fail when value is not within 1% of threshold"
1401        );
1402    }
1403
1404    #[test]
1405    fn test_falsification_evaluator_gte_condition() {
1406        let criterion = FalsificationCriterionV2 {
1407            id: "min_coverage".to_string(),
1408            metric: None,
1409            threshold: 0.95,
1410            condition: "coverage >= threshold".to_string(),
1411            severity: "critical".to_string(),
1412        };
1413
1414        let result = FalsificationEvaluator::evaluate_criterion(&criterion, 0.95);
1415        assert!(result.passed, "Should pass when coverage equals threshold");
1416
1417        let result_above = FalsificationEvaluator::evaluate_criterion(&criterion, 0.98);
1418        assert!(result_above.passed, "Should pass when coverage > threshold");
1419    }
1420
1421    #[test]
1422    fn test_falsification_evaluator_lte_condition() {
1423        let criterion = FalsificationCriterionV2 {
1424            id: "max_error".to_string(),
1425            metric: None,
1426            threshold: 0.01,
1427            condition: "error <= threshold".to_string(),
1428            severity: "critical".to_string(),
1429        };
1430
1431        let result = FalsificationEvaluator::evaluate_criterion(&criterion, 0.01);
1432        assert!(result.passed, "Should pass when error equals threshold");
1433
1434        let result_below = FalsificationEvaluator::evaluate_criterion(&criterion, 0.005);
1435        assert!(result_below.passed, "Should pass when error < threshold");
1436    }
1437
1438    #[test]
1439    fn test_falsification_evaluator_missing_metric() {
1440        let criteria = vec![FalsificationCriterionV2 {
1441            id: "gap".to_string(),
1442            metric: Some("missing_metric".to_string()),
1443            threshold: 0.25,
1444            condition: "gap < threshold".to_string(),
1445            severity: "critical".to_string(),
1446        }];
1447
1448        let values = HashMap::new(); // No values at all
1449        let results = FalsificationEvaluator::evaluate_all(&criteria, &values);
1450        assert_eq!(
1451            results.len(),
1452            0,
1453            "Should skip criteria with missing metrics"
1454        );
1455    }
1456
1457    #[test]
1458    fn test_replay_outputs_default() {
1459        let outputs = ReplayOutputs::default();
1460        assert!(outputs.wasm.is_none());
1461        assert!(outputs.mp4.is_none());
1462        assert!(outputs.tui_session.is_none());
1463    }
1464
1465    #[test]
1466    fn test_default_severity() {
1467        assert_eq!(default_severity(), "major");
1468    }
1469
1470    #[test]
1471    fn test_schema_validates_invalid_yaml_syntax() {
1472        let invalid_yaml = "{ this is: [ not valid yaml:";
1473        let result = validate_experiment_yaml(invalid_yaml);
1474        assert!(matches!(
1475            result,
1476            Err(SchemaValidationError::YamlParseError(_))
1477        ));
1478    }
1479
1480    #[test]
1481    fn test_schema_validates_emc_invalid_yaml_syntax() {
1482        let invalid_yaml = "{ this is: [ not valid yaml:";
1483        let result = validate_emc_yaml(invalid_yaml);
1484        assert!(matches!(
1485            result,
1486            Err(SchemaValidationError::YamlParseError(_))
1487        ));
1488    }
1489
1490    #[test]
1491    fn test_load_yaml_file_not_found() {
1492        let result = load_yaml_experiment(Path::new("/nonexistent/experiment.yaml"));
1493        assert!(matches!(result, Err(YamlLoadError::FileNotFound(_))));
1494    }
1495
1496    #[test]
1497    fn test_load_yaml_invalid_syntax() {
1498        let invalid_yaml = "{ this is: [ not valid yaml:";
1499        let mut file = NamedTempFile::new().unwrap();
1500        file.write_all(invalid_yaml.as_bytes()).unwrap();
1501
1502        let result = load_yaml_experiment(file.path());
1503        assert!(matches!(result, Err(YamlLoadError::ParseError(_))));
1504    }
1505}