forge_orchestration/scheduler/sim.rs
1//! Simulation-native workload model (the Eustress Engine primitive)
2//!
3//! Forge's differentiating job is running "AI models living inside simulation
4//! environments at scale." A simulation environment is not a single workload:
5//! it is a *world shard* (the authoritative sim state advancing one tick at a
6//! time) plus the *agent policy models* that observe and act inside that world.
7//! Those members are useless apart from one another — an agent policy with no
8//! world to act in, or a world with no agents, makes no progress — so they must
9//! be scheduled together, co-located, and torn down together.
10//!
11//! This module gives that primitive a name ([`SimCell`]) and expands it into the
12//! scheduler's existing [`Workload`] type so the rest of the stack (constraints,
13//! affinity, queues, node accounting) works unchanged. The all-or-nothing gang
14//! placement of the expanded members is handled by [`crate::scheduler::gang`];
15//! the tick-deadline ordering is handled by [`crate::scheduler::deadline`].
16//!
17//! # Design notes
18//!
19//! [`Workload`] has no free-form metadata map, so the linkage between a member
20//! and its cell/gang is carried two ways:
21//!
22//! 1. **Deterministically inside the id**: a member's [`Workload::id`] is
23//! `"simcell/{cell_id}/{role}/{ordinal}"`. Given any member workload you can
24//! recover its owning cell with [`parse_member_id`].
25//! 2. **Structurally via [`SimMember`]**: [`SimCell::members`] returns the
26//! workloads, while [`SimCell::gang_group`] returns a [`GangGroup`] that keeps
27//! the role/ordinal/co-placement key alongside each workload for the gang
28//! scheduler. No metadata map is required.
29
30use std::time::Duration;
31
32use chrono::{DateTime, Utc};
33use serde::{Deserialize, Serialize};
34
35use super::{ResourceRequirements, Workload};
36
37/// Axis-aligned spherical region used for spatial interest management.
38///
39/// Two cells whose regions do not overlap cannot influence each other this tick,
40/// which lets a higher layer decide they need not share interconnect locality.
41/// Kept deliberately minimal (sphere = center + radius) so it is cheap to test
42/// for overlap; richer AABB/oct-tree schemes can wrap this later.
43#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
44pub struct Region3D {
45 /// World-space X coordinate of the region center.
46 pub x: f64,
47 /// World-space Y coordinate of the region center.
48 pub y: f64,
49 /// World-space Z coordinate of the region center.
50 pub z: f64,
51 /// Interest radius around the center.
52 pub radius: f64,
53}
54
55impl Region3D {
56 /// Create a new spherical region.
57 pub fn new(x: f64, y: f64, z: f64, radius: f64) -> Self {
58 Self { x, y, z, radius }
59 }
60
61 /// Squared distance between two region centers (avoids a `sqrt`).
62 pub fn center_distance_sq(&self, other: &Region3D) -> f64 {
63 let dx = self.x - other.x;
64 let dy = self.y - other.y;
65 let dz = self.z - other.z;
66 dx * dx + dy * dy + dz * dz
67 }
68
69 /// Whether two interest spheres overlap (their regions can interact).
70 pub fn overlaps(&self, other: &Region3D) -> bool {
71 let r = self.radius + other.radius;
72 self.center_distance_sq(other) <= r * r
73 }
74}
75
76/// How the members of a [`SimCell`] must be placed relative to one another.
77///
78/// This is the *constraint* the gang scheduler honors; it is independent of the
79/// node-scoring algorithm used to pick *which* node.
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
81pub enum CoPlacement {
82 /// All members must land on the same node (shared host memory / loopback).
83 SameNode,
84 /// All members must land on the same node *and* their GPUs should be
85 /// interconnect-local (prefer contiguous / peer-capable devices) so the
86 /// world↔agent tensor exchange stays on-box (NVLink-style).
87 InterconnectLocalGpu,
88 /// Members should be spread across distinct nodes (e.g. replicated worlds
89 /// for fault isolation). All members must still be placeable, but on
90 /// different nodes.
91 Spread,
92}
93
94impl CoPlacement {
95 /// Whether this policy requires every member on a single node.
96 pub fn requires_same_node(&self) -> bool {
97 matches!(self, CoPlacement::SameNode | CoPlacement::InterconnectLocalGpu)
98 }
99
100 /// Whether this policy wants interconnect-local (peer) GPUs.
101 pub fn wants_gpu_locality(&self) -> bool {
102 matches!(self, CoPlacement::InterconnectLocalGpu)
103 }
104}
105
106/// Role of a member within a [`SimCell`]. Used to build deterministic ids and to
107/// let the gang scheduler reason about world-vs-agent without string parsing.
108#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
109pub enum MemberRole {
110 /// The authoritative simulation shard (advances world state per tick).
111 World,
112 /// An agent policy model observing/acting inside the world.
113 Agent,
114}
115
116impl MemberRole {
117 /// Stable lowercase token used inside workload ids.
118 pub fn token(&self) -> &'static str {
119 match self {
120 MemberRole::World => "world",
121 MemberRole::Agent => "agent",
122 }
123 }
124}
125
126/// An agent policy model co-resident with a sim world.
127#[derive(Debug, Clone, Serialize, Deserialize)]
128pub struct AgentPolicy {
129 /// Human-readable policy name (e.g. `"ppo-forager"`).
130 pub name: String,
131 /// Resources this policy needs. Agents typically need a GPU slice; set
132 /// `gpu_count`/`gpu_memory_mb` via [`ResourceRequirements::gpu`].
133 pub resources: ResourceRequirements,
134}
135
136impl AgentPolicy {
137 /// Create an agent policy with explicit resource requirements.
138 pub fn new(name: impl Into<String>, resources: ResourceRequirements) -> Self {
139 Self {
140 name: name.into(),
141 resources,
142 }
143 }
144
145 /// Convenience constructor for a GPU-backed policy.
146 ///
147 /// `gpu_memory_mb` is per-GPU; `cpu_millis`/`memory_mb` are the host-side
148 /// sidecar footprint of the policy server.
149 pub fn gpu(
150 name: impl Into<String>,
151 cpu_millis: u64,
152 memory_mb: u64,
153 gpu_memory_mb: u64,
154 ) -> Self {
155 Self::new(
156 name,
157 ResourceRequirements::new()
158 .cpu(cpu_millis)
159 .memory(memory_mb)
160 .gpu(1, gpu_memory_mb),
161 )
162 }
163}
164
165/// The simulation shard (authoritative world) of a [`SimCell`].
166#[derive(Debug, Clone, Serialize, Deserialize)]
167pub struct SimWorld {
168 /// Resources the world simulator needs (often CPU-heavy, GPU-optional).
169 pub resources: ResourceRequirements,
170}
171
172impl SimWorld {
173 /// Create a world shard with explicit resource requirements.
174 pub fn new(resources: ResourceRequirements) -> Self {
175 Self { resources }
176 }
177
178 /// Convenience constructor for a CPU-only world simulator.
179 pub fn cpu(cpu_millis: u64, memory_mb: u64) -> Self {
180 Self::new(ResourceRequirements::new().cpu(cpu_millis).memory(memory_mb))
181 }
182}
183
184/// A gang-schedulable, checkpointable simulation unit: one world shard plus its
185/// co-resident agent policy models, advancing on a fixed tick cadence.
186///
187/// This is *the* sim-native workload type. It is also called a "habitat" in
188/// product docs; [`SimCell`] is the canonical name here.
189#[derive(Debug, Clone, Serialize, Deserialize)]
190pub struct SimCell {
191 /// Stable identifier for the cell (used to build member ids and the gang id).
192 pub id: String,
193 /// Optional spatial footprint for interest management.
194 pub region: Option<Region3D>,
195 /// The authoritative simulation world shard.
196 pub world: SimWorld,
197 /// Co-resident agent policy models.
198 pub agents: Vec<AgentPolicy>,
199 /// Tick cadence (wall-clock budget per simulation step).
200 pub tick: Duration,
201 /// Absolute time the next tick is due — the EEVDF virtual deadline used by
202 /// [`crate::scheduler::deadline`].
203 pub next_deadline: DateTime<Utc>,
204 /// How members must be placed relative to one another.
205 pub co_placement: CoPlacement,
206 /// Base scheduling priority applied to every expanded member.
207 pub priority: i32,
208}
209
210/// A single expanded member of a [`SimCell`], pairing the scheduler-native
211/// [`Workload`] with the structural metadata the gang scheduler needs.
212#[derive(Debug, Clone)]
213pub struct SimMember {
214 /// The scheduler-native workload (carries [`ResourceRequirements`]).
215 pub workload: Workload,
216 /// Role within the cell.
217 pub role: MemberRole,
218 /// Ordinal within the role (world is always `0`; agents are `0..n`).
219 pub ordinal: usize,
220}
221
222/// Identity recovered from a member workload id.
223#[derive(Debug, Clone, PartialEq, Eq)]
224pub struct MemberIdentity {
225 /// Owning cell id.
226 pub cell_id: String,
227 /// Member role.
228 pub role: MemberRole,
229 /// Ordinal within the role.
230 pub ordinal: usize,
231}
232
233/// Prefix for all sim member workload ids.
234pub const MEMBER_ID_PREFIX: &str = "simcell";
235
236/// Build the deterministic workload id for a member.
237pub fn member_id(cell_id: &str, role: MemberRole, ordinal: usize) -> String {
238 format!("{MEMBER_ID_PREFIX}/{cell_id}/{}/{ordinal}", role.token())
239}
240
241/// Co-placement group key shared by all members of a cell. Members carrying the
242/// same key must satisfy the cell's [`CoPlacement`] policy together.
243pub fn co_placement_key(cell_id: &str) -> String {
244 format!("{MEMBER_ID_PREFIX}/{cell_id}")
245}
246
247/// Recover the cell/role/ordinal from a member workload id produced by
248/// [`member_id`]. Returns `None` if the id is not a sim member id.
249pub fn parse_member_id(id: &str) -> Option<MemberIdentity> {
250 let rest = id.strip_prefix(MEMBER_ID_PREFIX)?.strip_prefix('/')?;
251 // rest == "{cell_id}/{role}/{ordinal}". cell_id itself must not contain '/'.
252 let mut parts = rest.splitn(3, '/');
253 let cell_id = parts.next()?.to_string();
254 let role = match parts.next()? {
255 "world" => MemberRole::World,
256 "agent" => MemberRole::Agent,
257 _ => return None,
258 };
259 let ordinal: usize = parts.next()?.parse().ok()?;
260 if cell_id.is_empty() {
261 return None;
262 }
263 Some(MemberIdentity {
264 cell_id,
265 role,
266 ordinal,
267 })
268}
269
270impl SimCell {
271 /// Create a new sim cell. `next_deadline` defaults to `now + tick`.
272 pub fn new(id: impl Into<String>, world: SimWorld, tick: Duration) -> Self {
273 let tick_chrono = chrono::Duration::from_std(tick)
274 .unwrap_or_else(|_| chrono::Duration::milliseconds(0));
275 Self {
276 id: id.into(),
277 region: None,
278 world,
279 agents: Vec::new(),
280 tick,
281 next_deadline: Utc::now() + tick_chrono,
282 co_placement: CoPlacement::SameNode,
283 priority: 0,
284 }
285 }
286
287 /// Attach a spatial region for interest management.
288 pub fn with_region(mut self, region: Region3D) -> Self {
289 self.region = Some(region);
290 self
291 }
292
293 /// Add an agent policy member.
294 pub fn with_agent(mut self, agent: AgentPolicy) -> Self {
295 self.agents.push(agent);
296 self
297 }
298
299 /// Set the co-placement policy.
300 pub fn with_co_placement(mut self, policy: CoPlacement) -> Self {
301 self.co_placement = policy;
302 self
303 }
304
305 /// Set the base priority applied to every member.
306 pub fn with_priority(mut self, priority: i32) -> Self {
307 self.priority = priority;
308 self
309 }
310
311 /// Set an explicit next-tick deadline (overrides the `now + tick` default).
312 pub fn with_next_deadline(mut self, deadline: DateTime<Utc>) -> Self {
313 self.next_deadline = deadline;
314 self
315 }
316
317 /// Total number of members (world + agents).
318 pub fn member_count(&self) -> usize {
319 1 + self.agents.len()
320 }
321
322 /// Advance the deadline by one tick (used after a tick completes).
323 pub fn advance_deadline(&mut self) {
324 let tick_chrono = chrono::Duration::from_std(self.tick)
325 .unwrap_or_else(|_| chrono::Duration::milliseconds(0));
326 self.next_deadline += tick_chrono;
327 }
328
329 /// Expand this cell into the scheduler's [`SimMember`] list. The world is
330 /// member 0; agents follow in declaration order. Each member's workload id
331 /// is deterministic ([`member_id`]) and its name embeds the cell id.
332 pub fn expand(&self) -> Vec<SimMember> {
333 let mut members = Vec::with_capacity(self.member_count());
334
335 let world_workload = Workload::new(
336 member_id(&self.id, MemberRole::World, 0),
337 format!("{}-world", self.id),
338 )
339 .with_resources(self.world.resources.clone())
340 .with_priority(self.priority);
341 members.push(SimMember {
342 workload: world_workload,
343 role: MemberRole::World,
344 ordinal: 0,
345 });
346
347 for (i, agent) in self.agents.iter().enumerate() {
348 let agent_workload = Workload::new(
349 member_id(&self.id, MemberRole::Agent, i),
350 format!("{}-{}", self.id, agent.name),
351 )
352 .with_resources(agent.resources.clone())
353 .with_priority(self.priority);
354 members.push(SimMember {
355 workload: agent_workload,
356 role: MemberRole::Agent,
357 ordinal: i,
358 });
359 }
360
361 members
362 }
363
364 /// Expand into bare [`Workload`]s, as required by the task signature
365 /// `fn members(&self) -> Vec<Workload>`. Prefer [`SimCell::expand`] when you
366 /// need the role/ordinal structure.
367 pub fn members(&self) -> Vec<Workload> {
368 self.expand().into_iter().map(|m| m.workload).collect()
369 }
370
371 /// Build the all-or-nothing [`GangGroup`] for this cell.
372 pub fn gang_group(&self) -> GangGroup {
373 GangGroup {
374 id: co_placement_key(&self.id),
375 members: self.expand(),
376 all_or_nothing: true,
377 co_placement: self.co_placement,
378 }
379 }
380}
381
382/// An all-or-nothing, co-placed set of member workloads.
383///
384/// Consumed by [`crate::scheduler::gang::GangScheduler`]. The `id` is the cell's
385/// co-placement key; placement either commits *every* member or reserves nothing.
386#[derive(Debug, Clone)]
387pub struct GangGroup {
388 /// Group identifier (the cell co-placement key).
389 pub id: String,
390 /// The member workloads to place together.
391 pub members: Vec<SimMember>,
392 /// If true, partial placement is forbidden (the sim-native default).
393 pub all_or_nothing: bool,
394 /// Co-placement constraint for the whole group.
395 pub co_placement: CoPlacement,
396}
397
398impl GangGroup {
399 /// Number of members in the group.
400 pub fn len(&self) -> usize {
401 self.members.len()
402 }
403
404 /// Whether the group is empty.
405 pub fn is_empty(&self) -> bool {
406 self.members.is_empty()
407 }
408
409 /// Total GPU count required across all members.
410 pub fn total_gpu_count(&self) -> u32 {
411 self.members
412 .iter()
413 .map(|m| m.workload.resources.gpu_count)
414 .sum()
415 }
416
417 /// Iterator over the member workloads.
418 pub fn workloads(&self) -> impl Iterator<Item = &Workload> {
419 self.members.iter().map(|m| &m.workload)
420 }
421}
422
423#[cfg(test)]
424mod tests {
425 use super::*;
426
427 fn sample_cell() -> SimCell {
428 SimCell::new("alpha", SimWorld::cpu(2000, 4096), Duration::from_millis(50))
429 .with_agent(AgentPolicy::gpu("policy-a", 250, 512, 8192))
430 .with_agent(AgentPolicy::gpu("policy-b", 250, 512, 8192))
431 .with_co_placement(CoPlacement::InterconnectLocalGpu)
432 .with_priority(100)
433 }
434
435 #[test]
436 fn expands_to_world_plus_agents() {
437 let cell = sample_cell();
438 let members = cell.expand();
439
440 // 1 world + 2 agents.
441 assert_eq!(members.len(), 3);
442 assert_eq!(cell.member_count(), 3);
443
444 // Member 0 is the world, CPU-only.
445 assert_eq!(members[0].role, MemberRole::World);
446 assert_eq!(members[0].workload.resources.gpu_count, 0);
447 assert_eq!(members[0].workload.resources.cpu_millis, 2000);
448
449 // Agents are GPU-backed and carry the cell priority.
450 assert_eq!(members[1].role, MemberRole::Agent);
451 assert_eq!(members[1].workload.resources.gpu_count, 1);
452 assert_eq!(members[1].workload.resources.gpu_memory_mb, 8192);
453 assert_eq!(members[1].workload.priority, 100);
454 assert_eq!(members[2].ordinal, 1);
455 }
456
457 #[test]
458 fn member_ids_are_deterministic_and_parseable() {
459 let cell = sample_cell();
460 let members = cell.members();
461
462 assert_eq!(members[0].id, "simcell/alpha/world/0");
463 assert_eq!(members[1].id, "simcell/alpha/agent/0");
464 assert_eq!(members[2].id, "simcell/alpha/agent/1");
465
466 let id = parse_member_id(&members[2].id).unwrap();
467 assert_eq!(id.cell_id, "alpha");
468 assert_eq!(id.role, MemberRole::Agent);
469 assert_eq!(id.ordinal, 1);
470
471 // Non-member ids are rejected.
472 assert!(parse_member_id("some-other-workload").is_none());
473 assert!(parse_member_id("simcell//world/0").is_none());
474 }
475
476 #[test]
477 fn gang_group_carries_co_placement_and_gpu_totals() {
478 let cell = sample_cell();
479 let group = cell.gang_group();
480
481 assert_eq!(group.id, "simcell/alpha");
482 assert!(group.all_or_nothing);
483 assert_eq!(group.co_placement, CoPlacement::InterconnectLocalGpu);
484 assert_eq!(group.len(), 3);
485 // Two GPU agents => 2 GPUs total.
486 assert_eq!(group.total_gpu_count(), 2);
487 }
488
489 #[test]
490 fn region_overlap_is_symmetric_and_correct() {
491 let a = Region3D::new(0.0, 0.0, 0.0, 5.0);
492 let b = Region3D::new(8.0, 0.0, 0.0, 5.0); // distance 8 < 10 => overlap
493 let c = Region3D::new(20.0, 0.0, 0.0, 1.0); // distance 20 > 6 => no overlap
494
495 assert!(a.overlaps(&b));
496 assert!(b.overlaps(&a));
497 assert!(!a.overlaps(&c));
498 }
499
500 #[test]
501 fn deadline_defaults_and_advances_by_tick() {
502 let mut cell = SimCell::new("t", SimWorld::cpu(100, 128), Duration::from_millis(100));
503 let first = cell.next_deadline;
504 cell.advance_deadline();
505 let delta = cell.next_deadline - first;
506 assert_eq!(delta.num_milliseconds(), 100);
507 }
508}