use async_trait::async_trait;
use converge_pack::{AgentEffect, Context, ContextKey, ProposedFact, Suggestor};
use ferrox_ortools_sys::OrtoolsStatus;
use ferrox_ortools_sys::safe::CpModel;
use std::time::Instant;
use tracing::warn;
use super::greedy::REQUEST_PREFIX;
use super::problem::{JobShopPlan, JobShopRequest, ScheduledOp};
const PLAN_PREFIX: &str = "jspbench-plan-cpsat:";
pub struct CpSatJobShopSuggestor;
#[async_trait]
impl Suggestor for CpSatJobShopSuggestor {
fn name(&self) -> &'static str {
"CpSatJobShopSuggestor"
}
fn dependencies(&self) -> &[ContextKey] {
&[ContextKey::Seeds]
}
fn complexity_hint(&self) -> Option<&'static str> {
Some(concat!(
"NP-hard; CP-SAT interval-NoOverlap formulation; ",
"proves optimality for 15×10 instances in < 5 s on 10-core hardware"
))
}
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::<JobShopRequest>(fact.content()) {
Ok(req) => {
let plan = solve_cpsat_jsp(&req);
let confidence = match plan.status.as_str() {
"optimal" => 1.0,
"feasible" => 0.85,
_ => 0.0,
};
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 jspbench-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_cpsat_jsp(req: &JobShopRequest) -> JobShopPlan {
let t0 = Instant::now();
let horizon = req.horizon();
let mut model = CpModel::new();
let mut starts: Vec<Vec<i32>> = Vec::new();
let mut ends: Vec<Vec<i32>> = Vec::new();
let mut intervals: Vec<Vec<i32>> = Vec::new();
for job in &req.jobs {
let mut js = Vec::new();
let mut je = Vec::new();
let mut ji = Vec::new();
for (k, op) in job.operations.iter().enumerate() {
let s = model.new_int_var(0, horizon, &format!("s_{}_{k}", job.id));
let e = model.new_int_var(op.duration, horizon, &format!("e_{}_{k}", job.id));
let iv = model.new_fixed_interval_var(s, op.duration, e, &format!("iv_{}_{k}", job.id));
js.push(s);
je.push(e);
ji.push(iv);
}
starts.push(js);
ends.push(je);
intervals.push(ji);
}
let cmax = model.new_int_var(0, horizon, "cmax");
for (j, job) in req.jobs.iter().enumerate() {
for k in 0..job.operations.len().saturating_sub(1) {
model.add_linear_ge(&[starts[j][k + 1], ends[j][k]], &[1, -1], 0);
}
}
let mut machine_intervals: Vec<Vec<i32>> = vec![Vec::new(); req.num_machines];
for (j, job) in req.jobs.iter().enumerate() {
for (k, op) in job.operations.iter().enumerate() {
machine_intervals[op.machine_id].push(intervals[j][k]);
}
}
for ivs in &machine_intervals {
if ivs.len() > 1 {
model.add_no_overlap(ivs);
}
}
for (j, job) in req.jobs.iter().enumerate() {
let last = job.operations.len() - 1;
model.add_linear_ge(&[cmax, ends[j][last]], &[1, -1], 0);
}
model.minimize(&[cmax], &[1]);
let solution = model.solve(req.time_limit_seconds);
let elapsed = t0.elapsed().as_secs_f64();
let status = match solution.status() {
OrtoolsStatus::Optimal => "optimal",
OrtoolsStatus::Feasible => "feasible",
OrtoolsStatus::Infeasible => "infeasible",
_ => "error",
};
if !solution.status().is_success() {
return JobShopPlan {
request_id: req.id.clone(),
schedule: Vec::new(),
makespan: 0,
lower_bound: None,
solver: "cp-sat-v9.15".to_string(),
status: status.to_string(),
wall_time_seconds: elapsed,
};
}
let makespan = solution.value(cmax);
let mut schedule: Vec<ScheduledOp> = Vec::new();
for (j, job) in req.jobs.iter().enumerate() {
for (k, op) in job.operations.iter().enumerate() {
let start = solution.value(starts[j][k]);
schedule.push(ScheduledOp {
job_id: job.id,
job_name: job.name.clone(),
machine_id: op.machine_id,
op_index: k,
start,
end: start + op.duration,
});
}
}
schedule.sort_by_key(|s| (s.machine_id, s.start));
let lower_bound = if status == "optimal" {
Some(makespan)
} else {
None
};
JobShopPlan {
request_id: req.id.clone(),
schedule,
makespan,
lower_bound,
solver: "cp-sat-v9.15".to_string(),
status: status.to_string(),
wall_time_seconds: elapsed,
}
}
#[cfg(test)]
#[allow(clippy::cast_possible_wrap, clippy::similar_names)]
mod tests {
use super::*;
use crate::jobshop::problem::{Job, Operation};
use crate::test_support::MockContext;
fn op(machine: usize, dur: i64) -> Operation {
Operation {
machine_id: machine,
duration: dur,
}
}
fn job(id: usize, ops: Vec<Operation>) -> Job {
Job {
id,
name: format!("j{id}"),
operations: ops,
}
}
fn req(jobs: Vec<Job>, m: usize, time_limit: f64) -> JobShopRequest {
JobShopRequest {
id: "j".into(),
jobs,
num_machines: m,
time_limit_seconds: time_limit,
}
}
#[test]
fn small_instance_optimal() {
let r = req(
vec![
job(0, vec![op(0, 3), op(1, 2)]),
job(1, vec![op(1, 4), op(0, 1)]),
],
2,
5.0,
);
let plan = solve_cpsat_jsp(&r);
assert_eq!(plan.status, "optimal");
assert!(plan.lower_bound.is_some());
assert_eq!(plan.makespan, 6);
assert_eq!(plan.schedule.len(), 4);
}
#[test]
fn cpsat_beats_or_matches_greedy_on_small_instance() {
let jobs = vec![
job(0, vec![op(0, 3), op(1, 2), op(2, 4)]),
job(1, vec![op(1, 5), op(0, 3), op(2, 1)]),
job(2, vec![op(2, 2), op(1, 4), op(0, 3)]),
];
let r = req(jobs, 3, 10.0);
let opt = solve_cpsat_jsp(&r);
let g = crate::jobshop::greedy::solve_greedy(&r);
assert!(opt.makespan <= g.makespan);
}
#[tokio::test]
async fn suggestor_emits_proposal() {
let r = req(vec![job(0, vec![op(0, 3)])], 1, 1.0);
let body = serde_json::to_string(&r).unwrap();
let ctx = MockContext::empty().with_seed("jspbench-request:j", &body);
let s = CpSatJobShopSuggestor;
assert_eq!(s.name(), "CpSatJobShopSuggestor");
assert_eq!(s.dependencies(), &[ContextKey::Seeds]);
assert!(s.complexity_hint().is_some());
assert!(s.accepts(&ctx));
let eff = s.execute(&ctx).await;
assert_eq!(eff.proposals().len(), 1);
}
#[tokio::test]
async fn suggestor_skips_when_plan_present() {
let r = req(vec![job(0, vec![op(0, 3)])], 1, 1.0);
let body = serde_json::to_string(&r).unwrap();
let ctx = MockContext::empty()
.with_seed("jspbench-request:j", &body)
.with_strategy("jspbench-plan-cpsat:j", "{}");
let s = CpSatJobShopSuggestor;
assert!(!s.accepts(&ctx));
let eff = s.execute(&ctx).await;
assert_eq!(eff.proposals().len(), 0);
}
#[tokio::test]
async fn suggestor_handles_malformed_seed() {
let ctx = MockContext::empty().with_seed("jspbench-request:bad", "not json");
let s = CpSatJobShopSuggestor;
let eff = s.execute(&ctx).await;
assert_eq!(eff.proposals().len(), 0);
}
#[test]
fn stress_30s_jobshop_20x15() {
let n_jobs = 20;
let n_machines = 15;
let mut state: u64 = 0x1234_5678_ABCD_EF01;
let step = |s: &mut u64| -> u64 {
*s = s.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
*s
};
let mut jobs: Vec<Job> = Vec::with_capacity(n_jobs);
for j in 0..n_jobs {
let mut perm: Vec<usize> = (0..n_machines).collect();
for i in (1..n_machines).rev() {
let r = (step(&mut state) >> 33) as usize % (i + 1);
perm.swap(i, r);
}
let ops = perm
.into_iter()
.map(|m| {
let dur = ((step(&mut state) >> 33) & 0x7F) as i64 + 10;
op(m, dur)
})
.collect();
jobs.push(job(j, ops));
}
let r = req(jobs, n_machines, 30.0);
let started = std::time::Instant::now();
let plan = solve_cpsat_jsp(&r);
let elapsed = started.elapsed().as_secs_f64();
assert!(
matches!(plan.status.as_str(), "optimal" | "feasible"),
"stress should yield a feasible job-shop plan, got {} in {elapsed:.1}s",
plan.status
);
assert_eq!(plan.schedule.len(), n_jobs * n_machines);
assert!(plan.makespan > 0);
}
}