Skip to main content

ferrox/scheduling/
problem.rs

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