#![forbid(unsafe_code)]
use std::collections::VecDeque;
use ftui_render::budget::DegradationLevel;
use crate::conformal_predictor::{
BucketKey, ConformalConfig, ConformalPrediction, ConformalPredictor,
};
const DEFAULT_FALLBACK_BUDGET_US: f64 = 16_000.0;
#[derive(Debug, Clone)]
pub struct ConformalFrameGuardConfig {
pub conformal: ConformalConfig,
pub fallback_budget_us: f64,
pub time_series_window: usize,
pub nonconformity_window: usize,
}
impl Default for ConformalFrameGuardConfig {
fn default() -> Self {
let conformal = ConformalConfig::default();
let nonconformity_window = conformal.window_size;
Self {
conformal,
fallback_budget_us: DEFAULT_FALLBACK_BUDGET_US,
time_series_window: 512,
nonconformity_window,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GuardState {
Warmup,
Calibrated,
AtRisk,
}
impl GuardState {
pub fn as_str(self) -> &'static str {
match self {
Self::Warmup => "warmup",
Self::Calibrated => "calibrated",
Self::AtRisk => "at_risk",
}
}
}
#[derive(Debug, Clone)]
pub struct P99Prediction {
pub y_hat_us: f64,
pub upper_us: f64,
pub budget_us: f64,
pub exceeds_budget: bool,
pub calibration_size: usize,
pub fallback_level: u8,
pub state: GuardState,
pub interval_width_us: f64,
pub conformal: Option<ConformalPrediction>,
}
impl P99Prediction {
#[must_use]
pub fn to_jsonl(&self) -> String {
let conformal_fields = self
.conformal
.as_ref()
.map(|c| {
format!(
r#","conformal_quantile":{:.2},"conformal_bucket":"{}","conformal_confidence":{:.4}"#,
c.quantile, c.bucket, c.confidence,
)
})
.unwrap_or_default();
format!(
r#"{{"schema":"conformal-frame-guard-v1","y_hat_us":{:.1},"upper_us":{:.1},"budget_us":{:.1},"exceeds_budget":{},"calibration_size":{},"fallback_level":{},"state":"{}","interval_width_us":{:.1}{}}}"#,
self.y_hat_us,
self.upper_us,
self.budget_us,
self.exceeds_budget,
self.calibration_size,
self.fallback_level,
self.state.as_str(),
self.interval_width_us,
conformal_fields,
)
}
}
#[derive(Debug)]
pub struct ConformalFrameGuard {
config: ConformalFrameGuardConfig,
predictor: ConformalPredictor,
frame_times: VecDeque<f64>,
nonconformity_scores: VecDeque<f64>,
ema_us: f64,
ema_decay: f64,
state: GuardState,
observations: u64,
degradation_triggers: u64,
}
impl ConformalFrameGuard {
pub fn new(config: ConformalFrameGuardConfig) -> Self {
let predictor = ConformalPredictor::new(config.conformal.clone());
Self {
config,
predictor,
frame_times: VecDeque::new(),
nonconformity_scores: VecDeque::new(),
ema_us: 0.0,
ema_decay: 0.95,
state: GuardState::Warmup,
observations: 0,
degradation_triggers: 0,
}
}
pub fn with_defaults() -> Self {
Self::new(ConformalFrameGuardConfig::default())
}
pub fn observe(&mut self, frame_time_us: f64, key: BucketKey) {
if !frame_time_us.is_finite() || frame_time_us < 0.0 {
return;
}
self.observations += 1;
if self.observations == 1 {
self.ema_us = frame_time_us;
} else {
self.ema_us = self.ema_decay * self.ema_us + (1.0 - self.ema_decay) * frame_time_us;
}
self.frame_times.push_back(frame_time_us);
while self.frame_times.len() > self.config.time_series_window {
self.frame_times.pop_front();
}
let y_hat = self.ema_us;
let residual = frame_time_us - y_hat;
self.nonconformity_scores.push_back(residual);
while self.nonconformity_scores.len() > self.config.nonconformity_window {
self.nonconformity_scores.pop_front();
}
self.predictor.observe(key, y_hat, frame_time_us);
let samples = self.predictor.bucket_samples(key);
if samples < self.config.conformal.min_samples && self.state == GuardState::Warmup {
} else if self.state == GuardState::Warmup {
self.state = GuardState::Calibrated;
}
}
pub fn predict_p99(&mut self, budget_us: f64, key: BucketKey) -> P99Prediction {
let y_hat = if self.observations > 0 {
self.ema_us
} else {
0.0
};
let samples = self.predictor.bucket_samples(key);
let is_calibrated = samples >= self.config.conformal.min_samples;
if is_calibrated {
let prediction = self.predictor.predict(key, y_hat, budget_us);
let exceeds = prediction.upper_us > budget_us;
self.state = if exceeds {
self.degradation_triggers += 1;
GuardState::AtRisk
} else {
GuardState::Calibrated
};
P99Prediction {
y_hat_us: y_hat,
upper_us: prediction.upper_us,
budget_us,
exceeds_budget: exceeds,
calibration_size: prediction.sample_count,
fallback_level: prediction.fallback_level,
state: self.state,
interval_width_us: (prediction.upper_us - y_hat).max(0.0),
conformal: Some(prediction),
}
} else {
let fallback = self.config.fallback_budget_us;
let exceeds = y_hat > fallback;
if exceeds && self.state != GuardState::Warmup {
self.degradation_triggers += 1;
}
let state = if exceeds {
GuardState::AtRisk
} else {
GuardState::Warmup
};
self.state = state;
P99Prediction {
y_hat_us: y_hat,
upper_us: y_hat, budget_us: fallback,
exceeds_budget: exceeds,
calibration_size: samples,
fallback_level: 4, state,
interval_width_us: 0.0,
conformal: None,
}
}
}
#[inline]
pub fn state(&self) -> GuardState {
self.state
}
#[inline]
pub fn is_calibrated(&self) -> bool {
matches!(self.state, GuardState::Calibrated | GuardState::AtRisk)
}
#[inline]
pub fn observations(&self) -> u64 {
self.observations
}
#[inline]
pub fn degradation_triggers(&self) -> u64 {
self.degradation_triggers
}
pub fn nonconformity_scores(&self) -> &VecDeque<f64> {
&self.nonconformity_scores
}
pub fn frame_times(&self) -> &VecDeque<f64> {
&self.frame_times
}
#[inline]
pub fn ema_us(&self) -> f64 {
self.ema_us
}
pub fn predictor(&self) -> &ConformalPredictor {
&self.predictor
}
pub fn config(&self) -> &ConformalFrameGuardConfig {
&self.config
}
pub fn nonconformity_summary(&self) -> Option<NonconformitySummary> {
if self.nonconformity_scores.is_empty() {
return None;
}
let mut sorted: Vec<f64> = self.nonconformity_scores.iter().copied().collect();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let n = sorted.len();
let mean = sorted.iter().sum::<f64>() / n as f64;
let p50 = sorted[n / 2];
let p90 = sorted[(n as f64 * 0.90).ceil() as usize - 1];
let p99 = sorted[(n as f64 * 0.99).ceil() as usize - 1];
let max = sorted[n - 1];
Some(NonconformitySummary {
count: n,
mean,
p50,
p90,
p99,
max,
})
}
pub fn reset(&mut self) {
self.predictor.reset_all();
self.frame_times.clear();
self.nonconformity_scores.clear();
self.ema_us = 0.0;
self.state = GuardState::Warmup;
self.observations = 0;
}
pub fn suggest_action(
&self,
prediction: &P99Prediction,
current_level: DegradationLevel,
) -> Option<DegradationLevel> {
if prediction.exceeds_budget && !current_level.is_max() {
Some(current_level.next())
} else {
None
}
}
pub fn telemetry(&self) -> ConformalFrameGuardTelemetry {
ConformalFrameGuardTelemetry {
state: self.state,
observations: self.observations,
degradation_triggers: self.degradation_triggers,
ema_us: self.ema_us,
frame_times_len: self.frame_times.len(),
nonconformity_len: self.nonconformity_scores.len(),
summary: self.nonconformity_summary(),
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct NonconformitySummary {
pub count: usize,
pub mean: f64,
pub p50: f64,
pub p90: f64,
pub p99: f64,
pub max: f64,
}
impl NonconformitySummary {
#[must_use]
pub fn to_jsonl_fragment(&self) -> String {
format!(
r#""nc_count":{},"nc_mean":{:.2},"nc_p50":{:.2},"nc_p90":{:.2},"nc_p99":{:.2},"nc_max":{:.2}"#,
self.count, self.mean, self.p50, self.p90, self.p99, self.max,
)
}
}
#[derive(Debug, Clone)]
pub struct ConformalFrameGuardTelemetry {
pub state: GuardState,
pub observations: u64,
pub degradation_triggers: u64,
pub ema_us: f64,
pub frame_times_len: usize,
pub nonconformity_len: usize,
pub summary: Option<NonconformitySummary>,
}
impl ConformalFrameGuardTelemetry {
#[must_use]
pub fn to_jsonl(&self) -> String {
let summary_fields = self
.summary
.as_ref()
.map(|s| format!(",{}", s.to_jsonl_fragment()))
.unwrap_or_default();
format!(
r#"{{"schema":"conformal-frame-guard-telemetry-v1","state":"{}","observations":{},"degradation_triggers":{},"ema_us":{:.1},"frame_times_len":{},"nonconformity_len":{}{}}}"#,
self.state.as_str(),
self.observations,
self.degradation_triggers,
self.ema_us,
self.frame_times_len,
self.nonconformity_len,
summary_fields,
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::conformal_predictor::{DiffBucket, ModeBucket};
fn test_key() -> BucketKey {
BucketKey {
mode: ModeBucket::AltScreen,
diff: DiffBucket::Full,
size_bucket: 2,
}
}
#[test]
fn warmup_uses_fixed_fallback() {
let mut guard = ConformalFrameGuard::with_defaults();
let key = test_key();
let pred = guard.predict_p99(16_000.0, key);
assert_eq!(pred.fallback_level, 4);
assert_eq!(pred.state, GuardState::Warmup);
assert!(!pred.exceeds_budget); assert!(pred.conformal.is_none());
}
#[test]
fn warmup_with_slow_frames_signals_risk() {
let mut guard = ConformalFrameGuard::with_defaults();
let key = test_key();
for _ in 0..5 {
guard.observe(30_000.0, key);
}
let pred = guard.predict_p99(16_000.0, key);
assert_eq!(pred.fallback_level, 4);
assert!(pred.exceeds_budget); assert_eq!(pred.state, GuardState::AtRisk);
}
#[test]
fn calibration_transitions_from_warmup() {
let mut guard = ConformalFrameGuard::with_defaults();
let key = test_key();
for _ in 0..20 {
guard.observe(8_000.0, key);
}
assert!(guard.is_calibrated());
assert_eq!(guard.state(), GuardState::Calibrated);
}
#[test]
fn calibrated_prediction_has_conformal_data() {
let mut guard = ConformalFrameGuard::with_defaults();
let key = test_key();
for _ in 0..25 {
guard.observe(10_000.0, key);
}
let pred = guard.predict_p99(16_000.0, key);
assert!(pred.conformal.is_some());
assert!(pred.fallback_level < 4);
assert!(!pred.exceeds_budget); assert_eq!(pred.state, GuardState::Calibrated);
}
#[test]
fn calibrated_slow_frames_trigger_at_risk() {
let mut guard = ConformalFrameGuard::with_defaults();
let key = test_key();
for _ in 0..25 {
guard.observe(20_000.0, key);
}
let pred = guard.predict_p99(16_000.0, key);
assert!(pred.exceeds_budget);
assert_eq!(pred.state, GuardState::AtRisk);
assert!(guard.degradation_triggers() > 0);
}
#[test]
fn nonconformity_scores_tracked() {
let mut guard = ConformalFrameGuard::with_defaults();
let key = test_key();
for i in 0..10 {
guard.observe(10_000.0 + (i as f64 * 100.0), key);
}
assert_eq!(guard.nonconformity_scores().len(), 10);
assert_eq!(guard.frame_times().len(), 10);
}
#[test]
fn nonconformity_summary_computes_percentiles() {
let mut guard = ConformalFrameGuard::with_defaults();
let key = test_key();
for i in 0..100 {
guard.observe(10_000.0 + (i as f64 * 100.0), key);
}
let summary = guard.nonconformity_summary();
assert!(summary.is_some());
let s = summary.unwrap();
assert_eq!(s.count, 100);
assert!(s.p99 >= s.p90);
assert!(s.p90 >= s.p50);
assert!(s.max >= s.p99);
}
#[test]
fn reset_clears_state_but_preserves_triggers() {
let mut guard = ConformalFrameGuard::with_defaults();
let key = test_key();
for _ in 0..25 {
guard.observe(20_000.0, key);
}
let _ = guard.predict_p99(16_000.0, key);
let triggers_before = guard.degradation_triggers();
assert!(triggers_before > 0);
guard.reset();
assert_eq!(guard.state(), GuardState::Warmup);
assert_eq!(guard.observations(), 0);
assert!(guard.frame_times().is_empty());
assert!(guard.nonconformity_scores().is_empty());
assert_eq!(guard.degradation_triggers(), triggers_before);
}
#[test]
fn suggest_action_degrades_when_at_risk() {
let guard = ConformalFrameGuard::with_defaults();
let pred = P99Prediction {
y_hat_us: 18_000.0,
upper_us: 20_000.0,
budget_us: 16_000.0,
exceeds_budget: true,
calibration_size: 25,
fallback_level: 0,
state: GuardState::AtRisk,
interval_width_us: 2_000.0,
conformal: None,
};
let action = guard.suggest_action(&pred, DegradationLevel::Full);
assert_eq!(action, Some(DegradationLevel::SimpleBorders));
}
#[test]
fn suggest_action_holds_at_max_degradation() {
let guard = ConformalFrameGuard::with_defaults();
let pred = P99Prediction {
y_hat_us: 30_000.0,
upper_us: 35_000.0,
budget_us: 16_000.0,
exceeds_budget: true,
calibration_size: 25,
fallback_level: 0,
state: GuardState::AtRisk,
interval_width_us: 5_000.0,
conformal: None,
};
let action = guard.suggest_action(&pred, DegradationLevel::SkipFrame);
assert!(action.is_none());
}
#[test]
fn suggest_action_holds_when_within_budget() {
let guard = ConformalFrameGuard::with_defaults();
let pred = P99Prediction {
y_hat_us: 10_000.0,
upper_us: 14_000.0,
budget_us: 16_000.0,
exceeds_budget: false,
calibration_size: 25,
fallback_level: 0,
state: GuardState::Calibrated,
interval_width_us: 4_000.0,
conformal: None,
};
let action = guard.suggest_action(&pred, DegradationLevel::Full);
assert!(action.is_none());
}
#[test]
fn ema_tracks_frame_times() {
let mut guard = ConformalFrameGuard::with_defaults();
let key = test_key();
for _ in 0..50 {
guard.observe(10_000.0, key);
}
let ema = guard.ema_us();
assert!(
(ema - 10_000.0).abs() < 500.0,
"EMA should be ~10000, got {ema}"
);
}
#[test]
fn invalid_frame_time_ignored() {
let mut guard = ConformalFrameGuard::with_defaults();
let key = test_key();
guard.observe(f64::NAN, key);
guard.observe(f64::INFINITY, key);
guard.observe(-1.0, key);
assert_eq!(guard.observations(), 0);
assert!(guard.frame_times().is_empty());
}
#[test]
fn jsonl_output_is_valid_json() {
let pred = P99Prediction {
y_hat_us: 10_000.0,
upper_us: 14_000.0,
budget_us: 16_000.0,
exceeds_budget: false,
calibration_size: 25,
fallback_level: 0,
state: GuardState::Calibrated,
interval_width_us: 4_000.0,
conformal: None,
};
let json_str = pred.to_jsonl();
assert!(json_str.starts_with('{'));
assert!(json_str.ends_with('}'));
assert!(json_str.contains("conformal-frame-guard-v1"));
}
#[test]
fn telemetry_snapshot_captures_state() {
let mut guard = ConformalFrameGuard::with_defaults();
let key = test_key();
for _ in 0..30 {
guard.observe(12_000.0, key);
}
let telem = guard.telemetry();
assert_eq!(telem.observations, 30);
assert_eq!(telem.frame_times_len, 30);
assert_eq!(telem.nonconformity_len, 30);
assert!(telem.summary.is_some());
let json_str = telem.to_jsonl();
assert!(json_str.contains("conformal-frame-guard-telemetry-v1"));
}
#[test]
fn window_limits_respected() {
let config = ConformalFrameGuardConfig {
time_series_window: 10,
nonconformity_window: 5,
..Default::default()
};
let mut guard = ConformalFrameGuard::new(config);
let key = test_key();
for i in 0..100 {
guard.observe(10_000.0 + (i as f64), key);
}
assert_eq!(guard.frame_times().len(), 10);
assert_eq!(guard.nonconformity_scores().len(), 5);
}
}