Skip to main content

ferrox/scheduling/
problem.rs

1use serde::{Deserialize, Serialize};
2
3use converge_pack::{ExecutionIdentity, FactPayload};
4
5use crate::domain_types::{AgentId, Minutes, TaskId};
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
8#[serde(rename_all = "snake_case")]
9pub enum SchedulingSolveStatus {
10    Optimal,
11    Feasible,
12    Infeasible,
13    Unbounded,
14    Error,
15    Invalid,
16}
17
18impl SchedulingSolveStatus {
19    pub fn is_successful(self) -> bool {
20        matches!(self, Self::Optimal | Self::Feasible)
21    }
22}
23
24/// An agent that can execute tasks requiring one of its declared capabilities.
25#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
26#[serde(deny_unknown_fields)]
27pub struct SchedulingAgent {
28    pub id: AgentId,
29    pub name: String,
30    /// Capability tags this agent possesses (e.g. "python", "ml", "rust").
31    pub capabilities: Vec<String>,
32}
33
34/// A unit of work to be scheduled.
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
36#[serde(deny_unknown_fields)]
37pub struct SchedulingTask {
38    pub id: TaskId,
39    pub name: String,
40    /// The single capability an agent must have to execute this task.
41    pub required_capability: String,
42    /// Duration in minutes.
43    pub duration_min: Minutes,
44    /// Earliest start (minutes from horizon start).
45    pub release_min: Minutes,
46    /// Latest finish (minutes from horizon start).  Must be ≥ release + duration.
47    pub deadline_min: Minutes,
48}
49
50/// Seeded into `ContextKey::Seeds` with id prefix `"scheduling-request:"`.
51#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
52#[serde(deny_unknown_fields)]
53pub struct SchedulingRequest {
54    pub id: String,
55    pub agents: Vec<SchedulingAgent>,
56    pub tasks: Vec<SchedulingTask>,
57    /// Planning horizon in minutes.
58    pub horizon_min: i64,
59    /// Per-solver time budget in seconds.  Suggestors may honour or ignore this.
60    #[serde(default = "default_time_limit")]
61    pub time_limit_seconds: f64,
62}
63
64impl FactPayload for SchedulingRequest {
65    const FAMILY: &'static str = "ferrox.scheduling.request";
66    const VERSION: u16 = 1;
67}
68
69fn default_time_limit() -> f64 {
70    30.0
71}
72
73/// A single task-to-agent assignment with resolved timing.
74#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
75#[serde(deny_unknown_fields)]
76pub struct TaskAssignment {
77    pub task_id: TaskId,
78    pub task_name: String,
79    pub agent_id: AgentId,
80    pub agent_name: String,
81    pub start_min: Minutes,
82    pub end_min: Minutes,
83}
84
85/// Written to `ContextKey::Strategies` with id prefix `"scheduling-plan-<solver>:"`.
86#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
87#[serde(deny_unknown_fields)]
88pub struct SchedulingPlan {
89    pub request_id: String,
90    pub assignments: Vec<TaskAssignment>,
91    pub tasks_total: usize,
92    pub tasks_scheduled: usize,
93    /// Completion time of the last scheduled task (0 if nothing scheduled).
94    pub makespan_min: Minutes,
95    /// Short identifier for the algorithm that produced this plan.
96    pub solver: String,
97    pub execution_identity: ExecutionIdentity,
98    pub status: SchedulingSolveStatus,
99    pub wall_time_seconds: f64,
100}
101
102impl FactPayload for SchedulingPlan {
103    const FAMILY: &'static str = "ferrox.scheduling.plan";
104    const VERSION: u16 = 1;
105}
106
107impl SchedulingPlan {
108    /// Throughput ratio: scheduled / total tasks.  Used to derive confidence.
109    #[allow(clippy::cast_precision_loss)]
110    pub fn throughput_ratio(&self) -> f64 {
111        if self.tasks_total == 0 {
112            return 0.0;
113        }
114        self.tasks_scheduled as f64 / self.tasks_total as f64
115    }
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121    use crate::domain_types::Minutes;
122    use crate::solver_identity::non_native_solver_identity;
123
124    fn empty_plan(tasks_total: usize, tasks_scheduled: usize) -> SchedulingPlan {
125        SchedulingPlan {
126            request_id: "r".into(),
127            assignments: vec![],
128            tasks_total,
129            tasks_scheduled,
130            makespan_min: Minutes(0),
131            solver: "x".into(),
132            execution_identity: non_native_solver_identity("x", "test"),
133            status: SchedulingSolveStatus::Feasible,
134            wall_time_seconds: 0.0,
135        }
136    }
137
138    #[test]
139    fn throughput_ratio_zero_when_no_tasks() {
140        let p = empty_plan(0, 0);
141        assert!((p.throughput_ratio() - 0.0).abs() < f64::EPSILON);
142    }
143
144    #[test]
145    fn throughput_ratio_partial() {
146        let p = empty_plan(10, 7);
147        assert!((p.throughput_ratio() - 0.7).abs() < 1e-9);
148    }
149
150    #[test]
151    fn request_serde_round_trip_with_default_time_limit() {
152        let json = r#"{"id":"r","agents":[],"tasks":[],"horizon_min":120}"#;
153        let r: SchedulingRequest = serde_json::from_str(json).unwrap();
154        assert!((r.time_limit_seconds - 30.0).abs() < f64::EPSILON);
155    }
156}