Skip to main content

oxiphysics_collision/
error.rs

1// Copyright 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Error types for oxiphysics-collision.
5//!
6//! This module defines the error hierarchy used throughout the collision
7//! detection subsystem, including classification helpers, recovery hints,
8//! and a severity scale for runtime triage.
9
10use thiserror::Error;
11
12// ── Error ─────────────────────────────────────────────────────────────────────
13
14/// Main error type for the collision module.
15#[derive(Debug, Error)]
16pub enum Error {
17    /// Generic unclassified error message.
18    #[error("{0}")]
19    General(String),
20
21    /// A GJK query did not converge within the allowed iteration budget.
22    #[error("GJK did not converge after {iterations} iterations (dist={dist:.6})")]
23    GjkNoConverge {
24        /// Number of iterations attempted before giving up.
25        iterations: usize,
26        /// Remaining distance at termination.
27        dist: f64,
28    },
29
30    /// The EPA polytope expansion failed to find a valid separating face.
31    #[error("EPA failed: {reason}")]
32    EpaFailure {
33        /// Human-readable reason.
34        reason: String,
35    },
36
37    /// Conservative-advancement CCD did not converge within the allowed budget.
38    #[error("CCD did not converge: toi={toi:.6} after {iters} iters")]
39    CcdNoConverge {
40        /// Best TOI estimate when iterations were exhausted.
41        toi: f64,
42        /// Number of iterations attempted.
43        iters: usize,
44    },
45
46    /// A shape is degenerate (zero volume, zero radius, etc.).
47    #[error("degenerate shape: {detail}")]
48    DegenerateShape {
49        /// Description of the degeneracy.
50        detail: String,
51    },
52
53    /// An index (body, shape, node …) was out of range.
54    #[error("index {index} out of range (len={len})")]
55    IndexOutOfRange {
56        /// The offending index.
57        index: usize,
58        /// The valid range length.
59        len: usize,
60    },
61
62    /// The broadphase structure has exceeded its capacity.
63    #[error("broadphase capacity exceeded: {capacity} slots are full")]
64    BroadphaseCapacity {
65        /// Maximum capacity that was exceeded.
66        capacity: usize,
67    },
68
69    /// A numerical computation produced a non-finite value (NaN / Inf).
70    #[error("non-finite value encountered in {context}")]
71    NonFinite {
72        /// Where the non-finite was detected.
73        context: String,
74    },
75
76    /// The requested feature is not yet implemented for this shape combination.
77    #[error("unsupported shape pair: {shape_a} vs {shape_b}")]
78    UnsupportedShapePair {
79        /// Name of shape A.
80        shape_a: String,
81        /// Name of shape B.
82        shape_b: String,
83    },
84}
85
86/// Result type alias.
87pub type Result<T> = std::result::Result<T, Error>;
88
89// ── ErrorKind ────────────────────────────────────────────────────────────────
90
91/// Coarse classification of [`enum@Error`] variants for programmatic branching.
92#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
93pub enum ErrorKind {
94    /// Algorithm failed to converge.
95    Convergence,
96    /// Numerical / degenerate geometry.
97    Numerical,
98    /// Out-of-range index.
99    Bounds,
100    /// Capacity / resource limit exceeded.
101    Capacity,
102    /// Feature not available for this shape combination.
103    Unsupported,
104    /// Any other error.
105    Other,
106}
107
108impl Error {
109    /// Return the coarse [`ErrorKind`] for this error.
110    pub fn kind(&self) -> ErrorKind {
111        match self {
112            Error::GjkNoConverge { .. } | Error::CcdNoConverge { .. } => ErrorKind::Convergence,
113            Error::EpaFailure { .. } | Error::DegenerateShape { .. } | Error::NonFinite { .. } => {
114                ErrorKind::Numerical
115            }
116            Error::IndexOutOfRange { .. } => ErrorKind::Bounds,
117            Error::BroadphaseCapacity { .. } => ErrorKind::Capacity,
118            Error::UnsupportedShapePair { .. } => ErrorKind::Unsupported,
119            Error::General(_) => ErrorKind::Other,
120        }
121    }
122
123    /// Returns `true` when this error indicates a convergence failure.
124    pub fn is_convergence(&self) -> bool {
125        self.kind() == ErrorKind::Convergence
126    }
127
128    /// Returns `true` when this error indicates a numerical problem.
129    pub fn is_numerical(&self) -> bool {
130        self.kind() == ErrorKind::Numerical
131    }
132
133    /// Returns `true` when this error indicates an out-of-bounds access.
134    pub fn is_bounds(&self) -> bool {
135        self.kind() == ErrorKind::Bounds
136    }
137}
138
139// ── Severity ─────────────────────────────────────────────────────────────────
140
141/// Severity level for runtime triage and logging.
142#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
143pub enum Severity {
144    /// Informational — simulation continues normally.
145    Info = 0,
146    /// Warning — result may be approximate.
147    Warning = 1,
148    /// Error — a contact or query was skipped.
149    Error = 2,
150    /// Fatal — the simulation state is likely corrupt.
151    Fatal = 3,
152}
153
154impl Error {
155    /// Suggest a [`Severity`] level for this error.
156    pub fn severity(&self) -> Severity {
157        match self {
158            Error::GjkNoConverge { .. } | Error::CcdNoConverge { .. } => Severity::Warning,
159            Error::EpaFailure { .. } => Severity::Warning,
160            Error::DegenerateShape { .. } => Severity::Warning,
161            Error::NonFinite { .. } => Severity::Fatal,
162            Error::IndexOutOfRange { .. } => Severity::Error,
163            Error::BroadphaseCapacity { .. } => Severity::Error,
164            Error::UnsupportedShapePair { .. } => Severity::Warning,
165            Error::General(_) => Severity::Info,
166        }
167    }
168
169    /// Returns `true` if the severity is at least [`Severity::Error`].
170    pub fn is_severe(&self) -> bool {
171        self.severity() >= Severity::Error
172    }
173}
174
175// ── RecoveryHint ─────────────────────────────────────────────────────────────
176
177/// Suggested recovery action for a failed collision query.
178#[derive(Debug, Clone, PartialEq, Eq)]
179pub enum RecoveryHint {
180    /// Skip this pair this frame; it will be retried next frame.
181    SkipPair,
182    /// Treat the bodies as separated (no contact generated).
183    TreatAsSeparated,
184    /// Fall back to a simpler, less accurate algorithm.
185    UseFallback,
186    /// Halt the simulation — the state is likely corrupt.
187    Abort,
188}
189
190impl Error {
191    /// Suggest what the caller should do when encountering this error.
192    pub fn recovery_hint(&self) -> RecoveryHint {
193        match self {
194            Error::GjkNoConverge { .. }
195            | Error::EpaFailure { .. }
196            | Error::CcdNoConverge { .. } => RecoveryHint::TreatAsSeparated,
197            Error::DegenerateShape { .. } | Error::UnsupportedShapePair { .. } => {
198                RecoveryHint::SkipPair
199            }
200            Error::NonFinite { .. } => RecoveryHint::Abort,
201            Error::IndexOutOfRange { .. } | Error::BroadphaseCapacity { .. } => {
202                RecoveryHint::UseFallback
203            }
204            Error::General(_) => RecoveryHint::SkipPair,
205        }
206    }
207}
208
209// ── ErrorCollector ────────────────────────────────────────────────────────────
210
211/// Collects multiple non-fatal errors so the caller can inspect them in bulk
212/// after a simulation step, rather than short-circuiting on the first failure.
213#[derive(Debug, Default)]
214pub struct ErrorCollector {
215    errors: Vec<Error>,
216}
217
218impl ErrorCollector {
219    /// Create an empty collector.
220    pub fn new() -> Self {
221        Self { errors: Vec::new() }
222    }
223
224    /// Push an error into the collector.
225    ///
226    /// Returns `Err(error)` if the error is [`Severity::Fatal`] (caller must abort).
227    /// Otherwise stores it and returns `Ok(())`.
228    pub fn push(&mut self, err: Error) -> Result<()> {
229        if err.severity() == Severity::Fatal {
230            return Err(err);
231        }
232        self.errors.push(err);
233        Ok(())
234    }
235
236    /// Returns `true` if any errors were collected.
237    pub fn has_errors(&self) -> bool {
238        !self.errors.is_empty()
239    }
240
241    /// Number of collected errors.
242    pub fn len(&self) -> usize {
243        self.errors.len()
244    }
245
246    /// Returns `true` when no errors have been collected.
247    pub fn is_empty(&self) -> bool {
248        self.errors.is_empty()
249    }
250
251    /// Drain and return all collected errors.
252    pub fn take_errors(&mut self) -> Vec<Error> {
253        std::mem::take(&mut self.errors)
254    }
255
256    /// Count how many errors belong to a given [`ErrorKind`].
257    pub fn count_by_kind(&self, kind: ErrorKind) -> usize {
258        self.errors.iter().filter(|e| e.kind() == kind).count()
259    }
260
261    /// Returns all errors of at least the given severity.
262    pub fn errors_above(&self, min: Severity) -> Vec<&Error> {
263        self.errors.iter().filter(|e| e.severity() >= min).collect()
264    }
265}
266
267// ── ErrorBudget ───────────────────────────────────────────────────────────────
268
269/// Tracks per-category error budgets for rate-limited triage.
270///
271/// A budget tracks how many errors of each [`ErrorKind`] are tolerated before
272/// an action (e.g., logging) should be triggered.  Budgets are decremented on
273/// each call to [`ErrorBudget::check`]; when they reach zero the call returns
274/// `true` indicating the caller should act.
275#[derive(Debug, Clone)]
276pub struct ErrorBudget {
277    convergence: u32,
278    numerical: u32,
279    bounds: u32,
280    capacity: u32,
281    unsupported: u32,
282    other: u32,
283}
284
285impl Default for ErrorBudget {
286    fn default() -> Self {
287        Self {
288            convergence: 16,
289            numerical: 8,
290            bounds: 4,
291            capacity: 2,
292            unsupported: 32,
293            other: 64,
294        }
295    }
296}
297
298impl ErrorBudget {
299    /// Create a new budget with default thresholds.
300    pub fn new() -> Self {
301        Self::default()
302    }
303
304    /// Create a budget where every category has the same threshold.
305    pub fn uniform(threshold: u32) -> Self {
306        Self {
307            convergence: threshold,
308            numerical: threshold,
309            bounds: threshold,
310            capacity: threshold,
311            unsupported: threshold,
312            other: threshold,
313        }
314    }
315
316    /// Decrement the budget for the given error kind.
317    ///
318    /// Returns `true` if the budget was already zero (caller should act),
319    /// or if it just reached zero this call.
320    pub fn check(&mut self, kind: ErrorKind) -> bool {
321        let slot = match kind {
322            ErrorKind::Convergence => &mut self.convergence,
323            ErrorKind::Numerical => &mut self.numerical,
324            ErrorKind::Bounds => &mut self.bounds,
325            ErrorKind::Capacity => &mut self.capacity,
326            ErrorKind::Unsupported => &mut self.unsupported,
327            ErrorKind::Other => &mut self.other,
328        };
329        if *slot == 0 {
330            return true;
331        }
332        *slot -= 1;
333        *slot == 0
334    }
335
336    /// Reset all budgets to their default values.
337    pub fn reset(&mut self) {
338        *self = Self::default();
339    }
340
341    /// Returns `true` if the budget for this kind is exhausted (== 0).
342    pub fn is_exhausted(&self, kind: ErrorKind) -> bool {
343        match kind {
344            ErrorKind::Convergence => self.convergence == 0,
345            ErrorKind::Numerical => self.numerical == 0,
346            ErrorKind::Bounds => self.bounds == 0,
347            ErrorKind::Capacity => self.capacity == 0,
348            ErrorKind::Unsupported => self.unsupported == 0,
349            ErrorKind::Other => self.other == 0,
350        }
351    }
352}
353
354// ── ErrorStats ────────────────────────────────────────────────────────────────
355
356/// Accumulates error counts per kind and severity for a simulation step.
357///
358/// Useful for telemetry: after each physics step the engine can drain the
359/// collector, tally the stats here, then emit a single structured log line
360/// instead of one log per error.
361#[derive(Debug, Default, Clone)]
362pub struct ErrorStats {
363    /// Counts per `ErrorKind`.
364    pub by_kind: [u64; 6],
365    /// Counts per `Severity`.
366    pub by_severity: [u64; 4],
367    /// Total error count.
368    pub total: u64,
369}
370
371impl ErrorStats {
372    /// Create a zeroed stats block.
373    pub fn new() -> Self {
374        Self::default()
375    }
376
377    /// Record a single error observation.
378    pub fn record(&mut self, err: &Error) {
379        self.total += 1;
380        let ki = match err.kind() {
381            ErrorKind::Convergence => 0,
382            ErrorKind::Numerical => 1,
383            ErrorKind::Bounds => 2,
384            ErrorKind::Capacity => 3,
385            ErrorKind::Unsupported => 4,
386            ErrorKind::Other => 5,
387        };
388        self.by_kind[ki] += 1;
389        let si = match err.severity() {
390            Severity::Info => 0,
391            Severity::Warning => 1,
392            Severity::Error => 2,
393            Severity::Fatal => 3,
394        };
395        self.by_severity[si] += 1;
396    }
397
398    /// Absorb all errors from a [`ErrorCollector`], recording each one.
399    pub fn absorb(&mut self, collector: &mut ErrorCollector) {
400        for err in collector.take_errors() {
401            self.record(&err);
402        }
403    }
404
405    /// Returns the count for a specific [`ErrorKind`].
406    pub fn count_kind(&self, kind: ErrorKind) -> u64 {
407        let ki = match kind {
408            ErrorKind::Convergence => 0,
409            ErrorKind::Numerical => 1,
410            ErrorKind::Bounds => 2,
411            ErrorKind::Capacity => 3,
412            ErrorKind::Unsupported => 4,
413            ErrorKind::Other => 5,
414        };
415        self.by_kind[ki]
416    }
417
418    /// Returns the count for a specific [`Severity`].
419    pub fn count_severity(&self, sev: Severity) -> u64 {
420        let si = match sev {
421            Severity::Info => 0,
422            Severity::Warning => 1,
423            Severity::Error => 2,
424            Severity::Fatal => 3,
425        };
426        self.by_severity[si]
427    }
428
429    /// Reset all counters to zero.
430    pub fn reset(&mut self) {
431        *self = Self::default();
432    }
433
434    /// Returns `true` if any fatal errors were recorded.
435    pub fn has_fatals(&self) -> bool {
436        self.by_severity[3] > 0
437    }
438
439    /// Merge another stats block into this one.
440    pub fn merge(&mut self, other: &ErrorStats) {
441        self.total += other.total;
442        for i in 0..6 {
443            self.by_kind[i] += other.by_kind[i];
444        }
445        for i in 0..4 {
446            self.by_severity[i] += other.by_severity[i];
447        }
448    }
449}
450
451// ── ContextualError ───────────────────────────────────────────────────────────
452
453/// An [`enum@Error`] decorated with the body-pair indices that triggered it.
454///
455/// This lets the engine log "pair (3, 7) produced a GJK convergence failure"
456/// without baking body indices into every error variant.
457#[derive(Debug)]
458pub struct ContextualError {
459    /// The underlying error.
460    pub error: Error,
461    /// Index of the first body involved.
462    pub body_a: usize,
463    /// Index of the second body involved.
464    pub body_b: usize,
465    /// Optional free-form context tag (algorithm name, phase, etc.).
466    pub tag: Option<&'static str>,
467}
468
469impl ContextualError {
470    /// Wrap an [`enum@Error`] with body indices.
471    pub fn new(error: Error, body_a: usize, body_b: usize) -> Self {
472        Self {
473            error,
474            body_a,
475            body_b,
476            tag: None,
477        }
478    }
479
480    /// Attach an optional tag and return `self`.
481    pub fn with_tag(mut self, tag: &'static str) -> Self {
482        self.tag = Some(tag);
483        self
484    }
485
486    /// The kind of the wrapped error.
487    pub fn kind(&self) -> ErrorKind {
488        self.error.kind()
489    }
490
491    /// The severity of the wrapped error.
492    pub fn severity(&self) -> Severity {
493        self.error.severity()
494    }
495
496    /// Recovery hint for the wrapped error.
497    pub fn recovery_hint(&self) -> RecoveryHint {
498        self.error.recovery_hint()
499    }
500}
501
502impl std::fmt::Display for ContextualError {
503    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
504        match self.tag {
505            Some(tag) => write!(
506                f,
507                "[{}] bodies ({},{}) — {}",
508                tag, self.body_a, self.body_b, self.error
509            ),
510            None => write!(
511                f,
512                "bodies ({},{}) — {}",
513                self.body_a, self.body_b, self.error
514            ),
515        }
516    }
517}
518
519// ── ContextualCollector ────────────────────────────────────────────────────────
520
521/// Like [`ErrorCollector`] but stores [`ContextualError`] values.
522#[derive(Debug, Default)]
523pub struct ContextualCollector {
524    errors: Vec<ContextualError>,
525}
526
527impl ContextualCollector {
528    /// Create an empty collector.
529    pub fn new() -> Self {
530        Self::default()
531    }
532
533    /// Push a contextual error.
534    ///
535    /// Fatal errors are NOT stored — they are returned directly so the caller
536    /// can abort.
537    pub fn push(&mut self, err: ContextualError) -> std::result::Result<(), ContextualError> {
538        if err.severity() == Severity::Fatal {
539            return Err(err);
540        }
541        self.errors.push(err);
542        Ok(())
543    }
544
545    /// Number of collected errors.
546    pub fn len(&self) -> usize {
547        self.errors.len()
548    }
549
550    /// Returns `true` when no errors have been collected.
551    pub fn is_empty(&self) -> bool {
552        self.errors.is_empty()
553    }
554
555    /// Drain and return all collected errors.
556    pub fn take_errors(&mut self) -> Vec<ContextualError> {
557        std::mem::take(&mut self.errors)
558    }
559
560    /// Count errors involving a specific body index.
561    pub fn count_for_body(&self, body: usize) -> usize {
562        self.errors
563            .iter()
564            .filter(|e| e.body_a == body || e.body_b == body)
565            .count()
566    }
567
568    /// Collect all body pairs that produced errors.
569    pub fn error_pairs(&self) -> Vec<(usize, usize)> {
570        self.errors.iter().map(|e| (e.body_a, e.body_b)).collect()
571    }
572
573    /// Drain into an [`ErrorStats`] block.
574    pub fn drain_into_stats(&mut self, stats: &mut ErrorStats) {
575        for ce in self.take_errors() {
576            stats.record(&ce.error);
577        }
578    }
579}
580
581// ── Retry policy ──────────────────────────────────────────────────────────────
582
583/// Specifies how many times a failed collision query should be retried.
584#[derive(Debug, Clone, Copy, PartialEq, Eq)]
585pub struct RetryPolicy {
586    /// Maximum number of retries for convergence failures.
587    pub max_convergence_retries: u32,
588    /// Maximum number of retries for numerical failures.
589    pub max_numerical_retries: u32,
590}
591
592impl Default for RetryPolicy {
593    fn default() -> Self {
594        Self {
595            max_convergence_retries: 2,
596            max_numerical_retries: 1,
597        }
598    }
599}
600
601impl RetryPolicy {
602    /// Number of retries for an error of the given kind.
603    pub fn retries_for(&self, kind: ErrorKind) -> u32 {
604        match kind {
605            ErrorKind::Convergence => self.max_convergence_retries,
606            ErrorKind::Numerical => self.max_numerical_retries,
607            _ => 0,
608        }
609    }
610
611    /// Returns `true` if the policy allows any retries for the kind.
612    pub fn should_retry(&self, kind: ErrorKind) -> bool {
613        self.retries_for(kind) > 0
614    }
615}
616
617// ── tests ─────────────────────────────────────────────────────────────────────
618
619#[cfg(test)]
620mod tests {
621    use super::*;
622
623    #[test]
624    fn general_error_kind_is_other() {
625        let e = Error::General("oops".into());
626        assert_eq!(e.kind(), ErrorKind::Other);
627    }
628
629    #[test]
630    fn gjk_no_converge_kind() {
631        let e = Error::GjkNoConverge {
632            iterations: 64,
633            dist: 0.001,
634        };
635        assert_eq!(e.kind(), ErrorKind::Convergence);
636        assert!(e.is_convergence());
637    }
638
639    #[test]
640    fn ccd_no_converge_kind() {
641        let e = Error::CcdNoConverge {
642            toi: 0.5,
643            iters: 32,
644        };
645        assert_eq!(e.kind(), ErrorKind::Convergence);
646        assert!(e.is_convergence());
647    }
648
649    #[test]
650    fn non_finite_is_fatal_and_severe() {
651        let e = Error::NonFinite {
652            context: "EPA".into(),
653        };
654        assert_eq!(e.severity(), Severity::Fatal);
655        assert!(e.is_severe());
656        assert_eq!(e.recovery_hint(), RecoveryHint::Abort);
657    }
658
659    #[test]
660    fn degenerate_shape_warning() {
661        let e = Error::DegenerateShape {
662            detail: "radius=0".into(),
663        };
664        assert_eq!(e.severity(), Severity::Warning);
665        assert!(!e.is_severe());
666        assert!(e.is_numerical());
667        assert_eq!(e.recovery_hint(), RecoveryHint::SkipPair);
668    }
669
670    #[test]
671    fn index_out_of_range_is_bounds() {
672        let e = Error::IndexOutOfRange { index: 99, len: 10 };
673        assert_eq!(e.kind(), ErrorKind::Bounds);
674        assert!(e.is_bounds());
675        assert!(e.is_severe());
676    }
677
678    #[test]
679    fn collector_push_non_fatal_ok() {
680        let mut col = ErrorCollector::new();
681        col.push(Error::General("warn".into())).unwrap();
682        assert_eq!(col.len(), 1);
683        assert!(col.has_errors());
684    }
685
686    #[test]
687    fn collector_push_fatal_returns_err() {
688        let mut col = ErrorCollector::new();
689        let result = col.push(Error::NonFinite {
690            context: "test".into(),
691        });
692        assert!(result.is_err());
693        // Fatal errors are NOT stored in the collector — they are propagated
694        assert!(col.is_empty());
695    }
696
697    #[test]
698    fn collector_count_by_kind() {
699        let mut col = ErrorCollector::new();
700        col.push(Error::GjkNoConverge {
701            iterations: 10,
702            dist: 0.01,
703        })
704        .unwrap();
705        col.push(Error::CcdNoConverge { toi: 0.3, iters: 5 })
706            .unwrap();
707        col.push(Error::General("misc".into())).unwrap();
708        assert_eq!(col.count_by_kind(ErrorKind::Convergence), 2);
709        assert_eq!(col.count_by_kind(ErrorKind::Other), 1);
710    }
711
712    #[test]
713    fn collector_errors_above_error_severity() {
714        let mut col = ErrorCollector::new();
715        col.push(Error::GjkNoConverge {
716            iterations: 8,
717            dist: 0.0,
718        })
719        .unwrap();
720        col.push(Error::BroadphaseCapacity { capacity: 1024 })
721            .unwrap();
722        let severe = col.errors_above(Severity::Error);
723        assert_eq!(severe.len(), 1); // only BroadphaseCapacity is Error-level
724    }
725
726    #[test]
727    fn collector_take_errors_clears() {
728        let mut col = ErrorCollector::new();
729        col.push(Error::General("a".into())).unwrap();
730        col.push(Error::General("b".into())).unwrap();
731        let taken = col.take_errors();
732        assert_eq!(taken.len(), 2);
733        assert!(col.is_empty());
734    }
735
736    #[test]
737    fn severity_ordering() {
738        assert!(Severity::Fatal > Severity::Error);
739        assert!(Severity::Error > Severity::Warning);
740        assert!(Severity::Warning > Severity::Info);
741    }
742
743    #[test]
744    fn unsupported_shape_pair() {
745        let e = Error::UnsupportedShapePair {
746            shape_a: "Torus".into(),
747            shape_b: "Cone".into(),
748        };
749        assert_eq!(e.kind(), ErrorKind::Unsupported);
750        assert_eq!(e.recovery_hint(), RecoveryHint::SkipPair);
751        assert!(!e.is_severe());
752    }
753
754    #[test]
755    fn broadphase_capacity_error_level() {
756        let e = Error::BroadphaseCapacity { capacity: 4096 };
757        assert_eq!(e.kind(), ErrorKind::Capacity);
758        assert_eq!(e.severity(), Severity::Error);
759        assert_eq!(e.recovery_hint(), RecoveryHint::UseFallback);
760    }
761
762    #[test]
763    fn epa_failure_convergence_and_warn() {
764        let e = Error::EpaFailure {
765            reason: "degenerate polytope".into(),
766        };
767        assert_eq!(e.kind(), ErrorKind::Numerical);
768        assert_eq!(e.severity(), Severity::Warning);
769        assert_eq!(e.recovery_hint(), RecoveryHint::TreatAsSeparated);
770    }
771
772    #[test]
773    fn general_error_display() {
774        let e = Error::General("something went wrong".into());
775        assert_eq!(format!("{e}"), "something went wrong");
776    }
777
778    #[test]
779    fn gjk_no_converge_display_contains_iterations() {
780        let e = Error::GjkNoConverge {
781            iterations: 64,
782            dist: 0.001_234,
783        };
784        let s = format!("{e}");
785        assert!(
786            s.contains("64"),
787            "display should contain iteration count: {s}"
788        );
789    }
790
791    // ── ErrorBudget tests ────────────────────────────────────────────────────
792
793    #[test]
794    fn budget_uniform_starts_full() {
795        let b = ErrorBudget::uniform(5);
796        assert!(!b.is_exhausted(ErrorKind::Convergence));
797        assert!(!b.is_exhausted(ErrorKind::Numerical));
798        assert!(!b.is_exhausted(ErrorKind::Bounds));
799    }
800
801    #[test]
802    fn budget_check_decrements_to_zero() {
803        let mut b = ErrorBudget::uniform(2);
804        let first = b.check(ErrorKind::Convergence);
805        assert!(!first, "first check should not signal exhaustion yet");
806        let second = b.check(ErrorKind::Convergence);
807        assert!(second, "second check should signal exhaustion (reached 0)");
808        assert!(b.is_exhausted(ErrorKind::Convergence));
809    }
810
811    #[test]
812    fn budget_check_already_zero_returns_true() {
813        let mut b = ErrorBudget::uniform(0);
814        // Budget already zero — every check should return true immediately
815        assert!(b.check(ErrorKind::Numerical));
816        assert!(b.check(ErrorKind::Numerical));
817    }
818
819    #[test]
820    fn budget_reset_restores_defaults() {
821        let mut b = ErrorBudget::uniform(1);
822        b.check(ErrorKind::Convergence);
823        assert!(b.is_exhausted(ErrorKind::Convergence));
824        b.reset();
825        // After reset the budget should no longer be exhausted (default > 0)
826        assert!(!b.is_exhausted(ErrorKind::Convergence));
827    }
828
829    #[test]
830    fn budget_each_kind_independent() {
831        let mut b = ErrorBudget::uniform(1);
832        b.check(ErrorKind::Convergence);
833        assert!(b.is_exhausted(ErrorKind::Convergence));
834        assert!(!b.is_exhausted(ErrorKind::Numerical));
835        assert!(!b.is_exhausted(ErrorKind::Bounds));
836    }
837
838    // ── ErrorStats tests ─────────────────────────────────────────────────────
839
840    #[test]
841    fn stats_starts_zeroed() {
842        let s = ErrorStats::new();
843        assert_eq!(s.total, 0);
844        assert_eq!(s.count_kind(ErrorKind::Convergence), 0);
845        assert_eq!(s.count_severity(Severity::Fatal), 0);
846    }
847
848    #[test]
849    fn stats_record_convergence() {
850        let mut s = ErrorStats::new();
851        let e = Error::GjkNoConverge {
852            iterations: 10,
853            dist: 0.0,
854        };
855        s.record(&e);
856        assert_eq!(s.total, 1);
857        assert_eq!(s.count_kind(ErrorKind::Convergence), 1);
858        assert_eq!(s.count_severity(Severity::Warning), 1);
859        assert!(!s.has_fatals());
860    }
861
862    #[test]
863    fn stats_record_fatal() {
864        let mut s = ErrorStats::new();
865        let e = Error::NonFinite {
866            context: "test".into(),
867        };
868        s.record(&e);
869        assert_eq!(s.total, 1);
870        assert!(s.has_fatals());
871        assert_eq!(s.count_severity(Severity::Fatal), 1);
872    }
873
874    #[test]
875    fn stats_merge() {
876        let mut a = ErrorStats::new();
877        a.record(&Error::General("x".into()));
878        let mut b = ErrorStats::new();
879        b.record(&Error::General("y".into()));
880        b.record(&Error::GjkNoConverge {
881            iterations: 1,
882            dist: 0.0,
883        });
884        a.merge(&b);
885        assert_eq!(a.total, 3);
886        assert_eq!(a.count_kind(ErrorKind::Other), 2);
887        assert_eq!(a.count_kind(ErrorKind::Convergence), 1);
888    }
889
890    #[test]
891    fn stats_absorb_from_collector() {
892        let mut col = ErrorCollector::new();
893        col.push(Error::GjkNoConverge {
894            iterations: 5,
895            dist: 0.1,
896        })
897        .unwrap();
898        col.push(Error::CcdNoConverge { toi: 0.5, iters: 3 })
899            .unwrap();
900        let mut stats = ErrorStats::new();
901        stats.absorb(&mut col);
902        assert_eq!(stats.total, 2);
903        assert_eq!(stats.count_kind(ErrorKind::Convergence), 2);
904        // Collector should be empty now
905        assert!(col.is_empty());
906    }
907
908    #[test]
909    fn stats_reset_clears_all() {
910        let mut s = ErrorStats::new();
911        s.record(&Error::General("a".into()));
912        s.record(&Error::GjkNoConverge {
913            iterations: 1,
914            dist: 0.0,
915        });
916        s.reset();
917        assert_eq!(s.total, 0);
918        assert_eq!(s.count_kind(ErrorKind::Other), 0);
919        assert_eq!(s.count_kind(ErrorKind::Convergence), 0);
920    }
921
922    #[test]
923    fn stats_all_kinds_counted() {
924        let mut s = ErrorStats::new();
925        s.record(&Error::GjkNoConverge {
926            iterations: 1,
927            dist: 0.0,
928        }); // Convergence
929        s.record(&Error::EpaFailure { reason: "x".into() }); // Numerical
930        s.record(&Error::IndexOutOfRange { index: 1, len: 0 }); // Bounds
931        s.record(&Error::BroadphaseCapacity { capacity: 10 }); // Capacity
932        s.record(&Error::UnsupportedShapePair {
933            shape_a: "A".into(),
934            shape_b: "B".into(),
935        }); // Unsupported
936        s.record(&Error::General("misc".into())); // Other
937        assert_eq!(s.total, 6);
938        assert_eq!(s.count_kind(ErrorKind::Convergence), 1);
939        assert_eq!(s.count_kind(ErrorKind::Numerical), 1);
940        assert_eq!(s.count_kind(ErrorKind::Bounds), 1);
941        assert_eq!(s.count_kind(ErrorKind::Capacity), 1);
942        assert_eq!(s.count_kind(ErrorKind::Unsupported), 1);
943        assert_eq!(s.count_kind(ErrorKind::Other), 1);
944    }
945
946    // ── ContextualError tests ────────────────────────────────────────────────
947
948    #[test]
949    fn contextual_error_display_includes_bodies() {
950        let ce = ContextualError::new(
951            Error::GjkNoConverge {
952                iterations: 5,
953                dist: 0.01,
954            },
955            3,
956            7,
957        );
958        let s = format!("{ce}");
959        assert!(s.contains('3'), "should contain body_a: {s}");
960        assert!(s.contains('7'), "should contain body_b: {s}");
961    }
962
963    #[test]
964    fn contextual_error_with_tag_display() {
965        let ce = ContextualError::new(Error::General("fail".into()), 0, 1).with_tag("GJK");
966        let s = format!("{ce}");
967        assert!(s.contains("GJK"), "display should include tag: {s}");
968    }
969
970    #[test]
971    fn contextual_error_delegates_kind_and_severity() {
972        let ce = ContextualError::new(
973            Error::NonFinite {
974                context: "test".into(),
975            },
976            0,
977            1,
978        );
979        assert_eq!(ce.kind(), ErrorKind::Numerical);
980        assert_eq!(ce.severity(), Severity::Fatal);
981        assert_eq!(ce.recovery_hint(), RecoveryHint::Abort);
982    }
983
984    // ── ContextualCollector tests ─────────────────────────────────────────────
985
986    #[test]
987    fn contextual_collector_push_and_len() {
988        let mut col = ContextualCollector::new();
989        col.push(ContextualError::new(Error::General("x".into()), 0, 1))
990            .unwrap();
991        col.push(ContextualError::new(
992            Error::GjkNoConverge {
993                iterations: 1,
994                dist: 0.0,
995            },
996            2,
997            3,
998        ))
999        .unwrap();
1000        assert_eq!(col.len(), 2);
1001        assert!(!col.is_empty());
1002    }
1003
1004    #[test]
1005    fn contextual_collector_fatal_not_stored() {
1006        let mut col = ContextualCollector::new();
1007        let result = col.push(ContextualError::new(
1008            Error::NonFinite {
1009                context: "epa".into(),
1010            },
1011            0,
1012            1,
1013        ));
1014        assert!(result.is_err(), "fatal should not be stored");
1015        assert!(col.is_empty());
1016    }
1017
1018    #[test]
1019    fn contextual_collector_count_for_body() {
1020        let mut col = ContextualCollector::new();
1021        col.push(ContextualError::new(Error::General("a".into()), 5, 3))
1022            .unwrap();
1023        col.push(ContextualError::new(Error::General("b".into()), 5, 7))
1024            .unwrap();
1025        col.push(ContextualError::new(Error::General("c".into()), 1, 2))
1026            .unwrap();
1027        assert_eq!(col.count_for_body(5), 2);
1028        assert_eq!(col.count_for_body(3), 1);
1029        assert_eq!(col.count_for_body(99), 0);
1030    }
1031
1032    #[test]
1033    fn contextual_collector_error_pairs() {
1034        let mut col = ContextualCollector::new();
1035        col.push(ContextualError::new(Error::General("a".into()), 1, 2))
1036            .unwrap();
1037        col.push(ContextualError::new(Error::General("b".into()), 3, 4))
1038            .unwrap();
1039        let pairs = col.error_pairs();
1040        assert_eq!(pairs.len(), 2);
1041        assert!(pairs.contains(&(1, 2)));
1042        assert!(pairs.contains(&(3, 4)));
1043    }
1044
1045    #[test]
1046    fn contextual_collector_drain_into_stats() {
1047        let mut col = ContextualCollector::new();
1048        col.push(ContextualError::new(
1049            Error::GjkNoConverge {
1050                iterations: 3,
1051                dist: 0.01,
1052            },
1053            0,
1054            1,
1055        ))
1056        .unwrap();
1057        col.push(ContextualError::new(Error::General("misc".into()), 2, 3))
1058            .unwrap();
1059        let mut stats = ErrorStats::new();
1060        col.drain_into_stats(&mut stats);
1061        assert_eq!(stats.total, 2);
1062        assert!(col.is_empty());
1063    }
1064
1065    #[test]
1066    fn contextual_collector_take_clears() {
1067        let mut col = ContextualCollector::new();
1068        col.push(ContextualError::new(Error::General("x".into()), 0, 0))
1069            .unwrap();
1070        let taken = col.take_errors();
1071        assert_eq!(taken.len(), 1);
1072        assert!(col.is_empty());
1073    }
1074
1075    // ── RetryPolicy tests ────────────────────────────────────────────────────
1076
1077    #[test]
1078    fn retry_policy_convergence_defaults() {
1079        let p = RetryPolicy::default();
1080        assert!(p.should_retry(ErrorKind::Convergence));
1081        assert!(p.should_retry(ErrorKind::Numerical));
1082        assert!(!p.should_retry(ErrorKind::Bounds));
1083        assert!(!p.should_retry(ErrorKind::Capacity));
1084        assert!(!p.should_retry(ErrorKind::Unsupported));
1085    }
1086
1087    #[test]
1088    fn retry_policy_retries_for_kind() {
1089        let p = RetryPolicy::default();
1090        assert_eq!(p.retries_for(ErrorKind::Convergence), 2);
1091        assert_eq!(p.retries_for(ErrorKind::Numerical), 1);
1092        assert_eq!(p.retries_for(ErrorKind::Other), 0);
1093    }
1094
1095    #[test]
1096    fn retry_policy_custom() {
1097        let p = RetryPolicy {
1098            max_convergence_retries: 5,
1099            max_numerical_retries: 3,
1100        };
1101        assert_eq!(p.retries_for(ErrorKind::Convergence), 5);
1102        assert_eq!(p.retries_for(ErrorKind::Numerical), 3);
1103        assert!(p.should_retry(ErrorKind::Convergence));
1104        assert!(p.should_retry(ErrorKind::Numerical));
1105    }
1106
1107    // ── Edge-case / integration tests ────────────────────────────────────────
1108
1109    #[test]
1110    fn collector_is_empty_initially() {
1111        let col = ErrorCollector::new();
1112        assert!(col.is_empty());
1113        assert_eq!(col.len(), 0);
1114    }
1115
1116    #[test]
1117    fn error_kind_all_variants_have_recovery_hints() {
1118        // Smoke-test: all error variants produce a RecoveryHint without panicking
1119        let errors: Vec<Error> = vec![
1120            Error::General("g".into()),
1121            Error::GjkNoConverge {
1122                iterations: 1,
1123                dist: 0.0,
1124            },
1125            Error::EpaFailure { reason: "r".into() },
1126            Error::CcdNoConverge { toi: 0.0, iters: 0 },
1127            Error::DegenerateShape { detail: "d".into() },
1128            Error::IndexOutOfRange { index: 0, len: 0 },
1129            Error::BroadphaseCapacity { capacity: 0 },
1130            Error::NonFinite {
1131                context: "c".into(),
1132            },
1133            Error::UnsupportedShapePair {
1134                shape_a: "A".into(),
1135                shape_b: "B".into(),
1136            },
1137        ];
1138        for e in &errors {
1139            let _ = e.recovery_hint(); // must not panic
1140            let _ = e.kind();
1141            let _ = e.severity();
1142        }
1143    }
1144
1145    #[test]
1146    fn collector_errors_above_info_includes_all() {
1147        let mut col = ErrorCollector::new();
1148        col.push(Error::General("a".into())).unwrap();
1149        col.push(Error::GjkNoConverge {
1150            iterations: 1,
1151            dist: 0.0,
1152        })
1153        .unwrap();
1154        col.push(Error::BroadphaseCapacity { capacity: 10 })
1155            .unwrap();
1156        // Info is the lowest severity — all non-fatal errors should appear
1157        let above_info = col.errors_above(Severity::Info);
1158        assert_eq!(above_info.len(), 3);
1159    }
1160
1161    #[test]
1162    fn stats_count_severity_info() {
1163        let mut s = ErrorStats::new();
1164        s.record(&Error::General("x".into())); // severity = Info
1165        assert_eq!(s.count_severity(Severity::Info), 1);
1166        assert_eq!(s.count_severity(Severity::Warning), 0);
1167    }
1168}