1use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11use std::path::Path;
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct YamlExperiment {
20 pub id: String,
22 pub seed: u64,
24 pub emc_ref: String,
26 pub simulation: SimulationConfig,
28 pub falsification: FalsificationConfig,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct SimulationConfig {
35 #[serde(rename = "type")]
37 pub sim_type: String,
38 #[serde(default)]
40 pub parameters: HashMap<String, serde_yaml::Value>,
41}
42
43#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct FalsificationConfig {
46 pub criteria: Vec<FalsificationCriterionV2>,
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct FalsificationCriterionV2 {
53 pub id: String,
55 #[serde(default)]
57 pub metric: Option<String>,
58 pub threshold: f64,
60 pub condition: String,
62 #[serde(default = "default_severity")]
64 pub severity: String,
65}
66
67fn default_severity() -> String {
68 "major".to_string()
69}
70
71#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct ReplayFile {
74 pub version: String,
76 pub seed: u64,
78 pub experiment_ref: String,
80 pub timeline: Vec<ReplayStep>,
82 #[serde(default)]
84 pub outputs: ReplayOutputs,
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct ReplayStep {
90 pub step: u64,
92 pub state: HashMap<String, serde_yaml::Value>,
94 #[serde(default)]
96 pub equations: Vec<EquationEvaluation>,
97}
98
99#[derive(Debug, Clone, Serialize, Deserialize)]
101pub struct EquationEvaluation {
102 pub id: String,
104 pub value: f64,
106}
107
108#[derive(Debug, Clone, Default, Serialize, Deserialize)]
110pub struct ReplayOutputs {
111 #[serde(default)]
113 pub wasm: Option<String>,
114 #[serde(default)]
116 pub mp4: Option<String>,
117 #[serde(default)]
119 pub tui_session: Option<String>,
120}
121
122#[derive(Debug, Clone, PartialEq, Eq)]
128pub enum YamlLoadError {
129 FileNotFound(String),
131 ParseError(String),
133 MissingField(String),
135 InvalidConfig(String),
137 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
157pub fn load_yaml_experiment(path: &Path) -> Result<YamlExperiment, YamlLoadError> {
162 if !path.exists() {
164 return Err(YamlLoadError::FileNotFound(path.display().to_string()));
165 }
166
167 let contents =
169 std::fs::read_to_string(path).map_err(|e| YamlLoadError::ParseError(e.to_string()))?;
170
171 check_for_custom_code(&contents)?;
173
174 let experiment: YamlExperiment =
176 serde_yaml::from_str(&contents).map_err(|e| YamlLoadError::ParseError(e.to_string()))?;
177
178 validate_experiment(&experiment)?;
180
181 Ok(experiment)
182}
183
184fn 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
205fn 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#[derive(Debug)]
228pub struct ReplayRecorder {
229 seed: u64,
230 experiment_ref: String,
231 timeline: Vec<ReplayStep>,
232}
233
234impl ReplayRecorder {
235 #[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 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 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 #[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 #[must_use]
284 pub fn step_count(&self) -> usize {
285 contract_pre_iterator!();
286 self.timeline.len()
287 }
288}
289
290pub struct ReplayExporter;
292
293impl ReplayExporter {
294 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 pub fn export_json(replay: &ReplayFile) -> Result<String, serde_json::Error> {
309 serde_json::to_string_pretty(replay)
310 }
311}
312
313#[derive(Debug, Clone)]
319pub struct FalsificationEvalResult {
320 pub criterion_id: String,
322 pub passed: bool,
324 pub actual_value: f64,
326 pub threshold: f64,
328 pub message: String,
330}
331
332pub struct FalsificationEvaluator;
334
335impl FalsificationEvaluator {
336 #[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 fn evaluate_condition(condition: &str, value: f64, threshold: f64) -> bool {
368 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 (value - threshold).abs() < threshold * 0.01
386 }
387 }
388
389 #[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 #[must_use]
408 pub fn all_passed(results: &[FalsificationEvalResult]) -> bool {
409 results.iter().all(|r| r.passed)
410 }
411}
412
413#[derive(Debug, Clone, PartialEq, Eq)]
419pub enum SchemaValidationError {
420 SchemaNotFound(String),
422 SchemaParseError(String),
424 ValidationFailed(Vec<String>),
426 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
445pub struct SchemaValidator {
447 experiment_schema: serde_json::Value,
448 emc_schema: serde_json::Value,
449}
450
451impl SchemaValidator {
452 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 #[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 pub fn validate_experiment(&self, yaml_content: &str) -> Result<(), SchemaValidationError> {
510 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 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 return Err(SchemaValidationError::ValidationFailed(vec![
543 error.to_string()
544 ]));
545 }
546
547 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 Ok(())
568 }
569
570 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 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
591pub fn validate_experiment_yaml(yaml_content: &str) -> Result<(), SchemaValidationError> {
596 let validator = SchemaValidator::from_embedded();
597 validator.validate_experiment(yaml_content)
598}
599
600pub fn validate_emc_yaml(yaml_content: &str) -> Result<(), SchemaValidationError> {
605 let validator = SchemaValidator::from_embedded();
606 validator.validate_emc(yaml_content)
607}
608
609#[cfg(test)]
614mod tests {
615 use super::*;
616 use std::io::Write;
617 use tempfile::NamedTempFile;
618
619 #[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 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 #[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 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 #[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); let results = FalsificationEvaluator::evaluate_all(&criteria, &values);
1002 assert!(!FalsificationEvaluator::all_passed(&results));
1003 }
1004
1005 #[test]
1010 fn test_full_edd_v2_workflow() {
1011 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 let experiment = load_yaml_experiment(file.path()).expect("Should load valid experiment");
1036 assert_eq!(experiment.id, "TSP-GRASP-001");
1037
1038 let mut recorder = ReplayRecorder::new(experiment.seed, &experiment.id);
1040
1041 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 let replay = recorder.finalize();
1053 assert_eq!(replay.timeline.len(), 5);
1054
1055 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 #[test]
1069 fn test_schema_validator_from_embedded() {
1070 let validator = SchemaValidator::from_embedded();
1072 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 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 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(); 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}