Skip to main content

simular/orbit/
jidoka.rs

1//! Jidoka (自働化) - Graceful degradation anomaly detection.
2//!
3//! Implements Toyota's Jidoka principle with graceful degradation:
4//! - Pause simulation and highlight defects (don't crash)
5//! - Allow user intervention and recovery
6//! - Visual management (Mieruka) of status
7//!
8//! # TPS Review Feedback (v1.1.0)
9//!
10//! Jidoka should **pause, not crash** — the system stops and highlights
11//! the defect while remaining available for intervention [27].
12//!
13//! # References
14//!
15//! [27] Avizienis et al., "Dependable and Secure Computing," IEEE TDSC, 2004.
16
17use crate::orbit::physics::NBodyState;
18use serde::{Deserialize, Serialize};
19
20/// Jidoka response types with graceful degradation.
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub enum JidokaResponse {
23    /// All checks passed, continue normally.
24    Continue,
25
26    /// Warning detected, continue with visual indicator.
27    Warning {
28        message: String,
29        body_index: Option<usize>,
30        metric: String,
31        current: f64,
32        threshold: f64,
33    },
34
35    /// Critical violation, pause simulation for user intervention.
36    Pause {
37        violation: OrbitJidokaViolation,
38        recoverable: bool,
39        suggestion: String,
40    },
41
42    /// Fatal unrecoverable error, halt with state snapshot.
43    Halt { violation: OrbitJidokaViolation },
44}
45
46impl JidokaResponse {
47    /// Check if this response allows continuation.
48    #[must_use]
49    pub fn can_continue(&self) -> bool {
50        matches!(self, Self::Continue | Self::Warning { .. })
51    }
52
53    /// Check if this is a warning.
54    #[must_use]
55    pub fn is_warning(&self) -> bool {
56        matches!(self, Self::Warning { .. })
57    }
58
59    /// Check if simulation should pause.
60    #[must_use]
61    pub fn should_pause(&self) -> bool {
62        matches!(self, Self::Pause { .. })
63    }
64
65    /// Check if simulation should halt.
66    #[must_use]
67    pub fn should_halt(&self) -> bool {
68        matches!(self, Self::Halt { .. })
69    }
70}
71
72/// Jidoka violation types for orbital simulation.
73#[derive(Debug, Clone, Serialize, Deserialize)]
74pub enum OrbitJidokaViolation {
75    /// Non-finite value (NaN or Inf) detected.
76    NonFinite {
77        body_index: usize,
78        field: String,
79        value: f64,
80    },
81
82    /// Energy conservation violated.
83    EnergyDrift {
84        initial: f64,
85        current: f64,
86        relative_error: f64,
87        tolerance: f64,
88    },
89
90    /// Angular momentum conservation violated.
91    AngularMomentumDrift {
92        initial: f64,
93        current: f64,
94        relative_error: f64,
95        tolerance: f64,
96    },
97
98    /// Close encounter (collision risk).
99    CloseEncounter {
100        body_i: usize,
101        body_j: usize,
102        separation: f64,
103        threshold: f64,
104    },
105
106    /// Escape velocity exceeded (body leaving system).
107    EscapeVelocity {
108        body_index: usize,
109        velocity: f64,
110        escape_velocity: f64,
111    },
112}
113
114impl std::fmt::Display for OrbitJidokaViolation {
115    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
116        match self {
117            Self::NonFinite {
118                body_index,
119                field,
120                value,
121            } => {
122                write!(f, "Non-finite {field} at body {body_index}: {value}")
123            }
124            Self::EnergyDrift {
125                relative_error,
126                tolerance,
127                ..
128            } => {
129                write!(
130                    f,
131                    "Energy drift {relative_error:.2e} exceeds tolerance {tolerance:.2e}"
132                )
133            }
134            Self::AngularMomentumDrift {
135                relative_error,
136                tolerance,
137                ..
138            } => {
139                write!(
140                    f,
141                    "Angular momentum drift {relative_error:.2e} exceeds tolerance {tolerance:.2e}"
142                )
143            }
144            Self::CloseEncounter {
145                body_i,
146                body_j,
147                separation,
148                threshold,
149            } => {
150                write!(f, "Close encounter: bodies {body_i}-{body_j} at {separation:.2e}m (threshold: {threshold:.2e}m)")
151            }
152            Self::EscapeVelocity {
153                body_index,
154                velocity,
155                escape_velocity,
156            } => {
157                write!(f, "Body {body_index} at escape velocity: {velocity:.2e} > {escape_velocity:.2e} m/s")
158            }
159        }
160    }
161}
162
163/// Jidoka configuration for orbital simulation.
164#[derive(Debug, Clone, Serialize, Deserialize)]
165#[allow(clippy::struct_excessive_bools)] // Config struct with feature flags
166pub struct OrbitJidokaConfig {
167    /// Check for non-finite values.
168    pub check_finite: bool,
169    /// Check energy conservation.
170    pub check_energy: bool,
171    /// Energy drift tolerance (relative).
172    pub energy_tolerance: f64,
173    /// Energy warning threshold (fraction of tolerance).
174    pub energy_warning_fraction: f64,
175    /// Check angular momentum conservation.
176    pub check_angular_momentum: bool,
177    /// Angular momentum tolerance (relative).
178    pub angular_momentum_tolerance: f64,
179    /// Check for close encounters.
180    pub check_close_encounters: bool,
181    /// Close encounter threshold (meters).
182    pub close_encounter_threshold: f64,
183    /// Maximum warnings before pausing.
184    pub max_warnings_before_pause: usize,
185}
186
187impl Default for OrbitJidokaConfig {
188    fn default() -> Self {
189        Self {
190            check_finite: true,
191            check_energy: true,
192            energy_tolerance: 1e-6,
193            energy_warning_fraction: 0.8,
194            check_angular_momentum: true,
195            angular_momentum_tolerance: 1e-9,
196            check_close_encounters: true,
197            close_encounter_threshold: 1e6, // 1000 km
198            max_warnings_before_pause: 10,
199        }
200    }
201}
202
203/// Jidoka status for visualization (Mieruka).
204#[derive(Debug, Clone, Default, Serialize, Deserialize)]
205#[allow(clippy::struct_excessive_bools)] // Status struct with multiple checks
206pub struct JidokaStatus {
207    /// Energy relative error.
208    pub energy_error: f64,
209    /// Energy check passed.
210    pub energy_ok: bool,
211    /// Angular momentum relative error.
212    pub angular_momentum_error: f64,
213    /// Angular momentum check passed.
214    pub angular_momentum_ok: bool,
215    /// All values finite.
216    pub finite_ok: bool,
217    /// Minimum separation between bodies.
218    pub min_separation: f64,
219    /// Close encounter warning.
220    pub close_encounter_warning: bool,
221    /// Total warning count.
222    pub warning_count: usize,
223}
224
225/// Orbital Jidoka guard with graceful degradation.
226#[derive(Debug, Clone)]
227pub struct OrbitJidokaGuard {
228    config: OrbitJidokaConfig,
229    initial_energy: Option<f64>,
230    initial_angular_momentum: Option<f64>,
231    warning_count: usize,
232    status: JidokaStatus,
233}
234
235impl OrbitJidokaGuard {
236    /// Create a new Jidoka guard.
237    #[must_use]
238    pub fn new(config: OrbitJidokaConfig) -> Self {
239        Self {
240            config,
241            initial_energy: None,
242            initial_angular_momentum: None,
243            warning_count: 0,
244            status: JidokaStatus::default(),
245        }
246    }
247
248    /// Initialize with initial state values.
249    pub fn initialize(&mut self, state: &NBodyState) {
250        self.initial_energy = Some(state.total_energy());
251        self.initial_angular_momentum = Some(state.angular_momentum_magnitude());
252        self.warning_count = 0;
253    }
254
255    /// Reset warning count (e.g., after user intervention).
256    pub fn reset_warnings(&mut self) {
257        self.warning_count = 0;
258    }
259
260    /// Get current status for visualization.
261    #[must_use]
262    pub fn status(&self) -> &JidokaStatus {
263        &self.status
264    }
265
266    /// Check state and return response (graceful degradation).
267    pub fn check(&mut self, state: &NBodyState) -> JidokaResponse {
268        // Reset status
269        self.status = JidokaStatus::default();
270
271        // 1. Check finite values (fatal if violated)
272        if self.config.check_finite {
273            if let Some(response) = self.check_finite(state) {
274                return response;
275            }
276        }
277
278        // 2. Check energy conservation
279        if self.config.check_energy {
280            if let Some(response) = self.check_energy(state) {
281                return response;
282            }
283        }
284
285        // 3. Check angular momentum conservation
286        if self.config.check_angular_momentum {
287            if let Some(response) = self.check_angular_momentum(state) {
288                return response;
289            }
290        }
291
292        // 4. Check close encounters
293        if self.config.check_close_encounters {
294            if let Some(response) = self.check_close_encounters(state) {
295                return response;
296            }
297        }
298
299        // All checks passed
300        JidokaResponse::Continue
301    }
302
303    fn check_finite(&mut self, state: &NBodyState) -> Option<JidokaResponse> {
304        for (i, body) in state.bodies.iter().enumerate() {
305            if !body.position.is_finite() {
306                self.status.finite_ok = false;
307                return Some(JidokaResponse::Halt {
308                    violation: OrbitJidokaViolation::NonFinite {
309                        body_index: i,
310                        field: "position".to_string(),
311                        value: f64::NAN,
312                    },
313                });
314            }
315            if !body.velocity.is_finite() {
316                self.status.finite_ok = false;
317                return Some(JidokaResponse::Halt {
318                    violation: OrbitJidokaViolation::NonFinite {
319                        body_index: i,
320                        field: "velocity".to_string(),
321                        value: f64::NAN,
322                    },
323                });
324            }
325        }
326        self.status.finite_ok = true;
327        None
328    }
329
330    fn check_energy(&mut self, state: &NBodyState) -> Option<JidokaResponse> {
331        let initial = self.initial_energy?;
332
333        let current = state.total_energy();
334        let relative_error = if initial.abs() > f64::EPSILON {
335            (current - initial).abs() / initial.abs()
336        } else {
337            (current - initial).abs()
338        };
339
340        self.status.energy_error = relative_error;
341        self.status.energy_ok = relative_error <= self.config.energy_tolerance;
342
343        // Critical violation
344        if relative_error > self.config.energy_tolerance {
345            self.warning_count += 1;
346
347            if self.warning_count >= self.config.max_warnings_before_pause {
348                return Some(JidokaResponse::Pause {
349                    violation: OrbitJidokaViolation::EnergyDrift {
350                        initial,
351                        current,
352                        relative_error,
353                        tolerance: self.config.energy_tolerance,
354                    },
355                    recoverable: true,
356                    suggestion: "Consider reducing time step or using Yoshida integrator"
357                        .to_string(),
358                });
359            }
360
361            return Some(JidokaResponse::Warning {
362                message: format!("Energy drift: {relative_error:.2e}"),
363                body_index: None,
364                metric: "energy".to_string(),
365                current: relative_error,
366                threshold: self.config.energy_tolerance,
367            });
368        }
369
370        // Warning threshold
371        let warning_threshold = self.config.energy_tolerance * self.config.energy_warning_fraction;
372        if relative_error > warning_threshold {
373            return Some(JidokaResponse::Warning {
374                message: format!("Energy approaching tolerance: {relative_error:.2e}"),
375                body_index: None,
376                metric: "energy".to_string(),
377                current: relative_error,
378                threshold: self.config.energy_tolerance,
379            });
380        }
381
382        None
383    }
384
385    fn check_angular_momentum(&mut self, state: &NBodyState) -> Option<JidokaResponse> {
386        let initial = self.initial_angular_momentum?;
387
388        let current = state.angular_momentum_magnitude();
389        let relative_error = if initial.abs() > f64::EPSILON {
390            (current - initial).abs() / initial.abs()
391        } else {
392            (current - initial).abs()
393        };
394
395        self.status.angular_momentum_error = relative_error;
396        self.status.angular_momentum_ok = relative_error <= self.config.angular_momentum_tolerance;
397
398        if relative_error > self.config.angular_momentum_tolerance {
399            self.warning_count += 1;
400
401            if self.warning_count >= self.config.max_warnings_before_pause {
402                return Some(JidokaResponse::Pause {
403                    violation: OrbitJidokaViolation::AngularMomentumDrift {
404                        initial,
405                        current,
406                        relative_error,
407                        tolerance: self.config.angular_momentum_tolerance,
408                    },
409                    recoverable: true,
410                    suggestion:
411                        "Angular momentum should be conserved - check for numerical instability"
412                            .to_string(),
413                });
414            }
415        }
416
417        None
418    }
419
420    fn check_close_encounters(&mut self, state: &NBodyState) -> Option<JidokaResponse> {
421        let min_sep = state.min_separation();
422        self.status.min_separation = min_sep;
423        self.status.close_encounter_warning = min_sep < self.config.close_encounter_threshold;
424
425        if min_sep < self.config.close_encounter_threshold {
426            // Find the close pair
427            let n = state.bodies.len();
428            for i in 0..n {
429                for j in (i + 1)..n {
430                    let r = state.bodies[i].position - state.bodies[j].position;
431                    let sep = r.magnitude().get::<uom::si::length::meter>();
432
433                    if sep < self.config.close_encounter_threshold {
434                        self.warning_count += 1;
435
436                        if self.warning_count >= self.config.max_warnings_before_pause {
437                            return Some(JidokaResponse::Pause {
438                                violation: OrbitJidokaViolation::CloseEncounter {
439                                    body_i: i,
440                                    body_j: j,
441                                    separation: sep,
442                                    threshold: self.config.close_encounter_threshold,
443                                },
444                                recoverable: true,
445                                suggestion:
446                                    "Reduce time step or enable softening for close encounters"
447                                        .to_string(),
448                            });
449                        }
450
451                        return Some(JidokaResponse::Warning {
452                            message: format!("Close encounter: bodies {i}-{j} at {sep:.2e}m"),
453                            body_index: Some(i),
454                            metric: "separation".to_string(),
455                            current: sep,
456                            threshold: self.config.close_encounter_threshold,
457                        });
458                    }
459                }
460            }
461        }
462
463        None
464    }
465}
466
467#[cfg(test)]
468mod tests {
469    use super::*;
470    use crate::orbit::physics::{NBodyState, OrbitBody};
471    use crate::orbit::units::{OrbitMass, Position3D, Velocity3D, AU, EARTH_MASS, G, SOLAR_MASS};
472
473    fn create_sun_earth_system() -> NBodyState {
474        let v_circular = (G * SOLAR_MASS / AU).sqrt();
475        let bodies = vec![
476            OrbitBody::new(
477                OrbitMass::from_kg(SOLAR_MASS),
478                Position3D::zero(),
479                Velocity3D::zero(),
480            ),
481            OrbitBody::new(
482                OrbitMass::from_kg(EARTH_MASS),
483                Position3D::from_au(1.0, 0.0, 0.0),
484                Velocity3D::from_mps(0.0, v_circular, 0.0),
485            ),
486        ];
487        NBodyState::new(bodies, 0.0)
488    }
489
490    #[test]
491    fn test_jidoka_response_can_continue() {
492        assert!(JidokaResponse::Continue.can_continue());
493        assert!(JidokaResponse::Warning {
494            message: "test".to_string(),
495            body_index: None,
496            metric: "test".to_string(),
497            current: 0.0,
498            threshold: 1.0,
499        }
500        .can_continue());
501
502        assert!(!JidokaResponse::Pause {
503            violation: OrbitJidokaViolation::EnergyDrift {
504                initial: 1.0,
505                current: 2.0,
506                relative_error: 1.0,
507                tolerance: 0.1,
508            },
509            recoverable: true,
510            suggestion: String::new(),
511        }
512        .can_continue());
513    }
514
515    #[test]
516    fn test_jidoka_config_default() {
517        let config = OrbitJidokaConfig::default();
518        assert!(config.check_finite);
519        assert!(config.check_energy);
520        assert!(config.check_angular_momentum);
521        assert!((config.energy_tolerance - 1e-6).abs() < 1e-10);
522    }
523
524    #[test]
525    fn test_jidoka_guard_initialize() {
526        let config = OrbitJidokaConfig::default();
527        let mut guard = OrbitJidokaGuard::new(config);
528        let state = create_sun_earth_system();
529
530        guard.initialize(&state);
531
532        assert!(guard.initial_energy.is_some());
533        assert!(guard.initial_angular_momentum.is_some());
534    }
535
536    #[test]
537    fn test_jidoka_guard_check_healthy_state() {
538        let config = OrbitJidokaConfig::default();
539        let mut guard = OrbitJidokaGuard::new(config);
540        let state = create_sun_earth_system();
541
542        guard.initialize(&state);
543        let response = guard.check(&state);
544
545        assert!(response.can_continue());
546        assert!(guard.status().energy_ok);
547        assert!(guard.status().angular_momentum_ok);
548        assert!(guard.status().finite_ok);
549    }
550
551    #[test]
552    fn test_jidoka_guard_energy_violation() {
553        let mut config = OrbitJidokaConfig::default();
554        config.energy_tolerance = 1e-20; // Impossibly tight tolerance
555        config.max_warnings_before_pause = 1;
556
557        let mut guard = OrbitJidokaGuard::new(config);
558        let mut state = create_sun_earth_system();
559
560        guard.initialize(&state);
561
562        // Perturb energy by changing velocity
563        state.bodies[1].velocity = Velocity3D::from_mps(0.0, 30000.0, 0.0);
564
565        let response = guard.check(&state);
566
567        // Should pause after violation
568        assert!(response.should_pause() || response.is_warning());
569    }
570
571    #[test]
572    fn test_jidoka_guard_close_encounter() {
573        let mut config = OrbitJidokaConfig::default();
574        config.close_encounter_threshold = 1e15; // Very large threshold
575        config.max_warnings_before_pause = 1;
576
577        let mut guard = OrbitJidokaGuard::new(config);
578        let state = create_sun_earth_system();
579
580        guard.initialize(&state);
581        let response = guard.check(&state);
582
583        // Should warn about close encounter (Earth is within 1e15m of Sun)
584        assert!(response.is_warning() || response.should_pause());
585    }
586
587    #[test]
588    fn test_jidoka_violation_display() {
589        let violation = OrbitJidokaViolation::EnergyDrift {
590            initial: -1e33,
591            current: -1.1e33,
592            relative_error: 0.1,
593            tolerance: 1e-6,
594        };
595
596        let display = format!("{violation}");
597        assert!(display.contains("Energy drift"));
598        assert!(display.contains("1.00e-1"));
599    }
600
601    #[test]
602    fn test_jidoka_status_default() {
603        let status = JidokaStatus::default();
604        assert!(!status.energy_ok);
605        assert!(!status.angular_momentum_ok);
606        assert!(!status.finite_ok);
607        assert_eq!(status.warning_count, 0);
608    }
609
610    #[test]
611    fn test_jidoka_reset_warnings() {
612        let config = OrbitJidokaConfig::default();
613        let mut guard = OrbitJidokaGuard::new(config);
614
615        guard.warning_count = 5;
616        guard.reset_warnings();
617
618        assert_eq!(guard.warning_count, 0);
619    }
620
621    #[test]
622    fn test_jidoka_warning_accumulation() {
623        let mut config = OrbitJidokaConfig::default();
624        config.energy_tolerance = 1e-20; // Tight tolerance
625        config.max_warnings_before_pause = 5;
626
627        let mut guard = OrbitJidokaGuard::new(config);
628        let mut state = create_sun_earth_system();
629
630        guard.initialize(&state);
631        state.bodies[1].velocity = Velocity3D::from_mps(0.0, 35000.0, 0.0);
632
633        // Accumulate warnings
634        for _ in 0..4 {
635            let response = guard.check(&state);
636            assert!(response.is_warning());
637        }
638
639        // Fifth check should pause
640        let response = guard.check(&state);
641        assert!(response.should_pause());
642    }
643
644    #[test]
645    fn test_jidoka_response_is_warning() {
646        assert!(!JidokaResponse::Continue.is_warning());
647        assert!(JidokaResponse::Warning {
648            message: "test".to_string(),
649            body_index: None,
650            metric: "test".to_string(),
651            current: 0.0,
652            threshold: 1.0,
653        }
654        .is_warning());
655    }
656
657    #[test]
658    fn test_jidoka_response_should_pause() {
659        assert!(!JidokaResponse::Continue.should_pause());
660        assert!(JidokaResponse::Pause {
661            violation: OrbitJidokaViolation::EnergyDrift {
662                initial: 1.0,
663                current: 2.0,
664                relative_error: 1.0,
665                tolerance: 0.1,
666            },
667            recoverable: true,
668            suggestion: String::new(),
669        }
670        .should_pause());
671    }
672
673    #[test]
674    fn test_jidoka_response_should_halt() {
675        assert!(!JidokaResponse::Continue.should_halt());
676        assert!(JidokaResponse::Halt {
677            violation: OrbitJidokaViolation::NonFinite {
678                body_index: 0,
679                field: "position".to_string(),
680                value: f64::NAN,
681            },
682        }
683        .should_halt());
684    }
685
686    #[test]
687    fn test_jidoka_violation_display_non_finite() {
688        let violation = OrbitJidokaViolation::NonFinite {
689            body_index: 1,
690            field: "velocity".to_string(),
691            value: f64::INFINITY,
692        };
693        let display = format!("{violation}");
694        assert!(display.contains("Non-finite"));
695        assert!(display.contains("velocity"));
696        assert!(display.contains("body 1"));
697    }
698
699    #[test]
700    fn test_jidoka_violation_display_angular_momentum() {
701        let violation = OrbitJidokaViolation::AngularMomentumDrift {
702            initial: 1e40,
703            current: 1.1e40,
704            relative_error: 0.1,
705            tolerance: 1e-9,
706        };
707        let display = format!("{violation}");
708        assert!(display.contains("Angular momentum drift"));
709    }
710
711    #[test]
712    fn test_jidoka_violation_display_close_encounter() {
713        let violation = OrbitJidokaViolation::CloseEncounter {
714            body_i: 0,
715            body_j: 1,
716            separation: 1e6,
717            threshold: 1e7,
718        };
719        let display = format!("{violation}");
720        assert!(display.contains("Close encounter"));
721        assert!(display.contains("0-1"));
722    }
723
724    #[test]
725    fn test_jidoka_violation_display_escape() {
726        let violation = OrbitJidokaViolation::EscapeVelocity {
727            body_index: 1,
728            velocity: 50000.0,
729            escape_velocity: 42000.0,
730        };
731        let display = format!("{violation}");
732        assert!(display.contains("escape velocity"));
733        assert!(display.contains("Body 1"));
734    }
735
736    #[test]
737    fn test_jidoka_guard_halt_on_non_finite_position() {
738        let config = OrbitJidokaConfig::default();
739        let mut guard = OrbitJidokaGuard::new(config);
740        let mut state = create_sun_earth_system();
741
742        guard.initialize(&state);
743
744        // Inject NaN into position
745        state.bodies[0].position = Position3D::from_meters(f64::NAN, 0.0, 0.0);
746
747        let response = guard.check(&state);
748        assert!(response.should_halt());
749    }
750
751    #[test]
752    fn test_jidoka_guard_halt_on_non_finite_velocity() {
753        let config = OrbitJidokaConfig::default();
754        let mut guard = OrbitJidokaGuard::new(config);
755        let mut state = create_sun_earth_system();
756
757        guard.initialize(&state);
758
759        // Inject Inf into velocity
760        state.bodies[1].velocity = Velocity3D::from_mps(f64::INFINITY, 0.0, 0.0);
761
762        let response = guard.check(&state);
763        assert!(response.should_halt());
764    }
765
766    #[test]
767    fn test_jidoka_guard_check_without_initialize() {
768        let config = OrbitJidokaConfig::default();
769        let mut guard = OrbitJidokaGuard::new(config);
770        let state = create_sun_earth_system();
771
772        // Check without initializing - should still work for finite checks
773        let response = guard.check(&state);
774        assert!(response.can_continue());
775    }
776
777    #[test]
778    fn test_jidoka_guard_disabled_checks() {
779        let config = OrbitJidokaConfig {
780            check_finite: false,
781            check_energy: false,
782            check_angular_momentum: false,
783            check_close_encounters: false,
784            ..Default::default()
785        };
786        let mut guard = OrbitJidokaGuard::new(config);
787        let mut state = create_sun_earth_system();
788
789        guard.initialize(&state);
790
791        // Even with NaN, should continue because checks are disabled
792        state.bodies[0].position = Position3D::from_meters(f64::NAN, 0.0, 0.0);
793
794        let response = guard.check(&state);
795        assert!(response.can_continue());
796    }
797
798    #[test]
799    fn test_jidoka_energy_warning_threshold() {
800        let mut config = OrbitJidokaConfig::default();
801        config.energy_tolerance = 1e-3;
802        config.energy_warning_fraction = 0.8; // Warn at 80%
803
804        let mut guard = OrbitJidokaGuard::new(config);
805        let mut state = create_sun_earth_system();
806
807        guard.initialize(&state);
808
809        // Small perturbation to trigger warning but not violation
810        state.bodies[1].velocity = Velocity3D::from_mps(0.0, 29784.0, 0.0);
811
812        let response = guard.check(&state);
813        // Should be continue or warning, not pause
814        assert!(response.can_continue());
815    }
816
817    #[test]
818    fn test_jidoka_angular_momentum_violation() {
819        let mut config = OrbitJidokaConfig::default();
820        config.angular_momentum_tolerance = 1e-20; // Impossible tolerance
821        config.max_warnings_before_pause = 1;
822
823        let mut guard = OrbitJidokaGuard::new(config);
824        let mut state = create_sun_earth_system();
825
826        guard.initialize(&state);
827
828        // Change velocity direction to alter angular momentum
829        state.bodies[1].velocity = Velocity3D::from_mps(29784.0, 0.0, 0.0);
830
831        let response = guard.check(&state);
832        // Should pause on angular momentum violation
833        assert!(response.should_pause());
834    }
835}