Skip to main content

ftui_runtime/
resize_sla.rs

1#![forbid(unsafe_code)]
2
3//! Resize SLA monitoring with conformal alerting (bd-1rz0.21).
4//!
5//! This module provides SLA monitoring for resize operations by integrating
6//! the [`ConformalAlert`] system with resize telemetry hooks.
7//!
8//! # Mathematical Model
9//!
10//! The SLA monitor tracks resize latency (time from resize event to final
11//! frame apply) and uses conformal prediction to detect violations:
12//!
13//! ```text
14//! SLA violation := latency > conformal_threshold(calibration_data, alpha)
15//! ```
16//!
17//! The conformal threshold is computed using the (n+1) rule from
18//! [`crate::conformal_alert`], providing distribution-free coverage guarantees.
19//!
20//! # Key Invariants
21//!
22//! 1. **Latency bound**: Alert if latency exceeds calibrated threshold
23//! 2. **FPR control**: False positive rate <= alpha (configurable)
24//! 3. **Anytime-valid**: E-process layer prevents FPR inflation from early stopping
25//! 4. **Full provenance**: Every alert includes evidence ledger
26//!
27//! # Usage
28//!
29//! ```ignore
30//! use ftui_runtime::resize_sla::{ResizeSlaMonitor, SlaConfig};
31//! use ftui_runtime::resize_coalescer::{ResizeCoalescer, TelemetryHooks};
32//!
33//! let sla_monitor = ResizeSlaMonitor::new(SlaConfig::default());
34//! let hooks = sla_monitor.make_hooks();
35//!
36//! let coalescer = ResizeCoalescer::new(config, (80, 24))
37//!     .with_telemetry_hooks(hooks);
38//!
39//! // SLA violations are logged and can be queried
40//! if let Some(alert) = sla_monitor.last_alert() {
41//!     println!("SLA violation: {}", alert.evidence_summary());
42//! }
43//! ```
44
45use std::cell::RefCell;
46use std::sync::{Arc, Mutex};
47use web_time::Instant;
48
49use crate::conformal_alert::{AlertConfig, AlertDecision, AlertStats, ConformalAlert};
50use crate::resize_coalescer::{DecisionLog, TelemetryHooks};
51use crate::voi_sampling::{VoiConfig, VoiSampler, VoiSummary};
52
53/// Configuration for resize SLA monitoring.
54#[derive(Debug, Clone)]
55pub struct SlaConfig {
56    /// Significance level alpha for conformal alerting.
57    /// Lower alpha = more conservative (fewer false alarms). Default: 0.05.
58    pub alpha: f64,
59
60    /// Minimum latency samples before activating SLA monitoring.
61    /// Default: 20.
62    pub min_calibration: usize,
63
64    /// Maximum latency samples to retain for calibration.
65    /// Default: 200.
66    pub max_calibration: usize,
67
68    /// Target SLA latency in milliseconds.
69    /// Used for reference/logging; conformal threshold is data-driven.
70    /// Default: 100.0 (100ms).
71    pub target_latency_ms: f64,
72
73    /// Enable JSONL logging of SLA events.
74    /// Default: true.
75    pub enable_logging: bool,
76
77    /// Alert cooldown: minimum events between consecutive alerts.
78    /// Default: 10.
79    pub alert_cooldown: u64,
80
81    /// Hysteresis factor for alert boundary.
82    /// Default: 1.1.
83    pub hysteresis: f64,
84
85    /// Optional VOI sampling policy for latency measurements.
86    /// When set, latency observations are sampled via VOI decisions.
87    pub voi_sampling: Option<VoiConfig>,
88}
89
90impl Default for SlaConfig {
91    fn default() -> Self {
92        Self {
93            alpha: 0.05,
94            min_calibration: 20,
95            max_calibration: 200,
96            target_latency_ms: 100.0,
97            enable_logging: true,
98            alert_cooldown: 10,
99            hysteresis: 1.1,
100            voi_sampling: None,
101        }
102    }
103}
104
105/// Evidence for a single resize operation.
106#[derive(Debug, Clone)]
107pub struct ResizeEvidence {
108    /// Timestamp of the resize event.
109    pub timestamp: Instant,
110    /// Latency in milliseconds from resize to apply.
111    pub latency_ms: f64,
112    /// Final applied size (width, height).
113    pub applied_size: (u16, u16),
114    /// Whether this was a forced apply (deadline exceeded).
115    pub forced: bool,
116    /// Current regime at time of apply.
117    pub regime: &'static str,
118    /// Total coalesce time if coalesced.
119    pub coalesce_ms: Option<f64>,
120}
121
122/// SLA event log entry for JSONL output.
123#[derive(Debug, Clone)]
124pub struct SlaLogEntry {
125    /// Event index.
126    pub event_idx: u64,
127    /// Event type: "calibrate", "observe", "alert", "stats".
128    pub event_type: &'static str,
129    /// Latency in milliseconds.
130    pub latency_ms: f64,
131    /// Target SLA latency.
132    pub target_latency_ms: f64,
133    /// Current conformal threshold.
134    pub threshold_ms: f64,
135    /// E-value from conformal alerter.
136    pub e_value: f64,
137    /// Whether alert was triggered.
138    pub is_alert: bool,
139    /// Alert reason (if any).
140    pub alert_reason: Option<String>,
141    /// Applied size.
142    pub applied_size: (u16, u16),
143    /// Forced apply flag.
144    pub forced: bool,
145}
146
147/// Summary statistics for SLA monitoring.
148#[derive(Debug, Clone)]
149pub struct SlaSummary {
150    /// Total resize events observed.
151    pub total_events: u64,
152    /// Events in calibration phase.
153    pub calibration_events: usize,
154    /// Total SLA alerts triggered.
155    pub total_alerts: u64,
156    /// Current conformal threshold (ms).
157    pub current_threshold_ms: f64,
158    /// Mean latency from calibration (ms).
159    pub mean_latency_ms: f64,
160    /// Std latency from calibration (ms).
161    pub std_latency_ms: f64,
162    /// Current e-value.
163    pub current_e_value: f64,
164    /// Empirical false positive rate.
165    pub empirical_fpr: f64,
166    /// Target SLA (ms).
167    pub target_latency_ms: f64,
168}
169
170/// Resize SLA monitor with conformal alerting.
171///
172/// Tracks resize latency and alerts on SLA violations using distribution-free
173/// conformal prediction.
174pub struct ResizeSlaMonitor {
175    config: SlaConfig,
176    alerter: RefCell<ConformalAlert>,
177    event_count: RefCell<u64>,
178    total_alerts: RefCell<u64>,
179    last_alert: RefCell<Option<AlertDecision>>,
180    logs: RefCell<Vec<SlaLogEntry>>,
181    sampler: RefCell<Option<VoiSampler>>,
182}
183
184impl ResizeSlaMonitor {
185    /// Create a new SLA monitor with given configuration.
186    pub fn new(config: SlaConfig) -> Self {
187        let alert_config = AlertConfig {
188            alpha: config.alpha,
189            min_calibration: config.min_calibration,
190            max_calibration: config.max_calibration,
191            enable_logging: config.enable_logging,
192            hysteresis: config.hysteresis,
193            alert_cooldown: config.alert_cooldown,
194            ..AlertConfig::default()
195        };
196        let sampler = config.voi_sampling.clone().map(VoiSampler::new);
197
198        Self {
199            config,
200            alerter: RefCell::new(ConformalAlert::new(alert_config)),
201            event_count: RefCell::new(0),
202            total_alerts: RefCell::new(0),
203            last_alert: RefCell::new(None),
204            logs: RefCell::new(Vec::new()),
205            sampler: RefCell::new(sampler),
206        }
207    }
208
209    /// Process a resize apply decision log and return alert decision.
210    pub fn on_decision(&self, entry: &DecisionLog) -> Option<AlertDecision> {
211        // Extract latency from coalesce_ms or time_since_render_ms
212        let latency_ms = entry.coalesce_ms.unwrap_or(entry.time_since_render_ms);
213        let applied_size = entry.applied_size?;
214        if let Some(ref mut sampler) = *self.sampler.borrow_mut() {
215            let decision = sampler.decide(entry.timestamp);
216            if !decision.should_sample {
217                return None;
218            }
219            let result = self.process_latency(latency_ms, applied_size, entry.forced);
220            let violated = latency_ms > self.config.target_latency_ms;
221            sampler.observe_at(violated, entry.timestamp);
222            return result;
223        }
224
225        self.process_latency(latency_ms, applied_size, entry.forced)
226    }
227
228    /// Process a latency observation.
229    fn process_latency(
230        &self,
231        latency_ms: f64,
232        applied_size: (u16, u16),
233        forced: bool,
234    ) -> Option<AlertDecision> {
235        *self.event_count.borrow_mut() += 1;
236        let event_idx = *self.event_count.borrow();
237
238        let mut alerter = self.alerter.borrow_mut();
239
240        // Calibration phase: feed latencies to build baseline
241        if alerter.calibration_count() < self.config.min_calibration {
242            alerter.calibrate(latency_ms);
243
244            if self.config.enable_logging {
245                self.logs.borrow_mut().push(SlaLogEntry {
246                    event_idx,
247                    event_type: "calibrate",
248                    latency_ms,
249                    target_latency_ms: self.config.target_latency_ms,
250                    threshold_ms: alerter.threshold(),
251                    e_value: alerter.e_value(),
252                    is_alert: false,
253                    alert_reason: None,
254                    applied_size,
255                    forced,
256                });
257            }
258
259            return None;
260        }
261
262        // Detection phase: check for SLA violations
263        let decision = alerter.observe(latency_ms);
264
265        if self.config.enable_logging {
266            self.logs.borrow_mut().push(SlaLogEntry {
267                event_idx,
268                event_type: if decision.is_alert {
269                    "alert"
270                } else {
271                    "observe"
272                },
273                latency_ms,
274                target_latency_ms: self.config.target_latency_ms,
275                threshold_ms: decision.evidence.conformal_threshold,
276                e_value: decision.evidence.e_value,
277                is_alert: decision.is_alert,
278                alert_reason: if decision.is_alert {
279                    Some(format!("{:?}", decision.evidence.reason))
280                } else {
281                    None
282                },
283                applied_size,
284                forced,
285            });
286        }
287
288        if decision.is_alert {
289            *self.total_alerts.borrow_mut() += 1;
290            *self.last_alert.borrow_mut() = Some(decision.clone());
291        }
292
293        Some(decision)
294    }
295
296    /// Get the last alert (if any).
297    pub fn last_alert(&self) -> Option<AlertDecision> {
298        self.last_alert.borrow().clone()
299    }
300
301    /// Get SLA summary statistics.
302    pub fn summary(&self) -> SlaSummary {
303        let alerter = self.alerter.borrow();
304        let stats = alerter.stats();
305
306        SlaSummary {
307            total_events: *self.event_count.borrow(),
308            calibration_events: stats.calibration_samples,
309            total_alerts: *self.total_alerts.borrow(),
310            current_threshold_ms: stats.current_threshold,
311            mean_latency_ms: stats.calibration_mean,
312            std_latency_ms: stats.calibration_std,
313            current_e_value: stats.current_e_value,
314            empirical_fpr: stats.empirical_fpr,
315            target_latency_ms: self.config.target_latency_ms,
316        }
317    }
318
319    /// Get alerter stats directly.
320    pub fn alerter_stats(&self) -> AlertStats {
321        self.alerter.borrow().stats()
322    }
323
324    /// Get SLA logs.
325    pub fn logs(&self) -> Vec<SlaLogEntry> {
326        self.logs.borrow().clone()
327    }
328
329    /// Convert logs to JSONL format.
330    pub fn logs_to_jsonl(&self) -> String {
331        let logs = self.logs.borrow();
332        let mut output = String::new();
333
334        for entry in logs.iter() {
335            let line = format!(
336                r#"{{"event":"sla","idx":{},"type":"{}","latency_ms":{:.3},"target_ms":{:.1},"threshold_ms":{:.3},"e_value":{:.6},"alert":{},"reason":{},"size":[{},{}],"forced":{}}}"#,
337                entry.event_idx,
338                entry.event_type,
339                entry.latency_ms,
340                entry.target_latency_ms,
341                entry.threshold_ms,
342                entry.e_value,
343                entry.is_alert,
344                entry
345                    .alert_reason
346                    .as_ref()
347                    .map(|r| format!("\"{}\"", r))
348                    .unwrap_or_else(|| "null".to_string()),
349                entry.applied_size.0,
350                entry.applied_size.1,
351                entry.forced
352            );
353            output.push_str(&line);
354            output.push('\n');
355        }
356
357        output
358    }
359
360    /// Clear logs.
361    pub fn clear_logs(&self) {
362        self.logs.borrow_mut().clear();
363    }
364
365    /// Reset the monitor (keeps configuration).
366    pub fn reset(&self) {
367        let alert_config = AlertConfig {
368            alpha: self.config.alpha,
369            min_calibration: self.config.min_calibration,
370            max_calibration: self.config.max_calibration,
371            enable_logging: self.config.enable_logging,
372            hysteresis: self.config.hysteresis,
373            alert_cooldown: self.config.alert_cooldown,
374            ..AlertConfig::default()
375        };
376
377        *self.alerter.borrow_mut() = ConformalAlert::new(alert_config);
378        *self.event_count.borrow_mut() = 0;
379        *self.total_alerts.borrow_mut() = 0;
380        *self.last_alert.borrow_mut() = None;
381        self.logs.borrow_mut().clear();
382        *self.sampler.borrow_mut() = self.config.voi_sampling.clone().map(VoiSampler::new);
383    }
384
385    /// Current threshold in milliseconds.
386    pub fn threshold_ms(&self) -> f64 {
387        self.alerter.borrow().threshold()
388    }
389
390    /// Whether monitoring is active (past calibration phase).
391    pub fn is_active(&self) -> bool {
392        self.alerter.borrow().calibration_count() >= self.config.min_calibration
393    }
394
395    /// Number of calibration samples collected.
396    pub fn calibration_count(&self) -> usize {
397        self.alerter.borrow().calibration_count()
398    }
399
400    /// Sampling summary if VOI sampling is enabled.
401    pub fn sampling_summary(&self) -> Option<VoiSummary> {
402        self.sampler.borrow().as_ref().map(VoiSampler::summary)
403    }
404
405    /// Sampling logs rendered as JSONL (if enabled).
406    pub fn sampling_logs_to_jsonl(&self) -> Option<String> {
407        self.sampler
408            .borrow()
409            .as_ref()
410            .map(|sampler| sampler.logs_to_jsonl())
411    }
412}
413
414/// Create TelemetryHooks that feed into an SLA monitor.
415///
416/// Returns a tuple of (TelemetryHooks, Rc<ResizeSlaMonitor>) so the monitor
417/// can be queried after hooking into a ResizeCoalescer.
418///
419/// Note: Uses Rc + RefCell internally since TelemetryHooks callbacks are
420/// `Fn` (not `FnMut`) but we need to mutate the monitor state.
421pub fn make_sla_hooks(config: SlaConfig) -> (TelemetryHooks, Arc<Mutex<ResizeSlaMonitor>>) {
422    let monitor = Arc::new(Mutex::new(ResizeSlaMonitor::new(config)));
423    let monitor_clone = Arc::clone(&monitor);
424
425    // Hook into on_resize_applied events to track latency
426    let hooks = TelemetryHooks::new().on_resize_applied(move |entry: &DecisionLog| {
427        // Only process apply events (not coalesce)
428        if (entry.action == "apply" || entry.action == "apply_forced")
429            && let Ok(monitor) = monitor_clone.lock()
430        {
431            monitor.on_decision(entry);
432        }
433    });
434
435    (hooks, monitor)
436}
437
438// =============================================================================
439// Unit Tests (bd-1rz0.21)
440// =============================================================================
441
442#[cfg(test)]
443mod tests {
444    use super::*;
445    use crate::resize_coalescer::Regime;
446
447    fn test_config() -> SlaConfig {
448        SlaConfig {
449            alpha: 0.05,
450            min_calibration: 5,
451            max_calibration: 50,
452            target_latency_ms: 50.0,
453            enable_logging: true,
454            alert_cooldown: 0,
455            hysteresis: 1.0,
456            voi_sampling: None,
457        }
458    }
459
460    fn sample_decision_log(now: Instant, latency_ms: f64) -> DecisionLog {
461        DecisionLog {
462            timestamp: now,
463            elapsed_ms: 0.0,
464            event_idx: 1,
465            dt_ms: 0.0,
466            event_rate: 0.0,
467            regime: Regime::Steady,
468            action: "apply",
469            pending_size: None,
470            applied_size: Some((80, 24)),
471            time_since_render_ms: latency_ms,
472            coalesce_ms: Some(latency_ms),
473            forced: false,
474            transition_reason_code: None,
475            transition_confidence: None,
476        }
477    }
478
479    // =========================================================================
480    // Basic construction and state
481    // =========================================================================
482
483    #[test]
484    fn initial_state() {
485        let monitor = ResizeSlaMonitor::new(test_config());
486
487        assert!(!monitor.is_active());
488        assert_eq!(monitor.calibration_count(), 0);
489        assert!(monitor.last_alert().is_none());
490        assert!(monitor.logs().is_empty());
491    }
492
493    #[test]
494    fn calibration_phase() {
495        let monitor = ResizeSlaMonitor::new(test_config());
496
497        // Feed calibration samples
498        for i in 0..5 {
499            let result = monitor.process_latency(10.0 + i as f64, (80, 24), false);
500            assert!(result.is_none(), "Should be in calibration phase");
501        }
502
503        assert!(monitor.is_active());
504        assert_eq!(monitor.calibration_count(), 5);
505    }
506
507    #[test]
508    fn detection_phase_normal() {
509        let monitor = ResizeSlaMonitor::new(test_config());
510
511        // Calibrate
512        for i in 0..5 {
513            monitor.process_latency(10.0 + i as f64, (80, 24), false);
514        }
515
516        // Normal observation (within calibration range)
517        let result = monitor.process_latency(12.0, (80, 24), false);
518        assert!(result.is_some());
519        assert!(!result.unwrap().is_alert);
520    }
521
522    #[test]
523    fn detection_phase_alert() {
524        let mut config = test_config();
525        config.hysteresis = 0.1; // Lower threshold for easier triggering
526        let monitor = ResizeSlaMonitor::new(config);
527
528        // Calibrate with tight distribution
529        for _ in 0..5 {
530            monitor.process_latency(10.0, (80, 24), false);
531        }
532
533        // Extreme latency should trigger alert
534        let result = monitor.process_latency(1000.0, (80, 24), false);
535        assert!(result.is_some());
536
537        let decision = result.unwrap();
538        assert!(
539            decision.evidence.conformal_alert || decision.evidence.eprocess_alert,
540            "Extreme latency should trigger alert"
541        );
542    }
543
544    // =========================================================================
545    // Logging tests
546    // =========================================================================
547
548    #[test]
549    fn logging_captures_events() {
550        let monitor = ResizeSlaMonitor::new(test_config());
551
552        // Calibrate
553        for i in 0..5 {
554            monitor.process_latency(10.0 + i as f64, (80, 24), false);
555        }
556
557        // Observe
558        monitor.process_latency(12.0, (80, 24), false);
559        monitor.process_latency(15.0, (100, 40), true);
560
561        let logs = monitor.logs();
562        assert_eq!(logs.len(), 7);
563
564        // Check calibration entries
565        assert_eq!(logs[0].event_type, "calibrate");
566        assert_eq!(logs[4].event_type, "calibrate");
567
568        // Check observation entries
569        assert_eq!(logs[5].event_type, "observe");
570        assert_eq!(logs[6].applied_size, (100, 40));
571        assert!(logs[6].forced);
572    }
573
574    #[test]
575    fn jsonl_format() {
576        let monitor = ResizeSlaMonitor::new(test_config());
577
578        // Calibrate with 5 events (min_calibration=5), then 1 observation.
579        // The 6th value must fall within conformal bounds to be "observe"
580        // rather than "alert". Calibration on values 10-14 yields mean=12,
581        // threshold=2.0, so use 12.0 (residual=0) for the observation.
582        for i in 0..5 {
583            monitor.process_latency(10.0 + i as f64, (80, 24), false);
584        }
585        monitor.process_latency(12.0, (80, 24), false);
586
587        let jsonl = monitor.logs_to_jsonl();
588        assert!(jsonl.contains(r#""event":"sla""#));
589        assert!(jsonl.contains(r#""type":"calibrate""#));
590        assert!(jsonl.contains(r#""type":"observe""#));
591        assert!(jsonl.contains(r#""latency_ms":"#));
592        assert!(jsonl.contains(r#""threshold_ms":"#));
593    }
594
595    // =========================================================================
596    // Summary statistics
597    // =========================================================================
598
599    #[test]
600    fn summary_reflects_state() {
601        let monitor = ResizeSlaMonitor::new(test_config());
602
603        for i in 0..10 {
604            monitor.process_latency(10.0 + (i as f64) * 2.0, (80, 24), false);
605        }
606
607        let summary = monitor.summary();
608        assert_eq!(summary.total_events, 10);
609        assert!(summary.mean_latency_ms > 0.0);
610        assert!(summary.current_threshold_ms > 0.0);
611        assert_eq!(summary.target_latency_ms, 50.0);
612    }
613
614    // =========================================================================
615    // Reset behavior
616    // =========================================================================
617
618    #[test]
619    fn reset_clears_state() {
620        let monitor = ResizeSlaMonitor::new(test_config());
621
622        for i in 0..10 {
623            monitor.process_latency(10.0 + i as f64, (80, 24), false);
624        }
625
626        assert!(monitor.is_active());
627        assert!(!monitor.logs().is_empty());
628
629        monitor.reset();
630
631        assert!(!monitor.is_active());
632        assert!(monitor.logs().is_empty());
633        assert_eq!(monitor.calibration_count(), 0);
634    }
635
636    // =========================================================================
637    // Integration with DecisionLog
638    // =========================================================================
639
640    #[test]
641    fn on_decision_processes_entry() {
642        use crate::resize_coalescer::Regime;
643
644        let monitor = ResizeSlaMonitor::new(test_config());
645
646        // Create a DecisionLog entry representing an apply event
647        let entry = DecisionLog {
648            timestamp: Instant::now(),
649            elapsed_ms: 0.0,
650            event_idx: 1,
651            dt_ms: 0.0,
652            event_rate: 0.0,
653            regime: Regime::Steady,
654            action: "apply",
655            pending_size: None,
656            applied_size: Some((100, 40)),
657            time_since_render_ms: 15.0,
658            coalesce_ms: Some(15.0),
659            forced: false,
660            transition_reason_code: None,
661            transition_confidence: None,
662        };
663
664        let result = monitor.on_decision(&entry);
665        assert!(result.is_none()); // Still in calibration
666
667        // Feed more entries
668        for i in 0..5 {
669            let entry = DecisionLog {
670                timestamp: Instant::now(),
671                elapsed_ms: 0.0,
672                event_idx: 2 + i,
673                dt_ms: 0.0,
674                event_rate: 0.0,
675                regime: Regime::Steady,
676                action: "apply",
677                pending_size: None,
678                applied_size: Some((100, 40)),
679                time_since_render_ms: 15.0 + i as f64,
680                coalesce_ms: Some(15.0 + i as f64),
681                forced: false,
682                transition_reason_code: None,
683                transition_confidence: None,
684            };
685            monitor.on_decision(&entry);
686        }
687
688        assert!(monitor.is_active());
689    }
690
691    // =========================================================================
692    // Hook factory
693    // =========================================================================
694
695    #[test]
696    fn make_sla_hooks_creates_valid_hooks() {
697        let (_hooks, monitor) = make_sla_hooks(test_config());
698
699        // Verify monitor is accessible and not active initially
700        let monitor = monitor.lock().expect("sla monitor lock");
701        assert!(!monitor.is_active());
702        assert_eq!(monitor.calibration_count(), 0);
703    }
704
705    // =========================================================================
706    // Property tests
707    // =========================================================================
708
709    #[test]
710    fn property_calibration_mean_accurate() {
711        let monitor = ResizeSlaMonitor::new(test_config());
712
713        let samples: Vec<f64> = vec![10.0, 20.0, 30.0, 40.0, 50.0];
714        let expected_mean: f64 = samples.iter().sum::<f64>() / samples.len() as f64;
715
716        for &s in &samples {
717            monitor.process_latency(s, (80, 24), false);
718        }
719
720        let summary = monitor.summary();
721        assert!(
722            (summary.mean_latency_ms - expected_mean).abs() < 0.01,
723            "Mean should be accurate: {} vs {}",
724            summary.mean_latency_ms,
725            expected_mean
726        );
727    }
728
729    #[test]
730    fn property_alert_count_nondecreasing() {
731        let mut config = test_config();
732        config.hysteresis = 0.1;
733        config.alert_cooldown = 0;
734        let monitor = ResizeSlaMonitor::new(config);
735
736        // Calibrate
737        for _ in 0..5 {
738            monitor.process_latency(10.0, (80, 24), false);
739        }
740
741        let mut prev_alerts = 0u64;
742        for i in 0..20 {
743            let latency = if i % 3 == 0 { 1000.0 } else { 10.0 };
744            monitor.process_latency(latency, (80, 24), false);
745
746            let current_alerts = *monitor.total_alerts.borrow();
747            assert!(
748                current_alerts >= prev_alerts,
749                "Alert count should be non-decreasing"
750            );
751            prev_alerts = current_alerts;
752        }
753    }
754
755    #[test]
756    fn deterministic_behavior() {
757        let config = test_config();
758
759        let run = || {
760            let monitor = ResizeSlaMonitor::new(config.clone());
761            for i in 0..10 {
762                monitor.process_latency(10.0 + i as f64, (80, 24), false);
763            }
764            (
765                monitor.summary().mean_latency_ms,
766                monitor.threshold_ms(),
767                *monitor.total_alerts.borrow(),
768            )
769        };
770
771        let (m1, t1, a1) = run();
772        let (m2, t2, a2) = run();
773
774        assert!((m1 - m2).abs() < 1e-10, "Mean must be deterministic");
775        assert!((t1 - t2).abs() < 1e-10, "Threshold must be deterministic");
776        assert_eq!(a1, a2, "Alert count must be deterministic");
777    }
778
779    #[test]
780    fn voi_sampling_skips_when_policy_says_no() {
781        let mut config = test_config();
782        config.voi_sampling = Some(VoiConfig {
783            sample_cost: 10.0,
784            max_interval_events: 0,
785            max_interval_ms: 0,
786            ..VoiConfig::default()
787        });
788        let monitor = ResizeSlaMonitor::new(config);
789
790        let entry = sample_decision_log(Instant::now(), 12.0);
791        let result = monitor.on_decision(&entry);
792        assert!(result.is_none(), "Sampling should skip under high cost");
793
794        let summary = monitor.summary();
795        assert_eq!(summary.total_events, 0);
796        let sampling = monitor.sampling_summary().expect("sampling summary");
797        assert_eq!(sampling.total_events, 1);
798    }
799
800    #[test]
801    fn voi_sampling_forced_sample_records_event() {
802        let mut config = test_config();
803        // Skip calibration so the first sampled event reaches the observe
804        // phase and returns Some(AlertDecision) instead of None.
805        config.min_calibration = 0;
806        config.voi_sampling = Some(VoiConfig {
807            sample_cost: 10.0,
808            max_interval_events: 1,
809            ..VoiConfig::default()
810        });
811        let monitor = ResizeSlaMonitor::new(config);
812
813        let entry = sample_decision_log(Instant::now(), 12.0);
814        let result = monitor.on_decision(&entry);
815        assert!(result.is_some());
816
817        let summary = monitor.summary();
818        assert_eq!(summary.total_events, 1);
819        let sampling = monitor.sampling_summary().expect("sampling summary");
820        assert_eq!(sampling.total_samples, 1);
821    }
822
823    #[test]
824    fn sla_config_default_values() {
825        let config = SlaConfig::default();
826        assert!((config.alpha - 0.05).abs() < 1e-10);
827        assert_eq!(config.min_calibration, 20);
828        assert_eq!(config.max_calibration, 200);
829        assert!((config.target_latency_ms - 100.0).abs() < 1e-10);
830        assert!(config.enable_logging);
831        assert_eq!(config.alert_cooldown, 10);
832        assert!((config.hysteresis - 1.1).abs() < 1e-10);
833        assert!(config.voi_sampling.is_none());
834    }
835
836    #[test]
837    fn last_alert_initially_none() {
838        let monitor = ResizeSlaMonitor::new(test_config());
839        assert!(monitor.last_alert().is_none());
840    }
841
842    #[test]
843    fn clear_logs_empties_log_vec() {
844        let monitor = ResizeSlaMonitor::new(test_config());
845        let now = Instant::now();
846        for i in 0..3 {
847            monitor.on_decision(&sample_decision_log(now, 10.0 + i as f64));
848        }
849        assert!(!monitor.logs().is_empty());
850        monitor.clear_logs();
851        assert!(monitor.logs().is_empty());
852    }
853
854    #[test]
855    fn threshold_ms_returns_value() {
856        let monitor = ResizeSlaMonitor::new(test_config());
857        let threshold = monitor.threshold_ms();
858        // Before calibration, threshold should be some default
859        assert!(threshold.is_finite());
860    }
861
862    #[test]
863    fn is_active_after_calibration() {
864        let monitor = ResizeSlaMonitor::new(test_config());
865        assert!(!monitor.is_active());
866        let now = Instant::now();
867        for i in 0..5 {
868            monitor.on_decision(&sample_decision_log(now, 10.0 + i as f64));
869        }
870        assert!(monitor.is_active());
871    }
872
873    #[test]
874    fn calibration_count_tracks_samples() {
875        let monitor = ResizeSlaMonitor::new(test_config());
876        assert_eq!(monitor.calibration_count(), 0);
877        let now = Instant::now();
878        monitor.on_decision(&sample_decision_log(now, 10.0));
879        assert_eq!(monitor.calibration_count(), 1);
880    }
881
882    #[test]
883    fn alerter_stats_returns_valid() {
884        let monitor = ResizeSlaMonitor::new(test_config());
885        let stats = monitor.alerter_stats();
886        assert_eq!(stats.calibration_samples, 0);
887    }
888
889    #[test]
890    fn sampling_summary_none_without_voi() {
891        let monitor = ResizeSlaMonitor::new(test_config());
892        assert!(monitor.sampling_summary().is_none());
893    }
894
895    #[test]
896    fn sampling_logs_to_jsonl_none_without_voi() {
897        let monitor = ResizeSlaMonitor::new(test_config());
898        assert!(monitor.sampling_logs_to_jsonl().is_none());
899    }
900
901    // =========================================================================
902    // Edge-case tests (bd-1nn7a)
903    // =========================================================================
904
905    #[test]
906    fn edge_on_decision_none_applied_size() {
907        let monitor = ResizeSlaMonitor::new(test_config());
908        let entry = DecisionLog {
909            timestamp: Instant::now(),
910            elapsed_ms: 0.0,
911            event_idx: 1,
912            dt_ms: 0.0,
913            event_rate: 0.0,
914            regime: Regime::Steady,
915            action: "apply",
916            pending_size: None,
917            applied_size: None, // Missing applied_size
918            time_since_render_ms: 10.0,
919            coalesce_ms: Some(10.0),
920            forced: false,
921            transition_reason_code: None,
922            transition_confidence: None,
923        };
924        let result = monitor.on_decision(&entry);
925        assert!(
926            result.is_none(),
927            "Should return None when applied_size is None"
928        );
929        // Event should not be counted
930        assert_eq!(*monitor.event_count.borrow(), 0);
931    }
932
933    #[test]
934    fn edge_on_decision_coalesce_ms_none_falls_back() {
935        let monitor = ResizeSlaMonitor::new(test_config());
936        let entry = DecisionLog {
937            timestamp: Instant::now(),
938            elapsed_ms: 0.0,
939            event_idx: 1,
940            dt_ms: 0.0,
941            event_rate: 0.0,
942            regime: Regime::Steady,
943            action: "apply",
944            pending_size: None,
945            applied_size: Some((80, 24)),
946            time_since_render_ms: 42.0,
947            coalesce_ms: None, // Falls back to time_since_render_ms
948            forced: false,
949            transition_reason_code: None,
950            transition_confidence: None,
951        };
952        let result = monitor.on_decision(&entry);
953        // Should process using time_since_render_ms (42.0)
954        assert!(result.is_none()); // Still in calibration
955        assert_eq!(monitor.calibration_count(), 1);
956    }
957
958    #[test]
959    fn edge_zero_latency() {
960        let monitor = ResizeSlaMonitor::new(test_config());
961        for _ in 0..5 {
962            monitor.process_latency(0.0, (80, 24), false);
963        }
964        // All-zero calibration, observe zero -> no alert
965        let result = monitor.process_latency(0.0, (80, 24), false);
966        assert!(result.is_some());
967        assert!(!result.unwrap().is_alert);
968    }
969
970    #[test]
971    fn edge_negative_latency() {
972        let monitor = ResizeSlaMonitor::new(test_config());
973        // Negative latencies should not panic
974        for _ in 0..5 {
975            monitor.process_latency(-10.0, (80, 24), false);
976        }
977        let result = monitor.process_latency(-5.0, (80, 24), false);
978        assert!(result.is_some());
979    }
980
981    #[test]
982    fn edge_nan_latency() {
983        let monitor = ResizeSlaMonitor::new(test_config());
984        for _ in 0..5 {
985            monitor.process_latency(10.0, (80, 24), false);
986        }
987        // NaN latency should not panic
988        let result = monitor.process_latency(f64::NAN, (80, 24), false);
989        assert!(result.is_some());
990    }
991
992    #[test]
993    fn edge_infinity_latency() {
994        let mut config = test_config();
995        config.hysteresis = 0.1;
996        let monitor = ResizeSlaMonitor::new(config);
997        for _ in 0..5 {
998            monitor.process_latency(10.0, (80, 24), false);
999        }
1000        let result = monitor.process_latency(f64::INFINITY, (80, 24), false);
1001        assert!(result.is_some());
1002        // Infinite latency should trigger conformal alert
1003        assert!(result.unwrap().evidence.conformal_alert);
1004    }
1005
1006    #[test]
1007    fn edge_logging_disabled() {
1008        let mut config = test_config();
1009        config.enable_logging = false;
1010        let monitor = ResizeSlaMonitor::new(config);
1011
1012        for i in 0..10 {
1013            monitor.process_latency(10.0 + i as f64, (80, 24), false);
1014        }
1015
1016        assert!(
1017            monitor.logs().is_empty(),
1018            "Logs should be empty when disabled"
1019        );
1020        assert!(monitor.logs_to_jsonl().is_empty());
1021    }
1022
1023    #[test]
1024    fn edge_reset_then_reuse() {
1025        let monitor = ResizeSlaMonitor::new(test_config());
1026
1027        // First cycle
1028        for i in 0..10 {
1029            monitor.process_latency(10.0 + i as f64, (80, 24), false);
1030        }
1031        assert!(monitor.is_active());
1032        let summary1 = monitor.summary();
1033        assert_eq!(summary1.total_events, 10);
1034
1035        // Reset
1036        monitor.reset();
1037        assert!(!monitor.is_active());
1038        assert_eq!(monitor.calibration_count(), 0);
1039        assert!(monitor.last_alert().is_none());
1040
1041        // Second cycle with different data
1042        for i in 0..10 {
1043            monitor.process_latency(50.0 + i as f64, (120, 40), false);
1044        }
1045        assert!(monitor.is_active());
1046        let summary2 = monitor.summary();
1047        assert_eq!(summary2.total_events, 10);
1048        // Mean should reflect new data, not old
1049        assert!(summary2.mean_latency_ms > 40.0);
1050    }
1051
1052    #[test]
1053    fn edge_multiple_resets() {
1054        let monitor = ResizeSlaMonitor::new(test_config());
1055
1056        for _ in 0..3 {
1057            for i in 0..5 {
1058                monitor.process_latency(10.0 + i as f64, (80, 24), false);
1059            }
1060            monitor.reset();
1061        }
1062
1063        assert!(!monitor.is_active());
1064        assert_eq!(*monitor.event_count.borrow(), 0);
1065    }
1066
1067    #[test]
1068    fn edge_min_calibration_zero() {
1069        let mut config = test_config();
1070        config.min_calibration = 0;
1071        let monitor = ResizeSlaMonitor::new(config);
1072
1073        // Immediately active since min_calibration=0
1074        assert!(monitor.is_active());
1075
1076        // First observation goes directly to observe phase
1077        let result = monitor.process_latency(10.0, (80, 24), false);
1078        assert!(result.is_some());
1079    }
1080
1081    #[test]
1082    fn edge_last_alert_updates() {
1083        let mut config = test_config();
1084        config.hysteresis = 0.1;
1085        config.alert_cooldown = 0;
1086        let monitor = ResizeSlaMonitor::new(config);
1087
1088        // Calibrate
1089        for _ in 0..5 {
1090            monitor.process_latency(10.0, (80, 24), false);
1091        }
1092
1093        // Trigger alerts with extreme latency
1094        let mut got_alert = false;
1095        for _ in 0..10 {
1096            let result = monitor.process_latency(1000.0, (80, 24), false);
1097            if let Some(decision) = result
1098                && decision.is_alert
1099            {
1100                got_alert = true;
1101            }
1102        }
1103
1104        if got_alert {
1105            let last = monitor.last_alert();
1106            assert!(last.is_some());
1107            assert!(last.unwrap().is_alert);
1108        }
1109    }
1110
1111    #[test]
1112    fn edge_forced_flag_propagates_to_log() {
1113        let monitor = ResizeSlaMonitor::new(test_config());
1114
1115        monitor.process_latency(10.0, (80, 24), true);
1116
1117        let logs = monitor.logs();
1118        assert_eq!(logs.len(), 1);
1119        assert!(logs[0].forced);
1120    }
1121
1122    #[test]
1123    fn edge_applied_size_propagates_to_log() {
1124        let monitor = ResizeSlaMonitor::new(test_config());
1125
1126        monitor.process_latency(10.0, (200, 60), false);
1127
1128        let logs = monitor.logs();
1129        assert_eq!(logs.len(), 1);
1130        assert_eq!(logs[0].applied_size, (200, 60));
1131    }
1132
1133    #[test]
1134    fn edge_event_count_accuracy() {
1135        let monitor = ResizeSlaMonitor::new(test_config());
1136
1137        for i in 0..15 {
1138            monitor.process_latency(10.0 + i as f64, (80, 24), false);
1139        }
1140
1141        assert_eq!(*monitor.event_count.borrow(), 15);
1142        assert_eq!(monitor.summary().total_events, 15);
1143    }
1144
1145    #[test]
1146    fn edge_jsonl_with_alert() {
1147        let mut config = test_config();
1148        config.hysteresis = 0.1;
1149        config.alert_cooldown = 0;
1150        let monitor = ResizeSlaMonitor::new(config);
1151
1152        // Calibrate with tight distribution
1153        for _ in 0..5 {
1154            monitor.process_latency(10.0, (80, 24), false);
1155        }
1156
1157        // Trigger alert
1158        monitor.process_latency(10000.0, (80, 24), false);
1159
1160        let jsonl = monitor.logs_to_jsonl();
1161        // Should have both calibrate and either observe/alert entries
1162        assert!(jsonl.contains(r#""type":"calibrate""#));
1163        let has_alert_or_observe =
1164            jsonl.contains(r#""type":"alert""#) || jsonl.contains(r#""type":"observe""#);
1165        assert!(has_alert_or_observe);
1166    }
1167
1168    #[test]
1169    fn edge_summary_after_reset() {
1170        let monitor = ResizeSlaMonitor::new(test_config());
1171
1172        for i in 0..10 {
1173            monitor.process_latency(10.0 + i as f64, (80, 24), false);
1174        }
1175
1176        monitor.reset();
1177
1178        let summary = monitor.summary();
1179        assert_eq!(summary.total_events, 0);
1180        assert_eq!(summary.total_alerts, 0);
1181        assert_eq!(summary.calibration_events, 0);
1182    }
1183
1184    #[test]
1185    fn edge_sla_config_clone_debug() {
1186        let config = SlaConfig::default();
1187        let cloned = config.clone();
1188        assert_eq!(cloned.alpha, config.alpha);
1189        assert_eq!(cloned.min_calibration, config.min_calibration);
1190        let debug = format!("{:?}", config);
1191        assert!(debug.contains("SlaConfig"));
1192    }
1193
1194    #[test]
1195    fn edge_resize_evidence_clone_debug() {
1196        let ev = ResizeEvidence {
1197            timestamp: Instant::now(),
1198            latency_ms: 42.0,
1199            applied_size: (80, 24),
1200            forced: false,
1201            regime: "steady",
1202            coalesce_ms: Some(10.0),
1203        };
1204        let cloned = ev.clone();
1205        assert_eq!(cloned.latency_ms, 42.0);
1206        assert_eq!(cloned.applied_size, (80, 24));
1207        let debug = format!("{:?}", ev);
1208        assert!(debug.contains("ResizeEvidence"));
1209    }
1210
1211    #[test]
1212    fn edge_sla_log_entry_clone_debug() {
1213        let entry = SlaLogEntry {
1214            event_idx: 1,
1215            event_type: "calibrate",
1216            latency_ms: 10.0,
1217            target_latency_ms: 50.0,
1218            threshold_ms: 20.0,
1219            e_value: 1.0,
1220            is_alert: false,
1221            alert_reason: None,
1222            applied_size: (80, 24),
1223            forced: false,
1224        };
1225        let cloned = entry.clone();
1226        assert_eq!(cloned.event_idx, 1);
1227        assert_eq!(cloned.event_type, "calibrate");
1228        let debug = format!("{:?}", entry);
1229        assert!(debug.contains("SlaLogEntry"));
1230    }
1231
1232    #[test]
1233    fn edge_sla_summary_clone_debug() {
1234        let monitor = ResizeSlaMonitor::new(test_config());
1235        for i in 0..5 {
1236            monitor.process_latency(10.0 + i as f64, (80, 24), false);
1237        }
1238        let summary = monitor.summary();
1239        let cloned = summary.clone();
1240        assert_eq!(cloned.total_events, summary.total_events);
1241        let debug = format!("{:?}", summary);
1242        assert!(debug.contains("SlaSummary"));
1243    }
1244
1245    #[test]
1246    fn edge_max_calibration_small() {
1247        let mut config = test_config();
1248        config.max_calibration = 3;
1249        config.min_calibration = 3;
1250        let monitor = ResizeSlaMonitor::new(config);
1251
1252        // Feed more than max_calibration samples
1253        for i in 0..10 {
1254            monitor.process_latency(10.0 + i as f64, (80, 24), false);
1255        }
1256
1257        // Calibration window should be bounded
1258        assert!(monitor.calibration_count() <= 3);
1259    }
1260
1261    #[test]
1262    fn edge_large_latency_values() {
1263        let monitor = ResizeSlaMonitor::new(test_config());
1264        for _ in 0..5 {
1265            monitor.process_latency(1e15, (80, 24), false);
1266        }
1267        // Should handle very large values without panic
1268        let result = monitor.process_latency(1e15, (80, 24), false);
1269        assert!(result.is_some());
1270        let summary = monitor.summary();
1271        assert!(summary.mean_latency_ms.is_finite());
1272    }
1273}