1use crate::config::SimConfig;
32use crate::engine::state::SimState;
33use crate::error::{SimError, SimResult};
34use serde::{Deserialize, Serialize};
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
40pub enum ViolationSeverity {
41 Acceptable,
43 Warning,
45 Critical,
47 Fatal,
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize)]
53pub enum JidokaWarning {
54 EnergyDriftApproaching {
56 drift: f64,
58 tolerance: f64,
60 },
61 ConstraintApproaching {
63 name: String,
65 violation: f64,
67 tolerance: f64,
69 },
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct SeverityClassifier {
75 pub warning_fraction: f64,
77}
78
79impl Default for SeverityClassifier {
80 fn default() -> Self {
81 Self {
82 warning_fraction: 0.8,
83 }
84 }
85}
86
87impl SeverityClassifier {
88 #[must_use]
90 pub const fn new(warning_fraction: f64) -> Self {
91 Self { warning_fraction }
92 }
93
94 #[must_use]
96 pub fn classify_energy_drift(&self, drift: f64, tolerance: f64) -> ViolationSeverity {
97 if drift.is_nan() || drift.is_infinite() {
98 ViolationSeverity::Fatal
99 } else if drift > tolerance {
100 ViolationSeverity::Critical
101 } else if drift > tolerance * self.warning_fraction {
102 ViolationSeverity::Warning
103 } else {
104 ViolationSeverity::Acceptable
105 }
106 }
107
108 #[must_use]
110 pub fn classify_constraint(&self, violation: f64, tolerance: f64) -> ViolationSeverity {
111 let abs_violation = violation.abs();
112 if abs_violation.is_nan() || abs_violation.is_infinite() {
113 ViolationSeverity::Fatal
114 } else if abs_violation > tolerance {
115 ViolationSeverity::Critical
116 } else if abs_violation > tolerance * self.warning_fraction {
117 ViolationSeverity::Warning
118 } else {
119 ViolationSeverity::Acceptable
120 }
121 }
122}
123
124#[derive(Debug, Clone, Serialize, Deserialize)]
128pub enum JidokaViolation {
129 NonFiniteValue {
131 location: String,
133 value: f64,
135 },
136 EnergyDrift {
138 current: f64,
140 initial: f64,
142 drift: f64,
144 tolerance: f64,
146 },
147 ConstraintViolation {
149 name: String,
151 value: f64,
153 violation: f64,
155 tolerance: f64,
157 },
158}
159
160impl From<JidokaViolation> for SimError {
161 fn from(v: JidokaViolation) -> Self {
162 match v {
163 JidokaViolation::NonFiniteValue { location, .. } => Self::NonFiniteValue { location },
164 JidokaViolation::EnergyDrift {
165 drift, tolerance, ..
166 } => Self::EnergyDrift { drift, tolerance },
167 JidokaViolation::ConstraintViolation {
168 name,
169 violation,
170 tolerance,
171 ..
172 } => Self::ConstraintViolation {
173 name,
174 violation,
175 tolerance,
176 },
177 }
178 }
179}
180
181#[derive(Debug, Clone, Serialize, Deserialize)]
183pub struct JidokaConfig {
184 pub energy_tolerance: f64,
186 pub check_finite: bool,
188 pub constraint_tolerance: f64,
190 pub check_energy: bool,
192 #[serde(default)]
194 pub severity_classifier: SeverityClassifier,
195}
196
197impl Default for JidokaConfig {
198 fn default() -> Self {
199 Self {
200 energy_tolerance: 1e-6,
201 check_finite: true,
202 constraint_tolerance: 1e-8,
203 check_energy: true,
204 severity_classifier: SeverityClassifier::default(),
205 }
206 }
207}
208
209#[derive(Debug, Clone)]
224pub struct JidokaGuard {
225 config: JidokaConfig,
227 initial_energy: Option<f64>,
229}
230
231impl JidokaGuard {
232 #[must_use]
234 pub const fn new(config: JidokaConfig) -> Self {
235 Self {
236 config,
237 initial_energy: None,
238 }
239 }
240
241 #[must_use]
243 pub fn from_config(config: &SimConfig) -> Self {
244 Self::new(config.jidoka.clone())
245 }
246
247 pub fn check(&mut self, state: &SimState) -> SimResult<()> {
258 if self.config.check_finite {
260 self.check_finite(state)?;
261 }
262
263 if self.config.check_energy {
265 self.check_energy(state)?;
266 }
267
268 self.check_constraints(state)?;
270
271 Ok(())
272 }
273
274 #[allow(clippy::unused_self)] fn check_finite(&self, state: &SimState) -> SimResult<()> {
277 for (i, pos) in state.positions().iter().enumerate() {
279 if !pos.x.is_finite() {
280 return Err(SimError::NonFiniteValue {
281 location: format!("positions[{i}].x"),
282 });
283 }
284 if !pos.y.is_finite() {
285 return Err(SimError::NonFiniteValue {
286 location: format!("positions[{i}].y"),
287 });
288 }
289 if !pos.z.is_finite() {
290 return Err(SimError::NonFiniteValue {
291 location: format!("positions[{i}].z"),
292 });
293 }
294 }
295
296 for (i, vel) in state.velocities().iter().enumerate() {
298 if !vel.x.is_finite() {
299 return Err(SimError::NonFiniteValue {
300 location: format!("velocities[{i}].x"),
301 });
302 }
303 if !vel.y.is_finite() {
304 return Err(SimError::NonFiniteValue {
305 location: format!("velocities[{i}].y"),
306 });
307 }
308 if !vel.z.is_finite() {
309 return Err(SimError::NonFiniteValue {
310 location: format!("velocities[{i}].z"),
311 });
312 }
313 }
314
315 Ok(())
316 }
317
318 fn check_energy(&mut self, state: &SimState) -> SimResult<()> {
320 let current_energy = state.total_energy();
321
322 if !current_energy.is_finite() || current_energy.abs() < f64::EPSILON {
324 return Ok(());
325 }
326
327 match self.initial_energy {
328 None => {
329 self.initial_energy = Some(current_energy);
331 Ok(())
332 }
333 Some(initial) => {
334 let drift = (current_energy - initial).abs() / initial.abs().max(f64::EPSILON);
335
336 if drift > self.config.energy_tolerance {
337 Err(SimError::EnergyDrift {
338 drift,
339 tolerance: self.config.energy_tolerance,
340 })
341 } else {
342 Ok(())
343 }
344 }
345 }
346 }
347
348 fn check_constraints(&self, state: &SimState) -> SimResult<()> {
350 for (name, violation) in state.constraint_violations() {
351 if violation.abs() > self.config.constraint_tolerance {
352 return Err(SimError::ConstraintViolation {
353 name,
354 violation,
355 tolerance: self.config.constraint_tolerance,
356 });
357 }
358 }
359
360 Ok(())
361 }
362
363 #[allow(clippy::missing_const_for_fn)] pub fn reset(&mut self) {
366 self.initial_energy = None;
367 }
368
369 #[must_use]
371 pub const fn config(&self) -> &JidokaConfig {
372 &self.config
373 }
374
375 pub fn check_with_warnings(
384 &mut self,
385 state: &SimState,
386 ) -> Result<Vec<JidokaWarning>, SimError> {
387 let mut warnings = Vec::new();
388
389 if self.config.check_finite {
391 self.check_finite(state)?;
392 }
393
394 if self.config.check_energy {
396 if let Some(warning) = self.check_energy_graduated(state)? {
397 warnings.push(warning);
398 }
399 }
400
401 warnings.extend(self.check_constraints_graduated(state)?);
403
404 Ok(warnings)
405 }
406
407 fn check_energy_graduated(
409 &mut self,
410 state: &SimState,
411 ) -> Result<Option<JidokaWarning>, SimError> {
412 let current_energy = state.total_energy();
413
414 if !current_energy.is_finite() || current_energy.abs() < f64::EPSILON {
416 return Ok(None);
417 }
418
419 match self.initial_energy {
420 None => {
421 self.initial_energy = Some(current_energy);
422 Ok(None)
423 }
424 Some(initial) => {
425 let drift = (current_energy - initial).abs() / initial.abs().max(f64::EPSILON);
426 let severity = self
427 .config
428 .severity_classifier
429 .classify_energy_drift(drift, self.config.energy_tolerance);
430
431 match severity {
432 ViolationSeverity::Acceptable => Ok(None),
433 ViolationSeverity::Warning => Ok(Some(JidokaWarning::EnergyDriftApproaching {
434 drift,
435 tolerance: self.config.energy_tolerance,
436 })),
437 ViolationSeverity::Critical | ViolationSeverity::Fatal => {
438 Err(SimError::EnergyDrift {
439 drift,
440 tolerance: self.config.energy_tolerance,
441 })
442 }
443 }
444 }
445 }
446 }
447
448 fn check_constraints_graduated(
450 &self,
451 state: &SimState,
452 ) -> Result<Vec<JidokaWarning>, SimError> {
453 let mut warnings = Vec::new();
454
455 for (name, violation) in state.constraint_violations() {
456 let severity = self
457 .config
458 .severity_classifier
459 .classify_constraint(violation, self.config.constraint_tolerance);
460
461 match severity {
462 ViolationSeverity::Acceptable => {}
463 ViolationSeverity::Warning => {
464 warnings.push(JidokaWarning::ConstraintApproaching {
465 name,
466 violation,
467 tolerance: self.config.constraint_tolerance,
468 });
469 }
470 ViolationSeverity::Critical | ViolationSeverity::Fatal => {
471 return Err(SimError::ConstraintViolation {
472 name,
473 violation,
474 tolerance: self.config.constraint_tolerance,
475 });
476 }
477 }
478 }
479
480 Ok(warnings)
481 }
482}
483
484bitflags::bitflags! {
489 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
494 pub struct AbortConditions: u32 {
495 const NON_FINITE = 0b0001;
497 const GRADIENT_EXPLOSION = 0b0010;
499 const GRADIENT_VANISHING = 0b0100;
501 const BOUND_VIOLATION = 0b1000;
503 }
504}
505
506#[derive(Debug, Clone)]
521pub struct PreflightJidoka {
522 abort_on: AbortConditions,
524 gradient_explosion_threshold: f64,
526 gradient_vanishing_threshold: f64,
528 abort_count: u64,
530 upper_bound: f64,
532 lower_bound: f64,
534}
535
536impl Default for PreflightJidoka {
537 fn default() -> Self {
538 Self::new()
539 }
540}
541
542impl PreflightJidoka {
543 #[must_use]
545 pub fn new() -> Self {
546 Self {
547 abort_on: AbortConditions::NON_FINITE | AbortConditions::GRADIENT_EXPLOSION,
548 gradient_explosion_threshold: 1e6,
549 gradient_vanishing_threshold: 1e-10,
550 abort_count: 0,
551 upper_bound: 1e12,
552 lower_bound: -1e12,
553 }
554 }
555
556 #[must_use]
558 pub const fn with_conditions(conditions: AbortConditions) -> Self {
559 Self {
560 abort_on: conditions,
561 gradient_explosion_threshold: 1e6,
562 gradient_vanishing_threshold: 1e-10,
563 abort_count: 0,
564 upper_bound: 1e12,
565 lower_bound: -1e12,
566 }
567 }
568
569 #[must_use]
571 pub const fn with_explosion_threshold(mut self, threshold: f64) -> Self {
572 self.gradient_explosion_threshold = threshold;
573 self
574 }
575
576 #[must_use]
578 pub const fn with_vanishing_threshold(mut self, threshold: f64) -> Self {
579 self.gradient_vanishing_threshold = threshold;
580 self
581 }
582
583 #[must_use]
585 pub const fn with_bounds(mut self, lower: f64, upper: f64) -> Self {
586 self.lower_bound = lower;
587 self.upper_bound = upper;
588 self
589 }
590
591 pub fn check_value(&mut self, value: f64) -> SimResult<()> {
597 if self.abort_on.contains(AbortConditions::NON_FINITE) && !value.is_finite() {
599 self.abort_count += 1;
600 return Err(SimError::jidoka("Pre-flight: Non-finite value detected"));
601 }
602
603 if self.abort_on.contains(AbortConditions::BOUND_VIOLATION)
605 && (value < self.lower_bound || value > self.upper_bound)
606 {
607 self.abort_count += 1;
608 return Err(SimError::jidoka(format!(
609 "Pre-flight: Value {value:.2e} outside bounds [{:.2e}, {:.2e}]",
610 self.lower_bound, self.upper_bound
611 )));
612 }
613
614 Ok(())
615 }
616
617 pub fn check_values(&mut self, values: &[f64]) -> SimResult<()> {
623 for (i, &v) in values.iter().enumerate() {
624 if self.abort_on.contains(AbortConditions::NON_FINITE) && !v.is_finite() {
625 self.abort_count += 1;
626 return Err(SimError::jidoka(format!(
627 "Pre-flight: Non-finite value at index {i}"
628 )));
629 }
630
631 if self.abort_on.contains(AbortConditions::BOUND_VIOLATION)
632 && (v < self.lower_bound || v > self.upper_bound)
633 {
634 self.abort_count += 1;
635 return Err(SimError::jidoka(format!(
636 "Pre-flight: Value at index {i} ({v:.2e}) outside bounds"
637 )));
638 }
639 }
640
641 Ok(())
642 }
643
644 pub fn check_gradient_norm(&mut self, norm: f64) -> SimResult<()> {
650 if self.abort_on.contains(AbortConditions::NON_FINITE) && !norm.is_finite() {
651 self.abort_count += 1;
652 return Err(SimError::jidoka("Pre-flight: Non-finite gradient norm"));
653 }
654
655 if self.abort_on.contains(AbortConditions::GRADIENT_EXPLOSION)
656 && norm > self.gradient_explosion_threshold
657 {
658 self.abort_count += 1;
659 return Err(SimError::jidoka(format!(
660 "Pre-flight: Gradient explosion detected (norm={norm:.2e})"
661 )));
662 }
663
664 if self.abort_on.contains(AbortConditions::GRADIENT_VANISHING)
665 && norm < self.gradient_vanishing_threshold
666 && norm > 0.0
667 {
668 self.abort_count += 1;
669 return Err(SimError::jidoka(format!(
670 "Pre-flight: Gradient vanishing detected (norm={norm:.2e})"
671 )));
672 }
673
674 Ok(())
675 }
676
677 #[must_use]
679 pub const fn abort_count(&self) -> u64 {
680 self.abort_count
681 }
682
683 pub fn reset_count(&mut self) {
685 self.abort_count = 0;
686 }
687}
688
689#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
695pub enum JidokaResponse {
696 Andon,
698 AutoCorrect,
700 Monitor,
702}
703
704#[derive(Debug, Clone, Serialize, Deserialize)]
706pub enum TrainingAnomaly {
707 NaN {
709 location: String,
711 },
712 ModelCorruption {
714 description: String,
716 },
717 LossSpike {
719 current: f64,
721 expected: f64,
723 z_score: f64,
725 },
726 GradientExplosion {
728 norm: f64,
730 threshold: f64,
732 },
733 GradientVanishing {
735 norm: f64,
737 threshold: f64,
739 },
740 SlowConvergence {
742 recent_losses: Vec<f64>,
744 expected_rate: f64,
746 },
747 HighVariance {
749 variance: f64,
751 threshold: f64,
753 },
754}
755
756#[derive(Debug, Clone, Serialize, Deserialize)]
758pub enum RulePatch {
759 ReduceLearningRate {
761 factor: f64,
763 },
764 EnableGradientClipping {
766 max_norm: f64,
768 },
769 IncreaseBatchSize {
771 factor: usize,
773 },
774 EnableWarmup {
776 steps: usize,
778 },
779 SkipBatch,
781 Rollback {
783 steps: u64,
785 },
786}
787
788#[derive(Debug, Clone)]
793pub struct SelfHealingJidoka {
794 max_auto_corrections: usize,
796 correction_count: usize,
798 corrections_by_type: std::collections::HashMap<String, usize>,
800 applied_patches: Vec<RulePatch>,
802 max_same_type_corrections: usize,
804}
805
806impl Default for SelfHealingJidoka {
807 fn default() -> Self {
808 Self::new(10)
809 }
810}
811
812impl SelfHealingJidoka {
813 #[must_use]
815 pub fn new(max_auto_corrections: usize) -> Self {
816 Self {
817 max_auto_corrections,
818 correction_count: 0,
819 corrections_by_type: std::collections::HashMap::new(),
820 applied_patches: Vec::new(),
821 max_same_type_corrections: 3,
822 }
823 }
824
825 #[must_use]
827 pub const fn with_max_same_type(mut self, max: usize) -> Self {
828 self.max_same_type_corrections = max;
829 self
830 }
831
832 #[must_use]
834 pub fn classify_response(&self, anomaly: &TrainingAnomaly) -> JidokaResponse {
835 let anomaly_type = self.anomaly_type_key(anomaly);
836
837 let type_count = self
839 .corrections_by_type
840 .get(&anomaly_type)
841 .copied()
842 .unwrap_or(0);
843 if type_count >= self.max_same_type_corrections {
844 return JidokaResponse::Andon;
845 }
846
847 match anomaly {
848 TrainingAnomaly::NaN { .. } | TrainingAnomaly::ModelCorruption { .. } => {
850 JidokaResponse::Andon
851 }
852
853 TrainingAnomaly::LossSpike { z_score, .. } => {
855 if *z_score > 5.0 || self.correction_count >= self.max_auto_corrections {
856 JidokaResponse::Andon
857 } else {
858 JidokaResponse::AutoCorrect
859 }
860 }
861
862 TrainingAnomaly::GradientExplosion { .. }
863 | TrainingAnomaly::GradientVanishing { .. } => {
864 if self.correction_count < self.max_auto_corrections {
865 JidokaResponse::AutoCorrect
866 } else {
867 JidokaResponse::Andon
868 }
869 }
870
871 TrainingAnomaly::SlowConvergence { .. } | TrainingAnomaly::HighVariance { .. } => {
873 JidokaResponse::Monitor
874 }
875 }
876 }
877
878 #[must_use]
880 pub fn generate_patch(&self, anomaly: &TrainingAnomaly) -> Option<RulePatch> {
881 match anomaly {
882 TrainingAnomaly::LossSpike { z_score, .. } => {
883 if *z_score > 3.0 {
884 Some(RulePatch::SkipBatch)
885 } else {
886 Some(RulePatch::ReduceLearningRate { factor: 0.5 })
887 }
888 }
889
890 TrainingAnomaly::GradientExplosion { norm, .. } => {
891 Some(RulePatch::EnableGradientClipping {
892 max_norm: norm / 10.0,
893 })
894 }
895
896 TrainingAnomaly::GradientVanishing { .. } => {
897 Some(RulePatch::ReduceLearningRate { factor: 2.0 }) }
899
900 TrainingAnomaly::SlowConvergence { .. } => {
901 Some(RulePatch::EnableWarmup { steps: 1000 })
902 }
903
904 TrainingAnomaly::HighVariance { .. } => {
905 Some(RulePatch::IncreaseBatchSize { factor: 2 })
906 }
907
908 TrainingAnomaly::NaN { .. } | TrainingAnomaly::ModelCorruption { .. } => {
909 Some(RulePatch::Rollback { steps: 100 })
910 }
911 }
912 }
913
914 pub fn record_correction(&mut self, anomaly: &TrainingAnomaly, patch: RulePatch) {
916 let anomaly_type = self.anomaly_type_key(anomaly);
917 *self.corrections_by_type.entry(anomaly_type).or_insert(0) += 1;
918 self.correction_count += 1;
919 self.applied_patches.push(patch);
920 }
921
922 #[must_use]
924 pub const fn correction_count(&self) -> usize {
925 self.correction_count
926 }
927
928 #[must_use]
930 pub fn applied_patches(&self) -> &[RulePatch] {
931 &self.applied_patches
932 }
933
934 pub fn reset(&mut self) {
936 self.correction_count = 0;
937 self.corrections_by_type.clear();
938 self.applied_patches.clear();
939 }
940
941 #[allow(clippy::unused_self)]
943 fn anomaly_type_key(&self, anomaly: &TrainingAnomaly) -> String {
944 match anomaly {
945 TrainingAnomaly::NaN { .. } => "nan".to_string(),
946 TrainingAnomaly::ModelCorruption { .. } => "corruption".to_string(),
947 TrainingAnomaly::LossSpike { .. } => "loss_spike".to_string(),
948 TrainingAnomaly::GradientExplosion { .. } => "grad_explosion".to_string(),
949 TrainingAnomaly::GradientVanishing { .. } => "grad_vanishing".to_string(),
950 TrainingAnomaly::SlowConvergence { .. } => "slow_convergence".to_string(),
951 TrainingAnomaly::HighVariance { .. } => "high_variance".to_string(),
952 }
953 }
954}
955
956#[cfg(test)]
957mod tests {
958 use super::*;
959 use crate::engine::state::Vec3;
960
961 #[test]
962 fn test_finite_check_passes_valid_state() {
963 let mut guard = JidokaGuard::new(JidokaConfig::default());
964 let state = SimState::default();
965
966 assert!(guard.check(&state).is_ok());
967 }
968
969 #[test]
970 fn test_finite_check_catches_nan() {
971 let mut guard = JidokaGuard::new(JidokaConfig::default());
972 let mut state = SimState::default();
973
974 state.add_body(1.0, Vec3::new(f64::NAN, 0.0, 0.0), Vec3::zero());
976
977 let result = guard.check(&state);
978 assert!(result.is_err());
979
980 if let Err(SimError::NonFiniteValue { location }) = result {
981 assert!(location.contains("positions"));
982 } else {
983 panic!("Expected NonFiniteValue error");
984 }
985 }
986
987 #[test]
988 fn test_finite_check_catches_infinity() {
989 let mut guard = JidokaGuard::new(JidokaConfig::default());
990 let mut state = SimState::default();
991
992 state.add_body(1.0, Vec3::zero(), Vec3::new(0.0, f64::INFINITY, 0.0));
993
994 let result = guard.check(&state);
995 assert!(result.is_err());
996
997 if let Err(SimError::NonFiniteValue { location }) = result {
998 assert!(location.contains("velocities"));
999 } else {
1000 panic!("Expected NonFiniteValue error");
1001 }
1002 }
1003
1004 #[test]
1005 fn test_energy_drift_detection() {
1006 let config = JidokaConfig {
1007 energy_tolerance: 0.01,
1008 check_energy: true,
1009 ..Default::default()
1010 };
1011 let mut guard = JidokaGuard::new(config);
1012
1013 let mut state = SimState::default();
1015 state.add_body(1.0, Vec3::new(1.0, 0.0, 0.0), Vec3::new(1.0, 0.0, 0.0));
1016
1017 assert!(guard.check(&state).is_ok());
1019
1020 state.set_velocity(0, Vec3::new(10.0, 0.0, 0.0)); let result = guard.check(&state);
1024 assert!(result.is_err());
1025 assert!(matches!(result, Err(SimError::EnergyDrift { .. })));
1026 }
1027
1028 #[test]
1029 fn test_constraint_violation_detection() {
1030 let config = JidokaConfig {
1031 constraint_tolerance: 0.001,
1032 ..Default::default()
1033 };
1034 let mut guard = JidokaGuard::new(config);
1035
1036 let mut state = SimState::default();
1037 state.add_constraint("test_constraint", 0.01); let result = guard.check(&state);
1040 assert!(result.is_err());
1041 assert!(matches!(result, Err(SimError::ConstraintViolation { .. })));
1042 }
1043
1044 #[test]
1045 fn test_guard_reset() {
1046 let mut guard = JidokaGuard::new(JidokaConfig::default());
1047 let mut state = SimState::default();
1048
1049 state.add_body(1.0, Vec3::new(1.0, 0.0, 0.0), Vec3::new(1.0, 0.0, 0.0));
1051 state.set_potential_energy(1.0);
1052
1053 guard.check(&state).ok();
1055 assert!(
1056 guard.initial_energy.is_some(),
1057 "Initial energy should be recorded for non-zero energy state"
1058 );
1059
1060 guard.reset();
1062 assert!(guard.initial_energy.is_none());
1063 }
1064
1065 #[test]
1066 fn test_disabled_checks() {
1067 let config = JidokaConfig {
1068 check_finite: false,
1069 check_energy: false,
1070 ..Default::default()
1071 };
1072 let mut guard = JidokaGuard::new(config);
1073
1074 let mut state = SimState::default();
1075 state.add_body(1.0, Vec3::new(f64::NAN, 0.0, 0.0), Vec3::zero());
1076
1077 assert!(guard.check(&state).is_ok());
1079 }
1080
1081 #[test]
1084 fn test_severity_classifier_acceptable() {
1085 let classifier = SeverityClassifier::new(0.8);
1086
1087 let severity = classifier.classify_energy_drift(0.5, 1.0);
1089 assert_eq!(severity, ViolationSeverity::Acceptable);
1090
1091 let severity = classifier.classify_energy_drift(0.79, 1.0);
1093 assert_eq!(severity, ViolationSeverity::Acceptable);
1094 }
1095
1096 #[test]
1097 fn test_severity_classifier_warning() {
1098 let classifier = SeverityClassifier::new(0.8);
1099
1100 let severity = classifier.classify_energy_drift(0.81, 1.0);
1102 assert_eq!(severity, ViolationSeverity::Warning);
1103
1104 let severity = classifier.classify_energy_drift(0.99, 1.0);
1106 assert_eq!(severity, ViolationSeverity::Warning);
1107
1108 let severity = classifier.classify_energy_drift(0.8, 1.0);
1110 assert_eq!(severity, ViolationSeverity::Acceptable);
1111 }
1112
1113 #[test]
1114 fn test_severity_classifier_critical() {
1115 let classifier = SeverityClassifier::new(0.8);
1116
1117 let severity = classifier.classify_energy_drift(1.0, 1.0);
1119 assert_eq!(severity, ViolationSeverity::Warning); let severity = classifier.classify_energy_drift(1.01, 1.0);
1123 assert_eq!(severity, ViolationSeverity::Critical);
1124
1125 let severity = classifier.classify_energy_drift(2.0, 1.0);
1127 assert_eq!(severity, ViolationSeverity::Critical);
1128 }
1129
1130 #[test]
1131 fn test_severity_classifier_fatal() {
1132 let classifier = SeverityClassifier::new(0.8);
1133
1134 let severity = classifier.classify_energy_drift(f64::NAN, 1.0);
1136 assert_eq!(severity, ViolationSeverity::Fatal);
1137
1138 let severity = classifier.classify_energy_drift(f64::INFINITY, 1.0);
1140 assert_eq!(severity, ViolationSeverity::Fatal);
1141
1142 let severity = classifier.classify_energy_drift(f64::NEG_INFINITY, 1.0);
1144 assert_eq!(severity, ViolationSeverity::Fatal);
1145 }
1146
1147 #[test]
1148 fn test_severity_classifier_constraint() {
1149 let classifier = SeverityClassifier::new(0.8);
1150
1151 assert_eq!(
1153 classifier.classify_constraint(0.5, 1.0),
1154 ViolationSeverity::Acceptable
1155 );
1156 assert_eq!(
1157 classifier.classify_constraint(0.85, 1.0),
1158 ViolationSeverity::Warning
1159 );
1160 assert_eq!(
1161 classifier.classify_constraint(1.5, 1.0),
1162 ViolationSeverity::Critical
1163 );
1164
1165 assert_eq!(
1167 classifier.classify_constraint(-0.5, 1.0),
1168 ViolationSeverity::Acceptable
1169 );
1170 assert_eq!(
1171 classifier.classify_constraint(-0.85, 1.0),
1172 ViolationSeverity::Warning
1173 );
1174 assert_eq!(
1175 classifier.classify_constraint(-1.5, 1.0),
1176 ViolationSeverity::Critical
1177 );
1178 }
1179
1180 #[test]
1181 fn test_severity_classifier_default() {
1182 let classifier = SeverityClassifier::default();
1183 assert!((classifier.warning_fraction - 0.8).abs() < f64::EPSILON);
1184 }
1185
1186 #[test]
1189 fn test_check_with_warnings_no_warnings() {
1190 let mut guard = JidokaGuard::new(JidokaConfig::default());
1191 let state = SimState::default();
1192
1193 let result = guard.check_with_warnings(&state);
1194 assert!(result.is_ok());
1195 assert!(result.unwrap().is_empty());
1196 }
1197
1198 #[test]
1199 fn test_check_with_warnings_energy_warning() {
1200 let config = JidokaConfig {
1201 energy_tolerance: 1.0,
1202 check_energy: true,
1203 severity_classifier: SeverityClassifier::new(0.8),
1204 ..Default::default()
1205 };
1206 let mut guard = JidokaGuard::new(config);
1207
1208 let mut state = SimState::default();
1210 state.add_body(1.0, Vec3::new(1.0, 0.0, 0.0), Vec3::new(1.0, 0.0, 0.0));
1211 state.set_potential_energy(10.0);
1212
1213 let _ = guard.check_with_warnings(&state);
1215
1216 state.set_potential_energy(18.9);
1220
1221 let result = guard.check_with_warnings(&state);
1222 assert!(result.is_ok());
1223 let warnings = result.unwrap();
1224 assert!(!warnings.is_empty(), "Should have energy drift warning");
1225
1226 match &warnings[0] {
1227 JidokaWarning::EnergyDriftApproaching { drift, .. } => {
1228 assert!(*drift > 0.8, "Drift should be > 80%");
1229 assert!(*drift <= 1.0, "Drift should be <= 100%");
1230 }
1231 JidokaWarning::ConstraintApproaching { .. } => {
1232 panic!("Expected EnergyDriftApproaching warning")
1233 }
1234 }
1235 }
1236
1237 #[test]
1238 fn test_check_with_warnings_constraint_warning() {
1239 let config = JidokaConfig {
1240 constraint_tolerance: 1.0,
1241 severity_classifier: SeverityClassifier::new(0.8),
1242 check_energy: false, ..Default::default()
1244 };
1245 let mut guard = JidokaGuard::new(config);
1246
1247 let mut state = SimState::default();
1248 state.add_constraint("test", 0.9); let result = guard.check_with_warnings(&state);
1251 assert!(result.is_ok());
1252 let warnings = result.unwrap();
1253 assert!(!warnings.is_empty(), "Should have constraint warning");
1254
1255 match &warnings[0] {
1256 JidokaWarning::ConstraintApproaching {
1257 name, violation, ..
1258 } => {
1259 assert_eq!(name, "test");
1260 assert!((*violation - 0.9).abs() < f64::EPSILON);
1261 }
1262 JidokaWarning::EnergyDriftApproaching { .. } => {
1263 panic!("Expected ConstraintApproaching warning")
1264 }
1265 }
1266 }
1267
1268 #[test]
1269 fn test_check_with_warnings_critical_error() {
1270 let config = JidokaConfig {
1271 constraint_tolerance: 1.0,
1272 severity_classifier: SeverityClassifier::new(0.8),
1273 check_energy: false,
1274 ..Default::default()
1275 };
1276 let mut guard = JidokaGuard::new(config);
1277
1278 let mut state = SimState::default();
1279 state.add_constraint("critical", 1.5); let result = guard.check_with_warnings(&state);
1282 assert!(result.is_err());
1283 assert!(matches!(result, Err(SimError::ConstraintViolation { .. })));
1284 }
1285
1286 #[test]
1287 fn test_check_with_warnings_fatal_nan() {
1288 let mut guard = JidokaGuard::new(JidokaConfig::default());
1289 let mut state = SimState::default();
1290 state.add_body(1.0, Vec3::new(f64::NAN, 0.0, 0.0), Vec3::zero());
1291
1292 let result = guard.check_with_warnings(&state);
1293 assert!(result.is_err());
1294 assert!(matches!(result, Err(SimError::NonFiniteValue { .. })));
1295 }
1296
1297 #[test]
1298 fn test_violation_severity_ordering() {
1299 assert!(ViolationSeverity::Acceptable < ViolationSeverity::Warning);
1301 assert!(ViolationSeverity::Warning < ViolationSeverity::Critical);
1302 assert!(ViolationSeverity::Critical < ViolationSeverity::Fatal);
1303 }
1304
1305 #[test]
1308 fn test_preflight_check_value_valid() {
1309 let mut preflight = PreflightJidoka::new();
1310 assert!(preflight.check_value(1.0).is_ok());
1311 assert!(preflight.check_value(-1.0).is_ok());
1312 assert!(preflight.check_value(0.0).is_ok());
1313 assert_eq!(preflight.abort_count(), 0);
1314 }
1315
1316 #[test]
1317 fn test_preflight_check_value_nan() {
1318 let mut preflight = PreflightJidoka::new();
1319 assert!(preflight.check_value(f64::NAN).is_err());
1320 assert_eq!(preflight.abort_count(), 1);
1321 }
1322
1323 #[test]
1324 fn test_preflight_check_value_infinity() {
1325 let mut preflight = PreflightJidoka::new();
1326 assert!(preflight.check_value(f64::INFINITY).is_err());
1327 assert_eq!(preflight.abort_count(), 1);
1328
1329 assert!(preflight.check_value(f64::NEG_INFINITY).is_err());
1330 assert_eq!(preflight.abort_count(), 2);
1331 }
1332
1333 #[test]
1334 fn test_preflight_check_values() {
1335 let mut preflight = PreflightJidoka::new();
1336 let values = vec![1.0, 2.0, 3.0, 4.0];
1337 assert!(preflight.check_values(&values).is_ok());
1338
1339 let values_with_nan = vec![1.0, 2.0, f64::NAN, 4.0];
1340 assert!(preflight.check_values(&values_with_nan).is_err());
1341 }
1342
1343 #[test]
1344 fn test_preflight_gradient_explosion() {
1345 let mut preflight = PreflightJidoka::new().with_explosion_threshold(100.0);
1346
1347 assert!(preflight.check_gradient_norm(50.0).is_ok());
1348 assert!(preflight.check_gradient_norm(150.0).is_err());
1349 assert_eq!(preflight.abort_count(), 1);
1350 }
1351
1352 #[test]
1353 fn test_preflight_gradient_vanishing() {
1354 let mut preflight = PreflightJidoka::with_conditions(
1355 AbortConditions::NON_FINITE | AbortConditions::GRADIENT_VANISHING,
1356 )
1357 .with_vanishing_threshold(1e-8);
1358
1359 assert!(preflight.check_gradient_norm(1e-6).is_ok()); assert!(preflight.check_gradient_norm(1e-10).is_err()); assert!(preflight.check_gradient_norm(0.0).is_ok()); }
1363
1364 #[test]
1365 fn test_preflight_bounds() {
1366 let mut preflight = PreflightJidoka::with_conditions(AbortConditions::BOUND_VIOLATION)
1367 .with_bounds(-100.0, 100.0);
1368
1369 assert!(preflight.check_value(50.0).is_ok());
1370 assert!(preflight.check_value(-50.0).is_ok());
1371 assert!(preflight.check_value(150.0).is_err());
1372 assert!(preflight.check_value(-150.0).is_err());
1373 }
1374
1375 #[test]
1376 fn test_preflight_reset_count() {
1377 let mut preflight = PreflightJidoka::new();
1378 let _ = preflight.check_value(f64::NAN);
1379 assert_eq!(preflight.abort_count(), 1);
1380
1381 preflight.reset_count();
1382 assert_eq!(preflight.abort_count(), 0);
1383 }
1384
1385 #[test]
1388 fn test_self_healing_nan_always_andon() {
1389 let healer = SelfHealingJidoka::new(10);
1390 let anomaly = TrainingAnomaly::NaN {
1391 location: "loss".to_string(),
1392 };
1393 assert_eq!(healer.classify_response(&anomaly), JidokaResponse::Andon);
1394 }
1395
1396 #[test]
1397 fn test_self_healing_corruption_always_andon() {
1398 let healer = SelfHealingJidoka::new(10);
1399 let anomaly = TrainingAnomaly::ModelCorruption {
1400 description: "CRC mismatch".to_string(),
1401 };
1402 assert_eq!(healer.classify_response(&anomaly), JidokaResponse::Andon);
1403 }
1404
1405 #[test]
1406 fn test_self_healing_loss_spike_auto_correct() {
1407 let healer = SelfHealingJidoka::new(10);
1408 let anomaly = TrainingAnomaly::LossSpike {
1409 current: 10.0,
1410 expected: 1.0,
1411 z_score: 3.0,
1412 };
1413 assert_eq!(
1414 healer.classify_response(&anomaly),
1415 JidokaResponse::AutoCorrect
1416 );
1417 }
1418
1419 #[test]
1420 fn test_self_healing_extreme_loss_spike_andon() {
1421 let healer = SelfHealingJidoka::new(10);
1422 let anomaly = TrainingAnomaly::LossSpike {
1423 current: 100.0,
1424 expected: 1.0,
1425 z_score: 6.0, };
1427 assert_eq!(healer.classify_response(&anomaly), JidokaResponse::Andon);
1428 }
1429
1430 #[test]
1431 fn test_self_healing_gradient_explosion_auto_correct() {
1432 let healer = SelfHealingJidoka::new(10);
1433 let anomaly = TrainingAnomaly::GradientExplosion {
1434 norm: 1e7,
1435 threshold: 1e6,
1436 };
1437 assert_eq!(
1438 healer.classify_response(&anomaly),
1439 JidokaResponse::AutoCorrect
1440 );
1441 }
1442
1443 #[test]
1444 fn test_self_healing_slow_convergence_monitor() {
1445 let healer = SelfHealingJidoka::new(10);
1446 let anomaly = TrainingAnomaly::SlowConvergence {
1447 recent_losses: vec![1.0, 0.99, 0.98],
1448 expected_rate: 0.1,
1449 };
1450 assert_eq!(healer.classify_response(&anomaly), JidokaResponse::Monitor);
1451 }
1452
1453 #[test]
1454 fn test_self_healing_high_variance_monitor() {
1455 let healer = SelfHealingJidoka::new(10);
1456 let anomaly = TrainingAnomaly::HighVariance {
1457 variance: 0.5,
1458 threshold: 0.1,
1459 };
1460 assert_eq!(healer.classify_response(&anomaly), JidokaResponse::Monitor);
1461 }
1462
1463 #[test]
1464 fn test_self_healing_escalation_after_max_corrections() {
1465 let mut healer = SelfHealingJidoka::new(2);
1466
1467 let anomaly = TrainingAnomaly::GradientExplosion {
1468 norm: 1e7,
1469 threshold: 1e6,
1470 };
1471
1472 assert_eq!(
1474 healer.classify_response(&anomaly),
1475 JidokaResponse::AutoCorrect
1476 );
1477 let patch = healer.generate_patch(&anomaly).unwrap();
1478 healer.record_correction(&anomaly, patch);
1479
1480 assert_eq!(
1481 healer.classify_response(&anomaly),
1482 JidokaResponse::AutoCorrect
1483 );
1484 let patch = healer.generate_patch(&anomaly).unwrap();
1485 healer.record_correction(&anomaly, patch);
1486
1487 assert_eq!(healer.classify_response(&anomaly), JidokaResponse::Andon);
1489 }
1490
1491 #[test]
1492 fn test_self_healing_generate_patch() {
1493 let healer = SelfHealingJidoka::new(10);
1494
1495 let anomaly = TrainingAnomaly::LossSpike {
1497 current: 10.0,
1498 expected: 1.0,
1499 z_score: 4.0,
1500 };
1501 assert!(matches!(
1502 healer.generate_patch(&anomaly),
1503 Some(RulePatch::SkipBatch)
1504 ));
1505
1506 let anomaly = TrainingAnomaly::GradientExplosion {
1508 norm: 1e7,
1509 threshold: 1e6,
1510 };
1511 assert!(matches!(
1512 healer.generate_patch(&anomaly),
1513 Some(RulePatch::EnableGradientClipping { .. })
1514 ));
1515
1516 let anomaly = TrainingAnomaly::SlowConvergence {
1518 recent_losses: vec![],
1519 expected_rate: 0.1,
1520 };
1521 assert!(matches!(
1522 healer.generate_patch(&anomaly),
1523 Some(RulePatch::EnableWarmup { .. })
1524 ));
1525 }
1526
1527 #[test]
1528 fn test_self_healing_reset() {
1529 let mut healer = SelfHealingJidoka::new(10);
1530
1531 let anomaly = TrainingAnomaly::GradientExplosion {
1532 norm: 1e7,
1533 threshold: 1e6,
1534 };
1535 let patch = healer.generate_patch(&anomaly).unwrap();
1536 healer.record_correction(&anomaly, patch);
1537
1538 assert_eq!(healer.correction_count(), 1);
1539 assert!(!healer.applied_patches().is_empty());
1540
1541 healer.reset();
1542
1543 assert_eq!(healer.correction_count(), 0);
1544 assert!(healer.applied_patches().is_empty());
1545 }
1546
1547 #[test]
1548 fn test_self_healing_type_specific_escalation() {
1549 let mut healer = SelfHealingJidoka::new(100).with_max_same_type(2);
1550
1551 let explosion = TrainingAnomaly::GradientExplosion {
1552 norm: 1e7,
1553 threshold: 1e6,
1554 };
1555 let spike = TrainingAnomaly::LossSpike {
1556 current: 10.0,
1557 expected: 1.0,
1558 z_score: 3.0,
1559 };
1560
1561 for _ in 0..2 {
1563 let patch = healer.generate_patch(&explosion).unwrap();
1564 healer.record_correction(&explosion, patch);
1565 }
1566
1567 assert_eq!(healer.classify_response(&explosion), JidokaResponse::Andon);
1569
1570 assert_eq!(
1572 healer.classify_response(&spike),
1573 JidokaResponse::AutoCorrect
1574 );
1575 }
1576
1577 #[test]
1580 fn test_violation_severity_clone_debug() {
1581 let severity = ViolationSeverity::Warning;
1582 let cloned = severity.clone();
1583 assert_eq!(cloned, ViolationSeverity::Warning);
1584
1585 let debug = format!("{:?}", severity);
1586 assert!(debug.contains("Warning"));
1587 }
1588
1589 #[test]
1590 fn test_jidoka_warning_clone_debug() {
1591 let warning = JidokaWarning::EnergyDriftApproaching {
1592 drift: 0.9,
1593 tolerance: 1.0,
1594 };
1595 let cloned = warning.clone();
1596 let debug = format!("{:?}", cloned);
1597 assert!(debug.contains("EnergyDriftApproaching"));
1598
1599 let warning2 = JidokaWarning::ConstraintApproaching {
1600 name: "test".to_string(),
1601 violation: 0.5,
1602 tolerance: 1.0,
1603 };
1604 let debug2 = format!("{:?}", warning2);
1605 assert!(debug2.contains("ConstraintApproaching"));
1606 }
1607
1608 #[test]
1609 fn test_severity_classifier_clone_debug() {
1610 let classifier = SeverityClassifier::new(0.85);
1611 let cloned = classifier.clone();
1612 assert!((cloned.warning_fraction - 0.85).abs() < f64::EPSILON);
1613
1614 let debug = format!("{:?}", classifier);
1615 assert!(debug.contains("SeverityClassifier"));
1616 }
1617
1618 #[test]
1619 fn test_jidoka_config_debug() {
1620 let config = JidokaConfig::default();
1621 let debug = format!("{:?}", config);
1622 assert!(debug.contains("JidokaConfig"));
1623 }
1624
1625 #[test]
1626 fn test_jidoka_guard_debug() {
1627 let guard = JidokaGuard::new(JidokaConfig::default());
1628 let debug = format!("{:?}", guard);
1629 assert!(debug.contains("JidokaGuard"));
1630 }
1631
1632 #[test]
1633 fn test_violation_severity_ord_impl() {
1634 assert!(ViolationSeverity::Acceptable < ViolationSeverity::Warning);
1635 assert!(ViolationSeverity::Warning < ViolationSeverity::Critical);
1636 assert!(ViolationSeverity::Critical < ViolationSeverity::Fatal);
1637 }
1638}
1639
1640#[cfg(test)]
1641mod proptests {
1642 use super::*;
1643 use crate::engine::state::Vec3;
1644 use proptest::prelude::*;
1645
1646 proptest! {
1647 #[test]
1649 fn prop_valid_state_passes(
1650 x in -1e6f64..1e6,
1651 y in -1e6f64..1e6,
1652 z in -1e6f64..1e6,
1653 vx in -1e3f64..1e3,
1654 vy in -1e3f64..1e3,
1655 vz in -1e3f64..1e3,
1656 mass in 0.1f64..1e6,
1657 ) {
1658 let mut guard = JidokaGuard::new(JidokaConfig::default());
1659 let mut state = SimState::default();
1660
1661 state.add_body(mass, Vec3::new(x, y, z), Vec3::new(vx, vy, vz));
1662
1663 prop_assert!(guard.check(&state).is_ok());
1665 }
1666
1667 #[test]
1669 fn prop_severity_monotonic(
1670 tolerance in 0.001f64..100.0,
1671 warning_fraction in 0.5f64..0.99,
1672 ) {
1673 let classifier = SeverityClassifier::new(warning_fraction);
1674
1675 let below_warning = tolerance * warning_fraction * 0.5;
1677 let at_warning = tolerance * warning_fraction;
1678 let above_tolerance = tolerance * 1.5;
1679
1680 let sev_below = classifier.classify_energy_drift(below_warning, tolerance);
1681 let sev_at = classifier.classify_energy_drift(at_warning, tolerance);
1682 let sev_above = classifier.classify_energy_drift(above_tolerance, tolerance);
1683
1684 prop_assert!(sev_below <= sev_at);
1686 prop_assert!(sev_at <= sev_above);
1687 }
1688
1689 #[test]
1691 fn prop_acceptable_boundary(
1692 tolerance in 0.001f64..100.0,
1693 warning_fraction in 0.5f64..0.99,
1694 drift_fraction in 0.0f64..0.99,
1695 ) {
1696 let classifier = SeverityClassifier::new(warning_fraction);
1697 let drift = tolerance * warning_fraction * drift_fraction;
1698
1699 let severity = classifier.classify_energy_drift(drift, tolerance);
1700 prop_assert_eq!(severity, ViolationSeverity::Acceptable);
1701 }
1702
1703 #[test]
1705 fn prop_critical_boundary(
1706 tolerance in 0.001f64..100.0,
1707 excess_factor in 1.01f64..10.0,
1708 ) {
1709 let classifier = SeverityClassifier::default();
1710 let drift = tolerance * excess_factor;
1711
1712 let severity = classifier.classify_energy_drift(drift, tolerance);
1713 prop_assert_eq!(severity, ViolationSeverity::Critical);
1714 }
1715
1716 #[test]
1718 fn prop_constraint_abs_symmetry(
1719 violation in 0.001f64..100.0,
1720 tolerance in 0.01f64..100.0,
1721 ) {
1722 let classifier = SeverityClassifier::default();
1723
1724 let pos_severity = classifier.classify_constraint(violation, tolerance);
1725 let neg_severity = classifier.classify_constraint(-violation, tolerance);
1726
1727 prop_assert_eq!(pos_severity, neg_severity);
1729 }
1730 }
1731}