use ftui_render::budget::DegradationLevel;
use ftui_runtime::conformal_frame_guard::{
ConformalFrameGuard, ConformalFrameGuardConfig, GuardState,
};
use ftui_runtime::conformal_predictor::{BucketKey, ConformalConfig, DiffBucket, ModeBucket};
use ftui_runtime::degradation_cascade::{CascadeConfig, CascadeDecision, DegradationCascade};
fn make_key() -> BucketKey {
BucketKey {
mode: ModeBucket::AltScreen,
diff: DiffBucket::Full,
size_bucket: 2,
}
}
const BUDGET_US: f64 = 16_000.0;
#[test]
fn stable_regime_no_degradation() {
let mut cascade = DegradationCascade::with_defaults();
let key = make_key();
for _ in 0..50 {
cascade.post_render(10_000.0, key);
let result = cascade.pre_render(BUDGET_US, key);
assert_eq!(
result.level,
DegradationLevel::Full,
"stable regime should never degrade"
);
assert_ne!(
result.decision,
CascadeDecision::Degrade,
"should not trigger degrade"
);
}
assert_eq!(cascade.total_degrades(), 0);
assert_eq!(cascade.level(), DegradationLevel::Full);
}
#[test]
fn stable_regime_with_minor_jitter_no_degradation() {
let mut cascade = DegradationCascade::with_defaults();
let key = make_key();
let times = [8_000.0, 10_000.0, 13_000.0, 9_000.0, 12_000.0, 11_000.0];
for round in 0..10 {
for &t in × {
cascade.post_render(t, key);
let result = cascade.pre_render(BUDGET_US, key);
assert_eq!(
result.level,
DegradationLevel::Full,
"jittery but within-budget should not degrade (round {round})"
);
}
}
assert_eq!(cascade.total_degrades(), 0);
}
#[test]
fn spike_regime_triggers_degradation_quickly() {
let config = CascadeConfig {
guard: ConformalFrameGuardConfig {
conformal: ConformalConfig {
min_samples: 5, ..Default::default()
},
..Default::default()
},
..Default::default()
};
let mut cascade = DegradationCascade::new(config);
let key = make_key();
for _ in 0..5 {
cascade.post_render(25_000.0, key);
}
let mut degrade_frame = None;
for i in 0..10 {
cascade.post_render(25_000.0, key);
let result = cascade.pre_render(BUDGET_US, key);
if result.decision == CascadeDecision::Degrade {
degrade_frame = Some(i);
break;
}
}
assert!(
degrade_frame.is_some(),
"degradation should trigger within 10 frames"
);
let frame = degrade_frame.unwrap();
assert!(
frame <= 3,
"degradation should trigger within 3 frames after calibration, got frame {frame}"
);
}
#[test]
fn sustained_overload_progressively_degrades() {
let config = CascadeConfig {
guard: ConformalFrameGuardConfig {
conformal: ConformalConfig {
min_samples: 5,
..Default::default()
},
..Default::default()
},
degradation_floor: DegradationLevel::SkipFrame,
..Default::default()
};
let mut cascade = DegradationCascade::new(config);
let key = make_key();
for _ in 0..50 {
cascade.post_render(30_000.0, key);
cascade.pre_render(BUDGET_US, key);
}
assert!(
cascade.total_degrades() >= 2,
"sustained overload should cause multiple degrades"
);
assert!(
cascade.level() > DegradationLevel::Full,
"should be in degraded state"
);
}
#[test]
fn recovery_after_spike() {
let recovery_threshold = 8;
let config = CascadeConfig {
guard: ConformalFrameGuardConfig {
conformal: ConformalConfig {
min_samples: 5,
..Default::default()
},
..Default::default()
},
recovery_threshold,
..Default::default()
};
let mut cascade = DegradationCascade::new(config);
let key = make_key();
for _ in 0..10 {
cascade.post_render(25_000.0, key);
cascade.pre_render(BUDGET_US, key);
}
let degraded_level = cascade.level();
assert!(
degraded_level > DegradationLevel::Full,
"should be degraded after slow frames"
);
for _ in 0..50 {
cascade.post_render(8_000.0, key);
}
let mut recovered = false;
for _ in 0..30 {
cascade.post_render(8_000.0, key);
let result = cascade.pre_render(BUDGET_US, key);
if result.decision == CascadeDecision::Recover {
recovered = true;
break;
}
}
assert!(recovered, "should recover after switching to fast frames");
assert!(
cascade.level() < degraded_level,
"level should improve after recovery"
);
}
#[test]
fn recovery_streak_resets_on_new_spike() {
let config = CascadeConfig {
guard: ConformalFrameGuardConfig {
conformal: ConformalConfig {
min_samples: 5,
window_size: 15,
..Default::default()
},
..Default::default()
},
recovery_threshold: 100, ..Default::default()
};
let mut cascade = DegradationCascade::new(config);
let key = make_key();
for _ in 0..30 {
cascade.post_render(6_000.0, key);
cascade.pre_render(BUDGET_US, key);
}
assert_eq!(
cascade.level(),
DegradationLevel::Full,
"should not degrade on fast frames"
);
for _ in 0..10 {
cascade.post_render(30_000.0, key);
cascade.pre_render(BUDGET_US, key);
}
assert!(
cascade.level() > DegradationLevel::Full,
"should have degraded during spike"
);
for _ in 0..40 {
cascade.post_render(3_000.0, key);
cascade.pre_render(BUDGET_US, key);
}
let streak_before_spike = cascade.recovery_streak();
assert!(
streak_before_spike > 0,
"should have some recovery progress after 40 fast frames"
);
for _ in 0..10 {
cascade.post_render(30_000.0, key);
cascade.pre_render(BUDGET_US, key);
}
assert_eq!(
cascade.recovery_streak(),
0,
"spike should reset recovery streak"
);
}
#[test]
fn conformal_coverage_guarantee_empirical() {
let config = ConformalFrameGuardConfig {
conformal: ConformalConfig {
alpha: 0.05, min_samples: 20,
window_size: 256,
q_default: 10_000.0,
},
..Default::default()
};
let mut guard = ConformalFrameGuard::new(config);
let key = make_key();
let calibration_times: Vec<f64> = (0..100)
.map(|i| {
let offset = ((i % 7) as f64 - 3.0) * 500.0; 10_000.0 + offset
})
.collect();
for &t in &calibration_times {
guard.observe(t, key);
}
let mut covered = 0;
let total = 1000;
for i in 0..total {
let prediction = guard.predict_p99(BUDGET_US, key);
let offset = ((i % 11) as f64 - 5.0) * 500.0;
let actual = 10_000.0 + offset;
if actual <= prediction.upper_us {
covered += 1;
}
guard.observe(actual, key);
}
let coverage = covered as f64 / total as f64;
assert!(
coverage >= 0.90, "conformal coverage should be >=90%, got {coverage:.3} ({covered}/{total})"
);
}
#[test]
fn conformal_coverage_with_regime_change() {
let config = ConformalFrameGuardConfig {
conformal: ConformalConfig {
alpha: 0.05,
min_samples: 10,
window_size: 100,
q_default: 10_000.0,
},
..Default::default()
};
let mut guard = ConformalFrameGuard::new(config);
let key = make_key();
for _ in 0..30 {
guard.observe(10_000.0, key);
}
let mut covered_fast = 0;
let mut total_fast = 0;
let mut covered_slow = 0;
let mut total_slow = 0;
for _ in 0..50 {
let pred = guard.predict_p99(BUDGET_US, key);
let actual = 10_000.0;
if actual <= pred.upper_us {
covered_fast += 1;
}
total_fast += 1;
guard.observe(actual, key);
}
for _ in 0..100 {
let pred = guard.predict_p99(BUDGET_US, key);
let actual = 18_000.0;
if actual <= pred.upper_us {
covered_slow += 1;
}
total_slow += 1;
guard.observe(actual, key);
}
let fast_coverage = covered_fast as f64 / total_fast as f64;
let slow_coverage = covered_slow as f64 / total_slow as f64;
assert!(
fast_coverage >= 0.90,
"fast regime coverage should be >=90%, got {fast_coverage:.3}"
);
assert!(
slow_coverage >= 0.50,
"slow regime coverage should be >=50% (adapting), got {slow_coverage:.3}"
);
}
#[test]
fn e2e_cascade_widget_filtering() {
let config = CascadeConfig {
guard: ConformalFrameGuardConfig {
conformal: ConformalConfig {
min_samples: 5,
..Default::default()
},
..Default::default()
},
recovery_threshold: 5,
..Default::default()
};
let mut cascade = DegradationCascade::new(config);
let key = make_key();
for _ in 0..10 {
cascade.post_render(10_000.0, key);
cascade.pre_render(BUDGET_US, key);
}
assert!(
cascade.should_render_widget(true),
"essential should render at Full"
);
assert!(
cascade.should_render_widget(false),
"non-essential should render at Full"
);
for _ in 0..30 {
cascade.post_render(25_000.0, key);
cascade.pre_render(BUDGET_US, key);
}
if cascade.level() >= DegradationLevel::EssentialOnly {
assert!(
cascade.should_render_widget(true),
"essential should still render"
);
assert!(
!cascade.should_render_widget(false),
"non-essential should be skipped at EssentialOnly+"
);
}
for _ in 0..60 {
cascade.post_render(8_000.0, key);
cascade.pre_render(BUDGET_US, key);
}
if cascade.level() < DegradationLevel::EssentialOnly {
assert!(
cascade.should_render_widget(false),
"non-essential should render after recovery"
);
}
}
#[test]
fn e2e_cascade_evidence_trail() {
let config = CascadeConfig {
guard: ConformalFrameGuardConfig {
conformal: ConformalConfig {
min_samples: 5,
..Default::default()
},
..Default::default()
},
..Default::default()
};
let mut cascade = DegradationCascade::new(config);
let key = make_key();
let mut evidence_log = Vec::new();
for _ in 0..10 {
cascade.post_render(10_000.0, key);
cascade.pre_render(BUDGET_US, key);
if let Some(evidence) = cascade.last_evidence() {
evidence_log.push(evidence.to_jsonl());
}
}
for _ in 0..10 {
cascade.post_render(25_000.0, key);
cascade.pre_render(BUDGET_US, key);
if let Some(evidence) = cascade.last_evidence() {
evidence_log.push(evidence.to_jsonl());
}
}
for _ in 0..20 {
cascade.post_render(8_000.0, key);
cascade.pre_render(BUDGET_US, key);
if let Some(evidence) = cascade.last_evidence() {
evidence_log.push(evidence.to_jsonl());
}
}
assert!(!evidence_log.is_empty(), "should have evidence entries");
assert_eq!(
evidence_log.len(),
40,
"should have one evidence entry per frame"
);
for (i, line) in evidence_log.iter().enumerate() {
assert!(
line.starts_with('{') && line.ends_with('}'),
"evidence line {i} should be valid JSON: {line}"
);
assert!(
line.contains("degradation-cascade-v1"),
"evidence line {i} should have schema"
);
}
let has_degrade = evidence_log
.iter()
.any(|l| l.contains("\"decision\":\"degrade\""));
assert!(
has_degrade,
"evidence should contain at least one degrade event"
);
}
#[test]
fn e2e_cascade_telemetry_tracking() {
let mut cascade = DegradationCascade::with_defaults();
let key = make_key();
for _ in 0..25 {
cascade.post_render(12_000.0, key);
cascade.pre_render(BUDGET_US, key);
}
let telem = cascade.telemetry();
assert_eq!(telem.frame_idx, 25);
assert_eq!(telem.level, DegradationLevel::Full);
assert_eq!(telem.total_degrades, 0);
assert_eq!(telem.guard_state, GuardState::Calibrated);
assert!(telem.guard_observations > 0);
assert!(telem.guard_ema_us > 0.0);
let json_str = telem.to_jsonl();
assert!(json_str.contains("cascade-telemetry-v1"));
}
#[test]
fn handles_zero_budget_gracefully() {
let mut cascade = DegradationCascade::with_defaults();
let key = make_key();
for _ in 0..5 {
cascade.post_render(1_000.0, key);
}
let result = cascade.pre_render(0.0, key);
assert!(result.level <= DegradationLevel::SkipFrame);
}
#[test]
fn handles_extreme_frame_times() {
let mut cascade = DegradationCascade::with_defaults();
let key = make_key();
for _ in 0..25 {
cascade.post_render(100.0, key);
cascade.pre_render(BUDGET_US, key);
}
assert_eq!(cascade.level(), DegradationLevel::Full);
let mut cascade2 = DegradationCascade::with_defaults();
for _ in 0..25 {
cascade2.post_render(1_000_000.0, key);
cascade2.pre_render(BUDGET_US, key);
}
assert!(cascade2.level() > DegradationLevel::Full);
}
#[test]
fn guard_nonconformity_summary_after_calibration() {
let mut guard = ConformalFrameGuard::with_defaults();
let key = make_key();
for i in 0..50 {
let t = 10_000.0 + (i as f64 * 50.0); guard.observe(t, key);
}
let summary = guard.nonconformity_summary();
assert!(
summary.is_some(),
"should have summary after 50 observations"
);
let s = summary.unwrap();
assert_eq!(s.count, 50);
assert!(s.p99 >= s.p50, "p99 should be >= p50");
}