Skip to main content

simular/engine/
jidoka.rs

1//! Jidoka (自働化) - Autonomous anomaly detection.
2//!
3//! Implements Toyota's Jidoka principle: machines that detect problems
4//! and stop automatically to prevent defect propagation.
5//!
6//! # Anomaly Types
7//!
8//! 1. **Non-finite values**: NaN or Inf in any state variable
9//! 2. **Energy drift**: Total energy deviates from initial beyond tolerance
10//! 3. **Constraint violations**: Physical constraints exceeded
11//!
12//! # Severity Levels
13//!
14//! Following the Batuta Stack Review, Jidoka uses graduated severity:
15//! - **Acceptable**: Within tolerance, continue normally
16//! - **Warning**: Approaching tolerance, log and continue
17//! - **Critical**: Tolerance exceeded, stop the line
18//! - **Fatal**: Unrecoverable state, halt immediately
19//!
20//! # Advanced TPS Kaizen (Section 4.3)
21//!
22//! - **Pre-flight Jidoka**: In-process anomaly detection during computation [49]
23//! - **Andon vs Jidoka**: Self-healing auto-correction vs full stop [51][57]
24//!
25//! # Design
26//!
27//! The guard runs after every simulation step, ensuring immediate
28//! detection of anomalies. This prevents error propagation and
29//! enables root cause analysis.
30
31use crate::config::SimConfig;
32use crate::engine::state::SimState;
33use crate::error::{SimError, SimResult};
34use serde::{Deserialize, Serialize};
35
36/// Severity levels for Jidoka violations.
37///
38/// Graduated response avoids false positives (Muda of over-processing).
39#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
40pub enum ViolationSeverity {
41    /// Acceptable variance within tolerance (continue).
42    Acceptable,
43    /// Warning: approaching tolerance boundary (log, continue).
44    Warning,
45    /// Critical: tolerance exceeded (stop the line).
46    Critical,
47    /// Fatal: unrecoverable state (halt immediately).
48    Fatal,
49}
50
51/// Warning from Jidoka check (non-critical issue).
52#[derive(Debug, Clone, Serialize, Deserialize)]
53pub enum JidokaWarning {
54    /// Energy drift approaching tolerance.
55    EnergyDriftApproaching {
56        /// Current drift value.
57        drift: f64,
58        /// Tolerance threshold.
59        tolerance: f64,
60    },
61    /// Constraint approaching violation.
62    ConstraintApproaching {
63        /// Constraint name.
64        name: String,
65        /// Current violation amount.
66        violation: f64,
67        /// Tolerance threshold.
68        tolerance: f64,
69    },
70}
71
72/// Classifier for graduated Jidoka responses.
73#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct SeverityClassifier {
75    /// Warning threshold as fraction of tolerance (e.g., 0.8 = warn at 80%).
76    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    /// Create a new severity classifier.
89    #[must_use]
90    pub const fn new(warning_fraction: f64) -> Self {
91        Self { warning_fraction }
92    }
93
94    /// Classify energy drift severity.
95    #[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    /// Classify constraint violation severity.
109    #[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/// Jidoka violation types.
125///
126/// Each variant represents a specific anomaly that triggered the stop.
127#[derive(Debug, Clone, Serialize, Deserialize)]
128pub enum JidokaViolation {
129    /// Non-finite value (NaN or Inf) detected.
130    NonFiniteValue {
131        /// Location of the non-finite value (e.g., "position.x").
132        location: String,
133        /// The non-finite value itself.
134        value: f64,
135    },
136    /// Energy conservation violated.
137    EnergyDrift {
138        /// Current energy.
139        current: f64,
140        /// Initial energy.
141        initial: f64,
142        /// Relative drift.
143        drift: f64,
144        /// Configured tolerance.
145        tolerance: f64,
146    },
147    /// Constraint violated.
148    ConstraintViolation {
149        /// Constraint name.
150        name: String,
151        /// Current value.
152        value: f64,
153        /// Violation amount.
154        violation: f64,
155        /// Configured tolerance.
156        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/// Jidoka guard configuration.
182#[derive(Debug, Clone, Serialize, Deserialize)]
183pub struct JidokaConfig {
184    /// Maximum allowed relative energy drift.
185    pub energy_tolerance: f64,
186    /// NaN/Inf detection enabled.
187    pub check_finite: bool,
188    /// Constraint violation threshold.
189    pub constraint_tolerance: f64,
190    /// Enable energy conservation check.
191    pub check_energy: bool,
192    /// Severity classifier for graduated responses.
193    #[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/// Jidoka guard for autonomous anomaly detection.
210///
211/// # Example
212///
213/// ```rust
214/// use simular::engine::jidoka::{JidokaGuard, JidokaConfig};
215/// use simular::engine::state::SimState;
216///
217/// let mut guard = JidokaGuard::new(JidokaConfig::default());
218/// let state = SimState::default();
219///
220/// // Check will pass for valid state
221/// assert!(guard.check(&state).is_ok());
222/// ```
223#[derive(Debug, Clone)]
224pub struct JidokaGuard {
225    /// Configuration.
226    config: JidokaConfig,
227    /// Initial energy (set on first check).
228    initial_energy: Option<f64>,
229}
230
231impl JidokaGuard {
232    /// Create a new Jidoka guard with given configuration.
233    #[must_use]
234    pub const fn new(config: JidokaConfig) -> Self {
235        Self {
236            config,
237            initial_energy: None,
238        }
239    }
240
241    /// Create from simulation configuration.
242    #[must_use]
243    pub fn from_config(config: &SimConfig) -> Self {
244        Self::new(config.jidoka.clone())
245    }
246
247    /// Check state for anomalies (Jidoka inspection).
248    ///
249    /// This method should be called after every simulation step.
250    ///
251    /// # Errors
252    ///
253    /// Returns `SimError` if any anomaly is detected:
254    /// - `NonFiniteValue`: NaN or Inf found
255    /// - `EnergyDrift`: Energy conservation violated
256    /// - `ConstraintViolation`: Physical constraint exceeded
257    pub fn check(&mut self, state: &SimState) -> SimResult<()> {
258        // Check 1: Non-finite values (Poka-Yoke)
259        if self.config.check_finite {
260            self.check_finite(state)?;
261        }
262
263        // Check 2: Energy conservation
264        if self.config.check_energy {
265            self.check_energy(state)?;
266        }
267
268        // Check 3: Constraints
269        self.check_constraints(state)?;
270
271        Ok(())
272    }
273
274    /// Check for non-finite values in state.
275    #[allow(clippy::unused_self)] // Consistent method signature with other checks
276    fn check_finite(&self, state: &SimState) -> SimResult<()> {
277        // Check all positions
278        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        // Check all velocities
297        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    /// Check energy conservation.
319    fn check_energy(&mut self, state: &SimState) -> SimResult<()> {
320        let current_energy = state.total_energy();
321
322        // Skip if no energy defined
323        if !current_energy.is_finite() || current_energy.abs() < f64::EPSILON {
324            return Ok(());
325        }
326
327        match self.initial_energy {
328            None => {
329                // First check - record initial energy
330                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    /// Check constraint violations.
349    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    /// Reset the guard (clear initial energy).
364    #[allow(clippy::missing_const_for_fn)] // Mutable const not stable in all contexts
365    pub fn reset(&mut self) {
366        self.initial_energy = None;
367    }
368
369    /// Get current configuration.
370    #[must_use]
371    pub const fn config(&self) -> &JidokaConfig {
372        &self.config
373    }
374
375    /// Check state with graduated severity (smart Jidoka).
376    ///
377    /// Returns warnings for approaching violations without stopping.
378    /// Only returns errors for Critical or Fatal violations.
379    ///
380    /// # Errors
381    ///
382    /// Returns error for Critical/Fatal violations.
383    pub fn check_with_warnings(
384        &mut self,
385        state: &SimState,
386    ) -> Result<Vec<JidokaWarning>, SimError> {
387        let mut warnings = Vec::new();
388
389        // Check 1: Non-finite values (always Fatal)
390        if self.config.check_finite {
391            self.check_finite(state)?;
392        }
393
394        // Check 2: Energy conservation with graduated response
395        if self.config.check_energy {
396            if let Some(warning) = self.check_energy_graduated(state)? {
397                warnings.push(warning);
398            }
399        }
400
401        // Check 3: Constraints with graduated response
402        warnings.extend(self.check_constraints_graduated(state)?);
403
404        Ok(warnings)
405    }
406
407    /// Check energy with graduated severity.
408    fn check_energy_graduated(
409        &mut self,
410        state: &SimState,
411    ) -> Result<Option<JidokaWarning>, SimError> {
412        let current_energy = state.total_energy();
413
414        // Skip if no energy defined
415        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    /// Check constraints with graduated severity.
449    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
484// =============================================================================
485// Pre-flight Jidoka (Section 4.3.1)
486// =============================================================================
487
488bitflags::bitflags! {
489    /// Conditions that trigger immediate abort during computation [49][51].
490    ///
491    /// Pre-flight Jidoka prevents Muda of Processing by aborting before
492    /// defects propagate through the computation graph.
493    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
494    pub struct AbortConditions: u32 {
495        /// Abort on NaN or Infinity values.
496        const NON_FINITE = 0b0001;
497        /// Abort when gradient norm exceeds threshold.
498        const GRADIENT_EXPLOSION = 0b0010;
499        /// Abort when gradient norm drops below threshold.
500        const GRADIENT_VANISHING = 0b0100;
501        /// Abort on value exceeding physical bounds.
502        const BOUND_VIOLATION = 0b1000;
503    }
504}
505
506/// Pre-flight Jidoka guard for in-process anomaly detection [49][51].
507///
508/// Unlike post-process `JidokaGuard`, `PreflightJidoka` aborts *during*
509/// computation to prevent wasted work (Muda of Processing).
510///
511/// # Example
512///
513/// ```rust
514/// use simular::engine::jidoka::{PreflightJidoka, AbortConditions};
515///
516/// let mut preflight = PreflightJidoka::new();
517/// assert!(preflight.check_value(1.0).is_ok());
518/// assert!(preflight.check_value(f64::NAN).is_err());
519/// ```
520#[derive(Debug, Clone)]
521pub struct PreflightJidoka {
522    /// Abort conditions (OR'd together).
523    abort_on: AbortConditions,
524    /// Threshold for gradient explosion detection.
525    gradient_explosion_threshold: f64,
526    /// Threshold for gradient vanishing detection.
527    gradient_vanishing_threshold: f64,
528    /// Counter for early aborts (metrics).
529    abort_count: u64,
530    /// Upper bound for value checks.
531    upper_bound: f64,
532    /// Lower bound for value checks.
533    lower_bound: f64,
534}
535
536impl Default for PreflightJidoka {
537    fn default() -> Self {
538        Self::new()
539    }
540}
541
542impl PreflightJidoka {
543    /// Create with default abort conditions.
544    #[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    /// Create with custom abort conditions.
557    #[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    /// Set gradient explosion threshold.
570    #[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    /// Set gradient vanishing threshold.
577    #[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    /// Set value bounds.
584    #[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    /// Check a single value for anomalies.
592    ///
593    /// # Errors
594    ///
595    /// Returns error if value violates abort conditions.
596    pub fn check_value(&mut self, value: f64) -> SimResult<()> {
597        // Check non-finite
598        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        // Check bounds
604        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    /// Check a slice of values for anomalies.
618    ///
619    /// # Errors
620    ///
621    /// Returns error if any value violates abort conditions.
622    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    /// Check gradient norm for explosion/vanishing.
645    ///
646    /// # Errors
647    ///
648    /// Returns error if gradient is exploding or vanishing.
649    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    /// Get total abort count.
678    #[must_use]
679    pub const fn abort_count(&self) -> u64 {
680        self.abort_count
681    }
682
683    /// Reset abort count.
684    pub fn reset_count(&mut self) {
685        self.abort_count = 0;
686    }
687}
688
689// =============================================================================
690// Self-Healing Jidoka (Section 4.3.2)
691// =============================================================================
692
693/// Jidoka response type: Andon (stop) vs auto-correct [51][57].
694#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
695pub enum JidokaResponse {
696    /// Andon: Full stop, human intervention required.
697    Andon,
698    /// Auto-correct: Apply patch and continue.
699    AutoCorrect,
700    /// Monitor: Log warning, continue with observation.
701    Monitor,
702}
703
704/// Training anomaly types for ML simulation.
705#[derive(Debug, Clone, Serialize, Deserialize)]
706pub enum TrainingAnomaly {
707    /// NaN detected in parameters or loss.
708    NaN {
709        /// Location of NaN.
710        location: String,
711    },
712    /// Model corruption detected.
713    ModelCorruption {
714        /// Description of corruption.
715        description: String,
716    },
717    /// Loss spike detected.
718    LossSpike {
719        /// Current loss value.
720        current: f64,
721        /// Expected loss value.
722        expected: f64,
723        /// Z-score of spike.
724        z_score: f64,
725    },
726    /// Gradient explosion detected.
727    GradientExplosion {
728        /// Gradient norm.
729        norm: f64,
730        /// Threshold that was exceeded.
731        threshold: f64,
732    },
733    /// Gradient vanishing detected.
734    GradientVanishing {
735        /// Gradient norm.
736        norm: f64,
737        /// Threshold that was not met.
738        threshold: f64,
739    },
740    /// Slow convergence detected.
741    SlowConvergence {
742        /// Recent loss values.
743        recent_losses: Vec<f64>,
744        /// Expected improvement rate.
745        expected_rate: f64,
746    },
747    /// High variance in loss.
748    HighVariance {
749        /// Variance value.
750        variance: f64,
751        /// Threshold.
752        threshold: f64,
753    },
754}
755
756/// Corrective patch for self-healing [57].
757#[derive(Debug, Clone, Serialize, Deserialize)]
758pub enum RulePatch {
759    /// Reduce learning rate.
760    ReduceLearningRate {
761        /// Factor to reduce by.
762        factor: f64,
763    },
764    /// Enable gradient clipping.
765    EnableGradientClipping {
766        /// Max gradient norm.
767        max_norm: f64,
768    },
769    /// Increase batch size.
770    IncreaseBatchSize {
771        /// Factor to increase by.
772        factor: usize,
773    },
774    /// Enable learning rate warmup.
775    EnableWarmup {
776        /// Warmup steps.
777        steps: usize,
778    },
779    /// Skip batch.
780    SkipBatch,
781    /// Rollback to checkpoint.
782    Rollback {
783        /// Number of steps to rollback.
784        steps: u64,
785    },
786}
787
788/// Self-healing Jidoka controller for ML training [51][57].
789///
790/// Distinguishes between Andon (full stop) and auto-correction based on
791/// anomaly severity and correction history.
792#[derive(Debug, Clone)]
793pub struct SelfHealingJidoka {
794    /// Maximum auto-corrections before escalating to Andon.
795    max_auto_corrections: usize,
796    /// Current correction count.
797    correction_count: usize,
798    /// Correction count per anomaly type.
799    corrections_by_type: std::collections::HashMap<String, usize>,
800    /// Applied patches history.
801    applied_patches: Vec<RulePatch>,
802    /// Maximum patches of same type before escalation.
803    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    /// Create with maximum auto-correction limit.
814    #[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    /// Set maximum corrections of same type before escalation.
826    #[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    /// Determine response based on anomaly type and history.
833    #[must_use]
834    pub fn classify_response(&self, anomaly: &TrainingAnomaly) -> JidokaResponse {
835        let anomaly_type = self.anomaly_type_key(anomaly);
836
837        // Check type-specific correction count
838        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            // Fatal: Always Andon
849            TrainingAnomaly::NaN { .. } | TrainingAnomaly::ModelCorruption { .. } => {
850                JidokaResponse::Andon
851            }
852
853            // Recoverable: Auto-correct if under threshold
854            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            // Minor: Monitor only
872            TrainingAnomaly::SlowConvergence { .. } | TrainingAnomaly::HighVariance { .. } => {
873                JidokaResponse::Monitor
874            }
875        }
876    }
877
878    /// Generate corrective patch for anomaly.
879    #[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 }) // Increase LR
898            }
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    /// Record that a correction was applied.
915    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    /// Get total correction count.
923    #[must_use]
924    pub const fn correction_count(&self) -> usize {
925        self.correction_count
926    }
927
928    /// Get applied patches.
929    #[must_use]
930    pub fn applied_patches(&self) -> &[RulePatch] {
931        &self.applied_patches
932    }
933
934    /// Reset correction history.
935    pub fn reset(&mut self) {
936        self.correction_count = 0;
937        self.corrections_by_type.clear();
938        self.applied_patches.clear();
939    }
940
941    /// Get string key for anomaly type.
942    #[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        // Add a body with NaN position
975        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        // Initial state with some energy
1014        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        // First check records initial energy
1018        assert!(guard.check(&state).is_ok());
1019
1020        // Modify state to have significantly different energy
1021        state.set_velocity(0, Vec3::new(10.0, 0.0, 0.0)); // 100x kinetic energy
1022
1023        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); // Violation > tolerance
1038
1039        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        // Add a body with some energy so initial_energy gets recorded
1050        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        // Record initial energy - needs non-zero energy to record
1054        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        // Reset
1061        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        // Should pass because finite check is disabled
1078        assert!(guard.check(&state).is_ok());
1079    }
1080
1081    // === Severity Classifier Tests ===
1082
1083    #[test]
1084    fn test_severity_classifier_acceptable() {
1085        let classifier = SeverityClassifier::new(0.8);
1086
1087        // 50% of tolerance = Acceptable
1088        let severity = classifier.classify_energy_drift(0.5, 1.0);
1089        assert_eq!(severity, ViolationSeverity::Acceptable);
1090
1091        // 79% of tolerance = Acceptable (just under warning threshold)
1092        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        // Just above 80% of tolerance = Warning (boundary is > not >=)
1101        let severity = classifier.classify_energy_drift(0.81, 1.0);
1102        assert_eq!(severity, ViolationSeverity::Warning);
1103
1104        // 99% of tolerance = Warning
1105        let severity = classifier.classify_energy_drift(0.99, 1.0);
1106        assert_eq!(severity, ViolationSeverity::Warning);
1107
1108        // Exactly at 80% boundary is still Acceptable
1109        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        // 100% of tolerance = Critical (exactly at)
1118        let severity = classifier.classify_energy_drift(1.0, 1.0);
1119        assert_eq!(severity, ViolationSeverity::Warning); // At boundary, not over
1120
1121        // 101% of tolerance = Critical (over)
1122        let severity = classifier.classify_energy_drift(1.01, 1.0);
1123        assert_eq!(severity, ViolationSeverity::Critical);
1124
1125        // 200% of tolerance = Critical
1126        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        // NaN = Fatal
1135        let severity = classifier.classify_energy_drift(f64::NAN, 1.0);
1136        assert_eq!(severity, ViolationSeverity::Fatal);
1137
1138        // Infinity = Fatal
1139        let severity = classifier.classify_energy_drift(f64::INFINITY, 1.0);
1140        assert_eq!(severity, ViolationSeverity::Fatal);
1141
1142        // Negative Infinity = Fatal
1143        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        // Test positive violation
1152        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        // Test negative violation (abs applied)
1166        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    // === Check With Warnings Tests ===
1187
1188    #[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        // Initial state
1209        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        // First check records energy
1214        let _ = guard.check_with_warnings(&state);
1215
1216        // Modify to have ~85% drift (in warning zone)
1217        // Initial energy ~= 10.5 (10 potential + 0.5 kinetic)
1218        // For 85% drift we need ~19.4 total energy
1219        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, // Disable energy to isolate constraint test
1243            ..Default::default()
1244        };
1245        let mut guard = JidokaGuard::new(config);
1246
1247        let mut state = SimState::default();
1248        state.add_constraint("test", 0.9); // 90% of tolerance = warning
1249
1250        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); // 150% = critical
1280
1281        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        // Verify ordering: Acceptable < Warning < Critical < Fatal
1300        assert!(ViolationSeverity::Acceptable < ViolationSeverity::Warning);
1301        assert!(ViolationSeverity::Warning < ViolationSeverity::Critical);
1302        assert!(ViolationSeverity::Critical < ViolationSeverity::Fatal);
1303    }
1304
1305    // === Pre-flight Jidoka Tests (Section 4.3.1) ===
1306
1307    #[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()); // Above threshold
1360        assert!(preflight.check_gradient_norm(1e-10).is_err()); // Below threshold
1361        assert!(preflight.check_gradient_norm(0.0).is_ok()); // Zero is ok (not > 0)
1362    }
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    // === Self-Healing Jidoka Tests (Section 4.3.2) ===
1386
1387    #[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, // > 5.0 threshold
1426        };
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        // First two should auto-correct
1473        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        // Third should escalate to Andon
1488        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        // Loss spike with high z-score -> skip batch
1496        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        // Gradient explosion -> gradient clipping
1507        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        // Slow convergence -> warmup
1517        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        // Record 2 gradient explosions
1562        for _ in 0..2 {
1563            let patch = healer.generate_patch(&explosion).unwrap();
1564            healer.record_correction(&explosion, patch);
1565        }
1566
1567        // Third gradient explosion should be Andon (type limit exceeded)
1568        assert_eq!(healer.classify_response(&explosion), JidokaResponse::Andon);
1569
1570        // But loss spike should still be AutoCorrect (different type)
1571        assert_eq!(
1572            healer.classify_response(&spike),
1573            JidokaResponse::AutoCorrect
1574        );
1575    }
1576
1577    // === Clone and Debug Tests ===
1578
1579    #[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        /// Falsification: valid states should always pass.
1648        #[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            // All finite values should pass
1664            prop_assert!(guard.check(&state).is_ok());
1665        }
1666
1667        /// Falsification: severity levels are monotonic with drift.
1668        #[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            // Values below warning threshold
1676            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            // Monotonic: below_warning <= at_warning <= above_tolerance
1685            prop_assert!(sev_below <= sev_at);
1686            prop_assert!(sev_at <= sev_above);
1687        }
1688
1689        /// Falsification: acceptable never exceeds warning threshold.
1690        #[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        /// Falsification: critical always exceeds tolerance.
1704        #[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        /// Falsification: constraint classification handles negative values.
1717        #[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            // Absolute value applied, so both should be equal
1728            prop_assert_eq!(pos_severity, neg_severity);
1729        }
1730    }
1731}