Skip to main content

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}