Skip to main content

problemreductions/models/misc/
job_shop_scheduling.rs

1//! Job Shop Scheduling problem implementation.
2//!
3//! Given `m` processors and a set of jobs, each job consisting of an ordered
4//! sequence of processor-length tasks, find a schedule that minimizes the
5//! makespan (completion time of the last task) while respecting both within-job
6//! precedence and single-processor capacity constraints.
7
8use crate::registry::{CreateSpec, ProblemSchemaEntry};
9use crate::traits::Problem;
10use crate::types::Min;
11use serde::{Deserialize, Serialize};
12use std::collections::VecDeque;
13
14inventory::submit! {
15    ProblemSchemaEntry {
16        name: "JobShopScheduling",
17        display_name: "Job-Shop Scheduling",
18        aliases: &[],
19        dimensions: &[],
20        category: crate::registry::ProblemCategory::Misc,
21        module_path: module_path!(),
22        description: "Minimize the makespan of a job-shop schedule",
23        fields: JobShopSchedulingCreateSpec::FIELDS,
24    }
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct JobShopScheduling {
29    num_processors: usize,
30    jobs: Vec<Vec<(usize, i64)>>,
31}
32
33#[derive(Debug, Deserialize, crate::CreateSpec)]
34struct JobShopSchedulingCreateSpec {
35    /// Jobs expressed as ordered processor-duration operations.
36    #[create(codec = "semicolon-separated")]
37    jobs: Vec<Vec<(usize, i64)>>,
38    /// Optional processor count; omitted values are inferred from the jobs.
39    num_processors: Option<usize>,
40}
41
42impl TryFrom<JobShopSchedulingCreateSpec> for JobShopScheduling {
43    type Error = crate::registry::ConstructionError;
44
45    fn try_from(spec: JobShopSchedulingCreateSpec) -> Result<Self, Self::Error> {
46        let inferred_processors = spec
47            .jobs
48            .iter()
49            .flatten()
50            .map(|(processor, _)| *processor)
51            .max()
52            .map(|processor| {
53                processor
54                    .checked_add(1)
55                    .ok_or_else(|| "inferred processor count overflows usize".to_string())
56            })
57            .transpose()?;
58        let num_processors = spec.num_processors.or(inferred_processors).ok_or_else(|| {
59            "cannot infer processor count from an empty job list; provide num_processors"
60                .to_string()
61        })?;
62        if num_processors == 0 {
63            return Err("num_processors must be positive".to_string().into());
64        }
65
66        for (job_index, job) in spec.jobs.iter().enumerate() {
67            for (task_index, &(processor, _)) in job.iter().enumerate() {
68                if processor >= num_processors {
69                    return Err(format!(
70                        "job {job_index} task {task_index} uses processor {processor}, but num_processors is {num_processors}"
71                    ).into());
72                }
73            }
74            for (task_index, pair) in job.windows(2).enumerate() {
75                if pair[0].0 == pair[1].0 {
76                    return Err(format!(
77                        "job {job_index} tasks {task_index} and {} must use different processors",
78                        task_index + 1
79                    )
80                    .into());
81                }
82            }
83        }
84
85        Ok(Self {
86            num_processors,
87            jobs: spec.jobs,
88        })
89    }
90}
91
92struct FlattenedTasks {
93    job_task_ids: Vec<Vec<usize>>,
94    machine_task_ids: Vec<Vec<usize>>,
95    lengths: Vec<i64>,
96}
97
98impl JobShopScheduling {
99    pub fn new(num_processors: usize, jobs: Vec<Vec<(usize, i64)>>) -> Self {
100        let num_tasks: usize = jobs.iter().map(Vec::len).sum();
101        if num_tasks > 0 {
102            assert!(
103                num_processors > 0,
104                "num_processors must be positive when tasks are present"
105            );
106        }
107        assert!(
108            jobs.iter().flatten().all(|&(_, length)| length >= 0),
109            "operation lengths must be nonnegative"
110        );
111
112        for (job_index, job) in jobs.iter().enumerate() {
113            for (task_index, &(processor, _length)) in job.iter().enumerate() {
114                assert!(
115                    processor < num_processors,
116                    "job {job_index} task {task_index} uses processor {processor}, but num_processors = {num_processors}"
117                );
118            }
119
120            for (task_index, pair) in job.windows(2).enumerate() {
121                assert_ne!(
122                    pair[0].0,
123                    pair[1].0,
124                    "job {job_index} tasks {task_index} and {} must use different processors",
125                    task_index + 1
126                );
127            }
128        }
129
130        Self {
131            num_processors,
132            jobs,
133        }
134    }
135
136    pub fn num_processors(&self) -> usize {
137        self.num_processors
138    }
139
140    pub fn jobs(&self) -> &[Vec<(usize, i64)>] {
141        &self.jobs
142    }
143
144    pub fn num_jobs(&self) -> usize {
145        self.jobs.len()
146    }
147
148    pub fn num_tasks(&self) -> usize {
149        self.jobs.iter().map(Vec::len).sum()
150    }
151
152    fn flatten_tasks(&self) -> FlattenedTasks {
153        let mut job_task_ids = Vec::with_capacity(self.jobs.len());
154        let mut machine_task_ids = vec![Vec::new(); self.num_processors];
155        let mut lengths = Vec::with_capacity(self.num_tasks());
156        let mut task_id = 0usize;
157
158        for job in &self.jobs {
159            let mut ids = Vec::with_capacity(job.len());
160            for &(processor, length) in job {
161                ids.push(task_id);
162                machine_task_ids[processor].push(task_id);
163                lengths.push(length);
164                task_id += 1;
165            }
166            job_task_ids.push(ids);
167        }
168
169        FlattenedTasks {
170            job_task_ids,
171            machine_task_ids,
172            lengths,
173        }
174    }
175
176    fn decode_machine_orders(
177        &self,
178        config: &[usize],
179        flattened: &FlattenedTasks,
180    ) -> Option<Vec<Vec<usize>>> {
181        if config.len() != flattened.lengths.len() {
182            return None;
183        }
184
185        let mut offset = 0usize;
186        let mut orders = Vec::with_capacity(flattened.machine_task_ids.len());
187
188        for machine_tasks in &flattened.machine_task_ids {
189            let k = machine_tasks.len();
190            let perm = super::decode_lehmer(&config[offset..offset + k], k)?;
191            orders.push(perm.into_iter().map(|i| machine_tasks[i]).collect());
192            offset += k;
193        }
194
195        Some(orders)
196    }
197
198    /// Compute start times from a Lehmer-code config. Returns `None` if the
199    /// config is invalid or induces a cycle in the precedence DAG.
200    pub fn schedule_from_config(&self, config: &[usize]) -> Option<Vec<i64>> {
201        self.schedule_from_config_inner(config, &self.flatten_tasks())
202    }
203
204    fn schedule_from_config_inner(
205        &self,
206        config: &[usize],
207        flattened: &FlattenedTasks,
208    ) -> Option<Vec<i64>> {
209        let machine_orders = self.decode_machine_orders(config, flattened)?;
210        let num_tasks = flattened.lengths.len();
211
212        if num_tasks == 0 {
213            return Some(Vec::new());
214        }
215
216        let mut adjacency = vec![Vec::<usize>::new(); num_tasks];
217        let mut indegree = vec![0usize; num_tasks];
218
219        for job_ids in &flattened.job_task_ids {
220            for pair in job_ids.windows(2) {
221                adjacency[pair[0]].push(pair[1]);
222                indegree[pair[1]] += 1;
223            }
224        }
225
226        for machine_order in &machine_orders {
227            for pair in machine_order.windows(2) {
228                adjacency[pair[0]].push(pair[1]);
229                indegree[pair[1]] += 1;
230            }
231        }
232
233        let mut queue = VecDeque::new();
234        for (task_id, &degree) in indegree.iter().enumerate() {
235            if degree == 0 {
236                queue.push_back(task_id);
237            }
238        }
239
240        let mut start_times = vec![0i64; num_tasks];
241        let mut processed = 0usize;
242
243        while let Some(task_id) = queue.pop_front() {
244            processed += 1;
245            let finish = start_times[task_id].checked_add(flattened.lengths[task_id])?;
246
247            for &next_task in &adjacency[task_id] {
248                start_times[next_task] = start_times[next_task].max(finish);
249                indegree[next_task] -= 1;
250                if indegree[next_task] == 0 {
251                    queue.push_back(next_task);
252                }
253            }
254        }
255
256        if processed != num_tasks {
257            return None;
258        }
259
260        Some(start_times)
261    }
262}
263
264impl Problem for JobShopScheduling {
265    const NAME: &'static str = "JobShopScheduling";
266    type Solution = Vec<usize>;
267    type Value = Min<i64>;
268
269    crate::problem_parameters![
270        ("num_processors", num_processors),
271        ("num_jobs", num_jobs),
272        ("num_tasks", num_tasks),
273    ];
274
275    fn variant() -> Vec<(&'static str, &'static str)> {
276        crate::variant_params![]
277    }
278
279    fn evaluate(
280        &self,
281        config: &Self::Solution,
282    ) -> Result<Min<i64>, crate::traits::EvaluationError> {
283        let flattened = self.flatten_tasks();
284        if config.len() != flattened.lengths.len() {
285            return Err(crate::traits::EvaluationError::InvalidConfiguration(
286                "machine-order encoding length does not match the tasks".into(),
287            ));
288        }
289        let dimensions = flattened
290            .machine_task_ids
291            .iter()
292            .flat_map(|machine_tasks| super::lehmer_dims(machine_tasks.len()));
293        if config
294            .iter()
295            .zip(dimensions)
296            .any(|(&digit, radix)| digit >= radix)
297        {
298            return Err(crate::traits::EvaluationError::InvalidConfiguration(
299                "machine-order encoding contains an out-of-range digit".into(),
300            ));
301        }
302        Ok({
303            match self.schedule_from_config_inner(config, &flattened) {
304                Some(start_times) => {
305                    let makespan = start_times
306                        .iter()
307                        .enumerate()
308                        .map(|(i, &s)| s + flattened.lengths[i])
309                        .max()
310                        .unwrap_or(0);
311                    Min(Some(makespan))
312                }
313                None => Min(None),
314            }
315        })
316    }
317}
318
319impl crate::solvers::BruteForceProblem for JobShopScheduling {
320    fn dimensions(&self) -> Vec<usize> {
321        self.flatten_tasks()
322            .machine_task_ids
323            .into_iter()
324            .flat_map(|machine_tasks| super::lehmer_dims(machine_tasks.len()))
325            .collect()
326    }
327}
328
329crate::declare_variants! {
330    default JobShopScheduling => "factorial(num_tasks)" create JobShopSchedulingCreateSpec,
331}
332
333crate::register_brute_force! {
334    JobShopScheduling,
335}
336
337#[cfg(feature = "example-db")]
338pub(crate) fn canonical_model_example_specs() -> Vec<crate::example_db::specs::ModelExampleSpec> {
339    vec![crate::example_db::specs::ModelExampleSpec {
340        id: "job_shop_scheduling",
341        instance: Box::new(JobShopScheduling::new(
342            2,
343            vec![
344                vec![(0, 3), (1, 4)],
345                vec![(1, 2), (0, 3), (1, 2)],
346                vec![(0, 4), (1, 3)],
347                vec![(1, 5), (0, 2)],
348                vec![(0, 2), (1, 3), (0, 1)],
349            ],
350        )),
351        // Machine 0 order [0,3,5,8,9,11] => [0,0,0,0,0,0]
352        // Machine 1 order [2,7,1,6,10,4] => [1,3,0,1,1,0]
353        optimal_config: serde_json::json!(vec![0, 0, 0, 0, 0, 0, 1, 3, 0, 1, 1, 0]),
354        optimal_value: serde_json::json!(19),
355    }]
356}
357
358#[cfg(test)]
359#[path = "../../unit_tests/models/misc/job_shop_scheduling.rs"]
360mod tests;