use async_trait::async_trait;
use converge_pack::{AgentEffect, Context, ContextKey, ProposedFact, Suggestor};
use std::time::Instant;
use tracing::warn;
use super::problem::{SchedulingPlan, SchedulingRequest, TaskAssignment};
pub(super) const REQUEST_PREFIX: &str = "scheduling-request:";
const PLAN_PREFIX: &str = "scheduling-plan-greedy:";
pub struct GreedySchedulerSuggestor;
#[async_trait]
impl Suggestor for GreedySchedulerSuggestor {
fn name(&self) -> &'static str {
"GreedySchedulerSuggestor"
}
fn dependencies(&self) -> &[ContextKey] {
&[ContextKey::Seeds]
}
fn complexity_hint(&self) -> Option<&'static str> {
Some("O(n·m·log n) — EDF + earliest-available; deterministic, sub-ms for n ≤ 10 000 tasks")
}
fn accepts(&self, ctx: &dyn Context) -> bool {
ctx.get(ContextKey::Seeds).iter().any(|f| {
f.id().starts_with(REQUEST_PREFIX) && !own_plan_exists(ctx, request_id(f.id()))
})
}
async fn execute(&self, ctx: &dyn Context) -> AgentEffect {
let mut proposals = Vec::new();
for fact in ctx
.get(ContextKey::Seeds)
.iter()
.filter(|f| f.id().starts_with(REQUEST_PREFIX))
{
let rid = request_id(fact.id());
if own_plan_exists(ctx, rid) {
continue;
}
match serde_json::from_str::<SchedulingRequest>(fact.content()) {
Ok(req) => {
let plan = solve_greedy(&req);
let confidence = (plan.throughput_ratio() * 0.65).min(0.65);
proposals.push(
ProposedFact::new(
ContextKey::Strategies,
format!("{PLAN_PREFIX}{rid}"),
serde_json::to_string(&plan).unwrap_or_default(),
self.name(),
)
.with_confidence(confidence),
);
}
Err(e) => {
warn!(id = %fact.id(), error = %e, "malformed scheduling-request");
}
}
}
if proposals.is_empty() {
AgentEffect::empty()
} else {
AgentEffect::with_proposals(proposals)
}
}
}
fn request_id(fact_id: &str) -> &str {
fact_id.trim_start_matches(REQUEST_PREFIX)
}
fn own_plan_exists(ctx: &dyn Context, request_id: &str) -> bool {
let plan_id = format!("{PLAN_PREFIX}{request_id}");
ctx.get(ContextKey::Strategies)
.iter()
.any(|f| f.id() == plan_id.as_str())
}
pub fn solve_greedy(req: &SchedulingRequest) -> SchedulingPlan {
let t0 = Instant::now();
let mut ordered: Vec<_> = req.tasks.iter().collect();
ordered.sort_by_key(|t| t.deadline_min);
let mut next_free = vec![0i64; req.agents.len()];
let mut assignments: Vec<TaskAssignment> = Vec::new();
for task in &ordered {
let best = req
.agents
.iter()
.filter(|a| a.capabilities.contains(&task.required_capability))
.min_by_key(|a| next_free[a.id].max(task.release_min));
if let Some(agent) = best {
let start = next_free[agent.id].max(task.release_min);
let end = start + task.duration_min;
if end <= task.deadline_min {
next_free[agent.id] = end;
assignments.push(TaskAssignment {
task_id: task.id,
task_name: task.name.clone(),
agent_id: agent.id,
agent_name: agent.name.clone(),
start_min: start,
end_min: end,
});
}
}
}
assignments.sort_by_key(|a| a.start_min);
let makespan = assignments.iter().map(|a| a.end_min).max().unwrap_or(0);
let scheduled = assignments.len();
SchedulingPlan {
request_id: req.id.clone(),
assignments,
tasks_total: req.tasks.len(),
tasks_scheduled: scheduled,
makespan_min: makespan,
solver: "greedy-edf".to_string(),
status: "feasible".to_string(),
wall_time_seconds: t0.elapsed().as_secs_f64(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::scheduling::problem::{SchedulingAgent, SchedulingTask};
use crate::test_support::MockContext;
use converge_pack::Suggestor;
fn agent(id: usize, name: &str, caps: &[&str]) -> SchedulingAgent {
SchedulingAgent {
id,
name: name.into(),
capabilities: caps.iter().map(|s| (*s).into()).collect(),
}
}
fn task(
id: usize,
name: &str,
cap: &str,
duration: i64,
release: i64,
deadline: i64,
) -> SchedulingTask {
SchedulingTask {
id,
name: name.into(),
required_capability: cap.into(),
duration_min: duration,
release_min: release,
deadline_min: deadline,
}
}
fn req_with(tasks: Vec<SchedulingTask>, agents: Vec<SchedulingAgent>) -> SchedulingRequest {
SchedulingRequest {
id: "r1".into(),
agents,
tasks,
horizon_min: 480,
time_limit_seconds: 1.0,
}
}
#[test]
fn empty_request_yields_empty_plan() {
let req = req_with(vec![], vec![]);
let plan = solve_greedy(&req);
assert_eq!(plan.tasks_total, 0);
assert_eq!(plan.tasks_scheduled, 0);
assert_eq!(plan.makespan_min, 0);
assert!((plan.throughput_ratio() - 0.0).abs() < f64::EPSILON);
}
#[test]
fn single_capable_agent_schedules_in_order() {
let req = req_with(
vec![
task(1, "t1", "py", 30, 0, 60),
task(2, "t2", "py", 30, 0, 120),
],
vec![agent(0, "alice", &["py"])],
);
let plan = solve_greedy(&req);
assert_eq!(plan.tasks_scheduled, 2);
assert_eq!(plan.makespan_min, 60);
assert_eq!(plan.assignments[0].task_id, 1);
assert!((plan.throughput_ratio() - 1.0).abs() < f64::EPSILON);
}
#[test]
fn drops_task_with_unsatisfiable_deadline() {
let req = req_with(
vec![task(1, "tight", "py", 100, 0, 30)],
vec![agent(0, "alice", &["py"])],
);
let plan = solve_greedy(&req);
assert_eq!(plan.tasks_scheduled, 0);
assert_eq!(plan.tasks_total, 1);
}
#[test]
fn skips_task_when_no_capable_agent() {
let req = req_with(
vec![task(1, "rs-only", "rust", 30, 0, 120)],
vec![agent(0, "alice", &["py"])],
);
let plan = solve_greedy(&req);
assert_eq!(plan.tasks_scheduled, 0);
}
#[test]
fn picks_earliest_available_agent() {
let req = req_with(
vec![
task(1, "t1", "py", 60, 0, 120),
task(2, "t2", "py", 30, 0, 120),
],
vec![agent(0, "alice", &["py"]), agent(1, "bob", &["py"])],
);
let plan = solve_greedy(&req);
assert_eq!(plan.tasks_scheduled, 2);
let agents: std::collections::HashSet<_> =
plan.assignments.iter().map(|a| a.agent_id).collect();
assert_eq!(agents.len(), 2, "should distribute across both agents");
}
#[test]
fn release_time_delays_start() {
let req = req_with(
vec![task(1, "t1", "py", 30, 100, 200)],
vec![agent(0, "alice", &["py"])],
);
let plan = solve_greedy(&req);
assert_eq!(plan.assignments[0].start_min, 100);
assert_eq!(plan.assignments[0].end_min, 130);
}
#[tokio::test]
async fn suggestor_emits_proposal_for_seed() {
let req = req_with(
vec![task(1, "t1", "py", 30, 0, 60)],
vec![agent(0, "alice", &["py"])],
);
let body = serde_json::to_string(&req).unwrap();
let ctx = MockContext::empty().with_seed(&format!("{REQUEST_PREFIX}r1"), &body);
let s = GreedySchedulerSuggestor;
assert_eq!(s.name(), "GreedySchedulerSuggestor");
assert_eq!(s.dependencies(), &[ContextKey::Seeds]);
assert!(s.complexity_hint().is_some());
assert!(s.accepts(&ctx));
let effect = s.execute(&ctx).await;
assert_eq!(effect.proposals().len(), 1);
}
#[tokio::test]
async fn suggestor_skips_if_plan_already_present() {
let req = req_with(
vec![task(1, "t1", "py", 30, 0, 60)],
vec![agent(0, "alice", &["py"])],
);
let body = serde_json::to_string(&req).unwrap();
let ctx = MockContext::empty()
.with_seed(&format!("{REQUEST_PREFIX}r1"), &body)
.with_strategy("scheduling-plan-greedy:r1", "{}");
let s = GreedySchedulerSuggestor;
assert!(!s.accepts(&ctx));
let effect = s.execute(&ctx).await;
assert_eq!(effect.proposals().len(), 0);
}
#[tokio::test]
async fn suggestor_handles_malformed_seed() {
let ctx = MockContext::empty().with_seed(&format!("{REQUEST_PREFIX}bad"), "not json");
let s = GreedySchedulerSuggestor;
let effect = s.execute(&ctx).await;
assert_eq!(effect.proposals().len(), 0);
}
#[tokio::test]
async fn suggestor_ignores_other_seed_prefixes() {
let ctx = MockContext::empty().with_seed("unrelated:r1", "{}");
let s = GreedySchedulerSuggestor;
assert!(!s.accepts(&ctx));
}
}