khora_control/
analysis.rs1use crate::context::Context;
23use crate::metrics::MetricStore;
24use khora_core::platform::{BatteryLevel, ThermalStatus};
25use khora_core::telemetry::MetricId;
26
27const FRAME_TIME_WARN_THRESHOLD_MS: f32 = 18.0;
29const FRAME_TIME_CRITICAL_THRESHOLD_MS: f32 = 25.0;
31const FRAME_TIME_VARIANCE_THRESHOLD: f32 = 4.0;
33const FRAME_TIME_TREND_THRESHOLD: f32 = 2.0;
35const CPU_LOAD_CRITICAL: f32 = 0.95;
37const GPU_LOAD_CRITICAL: f32 = 0.95;
39const GPU_LOAD_WARN: f32 = 0.90;
41const MEM_PRESSURE_CRITICAL: f32 = 0.95;
43const MEM_PRESSURE_WARN: f32 = 0.85;
45const MEM_CHURN_COV_THRESHOLD: f32 = 0.15;
49
50#[derive(Debug, Clone)]
52pub struct AnalysisReport {
53 pub needs_negotiation: bool,
56 pub suggested_latency_ms: f32,
58 pub death_spiral_detected: bool,
61 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
76pub struct HeuristicEngine;
78
79impl HeuristicEngine {
80 pub fn analyze(&self, context: &Context, store: &MetricStore) -> AnalysisReport {
91 let mut report = AnalysisReport::default();
92 let mut pressure_count: u32 = 0;
93
94 report.suggested_latency_ms = 16.66;
96
97 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); 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); 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 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); 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 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 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 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 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 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 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 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 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 #[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 #[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 #[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 #[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); }
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); }
428
429 let report = engine.analyze(&ctx, &store);
430 assert!(report.needs_negotiation);
431 assert!(!report.alerts.is_empty());
432 }
433
434 #[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 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 #[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 #[test]
470 fn test_memory_pressure_triggers_negotiation() {
471 let engine = HeuristicEngine;
472 let mut ctx = simulation_context();
473 ctx.memory_pressure = 0.96; 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(); 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 #[test]
494 fn test_death_spiral_detection() {
495 let engine = HeuristicEngine;
496 let mut ctx = simulation_context();
497 ctx.hardware.thermal = ThermalStatus::Critical; ctx.hardware.cpu_load = 0.98; ctx.hardware.gpu_load = 0.97; 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; let store = MetricStore::new();
514
515 let report = engine.analyze(&ctx, &store);
516 assert!(!report.death_spiral_detected);
517 }
518}