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}