Skip to main content

problemreductions/models/misc/
open_shop_scheduling.rs

1//! Open Shop Scheduling problem implementation.
2//!
3//! Given `m` machines and a set of `n` jobs, each job consisting of one task
4//! per machine (the task order for each job is free), find a schedule that
5//! minimizes the makespan (completion time of the last task) while respecting
6//! both machine capacity (one job at a time per machine) and job capacity
7//! (each job uses at most one machine at a time) constraints.
8
9use crate::registry::{CreateSpec, ProblemSchemaEntry};
10use crate::traits::Problem;
11use crate::types::Min;
12use serde::{Deserialize, Serialize};
13
14inventory::submit! {
15    ProblemSchemaEntry {
16        name: "OpenShopScheduling",
17        display_name: "Open Shop Scheduling",
18        aliases: &[],
19        dimensions: &[],
20        category: crate::registry::ProblemCategory::Misc,
21        module_path: module_path!(),
22        description: "Minimize the makespan of an open-shop schedule",
23        fields: OpenShopSchedulingCreateSpec::FIELDS,
24    }
25}
26
27/// The Open Shop Scheduling problem.
28///
29/// Given `m` machines and `n` jobs, where job `j` has one task on each machine
30/// `i` with processing time `p[j][i]`, find a non-preemptive schedule that
31/// minimizes the makespan. Unlike flow-shop or job-shop scheduling, there is no
32/// prescribed order for the tasks of a given job — each job's tasks may be
33/// processed on the machines in any order.
34///
35/// # Constraints
36///
37/// 1. **Machine constraint:** Each machine processes at most one job at a time.
38/// 2. **Job constraint:** Each job occupies at most one machine at a time.
39///
40/// # Configuration Encoding
41///
42/// The configuration is a flat array of `n * m` non-negative start times in
43/// job-major order: `config[j * m + i]` is the start time of job `j` on
44/// machine `i`. A configuration is valid exactly when operations of the same
45/// job and operations on the same machine do not overlap.
46///
47/// # Example
48///
49/// ```
50/// use problemreductions::models::misc::OpenShopScheduling;
51/// use problemreductions::{Problem, BruteForce};
52/// use problemreductions::types::Min;
53///
54/// // 2 machines, 2 jobs
55/// let p = vec![vec![1, 2], vec![2, 1]];
56/// let problem = OpenShopScheduling::new(2, p);
57/// let solver = BruteForce::new();
58/// let solution = solver.solve(&problem).unwrap().unwrap();
59/// assert_eq!(problem.evaluate(&solution).unwrap(), Min(Some(3)));
60/// ```
61#[derive(Debug, Clone, Serialize, Deserialize)]
62#[serde(try_from = "OpenShopSchedulingSerde")]
63pub struct OpenShopScheduling {
64    /// Number of machines m.
65    num_machines: usize,
66    /// Processing time matrix: `processing_times[j][i]` is the time to process
67    /// job `j` on machine `i`. Dimensions: n jobs × m machines.
68    processing_times: Vec<Vec<i64>>,
69}
70
71#[derive(Deserialize)]
72struct OpenShopSchedulingSerde {
73    num_machines: usize,
74    processing_times: Vec<Vec<i64>>,
75}
76
77impl TryFrom<OpenShopSchedulingSerde> for OpenShopScheduling {
78    type Error = crate::registry::ConstructionError;
79
80    fn try_from(value: OpenShopSchedulingSerde) -> Result<Self, Self::Error> {
81        Self::try_new(value.num_machines, value.processing_times)
82    }
83}
84
85#[derive(Debug, Deserialize, crate::CreateSpec)]
86struct OpenShopSchedulingCreateSpec {
87    /// Number of machines m.
88    num_processors: usize,
89    /// Processing time of each job on each machine (n x m).
90    processing_times: Vec<Vec<i64>>,
91}
92
93impl TryFrom<OpenShopSchedulingCreateSpec> for OpenShopScheduling {
94    type Error = crate::registry::ConstructionError;
95
96    fn try_from(spec: OpenShopSchedulingCreateSpec) -> Result<Self, Self::Error> {
97        Self::try_new(spec.num_processors, spec.processing_times)
98    }
99}
100
101impl OpenShopScheduling {
102    /// Create a new Open Shop Scheduling instance.
103    ///
104    /// # Arguments
105    /// * `num_machines` - Number of machines m
106    /// * `processing_times` - `processing_times[j][i]` = processing time of job j on machine i.
107    ///   Each inner Vec must have length `num_machines`.
108    ///
109    /// # Panics
110    /// Panics if the processing matrix or its schedule horizon is invalid.
111    pub fn new(num_machines: usize, processing_times: Vec<Vec<i64>>) -> Self {
112        Self::try_new(num_machines, processing_times)
113            .expect("invalid open-shop scheduling instance")
114    }
115
116    /// Construct an instance, validating dimensions, durations, and the horizon.
117    pub fn try_new(
118        num_machines: usize,
119        processing_times: Vec<Vec<i64>>,
120    ) -> Result<Self, crate::registry::ConstructionError> {
121        for (job, times) in processing_times.iter().enumerate() {
122            if times.len() != num_machines {
123                return Err(format!(
124                    "processing_times[{job}] has {} entries, expected {num_machines}",
125                    times.len(),
126                )
127                .into());
128            }
129            if times.iter().any(|&time| time < 0) {
130                return Err(format!("processing_times[{job}] contains a negative duration").into());
131            }
132        }
133        processing_times
134            .len()
135            .checked_mul(num_machines)
136            .ok_or_else(|| {
137                crate::registry::ConstructionError::IntegerOverflow(
138                    "operation count overflows usize".into(),
139                )
140            })?;
141        let horizon = processing_times
142            .iter()
143            .flatten()
144            .try_fold(0i64, |total, &time| total.checked_add(time))
145            .ok_or_else(|| {
146                crate::registry::ConstructionError::IntegerOverflow(
147                    "schedule horizon overflows i64".into(),
148                )
149            })?;
150        usize::try_from(horizon)
151            .ok()
152            .and_then(|value| value.checked_add(1))
153            .ok_or_else(|| {
154                crate::registry::ConstructionError::IntegerOverflow(
155                    "schedule horizon domain overflows usize".into(),
156                )
157            })?;
158        Ok(Self {
159            num_machines,
160            processing_times,
161        })
162    }
163
164    /// Get the number of machines.
165    pub fn num_machines(&self) -> usize {
166        self.num_machines
167    }
168
169    /// Get the number of jobs.
170    pub fn num_jobs(&self) -> usize {
171        self.processing_times.len()
172    }
173
174    /// Get the processing time matrix.
175    pub fn processing_times(&self) -> &[Vec<i64>] {
176        &self.processing_times
177    }
178
179    /// Return the sum of all processing times, a valid serial-schedule horizon.
180    pub fn schedule_horizon(&self) -> usize {
181        self.processing_times
182            .iter()
183            .flatten()
184            .try_fold(0usize, |total, &time| {
185                usize::try_from(time)
186                    .ok()
187                    .and_then(|time| total.checked_add(time))
188            })
189            .expect("processing times must fit the brute-force schedule horizon")
190    }
191
192    fn finish_time(
193        &self,
194        config: &[usize],
195        job: usize,
196        machine: usize,
197    ) -> Result<i64, crate::traits::EvaluationError> {
198        let start = i64::try_from(config[job * self.num_machines + machine]).map_err(|_| {
199            crate::traits::EvaluationError::IntegerOverflow(
200                "converting an open-shop start time to i64".into(),
201            )
202        })?;
203        start
204            .checked_add(self.processing_times[job][machine])
205            .ok_or_else(|| {
206                crate::traits::EvaluationError::IntegerOverflow(
207                    "computing an open-shop completion time".into(),
208                )
209            })
210    }
211
212    fn operations_overlap(
213        &self,
214        config: &[usize],
215        first: (usize, usize),
216        second: (usize, usize),
217    ) -> Result<bool, crate::traits::EvaluationError> {
218        let (j1, i1) = first;
219        let (j2, i2) = second;
220        let s1 = i64::try_from(config[j1 * self.num_machines + i1]).map_err(|_| {
221            crate::traits::EvaluationError::IntegerOverflow("converting start time to i64".into())
222        })?;
223        let s2 = i64::try_from(config[j2 * self.num_machines + i2]).map_err(|_| {
224            crate::traits::EvaluationError::IntegerOverflow("converting start time to i64".into())
225        })?;
226        let f1 = self.finish_time(config, j1, i1)?;
227        let f2 = self.finish_time(config, j2, i2)?;
228        Ok(s1 < f2 && s2 < f1)
229    }
230}
231
232impl Problem for OpenShopScheduling {
233    const NAME: &'static str = "OpenShopScheduling";
234    type Solution = Vec<usize>;
235    type Value = Min<i64>;
236
237    crate::problem_parameters![
238        ("num_jobs", num_jobs),
239        ("num_machines", num_machines),
240        ("schedule_horizon", schedule_horizon),
241    ];
242
243    fn variant() -> Vec<(&'static str, &'static str)> {
244        crate::variant_params![]
245    }
246
247    fn evaluate(
248        &self,
249        config: &Self::Solution,
250    ) -> Result<Min<i64>, crate::traits::EvaluationError> {
251        let n = self.num_jobs();
252        let m = self.num_machines;
253        if config.len() != n * m {
254            return Err(crate::traits::EvaluationError::InvalidConfiguration(
255                "start-time representation length does not match the instance".into(),
256            ));
257        }
258        for machine in 0..m {
259            for first in 0..n {
260                for second in (first + 1)..n {
261                    if self.operations_overlap(config, (first, machine), (second, machine))? {
262                        return Ok(Min(None));
263                    }
264                }
265            }
266        }
267        for job in 0..n {
268            for first in 0..m {
269                for second in (first + 1)..m {
270                    if self.operations_overlap(config, (job, first), (job, second))? {
271                        return Ok(Min(None));
272                    }
273                }
274            }
275        }
276        let mut makespan = 0;
277        for job in 0..n {
278            for machine in 0..m {
279                makespan = makespan.max(self.finish_time(config, job, machine)?);
280            }
281        }
282        Ok(Min(Some(makespan)))
283    }
284}
285
286impl crate::solvers::BruteForceProblem for OpenShopScheduling {
287    fn dimensions(&self) -> Vec<usize> {
288        let domain = self
289            .schedule_horizon()
290            .checked_add(1)
291            .expect("schedule horizon overflow");
292        vec![domain; self.num_jobs() * self.num_machines]
293    }
294}
295
296crate::declare_variants! {
297    default OpenShopScheduling => "(schedule_horizon + 1)^(num_jobs * num_machines)" create OpenShopSchedulingCreateSpec,
298}
299
300crate::register_brute_force! {
301    OpenShopScheduling,
302}
303
304#[cfg(feature = "example-db")]
305pub(crate) fn canonical_model_example_specs() -> Vec<crate::example_db::specs::ModelExampleSpec> {
306    // 4 jobs × 3 machines example from issue #506.
307    // processing_times[j][i]:
308    //   J1: p[0] = [3, 1, 2]
309    //   J2: p[1] = [2, 3, 1]
310    //   J3: p[2] = [1, 2, 3]
311    //   J4: p[3] = [2, 2, 1]
312    //
313    // Per-machine totals: M1=8, M2=8, M3=7.  Per-job totals: J1=6, J2=6, J3=6, J4=5.
314    // Lower bound: max(8, 6) = 8. True optimal makespan = 8.
315    //
316    // Job-major start times: J1=[0,3,4], J2=[3,0,6], J3=[5,6,0], J4=[6,4,3].
317    // Each job and machine has non-overlapping operations; the last finish is 8.
318    vec![crate::example_db::specs::ModelExampleSpec {
319        id: "open_shop_scheduling",
320        instance: Box::new(OpenShopScheduling::new(
321            3,
322            vec![vec![3, 1, 2], vec![2, 3, 1], vec![1, 2, 3], vec![2, 2, 1]],
323        )),
324        optimal_config: serde_json::json!(vec![0, 3, 4, 3, 0, 6, 5, 6, 0, 6, 4, 3]),
325        optimal_value: serde_json::json!(8),
326    }]
327}
328
329#[cfg(test)]
330#[path = "../../unit_tests/models/misc/open_shop_scheduling.rs"]
331mod tests;