Skip to main content

dsfb_gray/
envelope.rs

1//! Admissibility envelopes: regime-conditioned bounds for residual classification.
2//!
3//! An [`AdmissibilityEnvelope`] defines the region of residual space considered
4//! operationally acceptable under a given workload phase. When a residual sign
5//! exits this envelope, the grammar layer transitions from `Admissible` to
6//! `Boundary` or `Violation`.
7//!
8//! ## Failure Mode FM-03: Envelope Miscalibration Across Workload Phases
9//!
10//! Envelopes calibrated during steady-state may misclassify warmup or cooldown
11//! transients as violations. Per-phase calibration is required.
12
13use crate::regime::WorkloadPhase;
14use crate::residual::ResidualSign;
15
16/// Admissibility envelope for a single residual source under a specific
17/// workload phase.
18///
19/// The envelope defines upper and lower bounds on the residual value,
20/// drift, and slew. A residual sign is classified based on its position
21/// relative to these bounds.
22#[derive(Debug, Clone, Copy)]
23pub struct AdmissibilityEnvelope {
24    /// Lower bound on acceptable residual magnitude.
25    pub residual_lower: f64,
26    /// Upper bound on acceptable residual magnitude.
27    pub residual_upper: f64,
28    /// Maximum acceptable drift magnitude (absolute value).
29    pub drift_limit: f64,
30    /// Maximum acceptable slew magnitude (absolute value).
31    pub slew_limit: f64,
32    /// Workload phase this envelope applies to.
33    pub phase: WorkloadPhase,
34    /// Boundary fraction: the fraction of the envelope width at which
35    /// the grammar transitions from Admissible to Boundary.
36    /// Typically 0.8 (i.e., 80% of the way to the limit).
37    pub boundary_fraction: f64,
38}
39
40/// Classification of a residual sign against an envelope.
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum EnvelopePosition {
43    /// Residual is well within the envelope interior.
44    Interior,
45    /// Residual is in the boundary zone (between boundary_fraction and 1.0).
46    BoundaryZone,
47    /// Residual has exited the envelope.
48    Exterior,
49}
50
51impl AdmissibilityEnvelope {
52    /// Create a new envelope with the given bounds.
53    ///
54    /// `boundary_fraction` is clamped to [0.5, 0.99].
55    pub fn new(
56        residual_lower: f64,
57        residual_upper: f64,
58        drift_limit: f64,
59        slew_limit: f64,
60        phase: WorkloadPhase,
61        boundary_fraction: f64,
62    ) -> Self {
63        Self {
64            residual_lower,
65            residual_upper,
66            drift_limit,
67            slew_limit,
68            phase,
69            boundary_fraction: boundary_fraction.clamp(0.5, 0.99),
70        }
71    }
72
73    /// Construct a symmetric envelope centered at zero with the given half-width.
74    pub fn symmetric(
75        half_width: f64,
76        drift_limit: f64,
77        slew_limit: f64,
78        phase: WorkloadPhase,
79    ) -> Self {
80        Self::new(-half_width, half_width, drift_limit, slew_limit, phase, 0.8)
81    }
82
83    /// Classify a residual sign against this envelope.
84    ///
85    /// The classification considers residual magnitude, drift, and slew
86    /// independently. The most severe classification wins.
87    pub fn classify(&self, sign: &ResidualSign) -> EnvelopePosition {
88        let r_pos = self.classify_scalar(sign.residual, self.residual_lower, self.residual_upper);
89        let d_pos = self.classify_symmetric(sign.drift, self.drift_limit);
90        let s_pos = self.classify_symmetric(sign.slew, self.slew_limit);
91
92        // Return the most severe classification
93        worst_position(worst_position(r_pos, d_pos), s_pos)
94    }
95
96    /// Classify a scalar value against asymmetric bounds [lower, upper].
97    fn classify_scalar(&self, value: f64, lower: f64, upper: f64) -> EnvelopePosition {
98        let range = upper - lower;
99        if range <= 0.0 {
100            return EnvelopePosition::Exterior;
101        }
102        let boundary_lower = lower + range * (1.0 - self.boundary_fraction) / 2.0;
103        let boundary_upper = upper - range * (1.0 - self.boundary_fraction) / 2.0;
104
105        if value < lower || value > upper {
106            EnvelopePosition::Exterior
107        } else if value < boundary_lower || value > boundary_upper {
108            EnvelopePosition::BoundaryZone
109        } else {
110            EnvelopePosition::Interior
111        }
112    }
113
114    /// Classify a scalar value against symmetric bounds [-limit, +limit].
115    fn classify_symmetric(&self, value: f64, limit: f64) -> EnvelopePosition {
116        if limit <= 0.0 {
117            return if value.abs() > 0.0 {
118                EnvelopePosition::Exterior
119            } else {
120                EnvelopePosition::Interior
121            };
122        }
123        let abs_val = value.abs();
124        let boundary = limit * self.boundary_fraction;
125
126        if abs_val > limit {
127            EnvelopePosition::Exterior
128        } else if abs_val > boundary {
129            EnvelopePosition::BoundaryZone
130        } else {
131            EnvelopePosition::Interior
132        }
133    }
134
135    /// Envelope width (upper - lower) for the residual dimension.
136    pub fn residual_width(&self) -> f64 {
137        self.residual_upper - self.residual_lower
138    }
139
140    /// Fractional position of a residual value within the envelope [0.0, 1.0+].
141    /// Values > 1.0 indicate exterior position.
142    pub fn fractional_position(&self, residual: f64) -> f64 {
143        let center = (self.residual_upper + self.residual_lower) / 2.0;
144        let half_width = self.residual_width() / 2.0;
145        if half_width <= 0.0 {
146            return f64::INFINITY;
147        }
148        (residual - center).abs() / half_width
149    }
150}
151
152/// Return the more severe of two envelope positions.
153fn worst_position(a: EnvelopePosition, b: EnvelopePosition) -> EnvelopePosition {
154    match (a, b) {
155        (EnvelopePosition::Exterior, _) | (_, EnvelopePosition::Exterior) => {
156            EnvelopePosition::Exterior
157        }
158        (EnvelopePosition::BoundaryZone, _) | (_, EnvelopePosition::BoundaryZone) => {
159            EnvelopePosition::BoundaryZone
160        }
161        (EnvelopePosition::Interior, EnvelopePosition::Interior) => EnvelopePosition::Interior,
162    }
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168    use crate::residual::ResidualSource;
169
170    fn sign(r: f64, d: f64, s: f64) -> ResidualSign {
171        ResidualSign {
172            residual: r,
173            drift: d,
174            slew: s,
175            timestamp_ns: 0,
176            source: ResidualSource::Latency,
177        }
178    }
179
180    #[test]
181    fn test_interior_classification() {
182        let env = AdmissibilityEnvelope::symmetric(10.0, 1.0, 0.5, WorkloadPhase::SteadyState);
183        assert_eq!(
184            env.classify(&sign(0.0, 0.0, 0.0)),
185            EnvelopePosition::Interior
186        );
187        assert_eq!(
188            env.classify(&sign(5.0, 0.3, 0.1)),
189            EnvelopePosition::Interior
190        );
191    }
192
193    #[test]
194    fn test_boundary_classification() {
195        let env = AdmissibilityEnvelope::symmetric(10.0, 1.0, 0.5, WorkloadPhase::SteadyState);
196        // 9.0 is 90% of half-width (10.0), beyond 80% boundary_fraction
197        assert_eq!(
198            env.classify(&sign(9.0, 0.0, 0.0)),
199            EnvelopePosition::BoundaryZone
200        );
201    }
202
203    #[test]
204    fn test_exterior_classification() {
205        let env = AdmissibilityEnvelope::symmetric(10.0, 1.0, 0.5, WorkloadPhase::SteadyState);
206        assert_eq!(
207            env.classify(&sign(11.0, 0.0, 0.0)),
208            EnvelopePosition::Exterior
209        );
210        assert_eq!(
211            env.classify(&sign(0.0, 1.5, 0.0)),
212            EnvelopePosition::Exterior
213        );
214    }
215
216    #[test]
217    fn test_drift_triggers_boundary() {
218        let env = AdmissibilityEnvelope::symmetric(10.0, 1.0, 0.5, WorkloadPhase::SteadyState);
219        // residual is fine, but drift is at 0.9 (90% of limit=1.0, beyond 80%)
220        assert_eq!(
221            env.classify(&sign(0.0, 0.9, 0.0)),
222            EnvelopePosition::BoundaryZone
223        );
224    }
225}