use async_trait::async_trait;
use converge_pack::{AgentEffect, Context, ContextKey, ProvenanceSource, Suggestor};
use std::time::Instant;
use tracing::warn;
use crate::provenance::FERROX_PROVENANCE;
use crate::solver_identity::non_native_solver_identity;
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()))
})
}
fn provenance(&self) -> &'static str {
FERROX_PROVENANCE.as_str()
}
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 fact.require_payload::<SchedulingRequest>() {
Ok(req) => {
let plan = solve_greedy(req);
let confidence = (plan.throughput_ratio() * 0.65).min(0.65);
proposals.push(
FERROX_PROVENANCE
.proposed_fact(
ContextKey::Strategies,
format!("{PLAN_PREFIX}{rid}"),
plan,
)
.with_confidence(confidence),
);
}
Err(e) => {
warn!(id = %fact.id(), error = %e, "unexpected scheduling-request payload");
}
}
}
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()
.enumerate()
.filter(|(_, a)| a.capabilities.contains(&task.required_capability))
.map(|(idx, a)| (idx, a, next_free[idx].max(task.release_min)))
.min_by_key(|(_, a, start)| (*start, a.id));
if let Some((idx, agent, start)) = best {
let end = start + task.duration_min;
if end <= task.deadline_min {
next_free[idx] = 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(),
execution_identity: non_native_solver_identity(
"greedy-edf",
"algorithm=edf_earliest_available",
),
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;
use converge_pack::TextPayload;
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 supports_non_dense_agent_ids() {
let req = req_with(
vec![
task(1, "t1", "py", 30, 0, 90),
task(2, "t2", "py", 30, 0, 90),
],
vec![agent(10, "alice", &["py"]), agent(20, "bob", &["py"])],
);
let plan = solve_greedy(&req);
assert_eq!(plan.tasks_scheduled, 2);
assert_eq!(plan.assignments[0].agent_id, 10);
}
#[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 ctx = MockContext::empty().with_seed(&format!("{REQUEST_PREFIX}r1"), req);
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 ctx = MockContext::empty()
.with_seed(&format!("{REQUEST_PREFIX}r1"), req)
.with_strategy("scheduling-plan-greedy:r1", TextPayload::new("existing"));
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"),
TextPayload::new("not a scheduling request"),
);
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", TextPayload::new("unrelated"));
let s = GreedySchedulerSuggestor;
assert!(!s.accepts(&ctx));
}
}