problemreductions/rules/openshopscheduling_ilp.rs
1//! Reduction from OpenShopScheduling to ILP<i32>.
2//!
3//! Disjunctive formulation with binary ordering variables and integer start times:
4//!
5//! **Variables:**
6//! - `x_{j,k,i}` for j < k, all machines i: binary, 1 if job j precedes job k on machine i.
7//! Index: pair index * m + i, where pair index = `j*(2n-j-1)/2 + (k-j-1)`.
8//! Count: n*(n-1)/2 * m variables.
9//! - `s_{j,i}` for all (j, i): integer start time of job j on machine i.
10//! Index: num_order_vars + j * m + i.
11//! Count: n * m variables.
12//! - `C` (makespan): integer, index num_order_vars + n * m.
13//!
14//! **Constraints:**
15//! 1. Binary bounds: 0 ≤ x_{j,k,i} ≤ 1 for all j < k, i.
16//! 2. Machine non-overlap for each pair (j, k) and machine i:
17//! - s_{k,i} ≥ s_{j,i} + p_{j,i} - M*(1 - x_{j,k,i}) → s_{k,i} - s_{j,i} + M*x_{j,k,i} ≥ p_{j,i}
18//! - s_{j,i} ≥ s_{k,i} + p_{k,i} - M*x_{j,k,i} → s_{j,i} - s_{k,i} - M*x_{j,k,i} ≥ p_{k,i} - M
19//! 3. Job non-overlap for each job j and each pair of machines (i, i'):
20//! Uses separate binary variable y_{j,i,i'} for i < i' to decide which task runs first.
21//! Variables y_{j,i,i'}: appended after s variables.
22//! - s_{j,i'} ≥ s_{j,i} + p_{j,i} - M*(1 - y_{j,i,i'})
23//! - s_{j,i} ≥ s_{j,i'} + p_{j,i'} - M*y_{j,i,i'}
24//! 4. Makespan: C ≥ s_{j,i} + p_{j,i} for all (j, i).
25//! 5. Non-negativity of start times: s_{j,i} ≥ 0 (implied by ILP non-negativity).
26//!
27//! **Objective:** Minimize C.
28
29use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP};
30use crate::models::misc::OpenShopScheduling;
31use crate::reduction;
32use crate::rules::traits::{ReduceTo, ReductionResult};
33
34/// Result of reducing OpenShopScheduling to ILP<i32>.
35///
36/// Variable layout:
37/// - `x_{j,k,i}` at index `pair_idx(j,k) * m + i` (num_pairs * m vars)
38/// - `s_{j,i}` at index `num_order_vars + j * m + i` (n * m vars)
39/// - `y_{j,i,i'}` for i < i': at `num_order_vars + n*m + j * num_machine_pairs + machine_pair_idx(i,i')`
40/// (n * m*(m-1)/2 vars)
41/// - `C`: at index `num_order_vars + n * m + n * m*(m-1)/2` (1 var)
42#[derive(Debug, Clone)]
43pub struct ReductionOSSToILP {
44 target: ILP<i32>,
45 num_jobs: usize,
46 num_machines: usize,
47 /// n*(n-1)/2 * m — start index of s_{j,i} variables
48 num_order_vars: usize,
49}
50
51impl ReductionOSSToILP {
52 fn pair_idx(&self, j: usize, k: usize) -> usize {
53 debug_assert!(j < k);
54 let n = self.num_jobs;
55 j * (2 * n - j - 1) / 2 + (k - j - 1)
56 }
57
58 fn x_var(&self, j: usize, k: usize, i: usize) -> usize {
59 self.pair_idx(j, k) * self.num_machines + i
60 }
61
62 fn s_var(&self, j: usize, i: usize) -> usize {
63 self.num_order_vars + j * self.num_machines + i
64 }
65
66 fn machine_pair_idx(&self, i: usize, ip: usize) -> usize {
67 debug_assert!(i < ip);
68 let m = self.num_machines;
69 i * (2 * m - i - 1) / 2 + (ip - i - 1)
70 }
71
72 fn y_var(&self, j: usize, i: usize, ip: usize) -> usize {
73 let num_machine_pairs = self.num_machines * self.num_machines.saturating_sub(1) / 2;
74 self.num_order_vars
75 + self.num_jobs * self.num_machines
76 + j * num_machine_pairs
77 + self.machine_pair_idx(i, ip)
78 }
79}
80
81impl ReductionResult for ReductionOSSToILP {
82 type Source = OpenShopScheduling;
83 type Target = ILP<i32>;
84
85 fn target_problem(&self) -> &ILP<i32> {
86 &self.target
87 }
88
89 /// Extract per-machine job orderings from the ILP start times, then
90 /// convert to the config format (direct permutation indices per machine).
91 fn extract_solution(&self, target_solution: &[usize]) -> Vec<usize> {
92 let n = self.num_jobs;
93 let m = self.num_machines;
94
95 // Read start times s_{j,i} for each (j, i)
96 let start = |j: usize, i: usize| -> usize {
97 let idx = self.num_order_vars + j * m + i;
98 target_solution.get(idx).copied().unwrap_or(0)
99 };
100
101 // For each machine, sort jobs by their start time on that machine
102 let mut config = Vec::with_capacity(n * m);
103 for i in 0..m {
104 let mut jobs: Vec<usize> = (0..n).collect();
105 jobs.sort_by_key(|&j| (start(j, i), j));
106 config.extend(jobs);
107 }
108 config
109 }
110}
111
112#[reduction(overhead = {
113 num_vars = "num_jobs * (num_jobs - 1) / 2 * num_machines + num_jobs * num_machines + num_jobs * num_machines * (num_machines - 1) / 2 + 1",
114 num_constraints = "num_jobs * (num_jobs - 1) / 2 * num_machines + num_jobs * num_machines + 1 + 2 * num_jobs * (num_jobs - 1) / 2 * num_machines + num_jobs * num_machines * (num_machines - 1) / 2 + 2 * num_jobs * num_machines * (num_machines - 1) / 2 + num_jobs * num_machines",
115})]
116impl ReduceTo<ILP<i32>> for OpenShopScheduling {
117 type Result = ReductionOSSToILP;
118
119 fn reduce_to(&self) -> Self::Result {
120 let n = self.num_jobs();
121 let m = self.num_machines();
122 let p = self.processing_times();
123
124 let num_pairs = n * n.saturating_sub(1) / 2;
125 let num_machine_pairs = m * m.saturating_sub(1) / 2;
126
127 // Variable counts
128 let num_order_vars = num_pairs * m; // x_{j,k,i}: binary
129 let num_start_vars = n * m; // s_{j,i}: integer
130 let num_job_pair_vars = n * num_machine_pairs; // y_{j,i,i'}: binary
131 let num_vars = num_order_vars + num_start_vars + num_job_pair_vars + 1; // +1 for C
132
133 let result = ReductionOSSToILP {
134 target: ILP::new(0, vec![], vec![], ObjectiveSense::Minimize),
135 num_jobs: n,
136 num_machines: m,
137 num_order_vars,
138 };
139
140 // Big-M: sum of all processing times (loose upper bound on makespan)
141 let total_p: usize = p.iter().flat_map(|row| row.iter()).sum();
142 let big_m = total_p as f64;
143
144 let c_var = num_order_vars + num_start_vars + num_job_pair_vars;
145
146 let mut constraints = Vec::new();
147
148 // 1. Binary bounds on x_{j,k,i}: 0 ≤ x ≤ 1
149 for j in 0..n {
150 for k in (j + 1)..n {
151 for i in 0..m {
152 let x = result.x_var(j, k, i);
153 constraints.push(LinearConstraint::le(vec![(x, 1.0)], 1.0));
154 }
155 }
156 }
157
158 // Upper bounds on start time variables: s_{j,i} ≤ total_p
159 // (no task can start after all tasks have finished)
160 for j in 0..n {
161 for i in 0..m {
162 let sji = result.s_var(j, i);
163 constraints.push(LinearConstraint::le(vec![(sji, 1.0)], big_m));
164 }
165 }
166
167 // Upper bound on makespan C ≤ total_p
168 constraints.push(LinearConstraint::le(vec![(c_var, 1.0)], big_m));
169
170 // 2. Machine non-overlap: for each pair (j,k) with j<k, each machine i
171 // x_{j,k,i}=1 means j precedes k on machine i:
172 // s_{k,i} ≥ s_{j,i} + p_{j,i} → s_{k,i} - s_{j,i} + M*x_{j,k,i} ≥ p_{j,i} (active when x=0)
173 // Actually: s_{k,i} ≥ s_{j,i} + p_{j,i} - M*(1 - x_{j,k,i})
174 // ⟺ s_{k,i} - s_{j,i} - M*x_{j,k,i} ≥ p_{j,i} - M
175 // And: s_{j,i} ≥ s_{k,i} + p_{k,i} - M*x_{j,k,i}
176 // ⟺ s_{j,i} - s_{k,i} + M*x_{j,k,i} ≥ p_{k,i} (active when x=1, i.e. k before j)
177 // Wait, let's be careful. x=1 means j before k.
178 // (a) if j before k: s_k ≥ s_j + p_{j,i} → when x=1 this is active, when x=0 inactive
179 // (b) if k before j (x=0): s_j ≥ s_k + p_{k,i}
180 //
181 // Linearization:
182 // (a) s_{k,i} - s_{j,i} + M*(1-x) ≥ p_{j,i}
183 // s_{k,i} - s_{j,i} - M*x ≥ p_{j,i} - M
184 // (b) s_{j,i} - s_{k,i} + M*x ≥ p_{k,i}
185 for j in 0..n {
186 for k in (j + 1)..n {
187 for (i, (&pji_val, &pki_val)) in p[j].iter().zip(p[k].iter()).enumerate() {
188 let x = result.x_var(j, k, i);
189 let sj = result.s_var(j, i);
190 let sk = result.s_var(k, i);
191 let pji = pji_val as f64;
192 let pki = pki_val as f64;
193
194 // (a) s_{k,i} - s_{j,i} - M*x_{j,k,i} >= p_{j,i} - M
195 constraints.push(LinearConstraint::ge(
196 vec![(sk, 1.0), (sj, -1.0), (x, -big_m)],
197 pji - big_m,
198 ));
199
200 // (b) s_{j,i} - s_{k,i} + M*x_{j,k,i} >= p_{k,i}
201 constraints.push(LinearConstraint::ge(
202 vec![(sj, 1.0), (sk, -1.0), (x, big_m)],
203 pki,
204 ));
205 }
206 }
207 }
208
209 // 3. Binary bounds on y_{j,i,i'}: 0 ≤ y ≤ 1
210 for j in 0..n {
211 for i in 0..m {
212 for ip in (i + 1)..m {
213 let y = result.y_var(j, i, ip);
214 constraints.push(LinearConstraint::le(vec![(y, 1.0)], 1.0));
215 }
216 }
217 }
218
219 // 4. Job non-overlap: for each job j and each pair (i, i') with i < i'
220 // y_{j,i,i'}=1 means machine i is scheduled before machine i' for job j:
221 // (a) s_{j,i'} ≥ s_{j,i} + p_{j,i} - M*(1-y)
222 // s_{j,i'} - s_{j,i} - M*y ≥ p_{j,i} - M
223 // (b) s_{j,i} ≥ s_{j,i'} + p_{j,i'} - M*y
224 // s_{j,i} - s_{j,i'} + M*y ≥ p_{j,i'}
225 for (j, pj) in p.iter().enumerate() {
226 for i in 0..m {
227 for ip in (i + 1)..m {
228 let y = result.y_var(j, i, ip);
229 let sji = result.s_var(j, i);
230 let sjip = result.s_var(j, ip);
231 let pji = pj[i] as f64;
232 let pjip = pj[ip] as f64;
233
234 // (a) s_{j,i'} - s_{j,i} - M*y >= p_{j,i} - M
235 constraints.push(LinearConstraint::ge(
236 vec![(sjip, 1.0), (sji, -1.0), (y, -big_m)],
237 pji - big_m,
238 ));
239
240 // (b) s_{j,i} - s_{j,i'} + M*y >= p_{j,i'}
241 constraints.push(LinearConstraint::ge(
242 vec![(sji, 1.0), (sjip, -1.0), (y, big_m)],
243 pjip,
244 ));
245 }
246 }
247 }
248
249 // 5. Makespan: C ≥ s_{j,i} + p_{j,i} ⟺ C - s_{j,i} ≥ p_{j,i}
250 for (j, pj) in p.iter().enumerate() {
251 for (i, &pji) in pj.iter().enumerate() {
252 let sji = result.s_var(j, i);
253 constraints.push(LinearConstraint::ge(
254 vec![(c_var, 1.0), (sji, -1.0)],
255 pji as f64,
256 ));
257 }
258 }
259
260 // Objective: minimize C
261 let objective = vec![(c_var, 1.0)];
262
263 ReductionOSSToILP {
264 target: ILP::new(num_vars, constraints, objective, ObjectiveSense::Minimize),
265 num_jobs: n,
266 num_machines: m,
267 num_order_vars,
268 }
269 }
270}
271
272#[cfg(feature = "example-db")]
273pub(crate) fn canonical_rule_example_specs() -> Vec<crate::example_db::specs::RuleExampleSpec> {
274 vec![crate::example_db::specs::RuleExampleSpec {
275 id: "openshopscheduling_to_ilp",
276 build: || {
277 // Small 2x2 instance for canonical example
278 let source = OpenShopScheduling::new(2, vec![vec![1, 2], vec![2, 1]]);
279 crate::example_db::specs::rule_example_via_ilp::<_, i32>(source)
280 },
281 }]
282}
283
284#[cfg(test)]
285#[path = "../unit_tests/rules/openshopscheduling_ilp.rs"]
286mod tests;