Skip to main content

ftui_runtime/
sos_barrier.rs

1#![forbid(unsafe_code)]
2
3//! SOS barrier certificate evaluator for frame-budget admissibility.
4//!
5//! Evaluates a pre-computed polynomial barrier certificate B(x1, x2) to
6//! determine whether the current frame-budget state is admissible (safe)
7//! or has crossed into the degradation region.
8//!
9//! The barrier certificate is computed offline by `scripts/solve_sos_barrier.py`
10//! using SOS/SDP relaxation. The Rust code here is ONLY the evaluator —
11//! no SDP solving happens at runtime.
12//!
13//! # State Space
14//!
15//! - `x1` = `budget_remaining`: fraction of frame budget remaining, \[0, 1\]
16//! - `x2` = `change_rate`: normalized rate of cell changes per frame, \[0, 1\]
17//!
18//! # Barrier Properties
19//!
20//! - `B(x) > 0` → safe region (budget available, manageable load)
21//! - `B(x) <= 0` → at or beyond unsafe boundary (trigger degradation)
22
23// Coefficients generated by scripts/solve_sos_barrier.py
24include!("sos_barrier_coeffs.rs");
25
26/// Result of evaluating the barrier certificate.
27#[derive(Debug, Clone, Copy, PartialEq)]
28pub struct BarrierResult {
29    /// The barrier function value B(x1, x2).
30    pub value: f64,
31    /// Whether the state is in the safe region (B > 0).
32    pub is_safe: bool,
33    /// The budget_remaining input.
34    pub budget_remaining: f64,
35    /// The change_rate input.
36    pub change_rate: f64,
37}
38
39/// Evaluate the barrier certificate at (budget_remaining, change_rate).
40///
41/// Both inputs should be in \[0, 1\]. Values outside this range are clamped.
42///
43/// Returns a [`BarrierResult`] with the barrier value and safety verdict.
44///
45/// # Performance
46///
47/// This evaluates a degree-4 polynomial with 15 terms using Horner-like
48/// nested evaluation. Expected runtime is well under 30ns on modern hardware.
49#[must_use]
50pub fn evaluate(budget_remaining: f64, change_rate: f64) -> BarrierResult {
51    let x1 = budget_remaining.clamp(0.0, 1.0);
52    let x2 = change_rate.clamp(0.0, 1.0);
53
54    let value = eval_polynomial(x1, x2);
55
56    BarrierResult {
57        value,
58        is_safe: value > 0.0,
59        budget_remaining: x1,
60        change_rate: x2,
61    }
62}
63
64/// Evaluate the bivariate polynomial B(x1, x2) = sum c[k] * x1^i * x2^j
65/// using nested Horner's method.
66///
67/// For each x1-degree i, we evaluate the univariate polynomial in x2 via
68/// Horner's method (innermost loop), then accumulate via Horner in x1
69/// (outermost loop). Zero allocations, O(n_terms) multiplications.
70#[inline]
71fn eval_polynomial(x1: f64, x2: f64) -> f64 {
72    // For each i = 0..=degree, the coefficients for that row are
73    // BARRIER_COEFFS[offset..offset+(degree-i+1)] corresponding to
74    // j = 0..=(degree-i).
75    //
76    // We evaluate each row as a univariate polynomial in x2 using Horner,
77    // then combine with powers of x1.
78
79    let mut result = 0.0;
80    let mut offset = 0;
81    let mut x1_power = 1.0;
82
83    for i in 0..=BARRIER_DEGREE {
84        let row_len = BARRIER_DEGREE - i + 1;
85        // Horner in x2: evaluate c[i,row_len-1]*x2^(row_len-1) + ... + c[i,0]
86        let mut row_val = BARRIER_COEFFS[offset + row_len - 1];
87        for j in (0..row_len - 1).rev() {
88            row_val = row_val * x2 + BARRIER_COEFFS[offset + j];
89        }
90        result += x1_power * row_val;
91        x1_power *= x1;
92        offset += row_len;
93    }
94
95    result
96}
97
98/// Margin of safety: how far inside the safe region the current state is.
99///
100/// Returns the barrier value directly — higher is safer, zero is the boundary,
101/// negative means degradation territory.
102#[must_use]
103pub fn safety_margin(budget_remaining: f64, change_rate: f64) -> f64 {
104    evaluate(budget_remaining, change_rate).value
105}
106
107/// Quick check: is the current state safe?
108#[must_use]
109pub fn is_admissible(budget_remaining: f64, change_rate: f64) -> bool {
110    evaluate(budget_remaining, change_rate).is_safe
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116
117    // ── Safe Region Tests ────────────────────────────────────────────────
118
119    #[test]
120    fn full_budget_no_changes_is_safe() {
121        let r = evaluate(1.0, 0.0);
122        assert!(r.is_safe);
123        assert!(
124            r.value > 1.0,
125            "B(1,0) should be strongly positive: {}",
126            r.value
127        );
128    }
129
130    #[test]
131    fn high_budget_low_change_is_safe() {
132        let r = evaluate(0.8, 0.1);
133        assert!(r.is_safe);
134    }
135
136    #[test]
137    fn half_budget_moderate_change_is_safe() {
138        let r = evaluate(0.5, 0.2);
139        assert!(r.is_safe);
140    }
141
142    #[test]
143    fn low_budget_low_change_is_safe() {
144        let r = evaluate(0.3, 0.1);
145        assert!(r.is_safe);
146    }
147
148    // ── Unsafe Region Tests ──────────────────────────────────────────────
149
150    #[test]
151    fn no_budget_high_change_is_unsafe() {
152        let r = evaluate(0.0, 0.8);
153        assert!(!r.is_safe);
154        assert!(r.value < 0.0);
155    }
156
157    #[test]
158    fn no_budget_max_change_is_unsafe() {
159        let r = evaluate(0.0, 1.0);
160        assert!(!r.is_safe);
161        assert!(r.value < -0.5);
162    }
163
164    #[test]
165    fn nearly_no_budget_very_high_change_is_unsafe() {
166        let r = evaluate(0.05, 0.95);
167        assert!(!r.is_safe);
168    }
169
170    // ── Boundary Tests ───────────────────────────────────────────────────
171
172    #[test]
173    fn origin_is_boundary() {
174        let r = evaluate(0.0, 0.0);
175        assert!(r.value.abs() < 1e-10, "B(0,0) should be ~0: {}", r.value);
176    }
177
178    // ── Input Clamping Tests ─────────────────────────────────────────────
179
180    #[test]
181    fn negative_budget_clamped_to_zero() {
182        let r = evaluate(-0.5, 0.0);
183        assert_eq!(r.budget_remaining, 0.0);
184    }
185
186    #[test]
187    fn over_budget_clamped_to_one() {
188        let r = evaluate(1.5, 0.0);
189        assert_eq!(r.budget_remaining, 1.0);
190    }
191
192    #[test]
193    fn negative_change_rate_clamped() {
194        let r = evaluate(0.5, -0.1);
195        assert_eq!(r.change_rate, 0.0);
196    }
197
198    #[test]
199    fn over_change_rate_clamped() {
200        let r = evaluate(0.5, 1.5);
201        assert_eq!(r.change_rate, 1.0);
202    }
203
204    // ── API Tests ────────────────────────────────────────────────────────
205
206    #[test]
207    fn safety_margin_matches_evaluate() {
208        let m = safety_margin(0.5, 0.2);
209        let r = evaluate(0.5, 0.2);
210        assert!((m - r.value).abs() < 1e-15);
211    }
212
213    #[test]
214    fn is_admissible_matches_evaluate() {
215        assert!(is_admissible(0.8, 0.1));
216        assert!(!is_admissible(0.0, 0.9));
217    }
218
219    // ── Monotonicity Tests ───────────────────────────────────────────────
220
221    #[test]
222    fn increasing_budget_increases_safety() {
223        let low = evaluate(0.2, 0.3);
224        let high = evaluate(0.8, 0.3);
225        assert!(
226            high.value > low.value,
227            "more budget should be safer: B(0.8,0.3)={} vs B(0.2,0.3)={}",
228            high.value,
229            low.value
230        );
231    }
232
233    #[test]
234    fn increasing_change_rate_decreases_safety() {
235        let low = evaluate(0.5, 0.1);
236        let high = evaluate(0.5, 0.8);
237        assert!(
238            low.value > high.value,
239            "more change should be less safe: B(0.5,0.1)={} vs B(0.5,0.8)={}",
240            low.value,
241            high.value
242        );
243    }
244
245    // ── Coefficient Integrity ────────────────────────────────────────────
246
247    #[test]
248    fn coefficients_match_expected_count() {
249        assert_eq!(BARRIER_COEFFS.len(), BARRIER_N_TERMS);
250        assert_eq!(BARRIER_N_TERMS, 15); // (4+1)(4+2)/2
251    }
252
253    #[test]
254    fn degree_is_four() {
255        assert_eq!(BARRIER_DEGREE, 4);
256    }
257}