Skip to main content

khora_agents/physics_agent/
agent.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//! Defines the PhysicsAgent — owns `LaneKind::Physics` lanes only.
16//!
17//! Per CLAD, an Agent owns exactly one `LaneKind` and stores **only** its
18//! own GORNA/strategy state.  The shared `PhysicsProvider` is fetched from
19//! the [`ServiceRegistry`] each frame — agents are not the owners.
20
21use std::sync::{Arc, Mutex};
22use std::time::Duration;
23
24use khora_core::agent::{Agent, AgentImportance, ExecutionPhase, ExecutionTiming};
25use khora_core::control::gorna::{
26    measured_frame_time_ms, AgentFrameStatusMap, AgentId, AgentStatus, NegotiationRequest,
27    NegotiationResponse, ResourceBudget, StrategyId, StrategyOption,
28};
29use khora_core::lane::PhysicsDeltaTime;
30use khora_core::lane::{LaneContext, LaneRegistry, Slot};
31use khora_core::physics::PhysicsProvider;
32use khora_core::EngineContext;
33use khora_data::ecs::World;
34use khora_lanes::physics_lane::StandardPhysicsLane;
35
36const COST_TO_MS_SCALE: f32 = 3.0;
37
38/// Strategies for physics simulation.
39///
40/// Debug-overlay extraction was previously a third variant here, but it
41/// is not a *strategy* of the same mission ("step the simulation") — it
42/// is a side-channel projection of provider state into the `World`.
43/// That work now lives in the
44/// [`physics_debug_extraction`](khora_data::ecs::systems::physics_debug_extraction)
45/// `DataSystem` so the simulation continues to step regardless of whether
46/// the debug overlay is active.
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
48pub enum PhysicsStrategy {
49    /// Standard high-precision physics.
50    #[default]
51    Standard,
52    /// Simplified physics for low-power mode.
53    Simplified,
54}
55
56/// The agent responsible for managing the physics simulation.
57///
58/// Holds **only** its own strategy state — the `PhysicsProvider` is fetched
59/// from `EngineContext::services` per frame.
60pub struct PhysicsAgent {
61    /// All physics lanes — the agent's strategies.
62    lanes: LaneRegistry,
63    /// Current selected strategy.
64    strategy: PhysicsStrategy,
65    /// Current GORNA strategy ID.
66    current_strategy: StrategyId,
67    /// Time budget allocated by GORNA.
68    time_budget: Duration,
69    /// Fixed timestep for physics simulation.
70    fixed_timestep: f32,
71    /// Shared, scheduler-written per-agent frame metrics, read in
72    /// `report_status`; the agent holds no per-frame counters.
73    frame_status: Option<AgentFrameStatusMap>,
74}
75
76impl Agent for PhysicsAgent {
77    fn id(&self) -> AgentId {
78        AgentId::Physics
79    }
80
81    fn negotiate(&mut self, _request: NegotiationRequest) -> NegotiationResponse {
82        // We don't have access to the physics provider here (negotiate runs
83        // on the DCC thread without an EngineContext).  Use a flat
84        // complexity factor — refined estimates can come later via the
85        // provider once GORNA supports it.
86        let complexity_factor = 1.0_f32;
87
88        NegotiationResponse {
89            strategies: vec![
90                StrategyOption {
91                    id: StrategyId::LowPower,
92                    estimated_time: Duration::from_secs_f32(
93                        (0.5 * complexity_factor * COST_TO_MS_SCALE).max(0.1) / 1000.0,
94                    ),
95                    estimated_vram: 0,
96                },
97                StrategyOption {
98                    id: StrategyId::Balanced,
99                    estimated_time: Duration::from_secs_f32(
100                        (1.5 * complexity_factor * COST_TO_MS_SCALE).max(0.5) / 1000.0,
101                    ),
102                    estimated_vram: 0,
103                },
104                StrategyOption {
105                    id: StrategyId::HighPerformance,
106                    estimated_time: Duration::from_secs_f32(
107                        (3.0 * complexity_factor * COST_TO_MS_SCALE).max(1.0) / 1000.0,
108                    ),
109                    estimated_vram: 0,
110                },
111            ],
112            timing_adjustment: None,
113        }
114    }
115
116    fn apply_budget(&mut self, budget: ResourceBudget) {
117        log::info!(
118            "PhysicsAgent: Strategy update to {:?} (time_limit={:?})",
119            budget.strategy_id,
120            budget.time_limit,
121        );
122
123        match budget.strategy_id {
124            StrategyId::LowPower => {
125                self.strategy = PhysicsStrategy::Simplified;
126                self.fixed_timestep = 1.0 / 30.0;
127            }
128            StrategyId::Balanced => {
129                self.strategy = PhysicsStrategy::Standard;
130                self.fixed_timestep = 1.0 / 60.0;
131            }
132            StrategyId::HighPerformance => {
133                self.strategy = PhysicsStrategy::Standard;
134                self.fixed_timestep = 1.0 / 120.0;
135            }
136            StrategyId::Custom(_) => {
137                log::warn!(
138                    "PhysicsAgent received unsupported custom strategy. Falling back to Standard."
139                );
140                self.strategy = PhysicsStrategy::Standard;
141                self.fixed_timestep = 1.0 / 60.0;
142            }
143        }
144
145        self.current_strategy = budget.strategy_id;
146        self.time_budget = budget.time_limit;
147    }
148
149    fn on_initialize(&mut self, context: &mut EngineContext<'_>) {
150        self.frame_status = context
151            .runtime
152            .resources
153            .get::<AgentFrameStatusMap>()
154            .cloned();
155    }
156
157    fn execute(&mut self, context: &mut EngineContext<'_>) {
158        // Look up the physics provider from services every frame.
159        let Some(provider_arc) = context
160            .runtime
161            .backends
162            .get::<Arc<Mutex<Box<dyn PhysicsProvider>>>>()
163        else {
164            log::debug!("PhysicsAgent: no physics provider registered, skipping step");
165            return;
166        };
167        let provider_arc: Arc<Mutex<Box<dyn PhysicsProvider>>> = (*provider_arc).clone();
168
169        let Some(world_any) = context.world_mut() else {
170            return;
171        };
172        let Some(world) = world_any.downcast_mut::<World>() else {
173            return;
174        };
175
176        let mut provider_guard = match provider_arc.lock() {
177            Ok(g) => g,
178            Err(e) => {
179                log::error!("PhysicsAgent: provider mutex poisoned: {}", e);
180                return;
181            }
182        };
183
184        let mut ctx = LaneContext::new();
185        ctx.insert(PhysicsDeltaTime(self.fixed_timestep));
186        ctx.insert(Slot::new(world));
187        ctx.insert(Slot::new(provider_guard.as_mut()));
188        // Forward the per-frame `OutputDeck` so the lane can publish a
189        // `PhysicsStepResult` marker that `physics_world_writeback` reads
190        // during Maintenance.
191        ctx.insert(Slot::new(&mut *context.deck));
192
193        // Both strategies dispatch the same lane today; LowPower simply
194        // tightens the fixed_timestep via apply_budget. A future
195        // `SimplifiedPhysicsLane` strategy could fork here.
196        let lane_name = "StandardPhysics";
197
198        if let Some(lane) = self.lanes.get(lane_name) {
199            if let Err(e) = lane.execute(&mut ctx) {
200                log::error!("Physics lane {} failed: {}", lane.strategy_name(), e);
201            }
202        }
203    }
204
205    fn report_status(&self) -> AgentStatus {
206        let measured_time_ms = measured_frame_time_ms(&self.frame_status, self.id());
207        let health_score = if self.time_budget.is_zero() || measured_time_ms <= 0.0 {
208            1.0
209        } else {
210            (self.time_budget.as_secs_f32() * 1000.0 / measured_time_ms).min(1.0)
211        };
212
213        AgentStatus {
214            agent_id: self.id(),
215            health_score,
216            current_strategy: self.current_strategy,
217            is_stalled: false,
218            message: format!("step_time={measured_time_ms:.2}ms"),
219        }
220    }
221
222    fn as_any(&self) -> &dyn std::any::Any {
223        self
224    }
225
226    fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
227        self
228    }
229
230    fn execution_timing(&self) -> ExecutionTiming {
231        ExecutionTiming {
232            allowed_phases: vec![ExecutionPhase::TRANSFORM],
233            default_phase: ExecutionPhase::TRANSFORM,
234            priority: 0.9,
235            importance: AgentImportance::Critical,
236            fixed_timestep: Some(Duration::from_secs_f32(self.fixed_timestep)),
237            dependencies: Vec::new(),
238        }
239    }
240}
241
242impl Default for PhysicsAgent {
243    fn default() -> Self {
244        let mut lanes = LaneRegistry::new();
245        lanes.register(Box::new(StandardPhysicsLane::new()));
246        // PhysicsDebugLane was previously registered here; debug overlay
247        // extraction now lives in the `physics_debug_extraction` DataSystem
248        // (PostSimulation phase), running alongside the sim instead of
249        // replacing it.
250
251        Self {
252            lanes,
253            strategy: PhysicsStrategy::Standard,
254            current_strategy: StrategyId::Balanced,
255            time_budget: Duration::ZERO,
256            fixed_timestep: 1.0 / 60.0,
257            frame_status: None,
258        }
259    }
260}