Skip to main content

khora_control/
analysis.rs

1// Copyright 2025 eraflo
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Heuristic analysis for the DCC.
16//!
17//! The `HeuristicEngine` is the analytical core that evaluates the full
18//! situational model (hardware state, execution phase, metric trends) to
19//! decide whether a GORNA renegotiation is necessary and what the global
20//! performance target should be.
21
22use crate::context::Context;
23use crate::metrics::MetricStore;
24use khora_core::platform::{BatteryLevel, ThermalStatus};
25use khora_core::telemetry::MetricId;
26
27/// Threshold (ms) above which frame time is considered problematic.
28const FRAME_TIME_WARN_THRESHOLD_MS: f32 = 18.0;
29/// Threshold (ms) above which frame time is critically high.
30const FRAME_TIME_CRITICAL_THRESHOLD_MS: f32 = 25.0;
31/// Threshold for frame time variance indicating stutter.
32const FRAME_TIME_VARIANCE_THRESHOLD: f32 = 4.0;
33/// Rising trend threshold (ms per sample window) triggering preemptive action.
34const FRAME_TIME_TREND_THRESHOLD: f32 = 2.0;
35/// CPU load threshold for triggering negotiation.
36const CPU_LOAD_CRITICAL: f32 = 0.95;
37/// GPU load threshold for triggering negotiation.
38const GPU_LOAD_CRITICAL: f32 = 0.95;
39/// GPU load threshold for a warning-level response.
40const GPU_LOAD_WARN: f32 = 0.90;
41/// Memory-pressure threshold (fraction of the RAM budget) for a critical response.
42const MEM_PRESSURE_CRITICAL: f32 = 0.95;
43/// Memory-pressure threshold for a warning-level response.
44const MEM_PRESSURE_WARN: f32 = 0.85;
45/// Coefficient-of-variation (stddev/mean) of resident bytes above which memory
46/// is "churning" — a scale-free signal of per-frame allocation hotspots. Pure
47/// glass-box diagnostic (no control action), so a noisy estimate can't misfire.
48const MEM_CHURN_COV_THRESHOLD: f32 = 0.15;
49
50/// Analysis results and alerts produced by the `HeuristicEngine`.
51#[derive(Debug, Clone)]
52pub struct AnalysisReport {
53    /// `true` if a resource conflict or performance drop is detected and GORNA
54    /// should run a full negotiation round.
55    pub needs_negotiation: bool,
56    /// Suggested global target latency (in ms) derived from analysis.
57    pub suggested_latency_ms: f32,
58    /// `true` if the engine is in a "death spiral" — multiple subsystems are
59    /// simultaneously failing to meet budgets and an emergency stop is required.
60    pub death_spiral_detected: bool,
61    /// Human-readable summary of analysis findings for telemetry/logging.
62    pub alerts: Vec<String>,
63}
64
65impl Default for AnalysisReport {
66    fn default() -> Self {
67        Self {
68            needs_negotiation: false,
69            suggested_latency_ms: 16.66,
70            death_spiral_detected: false,
71            alerts: Vec::new(),
72        }
73    }
74}
75
76/// Analyzes metrics and context to determine engine-wide strategy changes.
77pub struct HeuristicEngine;
78
79impl HeuristicEngine {
80    /// Analyzes the current situational model.
81    ///
82    /// Evaluates the full set of heuristics:
83    /// 1. **Phase heuristics**: Adjust target FPS for the current execution phase.
84    /// 2. **Thermal analysis**: Detect throttling / critical and reduce budgets.
85    /// 3. **Battery analysis**: Conserve power on low/critical battery.
86    /// 4. **Frame time analysis**: Detect sustained performance drops.
87    /// 5. **Stutter analysis**: Detect high frame time variance.
88    /// 6. **Trend analysis**: Preempt worsening performance via slope detection.
89    /// 7. **CPU/GPU pressure**: Detect resource saturation.
90    pub fn analyze(&self, context: &Context, store: &MetricStore) -> AnalysisReport {
91        let mut report = AnalysisReport::default();
92        let mut pressure_count: u32 = 0;
93
94        // ── 1. Target latency (60 FPS baseline) ──────────────────────────
95        report.suggested_latency_ms = 16.66;
96
97        // ── 2. Thermal Analysis ──────────────────────────────────────────
98        match context.hardware.thermal {
99            ThermalStatus::Critical => {
100                log::warn!("Heuristic: CRITICAL thermal state — emergency budget reduction.");
101                report.needs_negotiation = true;
102                report.suggested_latency_ms = f32::max(report.suggested_latency_ms, 50.0); // ~20 FPS cap
103                report
104                    .alerts
105                    .push("Thermal: CRITICAL — emergency load reduction.".into());
106                pressure_count += 1;
107            }
108            ThermalStatus::Throttling => {
109                log::warn!("Heuristic: Device is throttling. Recommending load reduction.");
110                report.needs_negotiation = true;
111                report.suggested_latency_ms = f32::max(report.suggested_latency_ms, 33.33); // 30 FPS cap
112                report
113                    .alerts
114                    .push("Thermal: Throttling — capping to 30 FPS.".into());
115                pressure_count += 1;
116            }
117            ThermalStatus::Warm => {
118                log::debug!("Heuristic: Device is warm. Monitoring.");
119            }
120            ThermalStatus::Cool => {}
121        }
122
123        // ── 3. Battery Analysis ──────────────────────────────────────────
124        match context.hardware.battery {
125            BatteryLevel::Critical => {
126                log::warn!("Heuristic: Battery CRITICAL — mandatory power saving.");
127                report.needs_negotiation = true;
128                report.suggested_latency_ms = f32::max(report.suggested_latency_ms, 50.0); // ~20 FPS
129                report
130                    .alerts
131                    .push("Battery: CRITICAL — mandatory power saving.".into());
132                pressure_count += 1;
133            }
134            BatteryLevel::Low => {
135                log::info!("Heuristic: Battery low — reducing target to 30 FPS.");
136                report.needs_negotiation = true;
137                report.suggested_latency_ms = f32::max(report.suggested_latency_ms, 33.33);
138                report
139                    .alerts
140                    .push("Battery: Low — capping to 30 FPS.".into());
141            }
142            BatteryLevel::High | BatteryLevel::Mains => {}
143        }
144
145        // ── 4. Frame Time Analysis ───────────────────────────────────────
146        let frame_time_id = MetricId::new("renderer", "frame_time");
147        let avg_frame_time = store.get_average(&frame_time_id);
148        let has_enough_samples = store.get_sample_count(&frame_time_id) >= 10;
149
150        if has_enough_samples {
151            if avg_frame_time > FRAME_TIME_CRITICAL_THRESHOLD_MS {
152                log::warn!(
153                    "Heuristic: Frame time critically high ({:.2}ms). Forcing negotiation.",
154                    avg_frame_time
155                );
156                report.needs_negotiation = true;
157                report.alerts.push(format!(
158                    "FrameTime: CRITICAL — avg {:.2}ms exceeds {:.0}ms.",
159                    avg_frame_time, FRAME_TIME_CRITICAL_THRESHOLD_MS
160                ));
161                pressure_count += 1;
162            } else if avg_frame_time > FRAME_TIME_WARN_THRESHOLD_MS {
163                log::debug!(
164                    "Heuristic: Frame time elevated ({:.2}ms). Triggering negotiation.",
165                    avg_frame_time
166                );
167                report.needs_negotiation = true;
168                report.alerts.push(format!(
169                    "FrameTime: Elevated — avg {:.2}ms above {:.0}ms threshold.",
170                    avg_frame_time, FRAME_TIME_WARN_THRESHOLD_MS
171                ));
172            }
173
174            // ── 5. Stutter Detection (variance) ─────────────────────────
175            let variance = store.get_variance(&frame_time_id);
176            if variance > FRAME_TIME_VARIANCE_THRESHOLD {
177                log::info!(
178                    "Heuristic: High frame time variance ({:.2}). Stutter detected.",
179                    variance
180                );
181                report.needs_negotiation = true;
182                report.alerts.push(format!(
183                    "Stutter: Variance {:.2} exceeds threshold {:.1}.",
184                    variance, FRAME_TIME_VARIANCE_THRESHOLD
185                ));
186            }
187
188            // ── 6. Trend Analysis (preemptive) ──────────────────────────
189            let trend = store.get_trend(&frame_time_id);
190            if trend > FRAME_TIME_TREND_THRESHOLD {
191                log::info!(
192                    "Heuristic: Frame time rising ({:+.2}ms trend). Preemptive negotiation.",
193                    trend
194                );
195                report.needs_negotiation = true;
196                report.alerts.push(format!(
197                    "Trend: Frame time rising at {:+.2}ms/window.",
198                    trend
199                ));
200            }
201        }
202
203        // ── 7. CPU Pressure ──────────────────────────────────────────────
204        if context.hardware.cpu_load > CPU_LOAD_CRITICAL {
205            log::warn!(
206                "Heuristic: CPU load critical ({:.2}). Triggering negotiation.",
207                context.hardware.cpu_load
208            );
209            report.needs_negotiation = true;
210            report.alerts.push(format!(
211                "CPU: Load {:.0}% exceeds critical threshold.",
212                context.hardware.cpu_load * 100.0
213            ));
214            pressure_count += 1;
215        }
216
217        // ── 8. GPU Pressure ──────────────────────────────────────────────
218        if context.hardware.gpu_load > GPU_LOAD_CRITICAL {
219            log::warn!(
220                "Heuristic: GPU load critical ({:.2}). Triggering negotiation.",
221                context.hardware.gpu_load
222            );
223            report.needs_negotiation = true;
224            report.alerts.push(format!(
225                "GPU: Load {:.0}% exceeds critical threshold.",
226                context.hardware.gpu_load * 100.0
227            ));
228            pressure_count += 1;
229        } else if context.hardware.gpu_load > GPU_LOAD_WARN {
230            log::debug!(
231                "Heuristic: GPU load elevated ({:.2}).",
232                context.hardware.gpu_load
233            );
234            report.needs_negotiation = true;
235            report.alerts.push(format!(
236                "GPU: Load {:.0}% above warning threshold.",
237                context.hardware.gpu_load * 100.0
238            ));
239        }
240
241        // ── 8b. Memory Pressure ──────────────────────────────────────────
242        // A first-class resource signal alongside CPU/GPU: when resident RAM
243        // approaches the developer-set budget, downgrade so memory-heavy
244        // strategies aren't selected. Inert when no budget is set (pressure 0).
245        if context.memory_pressure > MEM_PRESSURE_CRITICAL {
246            log::warn!(
247                "Heuristic: Memory pressure critical ({:.0}%). Triggering negotiation.",
248                context.memory_pressure * 100.0
249            );
250            report.needs_negotiation = true;
251            report.alerts.push(format!(
252                "Memory: pressure {:.0}% exceeds critical threshold.",
253                context.memory_pressure * 100.0
254            ));
255            pressure_count += 1;
256        } else if context.memory_pressure > MEM_PRESSURE_WARN {
257            log::debug!(
258                "Heuristic: Memory pressure elevated ({:.0}%).",
259                context.memory_pressure * 100.0
260            );
261            report.needs_negotiation = true;
262            report.alerts.push(format!(
263                "Memory: pressure {:.0}% above warning threshold.",
264                context.memory_pressure * 100.0
265            ));
266        }
267
268        // ── 8c. Allocation churn (glass-box diagnostic) ──────────────────
269        // High volatility of resident bytes points to per-frame allocation
270        // hotspots (allocator locks / page faults = hitch risk). Surfaced as an
271        // alert only — it never forces a strategy change.
272        let mem_bytes_id = MetricId::new("memory", "current_bytes");
273        if store.get_sample_count(&mem_bytes_id) >= 10 {
274            let avg = store.get_average(&mem_bytes_id);
275            if avg > 0.0 {
276                let cov = store.get_variance(&mem_bytes_id).sqrt() / avg;
277                if cov > MEM_CHURN_COV_THRESHOLD {
278                    log::info!("Heuristic: high allocation churn (CoV {:.2}).", cov);
279                    report.alerts.push(format!(
280                        "Memory: high allocation churn (CoV {:.2}) — possible per-frame alloc hotspot.",
281                        cov
282                    ));
283                }
284            }
285        }
286
287        // ── 9. Death Spiral Detection ────────────────────────────────────
288        // If 3+ independent pressure sources are active simultaneously,
289        // the engine is likely in a cascading failure ("death spiral").
290        if pressure_count >= 3 {
291            log::error!(
292                "Heuristic: DEATH SPIRAL detected ({} simultaneous pressure sources). \
293                 Emergency stop required.",
294                pressure_count
295            );
296            report.death_spiral_detected = true;
297            report.needs_negotiation = true;
298            report.alerts.push(format!(
299                "DEATH SPIRAL: {} simultaneous pressures.",
300                pressure_count
301            ));
302        }
303
304        report
305    }
306}
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311    use crate::metrics::MetricStore;
312    use khora_core::agent::EngineMode;
313
314    fn default_context() -> Context {
315        Context::default()
316    }
317
318    fn simulation_context() -> Context {
319        Context {
320            mode: EngineMode::Playing,
321            ..Default::default()
322        }
323    }
324
325    // ── Phase Heuristics ─────────────────────────────────────────────
326
327    #[test]
328    fn test_normal_simulation_no_negotiation() {
329        let engine = HeuristicEngine;
330        let ctx = simulation_context();
331        let store = MetricStore::new();
332
333        let report = engine.analyze(&ctx, &store);
334        assert!(!report.needs_negotiation);
335        assert!((report.suggested_latency_ms - 16.66).abs() < 0.1);
336        assert!(!report.death_spiral_detected);
337    }
338
339    #[test]
340    fn test_default_latency_target() {
341        let engine = HeuristicEngine;
342        let ctx = default_context();
343        let store = MetricStore::new();
344
345        let report = engine.analyze(&ctx, &store);
346        assert!((report.suggested_latency_ms - 16.66).abs() < 0.1);
347    }
348
349    // ── Thermal Heuristics ───────────────────────────────────────────
350
351    #[test]
352    fn test_thermal_throttling_triggers_negotiation() {
353        let engine = HeuristicEngine;
354        let mut ctx = simulation_context();
355        ctx.hardware.thermal = ThermalStatus::Throttling;
356        let store = MetricStore::new();
357
358        let report = engine.analyze(&ctx, &store);
359        assert!(report.needs_negotiation);
360        assert!(report.suggested_latency_ms >= 33.33);
361    }
362
363    #[test]
364    fn test_thermal_critical_emergency() {
365        let engine = HeuristicEngine;
366        let mut ctx = simulation_context();
367        ctx.hardware.thermal = ThermalStatus::Critical;
368        let store = MetricStore::new();
369
370        let report = engine.analyze(&ctx, &store);
371        assert!(report.needs_negotiation);
372        assert!(report.suggested_latency_ms >= 50.0);
373    }
374
375    // ── Battery Heuristics ───────────────────────────────────────────
376
377    #[test]
378    fn test_battery_low_caps_fps() {
379        let engine = HeuristicEngine;
380        let mut ctx = simulation_context();
381        ctx.hardware.battery = BatteryLevel::Low;
382        let store = MetricStore::new();
383
384        let report = engine.analyze(&ctx, &store);
385        assert!(report.needs_negotiation);
386        assert!(report.suggested_latency_ms >= 33.33);
387    }
388
389    #[test]
390    fn test_battery_critical_aggressive_cap() {
391        let engine = HeuristicEngine;
392        let mut ctx = simulation_context();
393        ctx.hardware.battery = BatteryLevel::Critical;
394        let store = MetricStore::new();
395
396        let report = engine.analyze(&ctx, &store);
397        assert!(report.needs_negotiation);
398        assert!(report.suggested_latency_ms >= 50.0);
399    }
400
401    // ── Frame Time Heuristics ────────────────────────────────────────
402
403    #[test]
404    fn test_high_frame_time_triggers_negotiation() {
405        let engine = HeuristicEngine;
406        let ctx = simulation_context();
407        let mut store = MetricStore::new();
408
409        let id = MetricId::new("renderer", "frame_time");
410        for _ in 0..20 {
411            store.push(id.clone(), 22.0); // 22ms > 18ms warn threshold
412        }
413
414        let report = engine.analyze(&ctx, &store);
415        assert!(report.needs_negotiation);
416    }
417
418    #[test]
419    fn test_critical_frame_time_pressure() {
420        let engine = HeuristicEngine;
421        let ctx = simulation_context();
422        let mut store = MetricStore::new();
423
424        let id = MetricId::new("renderer", "frame_time");
425        for _ in 0..20 {
426            store.push(id.clone(), 30.0); // 30ms > 25ms critical threshold
427        }
428
429        let report = engine.analyze(&ctx, &store);
430        assert!(report.needs_negotiation);
431        assert!(!report.alerts.is_empty());
432    }
433
434    // ── Stutter Detection ────────────────────────────────────────────
435
436    #[test]
437    fn test_high_variance_stutter_detection() {
438        let engine = HeuristicEngine;
439        let ctx = simulation_context();
440        let mut store = MetricStore::new();
441
442        let id = MetricId::new("renderer", "frame_time");
443        // Alternating between 5ms and 30ms = extreme stutter
444        for i in 0..20 {
445            store.push(id.clone(), if i % 2 == 0 { 5.0 } else { 30.0 });
446        }
447
448        let report = engine.analyze(&ctx, &store);
449        assert!(report.needs_negotiation);
450        assert!(report.alerts.iter().any(|a| a.contains("Variance")));
451    }
452
453    // ── GPU Pressure ─────────────────────────────────────────────────
454
455    #[test]
456    fn test_gpu_pressure_triggers_negotiation() {
457        let engine = HeuristicEngine;
458        let mut ctx = simulation_context();
459        ctx.hardware.gpu_load = 0.96;
460        let store = MetricStore::new();
461
462        let report = engine.analyze(&ctx, &store);
463        assert!(report.needs_negotiation);
464        assert!(report.alerts.iter().any(|a| a.contains("GPU")));
465    }
466
467    // ── Memory Heuristics ────────────────────────────────────────────
468
469    #[test]
470    fn test_memory_pressure_triggers_negotiation() {
471        let engine = HeuristicEngine;
472        let mut ctx = simulation_context();
473        ctx.memory_pressure = 0.96; // above critical
474        let store = MetricStore::new();
475
476        let report = engine.analyze(&ctx, &store);
477        assert!(report.needs_negotiation);
478        assert!(report.alerts.iter().any(|a| a.contains("Memory")));
479    }
480
481    #[test]
482    fn test_no_memory_pressure_no_alert() {
483        let engine = HeuristicEngine;
484        let ctx = simulation_context(); // memory_pressure defaults to 0.0
485        let store = MetricStore::new();
486
487        let report = engine.analyze(&ctx, &store);
488        assert!(!report.alerts.iter().any(|a| a.contains("Memory")));
489    }
490
491    // ── Death Spiral ─────────────────────────────────────────────────
492
493    #[test]
494    fn test_death_spiral_detection() {
495        let engine = HeuristicEngine;
496        let mut ctx = simulation_context();
497        ctx.hardware.thermal = ThermalStatus::Critical; // +1 pressure
498        ctx.hardware.cpu_load = 0.98; // +1 pressure
499        ctx.hardware.gpu_load = 0.97; // +1 pressure
500        let store = MetricStore::new();
501
502        let report = engine.analyze(&ctx, &store);
503        assert!(report.death_spiral_detected);
504        assert!(report.needs_negotiation);
505        assert!(report.alerts.iter().any(|a| a.contains("DEATH SPIRAL")));
506    }
507
508    #[test]
509    fn test_no_death_spiral_with_single_pressure() {
510        let engine = HeuristicEngine;
511        let mut ctx = simulation_context();
512        ctx.hardware.thermal = ThermalStatus::Throttling; // Only 1 pressure
513        let store = MetricStore::new();
514
515        let report = engine.analyze(&ctx, &store);
516        assert!(!report.death_spiral_detected);
517    }
518}