khora_control/
context.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//! Context for the Dynamic Context Core.
16
17pub use khora_core::platform::{BatteryLevel, ThermalStatus};
18
19/// Hardware context observed by the DCC.
20#[derive(Debug, Clone, Default)]
21pub struct HardwareState {
22    /// Current thermal status.
23    pub thermal: ThermalStatus,
24    /// Current battery/power status.
25    pub battery: BatteryLevel,
26    /// Overall CPU load (0.0 to 1.0).
27    pub cpu_load: f32,
28    /// Overall GPU load (0.0 to 1.0).
29    pub gpu_load: f32,
30    /// Available VRAM in bytes (if known).
31    pub available_vram: Option<u64>,
32    /// Total VRAM in bytes (if known).
33    pub total_vram: Option<u64>,
34}
35
36/// The high-level workload state of the engine.
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
38pub enum ExecutionPhase {
39    /// Engine is starting up and loading assets.
40    #[default]
41    Boot,
42    /// User is in an interactive menu.
43    Menu,
44    /// Full simulation is running (gameplay).
45    Simulation,
46    /// Application is minimized or lost focus.
47    Background,
48}
49
50impl ExecutionPhase {
51    /// Returns true if the transition from `self` to `target` is valid.
52    ///
53    /// Valid transitions:
54    /// - Boot → Menu, Simulation
55    /// - Menu → Simulation, Background
56    /// - Simulation → Menu, Background
57    /// - Background → Menu, Simulation
58    pub fn can_transition_to(&self, target: ExecutionPhase) -> bool {
59        match self {
60            ExecutionPhase::Boot => {
61                matches!(target, ExecutionPhase::Menu | ExecutionPhase::Simulation)
62            }
63            ExecutionPhase::Menu => matches!(
64                target,
65                ExecutionPhase::Simulation | ExecutionPhase::Background
66            ),
67            ExecutionPhase::Simulation => {
68                matches!(target, ExecutionPhase::Menu | ExecutionPhase::Background)
69            }
70            ExecutionPhase::Background => {
71                matches!(target, ExecutionPhase::Menu | ExecutionPhase::Simulation)
72            }
73        }
74    }
75
76    /// Returns a human-readable name for this phase.
77    pub fn name(&self) -> &'static str {
78        match self {
79            ExecutionPhase::Boot => "boot",
80            ExecutionPhase::Menu => "menu",
81            ExecutionPhase::Simulation => "simulation",
82            ExecutionPhase::Background => "background",
83        }
84    }
85
86    /// Parses a phase from a string (case-insensitive).
87    pub fn from_name(name: &str) -> Option<Self> {
88        match name.to_lowercase().as_str() {
89            "boot" => Some(ExecutionPhase::Boot),
90            "menu" => Some(ExecutionPhase::Menu),
91            "simulation" => Some(ExecutionPhase::Simulation),
92            "background" => Some(ExecutionPhase::Background),
93            _ => None,
94        }
95    }
96}
97
98/// The complete context model used for strategic decision making.
99#[derive(Debug, Clone)]
100pub struct Context {
101    /// Observed hardware state.
102    pub hardware: HardwareState,
103    /// Current engine execution phase.
104    pub phase: ExecutionPhase,
105    /// Global budget multiplier derived from thermal and battery state.
106    ///
107    /// Applied to all frame budgets to implement graceful performance degradation.
108    /// Ranges from 0.0 (emergency) to 1.0 (full performance).
109    ///
110    /// | Condition | Multiplier |
111    /// |---|---|
112    /// | Cool + Mains | 1.0 |
113    /// | Warm | 0.9 |
114    /// | Battery Low | 0.8 |
115    /// | Throttling | 0.6 |
116    /// | Critical thermal or battery | 0.4 |
117    pub global_budget_multiplier: f32,
118}
119
120impl Default for Context {
121    fn default() -> Self {
122        Self {
123            hardware: HardwareState::default(),
124            phase: ExecutionPhase::default(),
125            global_budget_multiplier: 1.0,
126        }
127    }
128}
129
130impl Context {
131    /// Recomputes `global_budget_multiplier` from the current hardware state.
132    ///
133    /// This should be called whenever `hardware.thermal` or `hardware.battery` changes.
134    pub fn refresh_budget_multiplier(&mut self) {
135        let thermal_factor: f32 = match self.hardware.thermal {
136            ThermalStatus::Cool => 1.0,
137            ThermalStatus::Warm => 0.9,
138            ThermalStatus::Throttling => 0.6,
139            ThermalStatus::Critical => 0.4,
140        };
141
142        let battery_factor = match self.hardware.battery {
143            BatteryLevel::Mains => 1.0,
144            BatteryLevel::High => 1.0,
145            BatteryLevel::Low => 0.8,
146            BatteryLevel::Critical => 0.5,
147        };
148
149        // Take the more restrictive of the two factors.
150        self.global_budget_multiplier = thermal_factor.min(battery_factor);
151    }
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157
158    #[test]
159    fn test_default_context_full_budget() {
160        let ctx = Context::default();
161        assert_eq!(ctx.global_budget_multiplier, 1.0);
162        assert_eq!(ctx.phase, ExecutionPhase::Boot);
163    }
164
165    #[test]
166    fn test_cool_mains_full_multiplier() {
167        let mut ctx = Context::default();
168        ctx.hardware.thermal = ThermalStatus::Cool;
169        ctx.hardware.battery = BatteryLevel::Mains;
170        ctx.refresh_budget_multiplier();
171        assert_eq!(ctx.global_budget_multiplier, 1.0);
172    }
173
174    #[test]
175    fn test_warm_reduces_multiplier() {
176        let mut ctx = Context::default();
177        ctx.hardware.thermal = ThermalStatus::Warm;
178        ctx.refresh_budget_multiplier();
179        assert!((ctx.global_budget_multiplier - 0.9).abs() < 0.001);
180    }
181
182    #[test]
183    fn test_throttling_heavy_reduction() {
184        let mut ctx = Context::default();
185        ctx.hardware.thermal = ThermalStatus::Throttling;
186        ctx.refresh_budget_multiplier();
187        assert!((ctx.global_budget_multiplier - 0.6).abs() < 0.001);
188    }
189
190    #[test]
191    fn test_critical_thermal_severe_reduction() {
192        let mut ctx = Context::default();
193        ctx.hardware.thermal = ThermalStatus::Critical;
194        ctx.refresh_budget_multiplier();
195        assert!((ctx.global_budget_multiplier - 0.4).abs() < 0.001);
196    }
197
198    #[test]
199    fn test_battery_low_reduces_multiplier() {
200        let mut ctx = Context::default();
201        ctx.hardware.battery = BatteryLevel::Low;
202        ctx.refresh_budget_multiplier();
203        assert!((ctx.global_budget_multiplier - 0.8).abs() < 0.001);
204    }
205
206    #[test]
207    fn test_battery_critical_severe_reduction() {
208        let mut ctx = Context::default();
209        ctx.hardware.battery = BatteryLevel::Critical;
210        ctx.refresh_budget_multiplier();
211        assert!((ctx.global_budget_multiplier - 0.5).abs() < 0.001);
212    }
213
214    #[test]
215    fn test_combined_thermal_and_battery_takes_minimum() {
216        let mut ctx = Context::default();
217        ctx.hardware.thermal = ThermalStatus::Throttling; // 0.6
218        ctx.hardware.battery = BatteryLevel::Critical; // 0.5
219        ctx.refresh_budget_multiplier();
220        // Should pick the more restrictive value: 0.5
221        assert!((ctx.global_budget_multiplier - 0.5).abs() < 0.001);
222    }
223}