Skip to main content

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::agent::EngineMode;
18pub use khora_core::platform::{BatteryLevel, ThermalStatus};
19
20/// Hardware context observed by the DCC.
21#[derive(Debug, Clone, Default)]
22pub struct HardwareState {
23    /// Current thermal status.
24    pub thermal: ThermalStatus,
25    /// Current battery/power status.
26    pub battery: BatteryLevel,
27    /// Overall CPU load (0.0 to 1.0).
28    pub cpu_load: f32,
29    /// Overall GPU load (0.0 to 1.0).
30    pub gpu_load: f32,
31    /// Available VRAM in bytes (if known).
32    pub available_vram: Option<u64>,
33    /// Total VRAM in bytes (if known).
34    pub total_vram: Option<u64>,
35    /// Currently-allocated system RAM in bytes, from the tracking allocator
36    /// (if a `MemoryMonitor` is feeding telemetry). `None` when unknown.
37    pub current_ram_bytes: Option<u64>,
38    /// Developer-set system-RAM budget in bytes. When both this and
39    /// `current_ram_bytes` are known, the DCC derives `memory_pressure` and
40    /// degrades budgets as the ceiling approaches. `None` disables the signal
41    /// (no overhead, no effect) — it matters chiefly on memory-constrained
42    /// targets (console / mobile / iGPU).
43    pub memory_budget_bytes: Option<u64>,
44}
45
46/// The complete context model used for strategic decision making.
47#[derive(Debug, Clone)]
48pub struct Context {
49    /// Observed hardware state.
50    pub hardware: HardwareState,
51    /// Current engine mode.
52    pub mode: EngineMode,
53    /// Global budget multiplier applied to all frame budgets for graceful
54    /// performance degradation. Ranges from 0.0 (emergency) to 1.0 (full
55    /// performance).
56    ///
57    /// Driven by the DCC's frame-time **PID** controller (`khora_core::control::pid`),
58    /// not a static table: the loop asservits this value so the *measured* frame
59    /// time tracks the heuristic-suggested latency (`AnalysisReport::suggested_latency_ms`,
60    /// itself modulated by thermal/battery/phase). On `Critical` thermal/battery or
61    /// high memory pressure the DCC additionally clamps it to a hard safety ceiling.
62    pub global_budget_multiplier: f32,
63    /// System-memory pressure in `[0, 1]` — `current_ram_bytes / memory_budget_bytes`,
64    /// or `0.0` when the budget is unset. A first-class resource signal alongside
65    /// thermal/CPU/GPU: drives graceful degradation here and feeds the AGDF
66    /// repack-headroom gate (a repack transiently doubles a column, so it is
67    /// declined under high pressure).
68    pub memory_pressure: f32,
69}
70
71impl Default for Context {
72    fn default() -> Self {
73        Self {
74            hardware: HardwareState::default(),
75            mode: EngineMode::Playing,
76            global_budget_multiplier: 1.0,
77            memory_pressure: 0.0,
78        }
79    }
80}
81
82/// Memory pressure at or above which the DCC clamps the budget multiplier to a
83/// hard safety ceiling, regardless of where the PID loop currently sits.
84pub const MEMORY_PRESSURE_CRITICAL: f32 = 0.95;
85
86impl Context {
87    /// Recomputes [`Context::memory_pressure`] from the current RAM usage versus
88    /// the developer-set budget.
89    ///
90    /// Pressure is `current_ram_bytes / memory_budget_bytes`, clamped to `[0, 1]`.
91    /// An unset budget yields `0.0` (the signal is inert). This is a first-class
92    /// resource signal consumed by the heuristic engine and the AGDF repack gate;
93    /// it no longer feeds the budget multiplier directly — that is the PID loop's
94    /// job — but it does drive the DCC's hard safety clamp (see [`safety_ceiling`]).
95    pub fn refresh_memory_pressure(&mut self) {
96        self.memory_pressure = match (
97            self.hardware.current_ram_bytes,
98            self.hardware.memory_budget_bytes,
99        ) {
100            (Some(current), Some(budget)) if budget > 0 => {
101                (current as f32 / budget as f32).clamp(0.0, 1.0)
102            }
103            _ => 0.0,
104        };
105    }
106}
107
108/// The hard ceiling on the budget multiplier for the current context.
109///
110/// The PID loop regulates the multiplier smoothly toward the frame-time target,
111/// but emergencies (`Critical` thermal/battery, near-budget memory pressure)
112/// demand an immediate cap that does not wait for the loop to converge. This is
113/// the feedforward / safety half of the controller: it can only ever *lower* the
114/// multiplier, never raise it.
115pub fn safety_ceiling(ctx: &Context) -> f32 {
116    let mut ceiling = 1.0_f32;
117    if ctx.hardware.thermal == ThermalStatus::Critical {
118        ceiling = ceiling.min(0.4);
119    }
120    if ctx.hardware.battery == BatteryLevel::Critical {
121        ceiling = ceiling.min(0.5);
122    }
123    if ctx.memory_pressure >= MEMORY_PRESSURE_CRITICAL {
124        ceiling = ceiling.min(0.5);
125    }
126    ceiling
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132
133    #[test]
134    fn test_default_context_full_budget() {
135        let ctx = Context::default();
136        assert_eq!(ctx.global_budget_multiplier, 1.0);
137        assert_eq!(ctx.mode, EngineMode::Playing);
138    }
139
140    #[test]
141    fn test_memory_pressure_from_budget() {
142        let mut ctx = Context::default();
143        ctx.hardware.current_ram_bytes = Some(960);
144        ctx.hardware.memory_budget_bytes = Some(1000);
145        ctx.refresh_memory_pressure();
146        assert!((ctx.memory_pressure - 0.96).abs() < 0.001);
147    }
148
149    #[test]
150    fn test_memory_pressure_zero_without_budget() {
151        let mut ctx = Context::default();
152        ctx.hardware.current_ram_bytes = Some(10_000_000);
153        ctx.hardware.memory_budget_bytes = None;
154        ctx.refresh_memory_pressure();
155        assert_eq!(ctx.memory_pressure, 0.0);
156    }
157
158    #[test]
159    fn test_safety_ceiling_open_when_healthy() {
160        let ctx = Context::default();
161        assert_eq!(safety_ceiling(&ctx), 1.0);
162    }
163
164    #[test]
165    fn test_safety_ceiling_critical_thermal() {
166        let mut ctx = Context::default();
167        ctx.hardware.thermal = ThermalStatus::Critical;
168        assert!((safety_ceiling(&ctx) - 0.4).abs() < 0.001);
169    }
170
171    #[test]
172    fn test_safety_ceiling_critical_battery() {
173        let mut ctx = Context::default();
174        ctx.hardware.battery = BatteryLevel::Critical;
175        assert!((safety_ceiling(&ctx) - 0.5).abs() < 0.001);
176    }
177
178    #[test]
179    fn test_safety_ceiling_high_memory_pressure() {
180        let ctx = Context {
181            memory_pressure: 0.96,
182            ..Default::default()
183        };
184        assert!((safety_ceiling(&ctx) - 0.5).abs() < 0.001);
185    }
186
187    #[test]
188    fn test_safety_ceiling_takes_minimum() {
189        let mut ctx = Context::default();
190        ctx.hardware.thermal = ThermalStatus::Critical; // 0.4
191        ctx.hardware.battery = BatteryLevel::Critical; // 0.5
192                                                       // Most restrictive wins.
193        assert!((safety_ceiling(&ctx) - 0.4).abs() < 0.001);
194    }
195
196    #[test]
197    fn test_non_critical_states_leave_ceiling_open() {
198        let mut ctx = Context::default();
199        ctx.hardware.thermal = ThermalStatus::Throttling;
200        ctx.hardware.battery = BatteryLevel::Low;
201        // Throttling / Low now shape the setpoint, not a hard clamp.
202        assert_eq!(safety_ceiling(&ctx), 1.0);
203    }
204}