From 8c694602582aa79985e3233d6f7f960dd2a012dd Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Mon, 30 Mar 2026 04:03:32 +0800 Subject: [PATCH 1/6] feat: add MinimumInternalMacroDataCompression model (#442) Implement the internal macro data compression problem (GJ SR23) with direct ILP reduction, CLI support, canonical example, and paper entry. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/paper/reductions.typ | 77 +++++ problemreductions-cli/src/cli.rs | 1 + problemreductions-cli/src/commands/create.rs | 74 ++++- ...minimum_internal_macro_data_compression.rs | 255 +++++++++++++++ src/models/misc/mod.rs | 4 + src/models/mod.rs | 10 +- ...minimuminternalmacrodatacompression_ilp.rs | 305 ++++++++++++++++++ src/rules/mod.rs | 3 + ...minimum_internal_macro_data_compression.rs | 174 ++++++++++ ...minimuminternalmacrodatacompression_ilp.rs | 120 +++++++ 10 files changed, 1008 insertions(+), 15 deletions(-) create mode 100644 src/models/misc/minimum_internal_macro_data_compression.rs create mode 100644 src/rules/minimuminternalmacrodatacompression_ilp.rs create mode 100644 src/unit_tests/models/misc/minimum_internal_macro_data_compression.rs create mode 100644 src/unit_tests/rules/minimuminternalmacrodatacompression_ilp.rs diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index 00bb1bf3..b95445c9 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -197,6 +197,7 @@ "SteinerTree": [Steiner Tree], "SteinerTreeInGraphs": [Steiner Tree in Graphs], "MinimumExternalMacroDataCompression": [Minimum External Macro Data Compression], + "MinimumInternalMacroDataCompression": [Minimum Internal Macro Data Compression], "StringToStringCorrection": [String-to-String Correction], "StrongConnectivityAugmentation": [Strong Connectivity Augmentation], "SubgraphIsomorphism": [Subgraph Isomorphism], @@ -5066,6 +5067,82 @@ A classical NP-complete problem from Garey and Johnson @garey1979[Ch.~3, p.~76], _Solution extraction._ Read $D$ from the $d_(j,c)$ indicators. Walk through the active segments (via $ell_i$ and $p_(i,lambda,delta)$) to reconstruct $C$. ] +#{ + let x = load-model-example("MinimumInternalMacroDataCompression") + let alpha-size = x.instance.alphabet_size + let s = x.instance.string + let n = s.len() + let h = x.instance.pointer_cost + let alpha-map = range(alpha-size).map(i => str.from-unicode(97 + i)) + let s-str = s.map(c => alpha-map.at(c)).join("") + let opt-val = metric-value(x.optimal_value) + [ + #problem-def("MinimumInternalMacroDataCompression")[ + Given a finite alphabet $Sigma$ of size $k$, a string $s in Sigma^*$ of length $n$, and a pointer cost $h in ZZ^+$, find a single compressed string $C in (Sigma union {"pointers"})^*$ such that $s$ can be obtained from $C$ by resolving all pointer references _within $C$ itself_ (left-to-right), minimizing the total cost $|C| + (h - 1) times$ (number of pointer occurrences in $C$). + ][ + A classical NP-hard data compression problem, listed as SR23 in Garey and Johnson @garey1979. Unlike the external variant (@def:MinimumExternalMacroDataCompression), there is no separate dictionary --- the compressed string $C$ serves as both dictionary and output, with pointers referencing substrings within $C$ itself. #cite(, form: "prose") proved NP-completeness via transformation from Vertex Cover. #cite(, form: "prose") showed that NP-completeness persists even when $h$ is any fixed integer $gt.eq 2$. The internal macro model is closely related to the smallest grammar problem, which is APX-hard @charikar2005.#footnote[No algorithm improving on brute-force enumeration is known for optimal internal macro compression.] + + *Example.* Let $Sigma = {#alpha-map.join(", ")}$ and $s = #s-str$ (length #n) with pointer cost $h = #h$. + + #pred-commands( + "pred create --example MinimumInternalMacroDataCompression -o min-imdc.json", + "pred solve min-imdc.json", + "pred evaluate min-imdc.json --config " + x.optimal_config.map(str).join(","), + ) + + #figure({ + let blue = graph-colors.at(0) + let green = graph-colors.at(1) + let cell(ch, highlight: false, ptr: false) = { + let fill = if ptr { green.transparentize(70%) } else if highlight { blue.transparentize(70%) } else { white } + box(width: 0.5cm, height: 0.55cm, fill: fill, stroke: 0.5pt + luma(120), + align(center + horizon, text(8pt, weight: "bold", ch))) + } + let ptr-cell(label) = { + box(width: 1.5cm, height: 0.55cm, fill: green.transparentize(70%), stroke: 0.5pt + luma(120), + align(center + horizon, text(7pt, weight: "bold", label))) + } + // Source string + // C = [a, b, c, ptr(0), ptr(0), EOS, ...] + align(center, stack(dir: ttb, spacing: 0.5cm, + // Source string + stack(dir: ltr, spacing: 0pt, + box(width: 1.5cm, height: 0.5cm, align(right + horizon, text(8pt)[$s: quad$])), + ..s.map(c => cell(alpha-map.at(c))), + ), + // Compressed string C + stack(dir: ltr, spacing: 0pt, + box(width: 1.5cm, height: 0.5cm, align(right + horizon, text(8pt)[$C: quad$])), + ..range(alpha-size).map(c => cell(alpha-map.at(c), highlight: true)), + ptr-cell[$arrow.r C[0..#alpha-size]$], + ptr-cell[$arrow.r C[0..#alpha-size]$], + ), + )) + }, + caption: [Minimum Internal Macro Data Compression: with $s = #s-str$ (length #n) and pointer cost $h = #h$, the optimal self-referencing compression $C$ starts with #alpha-size literals, then uses 2 pointers back to $C[0..#alpha-size]$, achieving cost $5 + (#h - 1) times 2 = #opt-val$ vs.~uncompressed cost #n.], + ) + + The compressed string $C$ has #alpha-size literal symbols followed by 2 pointers, each referencing $C[0..#alpha-size]$ to copy "#alpha-map.join("")". Each pointer costs $h = #h$ (the pointer token plus $h - 1 = #(h - 1)$ extra), so the total cost is $|C| + (h - 1) times |"pointers"| = 5 + #(h - 1) times 2 = #opt-val$, saving $#(n - int(opt-val))$ over the uncompressed cost of #n. + ] + ] +} + +#reduction-rule("MinimumInternalMacroDataCompression", "ILP")[ + The self-referencing compression problem is formulated as a binary ILP. Since there is no separate dictionary, only the string partition structure needs to be modeled. The partition is expressed as a flow on a DAG whose nodes are string positions and whose arcs are candidate segments. +][ + _Construction._ For alphabet $Sigma$ of size $k$, string $s$ of length $n$, and pointer cost $h$: + + _Variables:_ (1) Binary $ell_i in {0,1}$ for each string position $i in {0, dots, n-1}$: $ell_i = 1$ iff position $i$ is covered by a literal. (2) Binary $p_(i,lambda,r) in {0,1}$ for each valid triple $(i, lambda, r)$ where $r + lambda <= i$ and $s[r..r+lambda) = s[i..i+lambda)$: $p_(i,lambda,r) = 1$ iff positions $[i, i+lambda)$ are covered by a pointer referencing the decoded output starting at source position $r$. + + _Constraints:_ Partition flow: the segments form a partition of ${0, dots, n-1}$ via flow conservation on nodes $0, dots, n$. The string-matching constraint ($s[r..r+lambda) = s[i..i+lambda)$) and the precedence constraint ($r + lambda <= i$) are enforced structurally by only generating valid triples. + + _Objective:_ Minimize $sum_i ell_i + h sum_(i,lambda,r) p_(i,lambda,r)$. + + _Correctness._ ($arrow.r.double$) An optimal compressed string $C$ determines a feasible ILP assignment: activate the literal or pointer variable for each segment in the partition. The flow is satisfied by construction. ($arrow.l.double$) Any feasible ILP solution defines a valid partition of $s$ into literal and pointer segments with cost equal to the objective. + + _Solution extraction._ Walk through the active segments (via $ell_i$ and $p_(i,lambda,r)$) to reconstruct $C$, mapping source reference positions to compressed-string positions. +] + #{ let x = load-model-example("MinimumFeedbackArcSet") let nv = x.instance.graph.num_vertices diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index 8a134445..a20f5321 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -315,6 +315,7 @@ Flags by problem type: SequencingToMinimizeWeightedCompletionTime --lengths, --weights [--precedence-pairs] SequencingToMinimizeWeightedTardiness --sizes, --weights, --deadlines, --bound MinimumExternalMacroDataCompression --string, --pointer-cost [--alphabet-size] + MinimumInternalMacroDataCompression --string, --pointer-cost [--alphabet-size] SCS --strings [--alphabet-size] StringToStringCorrection --source-string, --target-string, --bound [--alphabet-size] D2CIF --arcs, --capacities, --source-1, --sink-1, --source-2, --sink-2, --requirement-1, --requirement-2 diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index f766e542..0276d363 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -25,14 +25,15 @@ use problemreductions::models::misc::{ ConjunctiveBooleanQuery, ConsistencyOfDatabaseFrequencyTables, EnsembleComputation, ExpectedRetrievalCost, FlowShopScheduling, FrequencyTable, GroupingBySwapping, IntExpr, IntegerExpressionMembership, JobShopScheduling, KnownValue, KthLargestMTuple, - LongestCommonSubsequence, MinimumExternalMacroDataCompression, MinimumTardinessSequencing, - MultiprocessorScheduling, PaintShop, PartiallyOrderedKnapsack, ProductionPlanning, QueryArg, + LongestCommonSubsequence, MinimumExternalMacroDataCompression, + MinimumInternalMacroDataCompression, MinimumTardinessSequencing, MultiprocessorScheduling, + PaintShop, PartiallyOrderedKnapsack, ProductionPlanning, QueryArg, RectilinearPictureCompression, RegisterSufficiency, ResourceConstrainedScheduling, - SchedulingToMinimizeWeightedCompletionTime, - SchedulingWithIndividualDeadlines, SequencingToMinimizeMaximumCumulativeCost, - SequencingToMinimizeWeightedCompletionTime, SequencingToMinimizeWeightedTardiness, - SequencingWithReleaseTimesAndDeadlines, SequencingWithinIntervals, ShortestCommonSupersequence, - StringToStringCorrection, SubsetSum, SumOfSquaresPartition, ThreePartition, TimetableDesign, + SchedulingToMinimizeWeightedCompletionTime, SchedulingWithIndividualDeadlines, + SequencingToMinimizeMaximumCumulativeCost, SequencingToMinimizeWeightedCompletionTime, + SequencingToMinimizeWeightedTardiness, SequencingWithReleaseTimesAndDeadlines, + SequencingWithinIntervals, ShortestCommonSupersequence, StringToStringCorrection, SubsetSum, + SumOfSquaresPartition, ThreePartition, TimetableDesign, }; use problemreductions::models::BiconnectivityAugmentation; use problemreductions::prelude::*; @@ -759,7 +760,7 @@ fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str { "--strings \"010110;100101;001011\" --alphabet-size 2" } "GroupingBySwapping" => "--string \"0,1,2,0,1,2\" --bound 5", - "MinimumExternalMacroDataCompression" => { + "MinimumExternalMacroDataCompression" | "MinimumInternalMacroDataCompression" => { "--string \"0,1,0,1\" --pointer-cost 2 --alphabet-size 2" } "MinimumCardinalityKey" => { @@ -897,8 +898,10 @@ fn help_flag_hint( "raw strings: \"ABAC;BACA\" or symbol lists: \"0,1,0;1,0,1\"" } ("GroupingBySwapping", "string") => "symbol list: \"0,1,2,0,1,2\"", - ("MinimumExternalMacroDataCompression", "string") => "symbol list: \"0,1,0,1\"", - ("MinimumExternalMacroDataCompression", "pointer_cost") => "positive integer: 2", + ("MinimumExternalMacroDataCompression", "string") + | ("MinimumInternalMacroDataCompression", "string") => "symbol list: \"0,1,0,1\"", + ("MinimumExternalMacroDataCompression", "pointer_cost") + | ("MinimumInternalMacroDataCompression", "pointer_cost") => "positive integer: 2", ("ShortestCommonSupersequence", "strings") => "symbol lists: \"0,1,2;1,2,0\"", ("MultipleChoiceBranching", "partition") => "semicolon-separated groups: \"0,1;2,3\"", ("IntegralFlowHomologousArcs", "homologous_pairs") => { @@ -3215,6 +3218,57 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { ) } + // MinimumInternalMacroDataCompression + "MinimumInternalMacroDataCompression" => { + let usage = "Usage: pred create MinimumInternalMacroDataCompression --string \"0,1,0,1\" --pointer-cost 2 [--alphabet-size 2]"; + let string_str = args.string.as_deref().ok_or_else(|| { + anyhow::anyhow!("MinimumInternalMacroDataCompression requires --string\n\n{usage}") + })?; + let pointer_cost = args.pointer_cost.ok_or_else(|| { + anyhow::anyhow!( + "MinimumInternalMacroDataCompression requires --pointer-cost\n\n{usage}" + ) + })?; + anyhow::ensure!( + pointer_cost > 0, + "--pointer-cost must be a positive integer\n\n{usage}" + ); + + let string: Vec = if string_str.trim().is_empty() { + Vec::new() + } else { + string_str + .split(',') + .map(|value| { + value + .trim() + .parse::() + .context("invalid symbol index") + }) + .collect::>>()? + }; + let inferred = string.iter().copied().max().map_or(0, |value| value + 1); + let alphabet_size = args.alphabet_size.unwrap_or(inferred); + anyhow::ensure!( + alphabet_size >= inferred, + "--alphabet-size {} is smaller than max symbol + 1 ({}) in the input string", + alphabet_size, + inferred + ); + anyhow::ensure!( + alphabet_size > 0 || string.is_empty(), + "MinimumInternalMacroDataCompression requires a positive alphabet for non-empty strings.\n\n{usage}" + ); + ( + ser(MinimumInternalMacroDataCompression::new( + alphabet_size, + string, + pointer_cost, + ))?, + resolved_variant.clone(), + ) + } + // ClosestVectorProblem "ClosestVectorProblem" => { let basis_str = args.basis.as_deref().ok_or_else(|| { diff --git a/src/models/misc/minimum_internal_macro_data_compression.rs b/src/models/misc/minimum_internal_macro_data_compression.rs new file mode 100644 index 00000000..15f76309 --- /dev/null +++ b/src/models/misc/minimum_internal_macro_data_compression.rs @@ -0,0 +1,255 @@ +//! Minimum Internal Macro Data Compression problem implementation. +//! +//! Given an alphabet Σ, a string s ∈ Σ*, and a pointer cost h, +//! find a single compressed string C ∈ (Σ ∪ {pointers})* minimizing the cost +//! |C| + (h−1) × (number of pointer occurrences in C), +//! such that s can be obtained from C by resolving all pointer references +//! within C itself (left-to-right, greedy longest match). +//! +//! Unlike external macro compression, there is no separate dictionary — the +//! compressed string C serves as both dictionary and output. +//! +//! This problem is NP-hard (Storer, 1977; Storer & Szymanski, 1978). +//! Reference: Garey & Johnson A4 SR23. + +use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::traits::Problem; +use crate::types::Min; +use serde::{Deserialize, Serialize}; + +inventory::submit! { + ProblemSchemaEntry { + name: "MinimumInternalMacroDataCompression", + display_name: "Minimum Internal Macro Data Compression", + aliases: &[], + dimensions: &[], + module_path: module_path!(), + description: "Find minimum-cost self-referencing compression of a string with embedded pointers", + fields: &[ + FieldInfo { name: "alphabet_size", type_name: "usize", description: "Size of the alphabet (symbols indexed 0..alphabet_size)" }, + FieldInfo { name: "string", type_name: "Vec", description: "Source string as symbol indices" }, + FieldInfo { name: "pointer_cost", type_name: "usize", description: "Pointer cost h (each pointer adds h−1 extra to the cost)" }, + ], + } +} + +/// Minimum Internal Macro Data Compression problem. +/// +/// Given an alphabet of size `k`, a string `s` over `{0, ..., k-1}`, and +/// a pointer cost `h`, find a compressed string C that minimizes +/// cost = |C| + (h−1) × (pointer count in C), where C uses itself as both +/// dictionary and compressed output. +/// +/// # Representation +/// +/// The configuration is a vector of `string_len` entries. Each entry is: +/// - A symbol index in `{0, ..., alphabet_size-1}` (literal) +/// - `alphabet_size` (end-of-string marker; positions after this are padding) +/// - A value in `{alphabet_size+1, ..., alphabet_size + string_len}`, +/// encoding a pointer to C\[v − alphabet_size − 1\] with greedy longest match. +/// +/// During decoding, pointers are resolved left-to-right. A pointer at position +/// i referencing position j (where j < i in the decoded output) copies symbols +/// from the already-decoded output starting at j using greedy longest match. +/// +/// # Example +/// +/// ``` +/// use problemreductions::models::misc::MinimumInternalMacroDataCompression; +/// use problemreductions::{Problem, Solver, BruteForce}; +/// +/// // Alphabet {a, b}, string "abab", pointer cost h=2 +/// let problem = MinimumInternalMacroDataCompression::new(2, vec![0, 1, 0, 1], 2); +/// let solver = BruteForce::new(); +/// let solution = solver.find_witness(&problem); +/// assert!(solution.is_some()); +/// ``` +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MinimumInternalMacroDataCompression { + alphabet_size: usize, + string: Vec, + pointer_cost: usize, +} + +impl MinimumInternalMacroDataCompression { + /// Create a new MinimumInternalMacroDataCompression instance. + /// + /// # Panics + /// + /// Panics if `alphabet_size` is 0 and the string is non-empty, or if + /// any symbol in the string is >= `alphabet_size`, or if `pointer_cost` is 0. + pub fn new(alphabet_size: usize, string: Vec, pointer_cost: usize) -> Self { + assert!( + alphabet_size > 0 || string.is_empty(), + "alphabet_size must be > 0 when the string is non-empty" + ); + assert!( + string + .iter() + .all(|&s| s < alphabet_size || alphabet_size == 0), + "all symbols must be less than alphabet_size" + ); + assert!(pointer_cost > 0, "pointer_cost must be positive"); + Self { + alphabet_size, + string, + pointer_cost, + } + } + + /// Returns the length of the source string. + pub fn string_len(&self) -> usize { + self.string.len() + } + + /// Returns the alphabet size. + pub fn alphabet_size(&self) -> usize { + self.alphabet_size + } + + /// Returns the pointer cost h. + pub fn pointer_cost(&self) -> usize { + self.pointer_cost + } + + /// Returns the source string. + pub fn string(&self) -> &[usize] { + &self.string + } + + /// Decode the compressed string C and return the decoded string, + /// the active length of C, and the pointer count. + /// Returns None if decoding fails (invalid pointer, circular reference, etc.). + fn decode(&self, config: &[usize]) -> Option<(Vec, usize, usize)> { + let n = self.string.len(); + let k = self.alphabet_size; + let eos = k; // end-of-string marker + + // Find active length: prefix before first end-of-string marker + let active_len = config.iter().position(|&v| v == eos).unwrap_or(n); + + // Verify contiguous: all after first EOS must be EOS or padding + for &v in &config[active_len..] { + if v != eos { + return None; + } + } + + // Decode left-to-right. A pointer at compressed position c_idx + // referencing C[j] copies from the decoded output that existed + // before this pointer (no overlapping/runaway copy). + let mut decoded = Vec::new(); + let mut pointer_count: usize = 0; + + for &v in &config[..active_len] { + if v < k { + // Literal symbol + decoded.push(v); + } else if v > k { + // Pointer: references C[ref_pos] in the compressed string + let ref_pos = v - k - 1; + if ref_pos >= decoded.len() { + return None; // pointer references undecoded position + } + // Greedy longest match from decoded[ref_pos..copy_start] + // (only pre-existing decoded content, no overlapping copy) + let copy_start = decoded.len(); + let mut matched = 0; + while copy_start + matched < n { + let src_idx = ref_pos + matched; + if src_idx >= copy_start { + break; // cannot read beyond pre-existing content + } + if decoded[src_idx] != self.string[copy_start + matched] { + break; + } + decoded.push(decoded[src_idx]); + matched += 1; + } + if matched == 0 { + return None; // pointer must copy at least one symbol + } + pointer_count += 1; + } else { + // v == eos, but we filtered those out above + return None; + } + } + + Some((decoded, active_len, pointer_count)) + } +} + +impl Problem for MinimumInternalMacroDataCompression { + const NAME: &'static str = "MinimumInternalMacroDataCompression"; + type Value = Min; + + fn variant() -> Vec<(&'static str, &'static str)> { + crate::variant_params![] + } + + fn dims(&self) -> Vec { + let n = self.string.len(); + let domain = self.alphabet_size + n + 1; // literals + EOS + pointers + vec![domain; n] + } + + fn evaluate(&self, config: &[usize]) -> Min { + let n = self.string.len(); + if config.len() != n { + return Min(None); + } + + // Handle empty string + if n == 0 { + return Min(Some(0)); + } + + match self.decode(config) { + Some((decoded, active_len, pointer_count)) => { + if decoded != self.string { + Min(None) + } else { + let cost = active_len + (self.pointer_cost - 1) * pointer_count; + Min(Some(cost)) + } + } + None => Min(None), + } + } +} + +crate::declare_variants! { + default MinimumInternalMacroDataCompression => "(alphabet_size + string_len + 1) ^ string_len", +} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_model_example_specs() -> Vec { + // Issue #442 example: alphabet {a,b,c} (3), s="abcabcabc" (9), h=2 + // Optimal: C = [a, b, c, ptr(0), ptr(0), EOS, EOS, EOS, EOS] + // active_len = 5, pointers = 2 + // cost = 5 + (2-1)*2 = 7 + // + // Config encoding: + // alphabet_size = 3, string_len = 9, domain = 3+9+1 = 13 + // Literals: 0=a, 1=b, 2=c + // EOS: 3 + // Pointers: 4=ptr(C[0]), 5=ptr(C[1]), ... + let s: Vec = vec![0, 1, 2, 0, 1, 2, 0, 1, 2]; + let optimal_config = vec![ + 0, 1, 2, // literals a, b, c + 4, // ptr(C[0]) -> greedy "abc" + 4, // ptr(C[0]) -> greedy "abc" + 3, 3, 3, 3, // EOS padding + ]; + vec![crate::example_db::specs::ModelExampleSpec { + id: "minimum_internal_macro_data_compression", + instance: Box::new(MinimumInternalMacroDataCompression::new(3, s, 2)), + optimal_config, + optimal_value: serde_json::json!(7), + }] +} + +#[cfg(test)] +#[path = "../../unit_tests/models/misc/minimum_internal_macro_data_compression.rs"] +mod tests; diff --git a/src/models/misc/mod.rs b/src/models/misc/mod.rs index 1b8c1f05..ead9c9bc 100644 --- a/src/models/misc/mod.rs +++ b/src/models/misc/mod.rs @@ -17,6 +17,7 @@ //! - [`MultiprocessorScheduling`]: Schedule tasks on processors to meet a deadline //! - [`LongestCommonSubsequence`]: Longest Common Subsequence //! - [`MinimumExternalMacroDataCompression`]: Minimize compression cost using external dictionary +//! - [`MinimumInternalMacroDataCompression`]: Minimize self-referencing compression cost //! - [`MinimumTardinessSequencing`]: Minimize tardy tasks in single-machine scheduling //! - [`PaintShop`]: Minimize color switches in paint shop scheduling //! - [`CosineProductIntegration`]: Balanced sign assignment for integer frequencies @@ -84,6 +85,7 @@ mod knapsack; mod kth_largest_m_tuple; mod longest_common_subsequence; mod minimum_external_macro_data_compression; +mod minimum_internal_macro_data_compression; mod minimum_tardiness_sequencing; mod multiprocessor_scheduling; pub(crate) mod paintshop; @@ -131,6 +133,7 @@ pub use knapsack::Knapsack; pub use kth_largest_m_tuple::KthLargestMTuple; pub use longest_common_subsequence::LongestCommonSubsequence; pub use minimum_external_macro_data_compression::MinimumExternalMacroDataCompression; +pub use minimum_internal_macro_data_compression::MinimumInternalMacroDataCompression; pub use minimum_tardiness_sequencing::MinimumTardinessSequencing; pub use multiprocessor_scheduling::MultiprocessorScheduling; pub use paintshop::PaintShop; @@ -202,6 +205,7 @@ pub(crate) fn canonical_model_example_specs() -> Vec, + /// Total number of variables. + total_vars: usize, +} + +impl VarLayout { + fn new(n: usize, source_string: &[usize]) -> Self { + let lit_offset = 0; + let ptr_offset = lit_offset + n; + + // Enumerate all valid (i, l, r) triples where: + // - i is the start position in the source (0..n) + // - l is the segment length (1..n-i) + // - r is the reference position in the source (0..i), meaning + // the pointer copies from source[r..r+l] which must equal source[i..i+l] + // AND r < i (pointer references earlier decoded content) + let mut ptr_triples = Vec::new(); + for i in 0..n { + for l in 1..=(n - i) { + for r in 0..i { + // The pointer copies from decoded[r..r+l]. With non-overlapping + // semantics, decoded has exactly i characters before this pointer, + // so we need r + l <= i. + if r + l <= i + && r + l <= n + && source_string[r..r + l] == source_string[i..i + l] + { + ptr_triples.push((i, l, r)); + } + } + } + } + + let total_vars = ptr_offset + ptr_triples.len(); + Self { + n, + lit_offset, + ptr_offset, + ptr_triples, + total_vars, + } + } + + fn lit_var(&self, i: usize) -> usize { + self.lit_offset + i + } +} + +/// Result of reducing MinimumInternalMacroDataCompression to ILP. +#[derive(Debug, Clone)] +pub struct ReductionIMDCToILP { + target: ILP, + layout: VarLayout, + source_string: Vec, + alphabet_size: usize, +} + +impl ReductionResult for ReductionIMDCToILP { + type Source = MinimumInternalMacroDataCompression; + type Target = ILP; + + fn target_problem(&self) -> &ILP { + &self.target + } + + fn extract_solution(&self, target_solution: &[usize]) -> Vec { + let n = self.layout.n; + let k = self.alphabet_size; + let eos = k; // end-of-string marker + + // First pass: collect segments and build source-to-compressed-position map. + // source_to_c_pos[i] = compressed position that covers source position i. + let mut source_to_c_pos = vec![0usize; n]; + let mut segments: Vec<(usize, usize, Option)> = Vec::new(); // (source_start, len, ref_source_pos) + let mut c_pos = 0; + let mut pos = 0; + + while pos < n { + if target_solution[self.layout.lit_var(pos)] == 1 { + source_to_c_pos[pos] = c_pos; + segments.push((pos, 1, None)); + c_pos += 1; + pos += 1; + continue; + } + let mut found = false; + for (idx, &(i, l, r)) in self.layout.ptr_triples.iter().enumerate() { + if i == pos && target_solution[self.layout.ptr_offset + idx] == 1 { + for offset in 0..l { + source_to_c_pos[pos + offset] = c_pos; + } + segments.push((pos, l, Some(r))); + c_pos += 1; + pos += l; + found = true; + break; + } + } + if !found { + pos += 1; + } + } + + // Second pass: build config using source_to_c_pos for pointer references + let mut config = vec![eos; n]; + for (idx, &(src_start, _len, ref_pos)) in segments.iter().enumerate() { + match ref_pos { + None => { + config[idx] = self.source_string[src_start]; + } + Some(r) => { + // Pointer references source position r, which is at + // compressed position source_to_c_pos[r] + config[idx] = k + 1 + source_to_c_pos[r]; + } + } + } + + config + } +} + +#[reduction( + overhead = { + num_vars = "string_len + string_len ^ 3", + num_constraints = "string_len + 1 + string_len", + } +)] +impl ReduceTo> for MinimumInternalMacroDataCompression { + type Result = ReductionIMDCToILP; + + fn reduce_to(&self) -> Self::Result { + let n = self.string_len(); + let k = self.alphabet_size(); + let h = self.pointer_cost(); + let s = self.string(); + + // Handle empty string + if n == 0 { + let layout = VarLayout::new(0, s); + let target = ILP::new(0, vec![], vec![], ObjectiveSense::Minimize); + return ReductionIMDCToILP { + target, + layout, + source_string: vec![], + alphabet_size: k, + }; + } + + let layout = VarLayout::new(n, s); + let num_vars = layout.total_vars; + let mut constraints = Vec::new(); + + // Flow conservation on DAG: positions 0..n are nodes. + // A segment covers source positions [i, i+l). + // Segments: lit[i] covers [i, i+1), ptr[i][l][r] covers [i, i+l). + // + // Flow constraints: + // At node 0: sum of outgoing segments = 1 + // At node j (1..n-1): sum of incoming = sum of outgoing + // At node n: sum of incoming = 1 + + let segment_terms = |i: usize, l: usize| -> Vec<(usize, f64)> { + let mut terms = Vec::new(); + if l == 1 { + terms.push((layout.lit_var(i), 1.0)); + } + // All ptr variables for segment (i, l, *) + for (idx, &(pi, pl, _)) in layout.ptr_triples.iter().enumerate() { + if pi == i && pl == l { + terms.push((layout.ptr_offset + idx, 1.0)); + } + } + terms + }; + + for node in 0..=n { + let mut all_terms: Vec<(usize, f64)> = Vec::new(); + + if node == 0 { + for l in 1..=n { + all_terms.extend(segment_terms(0, l)); + } + constraints.push(LinearConstraint::eq(all_terms, 1.0)); + } else if node == n { + for j in 0..n { + let l = n - j; + all_terms.extend(segment_terms(j, l)); + } + constraints.push(LinearConstraint::eq(all_terms, 1.0)); + } else { + let mut incoming = Vec::new(); + for j in 0..node { + let l = node - j; + incoming.extend(segment_terms(j, l)); + } + let mut outgoing = Vec::new(); + for l in 1..=(n - node) { + outgoing.extend(segment_terms(node, l)); + } + for (var, coef) in incoming { + all_terms.push((var, coef)); + } + for (var, coef) in outgoing { + all_terms.push((var, -coef)); + } + constraints.push(LinearConstraint::eq(all_terms, 0.0)); + } + } + + // Pointer precedence: for ptr[i][l][r], we need r < i (already enforced + // by the triple enumeration). Additionally, the content at source[r..r+l] + // must equal source[i..i+l] (also enforced by triple enumeration). + // No additional constraints needed since we pre-filtered valid triples. + + // Objective: minimize literals + h * pointers + // = sum lit[i] + h * sum ptr[i][l][r] + // Since each literal contributes 1 to |C| and each pointer contributes + // 1 to |C| plus (h-1) to the pointer penalty: + // cost = |C| + (h-1)*pointers = (lits + ptrs) + (h-1)*ptrs = lits + h*ptrs + let mut objective: Vec<(usize, f64)> = Vec::new(); + for i in 0..n { + objective.push((layout.lit_var(i), 1.0)); + } + for (idx, _) in layout.ptr_triples.iter().enumerate() { + objective.push((layout.ptr_offset + idx, h as f64)); + } + + let target = ILP::new(num_vars, constraints, objective, ObjectiveSense::Minimize); + + ReductionIMDCToILP { + target, + layout, + source_string: s.to_vec(), + alphabet_size: k, + } + } +} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_rule_example_specs() -> Vec { + use crate::export::SolutionPair; + + // s = "ab" (len 2), alphabet {a,b} (size 2), h=2 + // Optimal: uncompressed C="ab", cost = 2 + // ILP: lit[0]=1, lit[1]=1, no pointers + vec![crate::example_db::specs::RuleExampleSpec { + id: "minimuminternalmacrodatacompression_to_ilp", + build: || { + let source = MinimumInternalMacroDataCompression::new(2, vec![0, 1], 2); + let reduction = ReduceTo::>::reduce_to(&source); + let layout = &reduction.layout; + + let mut target_config = vec![0usize; layout.total_vars]; + target_config[layout.lit_var(0)] = 1; + target_config[layout.lit_var(1)] = 1; + + let source_config = reduction.extract_solution(&target_config); + + crate::example_db::specs::rule_example_with_witness::<_, ILP>( + source, + SolutionPair { + source_config, + target_config, + }, + ) + }, + }] +} + +#[cfg(test)] +#[path = "../unit_tests/rules/minimuminternalmacrodatacompression_ilp.rs"] +mod tests; diff --git a/src/rules/mod.rs b/src/rules/mod.rs index abb9e1fb..a9a4cc73 100644 --- a/src/rules/mod.rs +++ b/src/rules/mod.rs @@ -162,6 +162,8 @@ pub(crate) mod minimumfeedbackvertexset_ilp; #[cfg(feature = "ilp-solver")] pub(crate) mod minimumhittingset_ilp; #[cfg(feature = "ilp-solver")] +pub(crate) mod minimuminternalmacrodatacompression_ilp; +#[cfg(feature = "ilp-solver")] pub(crate) mod minimummultiwaycut_ilp; #[cfg(feature = "ilp-solver")] pub(crate) mod minimumsetcovering_ilp; @@ -349,6 +351,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec::NAME, + "MinimumInternalMacroDataCompression" + ); + assert_eq!( + ::variant(), + vec![] + ); + // dims: 9 slots, domain = 3 + 9 + 1 = 13 + let dims = problem.dims(); + assert_eq!(dims.len(), 9); + assert!(dims.iter().all(|&d| d == 13)); +} + +#[test] +fn test_minimum_internal_macro_data_compression_evaluate_uncompressed() { + // alphabet {a, b}, s = "ab", h = 2 + let problem = MinimumInternalMacroDataCompression::new(2, vec![0, 1], 2); + // Uncompressed: C = [a, b] = [0, 1] + // active_len = 2, pointers = 0 + // cost = 2 + 0 = 2 + assert_eq!(problem.evaluate(&[0, 1]), Min(Some(2))); +} + +#[test] +fn test_minimum_internal_macro_data_compression_evaluate_with_pointer() { + // alphabet {a, b}, s = "abab", h = 2 + let problem = MinimumInternalMacroDataCompression::new(2, vec![0, 1, 0, 1], 2); + // C = [a, b, ptr(0), EOS] = [0, 1, 3, 2] + // ptr(0) at position 2: refs decoded[0] = 'a', greedy match: 'a','b' = "ab" + // decoded = "abab" = s + // active_len = 3, pointers = 1 + // cost = 3 + (2-1)*1 = 4 + assert_eq!(problem.evaluate(&[0, 1, 3, 2]), Min(Some(4))); +} + +#[test] +fn test_minimum_internal_macro_data_compression_evaluate_invalid_decode() { + // alphabet {a, b}, s = "ab", h = 2 + let problem = MinimumInternalMacroDataCompression::new(2, vec![0, 1], 2); + // C = [b, a] decodes to "ba" != "ab" + assert_eq!(problem.evaluate(&[1, 0]), Min(None)); +} + +#[test] +fn test_minimum_internal_macro_data_compression_evaluate_wrong_length() { + let problem = MinimumInternalMacroDataCompression::new(2, vec![0, 1], 2); + assert_eq!(problem.evaluate(&[0]), Min(None)); + assert_eq!(problem.evaluate(&[0, 1, 0]), Min(None)); +} + +#[test] +fn test_minimum_internal_macro_data_compression_evaluate_interleaved_eos() { + // EOS then non-EOS is invalid + let problem = MinimumInternalMacroDataCompression::new(2, vec![0, 1], 2); + // config = [EOS, a] = [2, 0] + assert_eq!(problem.evaluate(&[2, 0]), Min(None)); +} + +#[test] +fn test_minimum_internal_macro_data_compression_evaluate_pointer_forward_ref() { + // alphabet {a, b}, s = "ab", h = 2 + let problem = MinimumInternalMacroDataCompression::new(2, vec![0, 1], 2); + // C = [ptr(0)] -> pointer at first position references decoded[0], but nothing decoded yet + // ptr(C[0]) encoded as 3 (alphabet_size + 1 + 0 = 2+1+0 = 3) + assert_eq!(problem.evaluate(&[3, 2]), Min(None)); +} + +#[test] +fn test_minimum_internal_macro_data_compression_empty_string() { + let problem = MinimumInternalMacroDataCompression::new(2, vec![], 2); + assert_eq!(problem.dims(), Vec::::new()); + assert_eq!(problem.evaluate(&[]), Min(Some(0))); +} + +#[test] +fn test_minimum_internal_macro_data_compression_brute_force_simple() { + // alphabet {a, b}, s = "ab", h = 2 + // Only valid compression is uncompressed [0, 1], cost = 2 + let problem = MinimumInternalMacroDataCompression::new(2, vec![0, 1], 2); + let solver = BruteForce::new(); + let witness = solver + .find_witness(&problem) + .expect("should find a solution"); + let val = problem.evaluate(&witness); + assert_eq!(val, Min(Some(2))); +} + +#[test] +fn test_minimum_internal_macro_data_compression_brute_force_repeated() { + // alphabet {a, b}, s = "abab", h = 2 + // domain = 2+4+1 = 7, 7^4 = 2401 configs (feasible) + let problem = MinimumInternalMacroDataCompression::new(2, vec![0, 1, 0, 1], 2); + let solver = BruteForce::new(); + let witness = solver + .find_witness(&problem) + .expect("should find a solution"); + let val = problem.evaluate(&witness); + assert!(val.0.is_some()); + // Optimal: C = [a, b, ptr(0), EOS] -> cost = 3 + 1 = 4 + // Or uncompressed: cost = 4 + 0 = 4 (same) + assert_eq!(val.0.unwrap(), 4); +} + +#[test] +fn test_minimum_internal_macro_data_compression_solve_aggregate() { + use crate::solvers::Solver; + let problem = MinimumInternalMacroDataCompression::new(2, vec![0, 1], 2); + let solver = BruteForce::new(); + let val = solver.solve(&problem); + assert_eq!(val, Min(Some(2))); +} + +#[test] +fn test_minimum_internal_macro_data_compression_serialization() { + let problem = MinimumInternalMacroDataCompression::new(3, vec![0, 1, 2], 2); + let json = serde_json::to_value(&problem).unwrap(); + let restored: MinimumInternalMacroDataCompression = serde_json::from_value(json).unwrap(); + assert_eq!(restored.alphabet_size(), problem.alphabet_size()); + assert_eq!(restored.string(), problem.string()); + assert_eq!(restored.pointer_cost(), problem.pointer_cost()); +} + +#[test] +fn test_minimum_internal_macro_data_compression_paper_example() { + // Issue example: alphabet {a,b,c} (3), s="abcabcabc" (9), h=2 + // Optimal: C = [a, b, c, ptr(0), ptr(0), EOS, EOS, EOS, EOS] + // active_len=5, pointers=2, cost = 5 + 1*2 = 7 + let problem = MinimumInternalMacroDataCompression::new(3, vec![0, 1, 2, 0, 1, 2, 0, 1, 2], 2); + let config = vec![0, 1, 2, 4, 4, 3, 3, 3, 3]; + // ptr(C[0]) = alphabet_size + 1 + 0 = 3 + 1 + 0 = 4 + let val = problem.evaluate(&config); + assert_eq!(val, Min(Some(7))); +} + +#[test] +fn test_minimum_internal_macro_data_compression_find_all_witnesses() { + // alphabet {a}, s = "a", h = 2 + // domain = 1+1+1 = 3, 3^1 = 3 configs + let problem = MinimumInternalMacroDataCompression::new(1, vec![0], 2); + let solver = BruteForce::new(); + let solutions = solver.find_all_witnesses(&problem); + // Only valid: [0] (literal 'a'), cost = 1 + assert_eq!(solutions.len(), 1); + assert_eq!(solutions[0], vec![0]); +} + +#[test] +fn test_minimum_internal_macro_data_compression_pointer_doubling() { + // alphabet {a}, s = "aaaa", h = 1 + // No overlapping copy: each ptr copies from pre-existing decoded content. + // C = [a, ptr(0), ptr(0), EOS] = [0, 2, 2, 1] + // - pos 0: literal 'a', decoded=[0] + // - pos 1: ptr(0), copy decoded[0..1]="a" (1 char), decoded=[0,0] + // - pos 2: ptr(0), copy decoded[0..2]="aa" (2 chars), decoded=[0,0,0,0] + // decoded = "aaaa" = s + // active_len = 3, pointers = 2, cost = 3 + 0*2 = 3 + let problem = MinimumInternalMacroDataCompression::new(1, vec![0, 0, 0, 0], 1); + let config = vec![0, 2, 2, 1]; // a, ptr(0), ptr(0), EOS + let val = problem.evaluate(&config); + assert_eq!(val, Min(Some(3))); +} diff --git a/src/unit_tests/rules/minimuminternalmacrodatacompression_ilp.rs b/src/unit_tests/rules/minimuminternalmacrodatacompression_ilp.rs new file mode 100644 index 00000000..39a64245 --- /dev/null +++ b/src/unit_tests/rules/minimuminternalmacrodatacompression_ilp.rs @@ -0,0 +1,120 @@ +use crate::models::algebraic::ILP; +use crate::models::misc::MinimumInternalMacroDataCompression; +use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::solvers::{BruteForce, Solver}; +use crate::traits::Problem; +use crate::types::Min; + +#[test] +fn test_imdc_to_ilp_closed_loop_simple() { + // s = "ab", alphabet {a,b}, h=2 + // Optimal: uncompressed, cost=2 + let source = MinimumInternalMacroDataCompression::new(2, vec![0, 1], 2); + let reduction = ReduceTo::>::reduce_to(&source); + let target = reduction.target_problem(); + + let solver = BruteForce::new(); + let target_witness = solver.find_witness(target).expect("ILP should be feasible"); + let source_config = reduction.extract_solution(&target_witness); + let val = source.evaluate(&source_config); + assert!(val.0.is_some()); + assert_eq!(val.0.unwrap(), 2); +} + +#[test] +fn test_imdc_to_ilp_closed_loop_repeated() { + // s = "abab", alphabet {a,b}, h=2 + // Optimal: cost=4 (uncompressed or pointer, both cost 4) + let source = MinimumInternalMacroDataCompression::new(2, vec![0, 1, 0, 1], 2); + let reduction = ReduceTo::>::reduce_to(&source); + let target = reduction.target_problem(); + + let solver = BruteForce::new(); + let target_witness = solver.find_witness(target).expect("ILP should be feasible"); + let source_config = reduction.extract_solution(&target_witness); + let val = source.evaluate(&source_config); + assert!(val.0.is_some()); + assert_eq!(val.0.unwrap(), 4); +} + +#[test] +fn test_imdc_to_ilp_closed_loop_low_pointer_cost() { + // s = "abab", alphabet {a,b}, h=1 + // With h=1, pointers cost 0 extra: cost = |C| + // Optimal with pointer: C=[a,b,ptr(0)], active=3, ptrs=1, cost=3+0=3 + let source = MinimumInternalMacroDataCompression::new(2, vec![0, 1, 0, 1], 1); + let reduction = ReduceTo::>::reduce_to(&source); + let target = reduction.target_problem(); + + let solver = BruteForce::new(); + let target_witness = solver.find_witness(target).expect("ILP should be feasible"); + let source_config = reduction.extract_solution(&target_witness); + let val = source.evaluate(&source_config); + assert!(val.0.is_some()); + // Verify against brute force + let bf_val = BruteForce::new().solve(&source); + assert_eq!(val, bf_val); +} + +#[test] +fn test_imdc_to_ilp_empty_string() { + let source = MinimumInternalMacroDataCompression::new(2, vec![], 2); + let reduction = ReduceTo::>::reduce_to(&source); + let target = reduction.target_problem(); + assert_eq!(target.num_variables(), 0); + let source_config = reduction.extract_solution(&[]); + assert_eq!(source.evaluate(&source_config), Min(Some(0))); +} + +#[test] +fn test_imdc_to_ilp_single_char() { + // s = "a", alphabet {a}, h=2 + // Only valid: literal, cost=1 + let source = MinimumInternalMacroDataCompression::new(1, vec![0], 2); + let reduction = ReduceTo::>::reduce_to(&source); + let target = reduction.target_problem(); + + let solver = BruteForce::new(); + let target_witness = solver.find_witness(target).expect("ILP should be feasible"); + let source_config = reduction.extract_solution(&target_witness); + assert_eq!(source.evaluate(&source_config), Min(Some(1))); +} + +#[test] +fn test_imdc_to_ilp_structure() { + // Verify the ILP has the right number of variables + let source = MinimumInternalMacroDataCompression::new(2, vec![0, 1, 0, 1], 2); + let reduction = ReduceTo::>::reduce_to(&source); + let target = reduction.target_problem(); + // n=4 literals + valid ptr triples + assert!(target.num_variables() >= 4); + // Must be a minimization problem + assert_eq!(target.dims(), vec![2; target.num_variables()]); +} + +#[test] +fn test_imdc_to_ilp_vs_brute_force() { + // Compare ILP result against brute-force for small instances + for (k, s, h) in [ + (1, vec![0, 0, 0], 2), + (2, vec![0, 1, 0], 2), + (2, vec![0, 0, 1, 1], 1), + ] { + let source = MinimumInternalMacroDataCompression::new(k, s.clone(), h); + let bf_val = BruteForce::new().solve(&source); + + let reduction = ReduceTo::>::reduce_to(&source); + let target = reduction.target_problem(); + let target_witness = BruteForce::new() + .find_witness(target) + .expect("ILP should be feasible"); + let source_config = reduction.extract_solution(&target_witness); + let ilp_val = source.evaluate(&source_config); + + assert_eq!( + ilp_val, bf_val, + "ILP and brute-force disagree for k={}, s={:?}, h={}", + k, s, h + ); + } +} From beda45621452ca002f832e63fc53be813f2aaa16 Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Mon, 30 Mar 2026 04:26:31 +0800 Subject: [PATCH 2/6] feat: generalize MinimumTardinessSequencing with weight parameter (#495) Add W type parameter to MinimumTardinessSequencing: W=One for unit-length tasks (existing behavior), W=usize for arbitrary task lengths. Includes ILP reduction for both variants, canonical examples, and updated tests. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/paper/reductions.typ | 4 +- problemreductions-cli/src/commands/create.rs | 69 +++-- .../misc/minimum_tardiness_sequencing.rs | 250 ++++++++++++------ src/rules/minimumtardinesssequencing_ilp.rs | 174 ++++++++---- src/types.rs | 9 + .../misc/minimum_tardiness_sequencing.rs | 165 ++++++++---- .../rules/minimumtardinesssequencing_ilp.rs | 54 +++- src/unit_tests/trait_consistency.rs | 2 +- 8 files changed, 522 insertions(+), 205 deletions(-) diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index b95445c9..78eb412e 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -6114,8 +6114,8 @@ A classical NP-complete problem from Garey and Johnson @garey1979[Ch.~3, p.~76], ] } #{ - let x = load-model-example("MinimumTardinessSequencing") - let ntasks = x.instance.num_tasks + let x = load-model-example("MinimumTardinessSequencing", variant: (weight: "One")) + let ntasks = x.instance.lengths.len() let deadlines = x.instance.deadlines let precs = x.instance.precedences let sol = (config: x.optimal_config, metric: x.optimal_value) diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index 0276d363..20195f5d 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -42,6 +42,7 @@ use problemreductions::topology::{ BipartiteGraph, DirectedGraph, Graph, KingsSubgraph, MixedGraph, SimpleGraph, TriangularSubgraph, UnitDiskGraph, }; +use problemreductions::types::One; use serde::Serialize; use std::collections::{BTreeMap, BTreeSet}; @@ -3564,33 +3565,55 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { "MinimumTardinessSequencing" => { let deadlines_str = args.deadlines.as_deref().ok_or_else(|| { anyhow::anyhow!( - "MinimumTardinessSequencing requires --deadlines and --n\n\n\ - Usage: pred create MinimumTardinessSequencing --n 5 --deadlines 5,5,5,3,3 [--precedence-pairs \"0>3,1>3,1>4,2>4\"]" - ) - })?; - let num_tasks = args.n.ok_or_else(|| { - anyhow::anyhow!( - "MinimumTardinessSequencing requires --n (number of tasks)\n\n\ - Usage: pred create MinimumTardinessSequencing --n 5 --deadlines 5,5,5,3,3" + "MinimumTardinessSequencing requires --deadlines\n\n\ + Usage: pred create MinimumTardinessSequencing --n 5 --deadlines 5,5,5,3,3 [--precedence-pairs \"0>3,1>3,1>4,2>4\"] [--sizes 3,2,2,1,2]" ) })?; let deadlines: Vec = util::parse_comma_list(deadlines_str)?; let precedences = parse_precedence_pairs(args.precedence_pairs.as_deref())?; - anyhow::ensure!( - deadlines.len() == num_tasks, - "deadlines length ({}) must equal num_tasks ({})", - deadlines.len(), - num_tasks - ); - validate_precedence_pairs(&precedences, num_tasks)?; - ( - ser(MinimumTardinessSequencing::new( - num_tasks, - deadlines, - precedences, - ))?, - resolved_variant.clone(), - ) + + if let Some(sizes_str) = args.sizes.as_deref() { + // Arbitrary-length variant (W = usize) + let lengths: Vec = util::parse_comma_list(sizes_str)?; + anyhow::ensure!( + lengths.len() == deadlines.len(), + "sizes length ({}) must equal deadlines length ({})", + lengths.len(), + deadlines.len() + ); + validate_precedence_pairs(&precedences, lengths.len())?; + ( + ser(MinimumTardinessSequencing::::with_lengths( + lengths, + deadlines, + precedences, + ))?, + resolved_variant.clone(), + ) + } else { + // Unit-length variant (W = One) + let num_tasks = args.n.ok_or_else(|| { + anyhow::anyhow!( + "MinimumTardinessSequencing requires --n (number of tasks) or --sizes\n\n\ + Usage: pred create MinimumTardinessSequencing --n 5 --deadlines 5,5,5,3,3" + ) + })?; + anyhow::ensure!( + deadlines.len() == num_tasks, + "deadlines length ({}) must equal num_tasks ({})", + deadlines.len(), + num_tasks + ); + validate_precedence_pairs(&precedences, num_tasks)?; + ( + ser(MinimumTardinessSequencing::::new( + num_tasks, + deadlines, + precedences, + ))?, + resolved_variant.clone(), + ) + } } // SchedulingWithIndividualDeadlines diff --git a/src/models/misc/minimum_tardiness_sequencing.rs b/src/models/misc/minimum_tardiness_sequencing.rs index eba25bb0..6b656f45 100644 --- a/src/models/misc/minimum_tardiness_sequencing.rs +++ b/src/models/misc/minimum_tardiness_sequencing.rs @@ -1,13 +1,16 @@ //! Minimum Tardiness Sequencing problem implementation. //! //! A classical NP-complete single-machine scheduling problem (SS2 from -//! Garey & Johnson, 1979) where unit-length tasks with precedence constraints +//! Garey & Johnson, 1979) where tasks with precedence constraints //! and deadlines must be scheduled to minimize the number of tardy tasks. -//! Corresponds to scheduling notation `1|prec, pj=1|sum Uj`. +//! +//! Variants: +//! - `MinimumTardinessSequencing` — unit-length tasks (`1|prec, pj=1|∑Uj`) +//! - `MinimumTardinessSequencing` — arbitrary-length tasks (`1|prec|∑Uj`) -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; use crate::traits::Problem; -use crate::types::Min; +use crate::types::{Min, One, WeightElement}; use serde::{Deserialize, Serialize}; inventory::submit! { @@ -15,12 +18,12 @@ inventory::submit! { name: "MinimumTardinessSequencing", display_name: "Minimum Tardiness Sequencing", aliases: &[], - dimensions: &[], + dimensions: &[VariantDimension::new("weight", "One", &["One", "usize"])], module_path: module_path!(), - description: "Schedule unit-length tasks with precedence constraints and deadlines to minimize the number of tardy tasks", + description: "Schedule tasks with precedence constraints and deadlines to minimize the number of tardy tasks", fields: &[ - FieldInfo { name: "num_tasks", type_name: "usize", description: "Number of tasks |T|" }, - FieldInfo { name: "deadlines", type_name: "Vec", description: "Deadline d(t) for each task (1-indexed finish time)" }, + FieldInfo { name: "lengths", type_name: "Vec", description: "Processing time l(t) for each task" }, + FieldInfo { name: "deadlines", type_name: "Vec", description: "Deadline d(t) for each task" }, FieldInfo { name: "precedences", type_name: "Vec<(usize, usize)>", description: "Precedence pairs (predecessor, successor)" }, ], } @@ -28,48 +31,41 @@ inventory::submit! { /// Minimum Tardiness Sequencing problem. /// -/// Given a set T of tasks, each with unit length and a deadline d(t), +/// Given a set T of tasks, each with a processing time l(t) and a deadline d(t), /// and a partial order (precedence constraints) on T, find a schedule -/// `sigma: T -> {0, 1, ..., |T|-1}` that is a valid permutation, -/// respects precedence constraints (`sigma(t) < sigma(t')` whenever `t < t'`), -/// and minimizes the number of tardy tasks (`|{t : sigma(t)+1 > d(t)}|`). +/// that is a valid permutation respecting precedence constraints +/// and minimizes the number of tardy tasks. /// -/// # Representation +/// # Type Parameters /// -/// Each task has a variable representing its position in the schedule. -/// A configuration is valid if and only if it is a bijective mapping -/// (permutation) that respects all precedence constraints. +/// * `W` - The weight/length type. `One` for unit-length tasks, `usize` for arbitrary. /// /// # Example /// /// ``` /// use problemreductions::models::misc::MinimumTardinessSequencing; +/// use problemreductions::types::One; /// use problemreductions::{Problem, Solver, BruteForce}; /// -/// let problem = MinimumTardinessSequencing::new( +/// // Unit-length: 3 tasks, task 0 must precede task 2 +/// let problem = MinimumTardinessSequencing::::new( /// 3, /// vec![2, 3, 1], -/// vec![(0, 2)], // task 0 must precede task 2 +/// vec![(0, 2)], /// ); /// let solver = BruteForce::new(); /// let solution = solver.find_witness(&problem); /// assert!(solution.is_some()); /// ``` #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct MinimumTardinessSequencing { - num_tasks: usize, +pub struct MinimumTardinessSequencing { + lengths: Vec, deadlines: Vec, precedences: Vec<(usize, usize)>, } -impl MinimumTardinessSequencing { - /// Create a new MinimumTardinessSequencing instance. - /// - /// # Arguments - /// - /// * `num_tasks` - Number of tasks. - /// * `deadlines` - Deadline for each task (1-indexed: a task at position `p` finishes at time `p+1`). - /// * `precedences` - List of `(predecessor, successor)` pairs. +impl MinimumTardinessSequencing { + /// Create a new unit-length MinimumTardinessSequencing instance. /// /// # Panics /// @@ -81,30 +77,72 @@ impl MinimumTardinessSequencing { num_tasks, "deadlines length must equal num_tasks" ); - for &(pred, succ) in &precedences { - assert!( - pred < num_tasks, - "predecessor index {} out of range (num_tasks = {})", - pred, - num_tasks - ); - assert!( - succ < num_tasks, - "successor index {} out of range (num_tasks = {})", - succ, - num_tasks - ); + validate_precedences(num_tasks, &precedences); + Self { + lengths: vec![One; num_tasks], + deadlines, + precedences, } + } +} + +impl MinimumTardinessSequencing { + /// Create a new arbitrary-length MinimumTardinessSequencing instance. + /// + /// # Panics + /// + /// Panics if `lengths.len() != deadlines.len()`, if any length is 0, + /// or if any task index in `precedences` is out of range. + pub fn with_lengths( + lengths: Vec, + deadlines: Vec, + precedences: Vec<(usize, usize)>, + ) -> Self { + assert_eq!( + lengths.len(), + deadlines.len(), + "lengths and deadlines must have the same length" + ); + assert!( + lengths.iter().all(|&l| l > 0), + "all task lengths must be positive" + ); + let num_tasks = lengths.len(); + validate_precedences(num_tasks, &precedences); Self { - num_tasks, + lengths, deadlines, precedences, } } +} + +fn validate_precedences(num_tasks: usize, precedences: &[(usize, usize)]) { + for &(pred, succ) in precedences { + assert!( + pred < num_tasks, + "predecessor index {} out of range (num_tasks = {})", + pred, + num_tasks + ); + assert!( + succ < num_tasks, + "successor index {} out of range (num_tasks = {})", + succ, + num_tasks + ); + } +} +impl MinimumTardinessSequencing { /// Returns the number of tasks. pub fn num_tasks(&self) -> usize { - self.num_tasks + self.deadlines.len() + } + + /// Returns the task lengths. + pub fn lengths(&self) -> &[W] { + &self.lengths } /// Returns the deadlines. @@ -121,45 +159,87 @@ impl MinimumTardinessSequencing { pub fn num_precedences(&self) -> usize { self.precedences.len() } + + /// Decode and validate a schedule, returning the inverse permutation (sigma). + /// Returns None if the config is invalid or violates precedences. + fn decode_and_validate(&self, config: &[usize]) -> Option> { + let n = self.num_tasks(); + let schedule = super::decode_lehmer(config, n)?; + + let mut sigma = vec![0usize; n]; + for (pos, &task) in schedule.iter().enumerate() { + sigma[task] = pos; + } + + for &(pred, succ) in &self.precedences { + if sigma[pred] >= sigma[succ] { + return None; + } + } + + Some(sigma) + } } -impl Problem for MinimumTardinessSequencing { +impl Problem for MinimumTardinessSequencing { const NAME: &'static str = "MinimumTardinessSequencing"; type Value = Min; fn variant() -> Vec<(&'static str, &'static str)> { - crate::variant_params![] + crate::variant_params![One] } fn dims(&self) -> Vec { - super::lehmer_dims(self.num_tasks) + super::lehmer_dims(self.num_tasks()) } fn evaluate(&self, config: &[usize]) -> Min { - let n = self.num_tasks; - let Some(schedule) = super::decode_lehmer(config, n) else { + let n = self.num_tasks(); + let Some(sigma) = self.decode_and_validate(config) else { return Min(None); }; - // schedule[i] = the task scheduled at position i. - // We need sigma(task) = position, i.e., the inverse permutation. - let mut sigma = vec![0usize; n]; - for (pos, &task) in schedule.iter().enumerate() { - sigma[task] = pos; + // Unit length: completion time at position p is p + 1 + let tardy_count = (0..n).filter(|&t| sigma[t] + 1 > self.deadlines[t]).count(); + + Min(Some(tardy_count)) + } +} + +impl Problem for MinimumTardinessSequencing { + const NAME: &'static str = "MinimumTardinessSequencing"; + type Value = Min; + + fn variant() -> Vec<(&'static str, &'static str)> { + crate::variant_params![usize] + } + + fn dims(&self) -> Vec { + super::lehmer_dims(self.num_tasks()) + } + + fn evaluate(&self, config: &[usize]) -> Min { + let n = self.num_tasks(); + let Some(sigma) = self.decode_and_validate(config) else { + return Min(None); + }; + + // Build schedule order from sigma (inverse permutation) + let mut schedule = vec![0usize; n]; + for (task, &pos) in sigma.iter().enumerate() { + schedule[pos] = task; } - // Check precedence constraints: for each (pred, succ), sigma(pred) < sigma(succ) - for &(pred, succ) in &self.precedences { - if sigma[pred] >= sigma[succ] { - return Min(None); - } + // Compute completion times using actual lengths + let mut completion = vec![0usize; n]; + let mut cumulative = 0usize; + for &task in &schedule { + cumulative += self.lengths[task]; + completion[task] = cumulative; } - // Count tardy tasks: task t is tardy if sigma(t) + 1 > d(t) - let tardy_count = sigma - .iter() - .enumerate() - .filter(|&(t, &pos)| pos + 1 > self.deadlines[t]) + let tardy_count = (0..n) + .filter(|&t| completion[t] > self.deadlines[t]) .count(); Min(Some(tardy_count)) @@ -167,24 +247,40 @@ impl Problem for MinimumTardinessSequencing { } crate::declare_variants! { - default MinimumTardinessSequencing => "2^num_tasks", + default MinimumTardinessSequencing => "2^num_tasks", + MinimumTardinessSequencing => "2^num_tasks", } #[cfg(feature = "example-db")] pub(crate) fn canonical_model_example_specs() -> Vec { - vec![crate::example_db::specs::ModelExampleSpec { - id: "minimum_tardiness_sequencing", - // 4 tasks with precedence 0 -> 2 (task 0 before task 2). - // Deadlines: task 0 by time 2, task 1 by time 3, task 2 by time 1, task 3 by time 4. - instance: Box::new(MinimumTardinessSequencing::new( - 4, - vec![2, 3, 1, 4], - vec![(0, 2)], - )), - // Lehmer code [0,0,0,0] = identity permutation (schedule order 0,1,2,3) - optimal_config: vec![0, 0, 0, 0], - optimal_value: serde_json::json!(1), - }] + vec![ + // Unit-length variant + crate::example_db::specs::ModelExampleSpec { + id: "minimum_tardiness_sequencing", + instance: Box::new(MinimumTardinessSequencing::::new( + 4, + vec![2, 3, 1, 4], + vec![(0, 2)], + )), + optimal_config: vec![0, 0, 0, 0], + optimal_value: serde_json::json!(1), + }, + // Arbitrary-length variant + crate::example_db::specs::ModelExampleSpec { + id: "minimum_tardiness_sequencing_weighted", + // 5 tasks, lengths [3,2,2,1,2], deadlines [4,3,8,3,6], prec (0→2, 1→3) + // Optimal schedule: t0,t4,t2,t1,t3 → 2 tardy + // Lehmer [0,3,1,0,0]: avail=[0,1,2,3,4] pick 0→0; [1,2,3,4] pick 3→4; + // [1,2,3] pick 1→2; [1,3] pick 0→1; [3] pick 0→3 + instance: Box::new(MinimumTardinessSequencing::::with_lengths( + vec![3, 2, 2, 1, 2], + vec![4, 3, 8, 3, 6], + vec![(0, 2), (1, 3)], + )), + optimal_config: vec![0, 3, 1, 0, 0], + optimal_value: serde_json::json!(2), + }, + ] } #[cfg(test)] diff --git a/src/rules/minimumtardinesssequencing_ilp.rs b/src/rules/minimumtardinesssequencing_ilp.rs index 8d976e35..05e790e7 100644 --- a/src/rules/minimumtardinesssequencing_ilp.rs +++ b/src/rules/minimumtardinesssequencing_ilp.rs @@ -2,21 +2,16 @@ //! //! Position-assignment ILP: binary x_{j,p} placing task j in position p, //! with binary tardy indicator u_j. Precedence constraints and a -//! deadline-based tardy indicator with big-M = n. +//! length-aware tardy indicator with big-M linearization. use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::misc::MinimumTardinessSequencing; use crate::reduction; use crate::rules::ilp_helpers::{one_hot_decode, permutation_to_lehmer}; use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::types::One; -/// Result of reducing MinimumTardinessSequencing to ILP. -/// -/// Variable layout: -/// - x_{j,p} for j in 0..n, p in 0..n: index `j*n + p` -/// - u_j for j in 0..n: index `n*n + j` -/// -/// Total: n^2 + n variables. +/// Result of reducing MinimumTardinessSequencing to ILP. #[derive(Debug, Clone)] pub struct ReductionMTSToILP { target: ILP, @@ -24,77 +19,151 @@ pub struct ReductionMTSToILP { } impl ReductionResult for ReductionMTSToILP { - type Source = MinimumTardinessSequencing; + type Source = MinimumTardinessSequencing; type Target = ILP; fn target_problem(&self) -> &ILP { &self.target } - /// Extract: decode position assignment x_{j,p} → permutation → Lehmer code. fn extract_solution(&self, target_solution: &[usize]) -> Vec { let n = self.num_tasks; - // Decode: for each position p, find which job j has x_{j,p}=1 let schedule = one_hot_decode(target_solution, n, n, 0); permutation_to_lehmer(&schedule) } } +/// Result of reducing MinimumTardinessSequencing to ILP. +#[derive(Debug, Clone)] +pub struct ReductionMTSWeightedToILP { + target: ILP, + num_tasks: usize, +} + +impl ReductionResult for ReductionMTSWeightedToILP { + type Source = MinimumTardinessSequencing; + type Target = ILP; + + fn target_problem(&self) -> &ILP { + &self.target + } + + fn extract_solution(&self, target_solution: &[usize]) -> Vec { + let n = self.num_tasks; + let schedule = one_hot_decode(target_solution, n, n, 0); + permutation_to_lehmer(&schedule) + } +} + +/// Build task assignment + position filling + precedence constraints (shared). +fn build_common_constraints( + n: usize, + precedences: &[(usize, usize)], + x_var: impl Fn(usize, usize) -> usize, +) -> Vec { + let mut constraints = Vec::new(); + + // 1. Each task assigned to exactly one position + for j in 0..n { + let terms: Vec<(usize, f64)> = (0..n).map(|p| (x_var(j, p), 1.0)).collect(); + constraints.push(LinearConstraint::eq(terms, 1.0)); + } + + // 2. Each position has exactly one task + for p in 0..n { + let terms: Vec<(usize, f64)> = (0..n).map(|j| (x_var(j, p), 1.0)).collect(); + constraints.push(LinearConstraint::eq(terms, 1.0)); + } + + // 3. Precedence constraints + for &(i, j) in precedences { + let mut terms: Vec<(usize, f64)> = Vec::new(); + for p in 0..n { + terms.push((x_var(j, p), p as f64)); + terms.push((x_var(i, p), -(p as f64))); + } + constraints.push(LinearConstraint::ge(terms, 1.0)); + } + + constraints +} + +// Unit-length variant #[reduction(overhead = { num_vars = "num_tasks * num_tasks + num_tasks", num_constraints = "2 * num_tasks + num_precedences + num_tasks", })] -impl ReduceTo> for MinimumTardinessSequencing { +impl ReduceTo> for MinimumTardinessSequencing { type Result = ReductionMTSToILP; fn reduce_to(&self) -> Self::Result { let n = self.num_tasks(); let num_x_vars = n * n; - let num_u_vars = n; - let num_vars = num_x_vars + num_u_vars; + let num_vars = num_x_vars + n; let big_m = n as f64; let x_var = |j: usize, p: usize| -> usize { j * n + p }; let u_var = |j: usize| -> usize { num_x_vars + j }; - let mut constraints = Vec::new(); + let mut constraints = build_common_constraints(n, self.precedences(), x_var); - // 1. Each task assigned to exactly one position: Σ_p x_{j,p} = 1 for all j + // Tardy indicator (unit length: completion = p+1) for j in 0..n { - let terms: Vec<(usize, f64)> = (0..n).map(|p| (x_var(j, p), 1.0)).collect(); - constraints.push(LinearConstraint::eq(terms, 1.0)); + let mut terms: Vec<(usize, f64)> = + (0..n).map(|p| (x_var(j, p), (p + 1) as f64)).collect(); + terms.push((u_var(j), -big_m)); + constraints.push(LinearConstraint::le(terms, self.deadlines()[j] as f64)); } - // 2. Each position has exactly one task: Σ_j x_{j,p} = 1 for all p - for p in 0..n { - let terms: Vec<(usize, f64)> = (0..n).map(|j| (x_var(j, p), 1.0)).collect(); - constraints.push(LinearConstraint::eq(terms, 1.0)); - } + let objective: Vec<(usize, f64)> = (0..n).map(|j| (u_var(j), 1.0)).collect(); - // 3. Precedence: Σ_p p*x_{i,p} + 1 <= Σ_p p*x_{j,p} for each (i,j) - // => Σ_p p*x_{j,p} - Σ_p p*x_{i,p} >= 1 - for &(i, j) in self.precedences() { - let mut terms: Vec<(usize, f64)> = Vec::new(); - for p in 0..n { - terms.push((x_var(j, p), p as f64)); - terms.push((x_var(i, p), -(p as f64))); - } - constraints.push(LinearConstraint::ge(terms, 1.0)); + ReductionMTSToILP { + target: ILP::new(num_vars, constraints, objective, ObjectiveSense::Minimize), + num_tasks: n, } + } +} + +// Arbitrary-length variant +#[reduction(overhead = { + num_vars = "num_tasks * num_tasks + num_tasks", + num_constraints = "2 * num_tasks + num_precedences + num_tasks * num_tasks", +})] +impl ReduceTo> for MinimumTardinessSequencing { + type Result = ReductionMTSWeightedToILP; - // 4. Tardy indicator: Σ_p (p+1)*x_{j,p} - d_j <= M*u_j for all j - // => Σ_p (p+1)*x_{j,p} - M*u_j <= d_j + fn reduce_to(&self) -> Self::Result { + let n = self.num_tasks(); + let num_x_vars = n * n; + let num_vars = num_x_vars + n; + let total_length: usize = self.lengths().iter().copied().sum(); + let big_m = total_length as f64; + + let x_var = |j: usize, p: usize| -> usize { j * n + p }; + let u_var = |j: usize| -> usize { num_x_vars + j }; + + let mut constraints = build_common_constraints(n, self.precedences(), x_var); + + // Tardy indicator for arbitrary lengths. + let lengths = self.lengths(); for j in 0..n { - let mut terms: Vec<(usize, f64)> = - (0..n).map(|p| (x_var(j, p), (p + 1) as f64)).collect(); - terms.push((u_var(j), -big_m)); - constraints.push(LinearConstraint::le(terms, self.deadlines()[j] as f64)); + for p in 0..n { + let mut terms: Vec<(usize, f64)> = Vec::new(); + terms.push((x_var(j, p), big_m)); + for pp in 0..p { + for (jj, &len) in lengths.iter().enumerate() { + terms.push((x_var(jj, pp), len as f64)); + } + } + terms.push((u_var(j), -big_m)); + let rhs = self.deadlines()[j] as f64 - lengths[j] as f64 + big_m; + constraints.push(LinearConstraint::le(terms, rhs)); + } } - // Objective: minimize Σ_j u_j let objective: Vec<(usize, f64)> = (0..n).map(|j| (u_var(j), 1.0)).collect(); - ReductionMTSToILP { + ReductionMTSWeightedToILP { target: ILP::new(num_vars, constraints, objective, ObjectiveSense::Minimize), num_tasks: n, } @@ -103,13 +172,26 @@ impl ReduceTo> for MinimumTardinessSequencing { #[cfg(feature = "example-db")] pub(crate) fn canonical_rule_example_specs() -> Vec { - vec![crate::example_db::specs::RuleExampleSpec { - id: "minimumtardinesssequencing_to_ilp", - build: || { - let source = MinimumTardinessSequencing::new(3, vec![2, 3, 1], vec![(0, 2)]); - crate::example_db::specs::rule_example_via_ilp::<_, bool>(source) + vec![ + crate::example_db::specs::RuleExampleSpec { + id: "minimumtardinesssequencing_to_ilp", + build: || { + let source = MinimumTardinessSequencing::::new(3, vec![2, 3, 1], vec![(0, 2)]); + crate::example_db::specs::rule_example_via_ilp::<_, bool>(source) + }, + }, + crate::example_db::specs::RuleExampleSpec { + id: "minimumtardinesssequencing_weighted_to_ilp", + build: || { + let source = MinimumTardinessSequencing::::with_lengths( + vec![2, 1, 3], + vec![3, 4, 5], + vec![(0, 2)], + ); + crate::example_db::specs::rule_example_via_ilp::<_, bool>(source) + }, }, - }] + ] } #[cfg(test)] diff --git a/src/types.rs b/src/types.rs index 4d14f6ca..4b698f30 100644 --- a/src/types.rs +++ b/src/types.rs @@ -59,6 +59,14 @@ impl WeightElement for f64 { } } +impl WeightElement for usize { + type Sum = usize; + const IS_UNIT: bool = false; + fn to_sum(&self) -> usize { + *self + } +} + /// The constant 1. Unit weight for unweighted problems. /// /// When used as the weight type parameter `W`, indicates that all weights @@ -553,6 +561,7 @@ use crate::impl_variant_param; impl_variant_param!(f64, "weight"); impl_variant_param!(i32, "weight", parent: f64, cast: |w| *w as f64); impl_variant_param!(One, "weight", parent: i32, cast: |_| 1i32); +impl_variant_param!(usize, "weight"); #[cfg(test)] #[path = "unit_tests/types.rs"] diff --git a/src/unit_tests/models/misc/minimum_tardiness_sequencing.rs b/src/unit_tests/models/misc/minimum_tardiness_sequencing.rs index fdcf29b6..4485cb34 100644 --- a/src/unit_tests/models/misc/minimum_tardiness_sequencing.rs +++ b/src/unit_tests/models/misc/minimum_tardiness_sequencing.rs @@ -1,10 +1,13 @@ use super::*; use crate::solvers::BruteForce; use crate::traits::Problem; +use crate::types::One; + +// ===== Unit-length variant (W = One) ===== #[test] fn test_minimum_tardiness_sequencing_basic() { - let problem = MinimumTardinessSequencing::new( + let problem = MinimumTardinessSequencing::::new( 5, vec![5, 5, 5, 3, 3], vec![(0, 3), (1, 3), (1, 4), (2, 4)], @@ -15,89 +18,65 @@ fn test_minimum_tardiness_sequencing_basic() { assert_eq!(problem.num_precedences(), 4); assert_eq!(problem.dims(), vec![5, 4, 3, 2, 1]); assert_eq!( - ::NAME, + as Problem>::NAME, "MinimumTardinessSequencing" ); - assert_eq!(::variant(), vec![]); } #[test] fn test_minimum_tardiness_sequencing_evaluate_optimal() { - // Example from issue: 5 tasks, optimal has 1 tardy task - let problem = MinimumTardinessSequencing::new( + let problem = MinimumTardinessSequencing::::new( 5, vec![5, 5, 5, 3, 3], vec![(0, 3), (1, 3), (1, 4), (2, 4)], ); - // Lehmer code [0,0,1,0,0] decodes to schedule [0,1,3,2,4]: - // available=[0,1,2,3,4] pick idx 0 -> 0; available=[1,2,3,4] pick idx 0 -> 1; - // available=[2,3,4] pick idx 1 -> 3; available=[2,4] pick idx 0 -> 2; available=[4] pick idx 0 -> 4. - // sigma: task 0 at pos 0, task 1 at pos 1, task 3 at pos 2, task 2 at pos 3, task 4 at pos 4. - // t0 finishes at 1 <= 5, t1 at 2 <= 5, t3 at 3 <= 3, t2 at 4 <= 5, t4 at 5 > 3 (tardy) let config = vec![0, 0, 1, 0, 0]; assert_eq!(problem.evaluate(&config), Min(Some(1))); } #[test] fn test_minimum_tardiness_sequencing_evaluate_invalid_lehmer() { - let problem = MinimumTardinessSequencing::new(3, vec![2, 3, 1], vec![]); - // dims = [3, 2, 1]; config [0, 2, 0] has 2 >= 2 (second dim), invalid Lehmer code + let problem = MinimumTardinessSequencing::::new(3, vec![2, 3, 1], vec![]); assert_eq!(problem.evaluate(&[0, 2, 0]), Min(None)); } #[test] fn test_minimum_tardiness_sequencing_evaluate_out_of_range() { - let problem = MinimumTardinessSequencing::new(3, vec![2, 3, 1], vec![]); - // dims = [3, 2, 1]; config [0, 1, 5] has 5 >= 1 (third dim), out of range + let problem = MinimumTardinessSequencing::::new(3, vec![2, 3, 1], vec![]); assert_eq!(problem.evaluate(&[0, 1, 5]), Min(None)); } #[test] fn test_minimum_tardiness_sequencing_evaluate_wrong_length() { - let problem = MinimumTardinessSequencing::new(3, vec![2, 3, 1], vec![]); + let problem = MinimumTardinessSequencing::::new(3, vec![2, 3, 1], vec![]); assert_eq!(problem.evaluate(&[0, 1]), Min(None)); assert_eq!(problem.evaluate(&[0, 1, 2, 3]), Min(None)); } #[test] fn test_minimum_tardiness_sequencing_evaluate_precedence_violation() { - let problem = MinimumTardinessSequencing::new( - 3, - vec![3, 3, 3], - vec![(0, 1)], // task 0 must precede task 1 - ); - // Lehmer [0,0,0] -> schedule [0,1,2] -> sigma [0,1,2]: sigma(0)=0 < sigma(1)=1, valid + let problem = MinimumTardinessSequencing::::new(3, vec![3, 3, 3], vec![(0, 1)]); assert_eq!(problem.evaluate(&[0, 0, 0]), Min(Some(0))); - // Lehmer [1,0,0] -> schedule [1,0,2] -> sigma [1,0,2]: sigma(0)=1 >= sigma(1)=0, violates assert_eq!(problem.evaluate(&[1, 0, 0]), Min(None)); - // Lehmer [2,1,0] -> schedule [2,1,0] -> sigma [2,1,0]: sigma(0)=2 >= sigma(1)=1, violates assert_eq!(problem.evaluate(&[2, 1, 0]), Min(None)); } #[test] fn test_minimum_tardiness_sequencing_evaluate_all_on_time() { - let problem = MinimumTardinessSequencing::new(3, vec![3, 3, 3], vec![]); - // All deadlines are 3, so any permutation of 3 tasks is on time - // Lehmer [0,0,0] -> schedule [0,1,2] + let problem = MinimumTardinessSequencing::::new(3, vec![3, 3, 3], vec![]); assert_eq!(problem.evaluate(&[0, 0, 0]), Min(Some(0))); - // Lehmer [2,1,0] -> schedule [2,1,0] assert_eq!(problem.evaluate(&[2, 1, 0]), Min(Some(0))); } #[test] fn test_minimum_tardiness_sequencing_evaluate_all_tardy() { - // Deadlines are all 0 (impossible to meet since earliest finish is 1) - // Wait: deadlines are usize and d(t)=0 means finish must be <= 0, but finish is at least 1 - // Actually, let's use deadlines that can't be met - let problem = MinimumTardinessSequencing::new(2, vec![0, 0], vec![]); - // Lehmer [0,0] -> schedule [0,1] -> sigma [0,1] - // pos 0 finishes at 1 > 0 (tardy), pos 1 finishes at 2 > 0 (tardy) + let problem = MinimumTardinessSequencing::::new(2, vec![0, 0], vec![]); assert_eq!(problem.evaluate(&[0, 0]), Min(Some(2))); } #[test] fn test_minimum_tardiness_sequencing_brute_force() { - let problem = MinimumTardinessSequencing::new( + let problem = MinimumTardinessSequencing::::new( 5, vec![5, 5, 5, 3, 3], vec![(0, 3), (1, 3), (1, 4), (2, 4)], @@ -107,29 +86,25 @@ fn test_minimum_tardiness_sequencing_brute_force() { .find_witness(&problem) .expect("should find a solution"); let metric = problem.evaluate(&solution); - // Optimal is 1 tardy task assert_eq!(metric, Min(Some(1))); } #[test] fn test_minimum_tardiness_sequencing_brute_force_no_precedences() { - // Without precedences, Moore's algorithm gives optimal - // 3 tasks: deadlines 1, 3, 2. Best is to schedule task with deadline 1 first. - let problem = MinimumTardinessSequencing::new(3, vec![1, 3, 2], vec![]); + let problem = MinimumTardinessSequencing::::new(3, vec![1, 3, 2], vec![]); let solver = BruteForce::new(); let solution = solver .find_witness(&problem) .expect("should find a solution"); let metric = problem.evaluate(&solution); - // All can be on time: t0 at pos 0 (finish 1 <= 1), t2 at pos 1 (finish 2 <= 2), t1 at pos 2 (finish 3 <= 3) assert_eq!(metric, Min(Some(0))); } #[test] fn test_minimum_tardiness_sequencing_serialization() { - let problem = MinimumTardinessSequencing::new(3, vec![2, 3, 1], vec![(0, 1)]); + let problem = MinimumTardinessSequencing::::new(3, vec![2, 3, 1], vec![(0, 1)]); let json = serde_json::to_value(&problem).unwrap(); - let restored: MinimumTardinessSequencing = serde_json::from_value(json).unwrap(); + let restored: MinimumTardinessSequencing = serde_json::from_value(json).unwrap(); assert_eq!(restored.num_tasks(), problem.num_tasks()); assert_eq!(restored.deadlines(), problem.deadlines()); assert_eq!(restored.precedences(), problem.precedences()); @@ -137,7 +112,7 @@ fn test_minimum_tardiness_sequencing_serialization() { #[test] fn test_minimum_tardiness_sequencing_empty() { - let problem = MinimumTardinessSequencing::new(0, vec![], vec![]); + let problem = MinimumTardinessSequencing::::new(0, vec![], vec![]); assert_eq!(problem.num_tasks(), 0); assert_eq!(problem.dims(), Vec::::new()); assert_eq!(problem.evaluate(&[]), Min(Some(0))); @@ -145,32 +120,122 @@ fn test_minimum_tardiness_sequencing_empty() { #[test] fn test_minimum_tardiness_sequencing_single_task() { - let problem = MinimumTardinessSequencing::new(1, vec![1], vec![]); + let problem = MinimumTardinessSequencing::::new(1, vec![1], vec![]); assert_eq!(problem.dims(), vec![1]); - // Task at position 0, finishes at 1 <= 1, not tardy assert_eq!(problem.evaluate(&[0]), Min(Some(0))); - let problem_tardy = MinimumTardinessSequencing::new(1, vec![0], vec![]); - // Task at position 0, finishes at 1 > 0, tardy + let problem_tardy = MinimumTardinessSequencing::::new(1, vec![0], vec![]); assert_eq!(problem_tardy.evaluate(&[0]), Min(Some(1))); } #[test] #[should_panic(expected = "deadlines length must equal num_tasks")] fn test_minimum_tardiness_sequencing_mismatched_deadlines() { - MinimumTardinessSequencing::new(3, vec![1, 2], vec![]); + MinimumTardinessSequencing::::new(3, vec![1, 2], vec![]); } #[test] #[should_panic(expected = "predecessor index 5 out of range")] fn test_minimum_tardiness_sequencing_invalid_precedence() { - MinimumTardinessSequencing::new(3, vec![1, 2, 3], vec![(5, 0)]); + MinimumTardinessSequencing::::new(3, vec![1, 2, 3], vec![(5, 0)]); } #[test] fn test_minimum_tardiness_sequencing_cyclic_precedences() { - // Cyclic precedences: 0 -> 1 -> 2 -> 0. No valid schedule exists. - let problem = MinimumTardinessSequencing::new(3, vec![3, 3, 3], vec![(0, 1), (1, 2), (2, 0)]); + let problem = + MinimumTardinessSequencing::::new(3, vec![3, 3, 3], vec![(0, 1), (1, 2), (2, 0)]); let solver = BruteForce::new(); assert!(solver.find_witness(&problem).is_none()); } + +// ===== Arbitrary-length variant (W = usize) ===== + +#[test] +fn test_minimum_tardiness_sequencing_weighted_basic() { + let problem = MinimumTardinessSequencing::::with_lengths( + vec![3, 2, 2, 1, 2], + vec![4, 3, 8, 3, 6], + vec![(0, 2), (1, 3)], + ); + assert_eq!(problem.num_tasks(), 5); + assert_eq!(problem.lengths(), &[3, 2, 2, 1, 2]); + assert_eq!(problem.deadlines(), &[4, 3, 8, 3, 6]); + assert_eq!(problem.num_precedences(), 2); +} + +#[test] +fn test_minimum_tardiness_sequencing_weighted_evaluate() { + // Issue example: 5 tasks, lengths [3,2,2,1,2], deadlines [4,3,8,3,6], prec (0→2, 1→3) + // Schedule: t0,t4,t2,t1,t3 + // Lehmer [0,3,1,0,0] -> schedule [0,4,2,1,3] + let problem = MinimumTardinessSequencing::::with_lengths( + vec![3, 2, 2, 1, 2], + vec![4, 3, 8, 3, 6], + vec![(0, 2), (1, 3)], + ); + // t0(l=3): finish=3, deadline=4 → on time + // t4(l=2): finish=5, deadline=6 → on time + // t2(l=2): finish=7, deadline=8 → on time + // t1(l=2): finish=9, deadline=3 → tardy + // t3(l=1): finish=10, deadline=3 → tardy + assert_eq!(problem.evaluate(&[0, 3, 1, 0, 0]), Min(Some(2))); +} + +#[test] +fn test_minimum_tardiness_sequencing_weighted_brute_force() { + let problem = MinimumTardinessSequencing::::with_lengths( + vec![3, 2, 2, 1, 2], + vec![4, 3, 8, 3, 6], + vec![(0, 2), (1, 3)], + ); + let solver = BruteForce::new(); + let solution = solver + .find_witness(&problem) + .expect("should find a solution"); + let metric = problem.evaluate(&solution); + assert_eq!(metric, Min(Some(2))); +} + +#[test] +fn test_minimum_tardiness_sequencing_weighted_serialization() { + let problem = MinimumTardinessSequencing::::with_lengths( + vec![3, 2, 2], + vec![4, 3, 8], + vec![(0, 1)], + ); + let json = serde_json::to_value(&problem).unwrap(); + let restored: MinimumTardinessSequencing = serde_json::from_value(json).unwrap(); + assert_eq!(restored.num_tasks(), problem.num_tasks()); + assert_eq!(restored.lengths(), problem.lengths()); + assert_eq!(restored.deadlines(), problem.deadlines()); +} + +#[test] +fn test_minimum_tardiness_sequencing_weighted_different_lengths() { + // 3 tasks: lengths [1,5,1], deadlines [2,6,3] + // Schedule [0,2,1]: t0(l=1,fin=1≤2✓), t2(l=1,fin=2≤3✓), t1(l=5,fin=7>6✗) → 1 tardy + // Schedule [0,1,2]: t0(l=1,fin=1≤2✓), t1(l=5,fin=6≤6✓), t2(l=1,fin=7>3✗) → 1 tardy + // Schedule [1,0,2]: t1(l=5,fin=5≤6✓), t0(l=1,fin=6>2✗), t2(l=1,fin=7>3✗) → 2 tardy + let problem = + MinimumTardinessSequencing::::with_lengths(vec![1, 5, 1], vec![2, 6, 3], vec![]); + let solver = BruteForce::new(); + let solution = solver + .find_witness(&problem) + .expect("should find a solution"); + assert_eq!(problem.evaluate(&solution), Min(Some(1))); +} + +#[test] +#[should_panic(expected = "all task lengths must be positive")] +fn test_minimum_tardiness_sequencing_weighted_zero_length() { + MinimumTardinessSequencing::::with_lengths(vec![1, 0, 2], vec![3, 3, 3], vec![]); +} + +#[test] +fn test_minimum_tardiness_sequencing_paper_example() { + // Issue example (unit-length): 4 tasks, deadlines [2,3,1,4], prec (0→2) + // Lehmer [0,0,0,0] = schedule [0,1,2,3] + // t0: finish=1≤2✓, t1: finish=2≤3✓, t2: finish=3>1✗, t3: finish=4≤4✓ → 1 tardy + let problem = MinimumTardinessSequencing::::new(4, vec![2, 3, 1, 4], vec![(0, 2)]); + assert_eq!(problem.evaluate(&[0, 0, 0, 0]), Min(Some(1))); +} diff --git a/src/unit_tests/rules/minimumtardinesssequencing_ilp.rs b/src/unit_tests/rules/minimumtardinesssequencing_ilp.rs index 2beb3ee2..21a0deae 100644 --- a/src/unit_tests/rules/minimumtardinesssequencing_ilp.rs +++ b/src/unit_tests/rules/minimumtardinesssequencing_ilp.rs @@ -3,10 +3,13 @@ use crate::models::algebraic::ILP; use crate::rules::test_helpers::assert_optimization_round_trip_from_optimization_target; use crate::solvers::{BruteForce, ILPSolver}; use crate::traits::Problem; +use crate::types::One; + +// ===== Unit-length variant ===== #[test] fn test_minimumtardinesssequencing_to_ilp_closed_loop() { - let problem = MinimumTardinessSequencing::new(3, vec![2, 3, 1], vec![(0, 2)]); + let problem = MinimumTardinessSequencing::::new(3, vec![2, 3, 1], vec![(0, 2)]); let reduction = ReduceTo::>::reduce_to(&problem); assert_optimization_round_trip_from_optimization_target( @@ -18,7 +21,7 @@ fn test_minimumtardinesssequencing_to_ilp_closed_loop() { #[test] fn test_minimumtardinesssequencing_to_ilp_bf_vs_ilp() { - let problem = MinimumTardinessSequencing::new(4, vec![2, 3, 1, 4], vec![(0, 2)]); + let problem = MinimumTardinessSequencing::::new(4, vec![2, 3, 1, 4], vec![(0, 2)]); let reduction = ReduceTo::>::reduce_to(&problem); let bf = BruteForce::new(); @@ -37,7 +40,7 @@ fn test_minimumtardinesssequencing_to_ilp_bf_vs_ilp() { #[test] fn test_minimumtardinesssequencing_to_ilp_no_precedences() { - let problem = MinimumTardinessSequencing::new(3, vec![1, 2, 3], vec![]); + let problem = MinimumTardinessSequencing::::new(3, vec![1, 2, 3], vec![]); let reduction = ReduceTo::>::reduce_to(&problem); let ilp_solution = ILPSolver::new() @@ -49,8 +52,7 @@ fn test_minimumtardinesssequencing_to_ilp_no_precedences() { #[test] fn test_minimumtardinesssequencing_to_ilp_all_tight() { - // All deadlines equal 1: only one task can be on time - let problem = MinimumTardinessSequencing::new(3, vec![1, 1, 1], vec![]); + let problem = MinimumTardinessSequencing::::new(3, vec![1, 1, 1], vec![]); let reduction = ReduceTo::>::reduce_to(&problem); let ilp_solution = ILPSolver::new() @@ -59,6 +61,46 @@ fn test_minimumtardinesssequencing_to_ilp_all_tight() { let extracted = reduction.extract_solution(&ilp_solution); let value = problem.evaluate(&extracted); assert!(value.is_valid()); - // At most 2 tardy tasks (only first task is on time if d=1) assert_eq!(value.0, Some(2)); } + +// ===== Arbitrary-length variant ===== + +#[test] +fn test_minimumtardinesssequencing_weighted_to_ilp_closed_loop() { + let problem = MinimumTardinessSequencing::::with_lengths( + vec![2, 1, 3], + vec![3, 4, 5], + vec![(0, 2)], + ); + let reduction = ReduceTo::>::reduce_to(&problem); + + assert_optimization_round_trip_from_optimization_target( + &problem, + &reduction, + "MinimumTardinessSequencing->ILP closed loop", + ); +} + +#[test] +fn test_minimumtardinesssequencing_weighted_to_ilp_vs_brute_force() { + let problem = MinimumTardinessSequencing::::with_lengths( + vec![3, 2, 2, 1, 2], + vec![4, 3, 8, 3, 6], + vec![(0, 2), (1, 3)], + ); + + let bf = BruteForce::new(); + let bf_witness = bf.find_witness(&problem).expect("should have solution"); + let bf_value = problem.evaluate(&bf_witness); + + let reduction = ReduceTo::>::reduce_to(&problem); + let ilp_solution = ILPSolver::new() + .solve(reduction.target_problem()) + .expect("ILP should be solvable"); + let extracted = reduction.extract_solution(&ilp_solution); + let ilp_value = problem.evaluate(&extracted); + + assert_eq!(bf_value, ilp_value); + assert_eq!(ilp_value.0, Some(2)); +} diff --git a/src/unit_tests/trait_consistency.rs b/src/unit_tests/trait_consistency.rs index e7f89c40..d1be4fde 100644 --- a/src/unit_tests/trait_consistency.rs +++ b/src/unit_tests/trait_consistency.rs @@ -207,7 +207,7 @@ fn test_all_problems_implement_trait_correctly() { "SequencingToMinimizeWeightedTardiness", ); check_problem_trait( - &MinimumTardinessSequencing::new(3, vec![2, 3, 1], vec![(0, 2)]), + &MinimumTardinessSequencing::::new(3, vec![2, 3, 1], vec![(0, 2)]), "MinimumTardinessSequencing", ); check_problem_trait( From a37e58deceecbb37ffd6da7804a9a02236d57632 Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Mon, 30 Mar 2026 04:42:00 +0800 Subject: [PATCH 3/6] feat: add SequencingToMinimizeTardyTaskWeight model (#496) Implement the weighted tardy task scheduling problem (GJ SS3) with direct ILP reduction, CLI support, canonical example, and paper entry. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/paper/reductions.typ | 61 ++++- problemreductions-cli/src/cli.rs | 1 + problemreductions-cli/src/commands/create.rs | 59 ++++- src/models/misc/mod.rs | 4 + ...equencing_to_minimize_tardy_task_weight.rs | 214 ++++++++++++++++++ src/models/mod.rs | 10 +- src/rules/mod.rs | 3 + ...sequencingtominimizetardytaskweight_ilp.rs | 116 ++++++++++ ...equencing_to_minimize_tardy_task_weight.rs | 198 ++++++++++++++++ ...sequencingtominimizetardytaskweight_ilp.rs | 84 +++++++ 10 files changed, 740 insertions(+), 10 deletions(-) create mode 100644 src/models/misc/sequencing_to_minimize_tardy_task_weight.rs create mode 100644 src/rules/sequencingtominimizetardytaskweight_ilp.rs create mode 100644 src/unit_tests/models/misc/sequencing_to_minimize_tardy_task_weight.rs create mode 100644 src/unit_tests/rules/sequencingtominimizetardytaskweight_ilp.rs diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index 78eb412e..8509a419 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -188,6 +188,7 @@ "SchedulingToMinimizeWeightedCompletionTime": [Scheduling to Minimize Weighted Completion Time], "SchedulingWithIndividualDeadlines": [Scheduling With Individual Deadlines], "SequencingToMinimizeMaximumCumulativeCost": [Sequencing to Minimize Maximum Cumulative Cost], + "SequencingToMinimizeTardyTaskWeight": [Sequencing to Minimize Tardy Task Weight], "SequencingToMinimizeWeightedCompletionTime": [Sequencing to Minimize Weighted Completion Time], "SequencingToMinimizeWeightedTardiness": [Sequencing to Minimize Weighted Tardiness], "SequencingWithReleaseTimesAndDeadlines": [Sequencing with Release Times and Deadlines], @@ -6431,7 +6432,47 @@ A classical NP-complete problem from Garey and Johnson @garey1979[Ch.~3, p.~76], ] } -#{ +#{ + let x = load-model-example("SequencingToMinimizeTardyTaskWeight") + let lengths = x.instance.lengths + let weights = x.instance.weights + let deadlines = x.instance.deadlines + let ntasks = lengths.len() + let schedule = x.optimal_config + let completions = { + let t = 0 + let result = () + for task in schedule { + t += lengths.at(task) + result.push(t) + } + result + } + let tardy-weights = schedule.enumerate().map(((pos, task)) => { + if completions.at(pos) > deadlines.at(task) { weights.at(task) } else { 0 } + }) + [ + #problem-def("SequencingToMinimizeTardyTaskWeight")[ + Given a set $T$ of $n$ tasks, a processing-time function $ell: T -> ZZ^+$, a weight function $w: T -> ZZ^+$, and a deadline function $d: T -> ZZ^+$, find a one-machine schedule that minimizes + $sum_(t in T) w(t) U(t),$ + where $U(t) = 1$ if the completion time $C(t) > d(t)$ (task $t$ is tardy) and $U(t) = 0$ otherwise. + ][ + Sequencing to Minimize Tardy Task Weight is problem SS8 in Garey & Johnson @garey1979, usually written $1 || sum w_j U_j$. The unweighted variant $1 || sum U_j$ (minimize number of tardy tasks) is solvable in $O(n log n)$ by Moore's algorithm @moore1968, but the weighted version is NP-complete. The problem is closely related to $1 || sum w_j T_j$ (Sequencing to Minimize Weighted Tardiness) but differs in using a 0/1 indicator $U_j$ instead of the actual tardiness $T_j = max(0, C_j - d_j)$. + + Configurations are direct permutation encodings: the config vector $(sigma_0, dots, sigma_{n-1})$ specifies which task occupies each position, i.e., $sigma_p$ is the index of the task scheduled at position $p$. A configuration is valid iff it is a permutation of $\{0, dots, n-1\}$. + + *Example.* Consider $n = #ntasks$ tasks with lengths $ell = (#lengths.map(v => str(v)).join(", "))$, weights $w = (#weights.map(v => str(v)).join(", "))$, and deadlines $d = (#deadlines.map(v => str(v)).join(", "))$. The optimal schedule $(#schedule.map(t => $t_(#(t + 1))$).join(", "))$ achieves completion times $(#completions.map(v => str(v)).join(", "))$. Tasks with completion time exceeding their deadline contribute weights $(#tardy-weights.map(v => str(v)).join(", "))$, giving total tardy weight $#x.optimal_value$. + + #pred-commands( + "pred create --example SequencingToMinimizeTardyTaskWeight -o sequencing-to-minimize-tardy-task-weight.json", + "pred solve sequencing-to-minimize-tardy-task-weight.json", + "pred evaluate sequencing-to-minimize-tardy-task-weight.json --config " + x.optimal_config.map(str).join(","), + ) + ] + ] +} + +#{ let x = load-model-example("IntegralFlowHomologousArcs") let arcs = x.instance.graph.arcs let sol = x.optimal_config @@ -9465,6 +9506,24 @@ The following reductions to Integer Linear Programming are straightforward formu _Solution extraction._ Decode the position assignment and convert the resulting permutation to Lehmer code. ] +#reduction-rule("SequencingToMinimizeTardyTaskWeight", "ILP")[ + Place each task in exactly one schedule position with a binary tardy indicator forced on whenever the completion time at that position exceeds the task's deadline. +][ + _Construction._ Variables: binary $x_(j,p)$ with $x_(j,p) = 1$ iff task $j$ occupies position $p$, and binary tardy indicator $u_j$. Let $M = sum_j ell_j$. The ILP is: + $ + "minimize" quad & sum_j w_j u_j \ + "subject to" quad & sum_p x_(j,p) = 1 quad forall j \ + & sum_j x_(j,p) = 1 quad forall p \ + & M x_(j,p) + sum_(p' < p) sum_(j') ell_(j') x_(j',p') - M u_j <= d_j - ell_j + M quad forall j, p \ + & x_(j,p) in {0, 1}, u_j in {0, 1} + $. + The third family of constraints enforces: if task $j$ is at position $p$ (so $x_(j,p) = 1$), then its completion time $ell_j + sum_(p' < p) sum_(j') ell_(j') x_(j',p')$ exceeds $d_j$ only when $u_j = 1$. + + _Correctness._ ($arrow.r.double$) Any schedule induces completion times; for each tardy task the big-$M$ constraint forces $u_j = 1$, so the objective counts exactly the total tardy weight. ($arrow.l.double$) Any feasible ILP assignment is a valid permutation (by the assignment constraints) and the tardy indicators agree with the actual completion times. + + _Solution extraction._ Read the unique position $p$ with $x_(j,p) = 1$ for each task $j$ to recover the schedule permutation. +] + #reduction-rule("SequencingToMinimizeWeightedTardiness", "ILP")[ Encode the single-machine order with pairwise precedence bits and completion times, then linearize the weighted tardiness bound with nonnegative tardiness variables. ][ diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index a20f5321..226c56d0 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -312,6 +312,7 @@ Flags by problem type: RectilinearPictureCompression --matrix (0/1), --k SchedulingWithIndividualDeadlines --n, --num-processors/--m, --deadlines [--precedence-pairs] SequencingToMinimizeMaximumCumulativeCost --costs [--precedence-pairs] + SequencingToMinimizeTardyTaskWeight --sizes, --weights, --deadlines SequencingToMinimizeWeightedCompletionTime --lengths, --weights [--precedence-pairs] SequencingToMinimizeWeightedTardiness --sizes, --weights, --deadlines, --bound MinimumExternalMacroDataCompression --string, --pointer-cost [--alphabet-size] diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index 20195f5d..b73fc011 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -30,10 +30,10 @@ use problemreductions::models::misc::{ PaintShop, PartiallyOrderedKnapsack, ProductionPlanning, QueryArg, RectilinearPictureCompression, RegisterSufficiency, ResourceConstrainedScheduling, SchedulingToMinimizeWeightedCompletionTime, SchedulingWithIndividualDeadlines, - SequencingToMinimizeMaximumCumulativeCost, SequencingToMinimizeWeightedCompletionTime, - SequencingToMinimizeWeightedTardiness, SequencingWithReleaseTimesAndDeadlines, - SequencingWithinIntervals, ShortestCommonSupersequence, StringToStringCorrection, SubsetSum, - SumOfSquaresPartition, ThreePartition, TimetableDesign, + SequencingToMinimizeMaximumCumulativeCost, SequencingToMinimizeTardyTaskWeight, + SequencingToMinimizeWeightedCompletionTime, SequencingToMinimizeWeightedTardiness, + SequencingWithReleaseTimesAndDeadlines, SequencingWithinIntervals, ShortestCommonSupersequence, + StringToStringCorrection, SubsetSum, SumOfSquaresPartition, ThreePartition, TimetableDesign, }; use problemreductions::models::BiconnectivityAugmentation; use problemreductions::prelude::*; @@ -3685,6 +3685,57 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { ) } + // SequencingToMinimizeTardyTaskWeight + "SequencingToMinimizeTardyTaskWeight" => { + let sizes_str = args.sizes.as_deref().ok_or_else(|| { + anyhow::anyhow!( + "SequencingToMinimizeTardyTaskWeight requires --sizes, --weights, and --deadlines\n\n\ + Usage: pred create SequencingToMinimizeTardyTaskWeight --sizes 3,2,4,1,2 --weights 5,3,7,2,4 --deadlines 6,4,10,2,8" + ) + })?; + let weights_str = args.weights.as_deref().ok_or_else(|| { + anyhow::anyhow!( + "SequencingToMinimizeTardyTaskWeight requires --weights\n\n\ + Usage: pred create SequencingToMinimizeTardyTaskWeight --sizes 3,2,4,1,2 --weights 5,3,7,2,4 --deadlines 6,4,10,2,8" + ) + })?; + let deadlines_str = args.deadlines.as_deref().ok_or_else(|| { + anyhow::anyhow!( + "SequencingToMinimizeTardyTaskWeight requires --deadlines\n\n\ + Usage: pred create SequencingToMinimizeTardyTaskWeight --sizes 3,2,4,1,2 --weights 5,3,7,2,4 --deadlines 6,4,10,2,8" + ) + })?; + let lengths: Vec = util::parse_comma_list(sizes_str)?; + let weights: Vec = util::parse_comma_list(weights_str)?; + let deadlines: Vec = util::parse_comma_list(deadlines_str)?; + anyhow::ensure!( + lengths.len() == weights.len(), + "sizes length ({}) must equal weights length ({})", + lengths.len(), + weights.len() + ); + anyhow::ensure!( + lengths.len() == deadlines.len(), + "sizes length ({}) must equal deadlines length ({})", + lengths.len(), + deadlines.len() + ); + anyhow::ensure!( + lengths.iter().all(|&l| l > 0), + "task lengths must be positive" + ); + anyhow::ensure!( + weights.iter().all(|&w| w > 0), + "task weights must be positive" + ); + ( + ser(SequencingToMinimizeTardyTaskWeight::new( + lengths, weights, deadlines, + ))?, + resolved_variant.clone(), + ) + } + // SequencingToMinimizeWeightedCompletionTime "SequencingToMinimizeWeightedCompletionTime" => { let lengths_str = args.lengths.as_deref().ok_or_else(|| { diff --git a/src/models/misc/mod.rs b/src/models/misc/mod.rs index ead9c9bc..ccda3a4b 100644 --- a/src/models/misc/mod.rs +++ b/src/models/misc/mod.rs @@ -31,6 +31,7 @@ //! - [`SchedulingWithIndividualDeadlines`]: Meet per-task deadlines on parallel processors //! - [`StackerCrane`]: Minimize the total length of a closed walk through required arcs //! - [`SequencingToMinimizeMaximumCumulativeCost`]: Keep every cumulative schedule cost prefix under a bound +//! - [`SequencingToMinimizeTardyTaskWeight`]: Minimize total weight of tardy tasks //! - [`SequencingToMinimizeWeightedCompletionTime`]: Minimize total weighted completion time //! - [`SequencingToMinimizeWeightedTardiness`]: Decide whether a schedule meets a weighted tardiness bound //! - [`SequencingWithReleaseTimesAndDeadlines`]: Single-machine scheduling feasibility @@ -99,6 +100,7 @@ pub(crate) mod resource_constrained_scheduling; mod scheduling_to_minimize_weighted_completion_time; mod scheduling_with_individual_deadlines; mod sequencing_to_minimize_maximum_cumulative_cost; +mod sequencing_to_minimize_tardy_task_weight; mod sequencing_to_minimize_weighted_completion_time; mod sequencing_to_minimize_weighted_tardiness; mod sequencing_with_release_times_and_deadlines; @@ -147,6 +149,7 @@ pub use resource_constrained_scheduling::ResourceConstrainedScheduling; pub use scheduling_to_minimize_weighted_completion_time::SchedulingToMinimizeWeightedCompletionTime; pub use scheduling_with_individual_deadlines::SchedulingWithIndividualDeadlines; pub use sequencing_to_minimize_maximum_cumulative_cost::SequencingToMinimizeMaximumCumulativeCost; +pub use sequencing_to_minimize_tardy_task_weight::SequencingToMinimizeTardyTaskWeight; pub use sequencing_to_minimize_weighted_completion_time::SequencingToMinimizeWeightedCompletionTime; pub use sequencing_to_minimize_weighted_tardiness::SequencingToMinimizeWeightedTardiness; pub use sequencing_with_release_times_and_deadlines::SequencingWithReleaseTimesAndDeadlines; @@ -193,6 +196,7 @@ pub(crate) fn canonical_model_example_specs() -> Vec", description: "Processing time for each task" }, + FieldInfo { name: "weights", type_name: "Vec", description: "Weight w(t) for each task" }, + FieldInfo { name: "deadlines", type_name: "Vec", description: "Deadline d(t) for each task" }, + ], + } +} + +/// Sequencing to Minimize Tardy Task Weight problem. +/// +/// Given tasks with processing times `l(t)`, weights `w(t)`, and deadlines +/// `d(t)`, find a single-machine schedule that minimizes `sum_{t tardy} w(t)`, +/// where task `t` is tardy if its completion time `C(t) > d(t)`. +/// +/// This is the weighted generalization of minimizing the number of tardy tasks +/// (problem SS8 in Garey & Johnson, 1979, written $1 || sum w_j U_j$). +/// +/// Configurations are direct permutation encodings with `dims() = [n; n]`: +/// each position holds the index of the task scheduled at that position. +/// A configuration is valid iff it is a permutation of `0..n`. +#[derive(Debug, Clone, Serialize)] +pub struct SequencingToMinimizeTardyTaskWeight { + lengths: Vec, + weights: Vec, + deadlines: Vec, +} + +#[derive(Deserialize)] +struct SequencingToMinimizeTardyTaskWeightSerde { + lengths: Vec, + weights: Vec, + deadlines: Vec, +} + +impl SequencingToMinimizeTardyTaskWeight { + fn validate(lengths: &[u64], weights: &[u64], deadlines: &[u64]) -> Result<(), String> { + if lengths.len() != weights.len() { + return Err("lengths length must equal weights length".to_string()); + } + if lengths.len() != deadlines.len() { + return Err("lengths length must equal deadlines length".to_string()); + } + if lengths.contains(&0) { + return Err("task lengths must be positive".to_string()); + } + if weights.contains(&0) { + return Err("task weights must be positive".to_string()); + } + Ok(()) + } + + /// Create a new sequencing instance. + /// + /// # Panics + /// + /// Panics if `lengths`, `weights`, and `deadlines` are not all the same + /// length, or if any length or weight is zero. + pub fn new(lengths: Vec, weights: Vec, deadlines: Vec) -> Self { + Self::validate(&lengths, &weights, &deadlines).unwrap_or_else(|err| panic!("{err}")); + Self { + lengths, + weights, + deadlines, + } + } + + /// Returns the number of tasks. + pub fn num_tasks(&self) -> usize { + self.lengths.len() + } + + /// Returns the processing times. + pub fn lengths(&self) -> &[u64] { + &self.lengths + } + + /// Returns the task weights. + pub fn weights(&self) -> &[u64] { + &self.weights + } + + /// Returns the task deadlines. + pub fn deadlines(&self) -> &[u64] { + &self.deadlines + } + + /// Decode a direct permutation configuration. + /// + /// Returns the schedule as `Some(Vec)` if the config is a valid + /// permutation of `0..n`, or `None` otherwise. + fn decode_permutation(config: &[usize], n: usize) -> Option> { + if config.len() != n { + return None; + } + let mut seen = vec![false; n]; + for &task in config { + if task >= n || seen[task] { + return None; + } + seen[task] = true; + } + Some(config.to_vec()) + } + + fn tardy_task_weight(&self, schedule: &[usize]) -> Min { + let mut elapsed: u64 = 0; + let mut total: u64 = 0; + for &task in schedule { + elapsed = elapsed + .checked_add(self.lengths[task]) + .expect("total processing time overflowed u64"); + if elapsed > self.deadlines[task] { + total = total + .checked_add(self.weights[task]) + .expect("tardy task weight overflowed u64"); + } + } + Min(Some(total)) + } +} + +impl TryFrom for SequencingToMinimizeTardyTaskWeight { + type Error = String; + + fn try_from(value: SequencingToMinimizeTardyTaskWeightSerde) -> Result { + Self::validate(&value.lengths, &value.weights, &value.deadlines)?; + Ok(Self { + lengths: value.lengths, + weights: value.weights, + deadlines: value.deadlines, + }) + } +} + +impl<'de> Deserialize<'de> for SequencingToMinimizeTardyTaskWeight { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value = SequencingToMinimizeTardyTaskWeightSerde::deserialize(deserializer)?; + Self::try_from(value).map_err(serde::de::Error::custom) + } +} + +impl Problem for SequencingToMinimizeTardyTaskWeight { + const NAME: &'static str = "SequencingToMinimizeTardyTaskWeight"; + type Value = Min; + + fn variant() -> Vec<(&'static str, &'static str)> { + crate::variant_params![] + } + + fn dims(&self) -> Vec { + let n = self.num_tasks(); + vec![n; n] + } + + fn evaluate(&self, config: &[usize]) -> Min { + let n = self.num_tasks(); + let Some(schedule) = Self::decode_permutation(config, n) else { + return Min(None); + }; + self.tardy_task_weight(&schedule) + } +} + +crate::declare_variants! { + default SequencingToMinimizeTardyTaskWeight => "factorial(num_tasks)", +} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_model_example_specs() -> Vec { + vec![crate::example_db::specs::ModelExampleSpec { + id: "sequencing_to_minimize_tardy_task_weight", + // 5 tasks, lengths [3,2,4,1,2], weights [5,3,7,2,4], deadlines [6,4,10,2,8] + // Optimal schedule: [t4,t1,t5,t3,t2] = config [3,0,4,2,1] + // Start times: t4 starts 0, completes 1 (tardy: C=1 <= d=2, ok) + // t1 starts 1, completes 4 (tardy: C=4 <= d=6, ok) + // t5 starts 4, completes 6 (tardy: C=6 <= d=8, ok) + // t3 starts 6, completes 10 (tardy: C=10 <= d=10, ok) + // t2 starts 10, completes 12 (tardy: C=12 > d=4, tardy weight 3) + // Total tardy weight = 3 + instance: Box::new(SequencingToMinimizeTardyTaskWeight::new( + vec![3, 2, 4, 1, 2], + vec![5, 3, 7, 2, 4], + vec![6, 4, 10, 2, 8], + )), + optimal_config: vec![3, 0, 4, 2, 1], + optimal_value: serde_json::json!(3), + }] +} + +#[cfg(test)] +#[path = "../../unit_tests/models/misc/sequencing_to_minimize_tardy_task_weight.rs"] +mod tests; diff --git a/src/models/mod.rs b/src/models/mod.rs index 1faed96a..82d7a06e 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -46,11 +46,11 @@ pub use misc::{ PaintShop, Partition, PrecedenceConstrainedScheduling, ProductionPlanning, QueryArg, RectilinearPictureCompression, RegisterSufficiency, ResourceConstrainedScheduling, SchedulingToMinimizeWeightedCompletionTime, SchedulingWithIndividualDeadlines, - SequencingToMinimizeMaximumCumulativeCost, SequencingToMinimizeWeightedCompletionTime, - SequencingToMinimizeWeightedTardiness, SequencingWithReleaseTimesAndDeadlines, - SequencingWithinIntervals, ShortestCommonSupersequence, StackerCrane, StaffScheduling, - StringToStringCorrection, SubsetSum, SumOfSquaresPartition, Term, ThreePartition, - TimetableDesign, + SequencingToMinimizeMaximumCumulativeCost, SequencingToMinimizeTardyTaskWeight, + SequencingToMinimizeWeightedCompletionTime, SequencingToMinimizeWeightedTardiness, + SequencingWithReleaseTimesAndDeadlines, SequencingWithinIntervals, ShortestCommonSupersequence, + StackerCrane, StaffScheduling, StringToStringCorrection, SubsetSum, SumOfSquaresPartition, + Term, ThreePartition, TimetableDesign, }; pub use set::{ ComparativeContainment, ConsecutiveSets, ExactCoverBy3Sets, IntegerKnapsack, MaximumSetPacking, diff --git a/src/rules/mod.rs b/src/rules/mod.rs index a9a4cc73..1933cd14 100644 --- a/src/rules/mod.rs +++ b/src/rules/mod.rs @@ -214,6 +214,8 @@ pub(crate) mod schedulingwithindividualdeadlines_ilp; #[cfg(feature = "ilp-solver")] pub(crate) mod sequencingtominimizemaximumcumulativecost_ilp; #[cfg(feature = "ilp-solver")] +pub(crate) mod sequencingtominimizetardytaskweight_ilp; +#[cfg(feature = "ilp-solver")] pub(crate) mod sequencingtominimizeweightedcompletiontime_ilp; #[cfg(feature = "ilp-solver")] pub(crate) mod sequencingtominimizeweightedtardiness_ilp; @@ -381,6 +383,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec. +//! +//! Position-assignment ILP: binary x_{j,p} placing task j in position p, +//! with binary tardy indicator u_j. A big-M constraint forces u_j = 1 +//! whenever the completion time at position p exceeds the deadline d_j. + +use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; +use crate::models::misc::SequencingToMinimizeTardyTaskWeight; +use crate::reduction; +use crate::rules::ilp_helpers::one_hot_decode; +use crate::rules::traits::{ReduceTo, ReductionResult}; + +/// Result of reducing SequencingToMinimizeTardyTaskWeight to ILP. +#[derive(Debug, Clone)] +pub struct ReductionSTMTTWToILP { + target: ILP, + num_tasks: usize, +} + +impl ReductionResult for ReductionSTMTTWToILP { + type Source = SequencingToMinimizeTardyTaskWeight; + type Target = ILP; + + fn target_problem(&self) -> &ILP { + &self.target + } + + fn extract_solution(&self, target_solution: &[usize]) -> Vec { + let n = self.num_tasks; + // Decode the n*n block of x_{j,p} variables into a schedule permutation. + // The source uses direct permutation encoding (config = schedule directly), + // so return the schedule as-is (it is already a permutation of 0..n). + one_hot_decode(target_solution, n, n, 0) + } +} + +#[reduction(overhead = { + num_vars = "num_tasks * num_tasks + num_tasks", + num_constraints = "2 * num_tasks + num_tasks * num_tasks", +})] +impl ReduceTo> for SequencingToMinimizeTardyTaskWeight { + type Result = ReductionSTMTTWToILP; + + fn reduce_to(&self) -> Self::Result { + let n = self.num_tasks(); + let num_x_vars = n * n; + let num_vars = num_x_vars + n; + let total_length: u64 = self.lengths().iter().copied().sum(); + let big_m = total_length as f64; + + let x_var = |j: usize, p: usize| -> usize { j * n + p }; + let u_var = |j: usize| -> usize { num_x_vars + j }; + + let mut constraints = Vec::new(); + + // 1. Each task assigned to exactly one position + for j in 0..n { + let terms: Vec<(usize, f64)> = (0..n).map(|p| (x_var(j, p), 1.0)).collect(); + constraints.push(LinearConstraint::eq(terms, 1.0)); + } + + // 2. Each position has exactly one task + for p in 0..n { + let terms: Vec<(usize, f64)> = (0..n).map(|j| (x_var(j, p), 1.0)).collect(); + constraints.push(LinearConstraint::eq(terms, 1.0)); + } + + // 3. Tardy indicator: for each (j, p), if x_{j,p}=1 then + // completion_time_at_p >= l_j + sum_{p' < p} sum_{j'} l_{j'} * x_{j',p'} + // If completion > d_j then u_j must be 1. + // Linearized as: big_m * x_{j,p} + sum_{p' = Vec::new(); + terms.push((x_var(j, p), big_m)); + for pp in 0..p { + for (jj, &len) in lengths.iter().enumerate() { + terms.push((x_var(jj, pp), len as f64)); + } + } + terms.push((u_var(j), -big_m)); + let rhs = self.deadlines()[j] as f64 - lengths[j] as f64 + big_m; + constraints.push(LinearConstraint::le(terms, rhs)); + } + } + + // Objective: minimize sum w_j * u_j + let weights = self.weights(); + let objective: Vec<(usize, f64)> = (0..n).map(|j| (u_var(j), weights[j] as f64)).collect(); + + ReductionSTMTTWToILP { + target: ILP::new(num_vars, constraints, objective, ObjectiveSense::Minimize), + num_tasks: n, + } + } +} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_rule_example_specs() -> Vec { + vec![crate::example_db::specs::RuleExampleSpec { + id: "sequencingtominimizetardytaskweight_to_ilp", + build: || { + let source = SequencingToMinimizeTardyTaskWeight::new( + vec![3, 2, 4, 1, 2], + vec![5, 3, 7, 2, 4], + vec![6, 4, 10, 2, 8], + ); + crate::example_db::specs::rule_example_via_ilp::<_, bool>(source) + }, + }] +} + +#[cfg(test)] +#[path = "../unit_tests/rules/sequencingtominimizetardytaskweight_ilp.rs"] +mod tests; diff --git a/src/unit_tests/models/misc/sequencing_to_minimize_tardy_task_weight.rs b/src/unit_tests/models/misc/sequencing_to_minimize_tardy_task_weight.rs new file mode 100644 index 00000000..023b18c7 --- /dev/null +++ b/src/unit_tests/models/misc/sequencing_to_minimize_tardy_task_weight.rs @@ -0,0 +1,198 @@ +use super::*; +use crate::solvers::BruteForce; +use crate::traits::Problem; +use crate::types::Min; + +#[test] +fn test_sequencing_to_minimize_tardy_task_weight_basic() { + let problem = SequencingToMinimizeTardyTaskWeight::new( + vec![3, 2, 4, 1, 2], + vec![5, 3, 7, 2, 4], + vec![6, 4, 10, 2, 8], + ); + + assert_eq!(problem.num_tasks(), 5); + assert_eq!(problem.lengths(), &[3, 2, 4, 1, 2]); + assert_eq!(problem.weights(), &[5, 3, 7, 2, 4]); + assert_eq!(problem.deadlines(), &[6, 4, 10, 2, 8]); + assert_eq!(problem.dims(), vec![5, 5, 5, 5, 5]); + assert_eq!( + ::NAME, + "SequencingToMinimizeTardyTaskWeight" + ); + assert_eq!( + ::variant(), + vec![] + ); +} + +#[test] +fn test_sequencing_to_minimize_tardy_task_weight_evaluate_issue_example() { + let problem = SequencingToMinimizeTardyTaskWeight::new( + vec![3, 2, 4, 1, 2], + vec![5, 3, 7, 2, 4], + vec![6, 4, 10, 2, 8], + ); + + // Schedule [3,0,4,2,1] = t3,t0,t4,t2,t1 + // t3: completes at 1, deadline=2, on time + // t0: completes at 1+3=4, deadline=6, on time + // t4: completes at 4+2=6, deadline=8, on time + // t2: completes at 6+4=10, deadline=10, on time + // t1: completes at 10+2=12, deadline=4, TARDY weight=3 + // Total = 3 + assert_eq!(problem.evaluate(&[3, 0, 4, 2, 1]), Min(Some(3))); +} + +#[test] +fn test_sequencing_to_minimize_tardy_task_weight_evaluate_all_on_time() { + // Single task with generous deadline + let problem = SequencingToMinimizeTardyTaskWeight::new(vec![2, 3], vec![5, 4], vec![10, 10]); + // Both orders: no task is tardy + assert_eq!(problem.evaluate(&[0, 1]), Min(Some(0))); + assert_eq!(problem.evaluate(&[1, 0]), Min(Some(0))); +} + +#[test] +fn test_sequencing_to_minimize_tardy_task_weight_evaluate_all_tardy() { + // Tight deadlines: every task is tardy regardless of order (3 tasks, total length=9) + let problem = + SequencingToMinimizeTardyTaskWeight::new(vec![3, 3, 3], vec![1, 2, 3], vec![2, 2, 2]); + // [0,1,2]: t0 completes 3>2 tardy(1), t1 completes 6>2 tardy(2), t2 completes 9>2 tardy(3) = 6 + assert_eq!(problem.evaluate(&[0, 1, 2]), Min(Some(6))); +} + +#[test] +fn test_sequencing_to_minimize_tardy_task_weight_evaluate_invalid_config() { + let problem = + SequencingToMinimizeTardyTaskWeight::new(vec![2, 3, 1], vec![1, 2, 3], vec![5, 6, 7]); + + // Wrong length + assert_eq!(problem.evaluate(&[0, 1]), Min(None)); + assert_eq!(problem.evaluate(&[0, 1, 2, 0]), Min(None)); + // Not a permutation (duplicate) + assert_eq!(problem.evaluate(&[0, 0, 1]), Min(None)); + // Out of range + assert_eq!(problem.evaluate(&[0, 1, 3]), Min(None)); +} + +#[test] +fn test_sequencing_to_minimize_tardy_task_weight_brute_force_small() { + // 3 tasks so brute force is fast (3^3 = 27 configs) + let problem = + SequencingToMinimizeTardyTaskWeight::new(vec![3, 2, 1], vec![4, 2, 3], vec![4, 3, 6]); + let solver = BruteForce::new(); + let solution = solver + .find_witness(&problem) + .expect("should find a solution"); + let value = problem.evaluate(&solution); + assert!(value.is_valid()); + + // Check it's truly optimal by brute-forcing all permutations + let permutations: Vec> = vec![ + vec![0, 1, 2], + vec![0, 2, 1], + vec![1, 0, 2], + vec![1, 2, 0], + vec![2, 0, 1], + vec![2, 1, 0], + ]; + let best = permutations + .iter() + .filter_map(|perm| problem.evaluate(perm).0) + .min() + .unwrap(); + assert_eq!(value, Min(Some(best))); +} + +#[test] +fn test_sequencing_to_minimize_tardy_task_weight_paper_example() { + let problem = SequencingToMinimizeTardyTaskWeight::new( + vec![3, 2, 4, 1, 2], + vec![5, 3, 7, 2, 4], + vec![6, 4, 10, 2, 8], + ); + let expected_config = vec![3, 0, 4, 2, 1]; + assert_eq!(problem.evaluate(&expected_config), Min(Some(3))); + + let solver = BruteForce::new(); + let solution = solver + .find_witness(&problem) + .expect("should find a solution"); + assert_eq!(problem.evaluate(&solution), Min(Some(3))); +} + +#[test] +fn test_sequencing_to_minimize_tardy_task_weight_serialization() { + let problem = + SequencingToMinimizeTardyTaskWeight::new(vec![3, 2, 1], vec![4, 2, 3], vec![4, 3, 6]); + let json = serde_json::to_value(&problem).unwrap(); + let restored: SequencingToMinimizeTardyTaskWeight = serde_json::from_value(json).unwrap(); + + assert_eq!(restored.lengths(), problem.lengths()); + assert_eq!(restored.weights(), problem.weights()); + assert_eq!(restored.deadlines(), problem.deadlines()); +} + +#[test] +fn test_sequencing_to_minimize_tardy_task_weight_deserialization_rejects_zero_length() { + let err = serde_json::from_value::(serde_json::json!({ + "lengths": [0, 1, 3], + "weights": [1, 2, 3], + "deadlines": [5, 5, 5], + })) + .unwrap_err(); + assert!(err.to_string().contains("task lengths must be positive")); +} + +#[test] +fn test_sequencing_to_minimize_tardy_task_weight_deserialization_rejects_zero_weight() { + let err = serde_json::from_value::(serde_json::json!({ + "lengths": [1, 2, 3], + "weights": [0, 2, 3], + "deadlines": [5, 5, 5], + })) + .unwrap_err(); + assert!(err.to_string().contains("task weights must be positive")); +} + +#[test] +fn test_sequencing_to_minimize_tardy_task_weight_single_task() { + let problem = SequencingToMinimizeTardyTaskWeight::new(vec![3], vec![2], vec![5]); + assert_eq!(problem.dims(), vec![1]); + // completes at 3, deadline 5, on time + assert_eq!(problem.evaluate(&[0]), Min(Some(0))); +} + +#[test] +fn test_sequencing_to_minimize_tardy_task_weight_single_task_tardy() { + let problem = SequencingToMinimizeTardyTaskWeight::new(vec![3], vec![2], vec![2]); + // completes at 3, deadline 2, tardy, weight 2 + assert_eq!(problem.evaluate(&[0]), Min(Some(2))); +} + +#[test] +fn test_sequencing_to_minimize_tardy_task_weight_empty() { + let problem = SequencingToMinimizeTardyTaskWeight::new(vec![], vec![], vec![]); + assert_eq!(problem.num_tasks(), 0); + assert_eq!(problem.dims(), Vec::::new()); + assert_eq!(problem.evaluate(&[]), Min(Some(0))); +} + +#[test] +#[should_panic(expected = "lengths length must equal weights length")] +fn test_sequencing_to_minimize_tardy_task_weight_mismatched_lengths_weights() { + SequencingToMinimizeTardyTaskWeight::new(vec![2, 1], vec![3], vec![5, 5]); +} + +#[test] +#[should_panic(expected = "lengths length must equal deadlines length")] +fn test_sequencing_to_minimize_tardy_task_weight_mismatched_lengths_deadlines() { + SequencingToMinimizeTardyTaskWeight::new(vec![2, 1], vec![3, 4], vec![5]); +} + +#[test] +#[should_panic(expected = "task lengths must be positive")] +fn test_sequencing_to_minimize_tardy_task_weight_zero_length() { + SequencingToMinimizeTardyTaskWeight::new(vec![0, 1], vec![2, 3], vec![5, 5]); +} diff --git a/src/unit_tests/rules/sequencingtominimizetardytaskweight_ilp.rs b/src/unit_tests/rules/sequencingtominimizetardytaskweight_ilp.rs new file mode 100644 index 00000000..71199ad2 --- /dev/null +++ b/src/unit_tests/rules/sequencingtominimizetardytaskweight_ilp.rs @@ -0,0 +1,84 @@ +use super::*; +use crate::models::algebraic::ILP; +use crate::rules::test_helpers::assert_optimization_round_trip_from_optimization_target; +use crate::solvers::{BruteForce, ILPSolver}; +use crate::traits::Problem; + +#[test] +fn test_sequencingtominimizetardytaskweight_to_ilp_closed_loop() { + let problem = + SequencingToMinimizeTardyTaskWeight::new(vec![3, 2, 1], vec![4, 2, 3], vec![4, 3, 6]); + let reduction = ReduceTo::>::reduce_to(&problem); + + assert_optimization_round_trip_from_optimization_target( + &problem, + &reduction, + "SequencingToMinimizeTardyTaskWeight->ILP closed loop", + ); +} + +#[test] +fn test_sequencingtominimizetardytaskweight_to_ilp_bf_vs_ilp() { + let problem = SequencingToMinimizeTardyTaskWeight::new( + vec![3, 2, 4, 1, 2], + vec![5, 3, 7, 2, 4], + vec![6, 4, 10, 2, 8], + ); + + let bf = BruteForce::new(); + let bf_witness = bf.find_witness(&problem).expect("should find a solution"); + let bf_value = problem.evaluate(&bf_witness); + + let reduction = ReduceTo::>::reduce_to(&problem); + let ilp_solution = ILPSolver::new() + .solve(reduction.target_problem()) + .expect("ILP should be solvable"); + let extracted = reduction.extract_solution(&ilp_solution); + let ilp_value = problem.evaluate(&extracted); + + assert_eq!(bf_value, ilp_value); + assert_eq!(ilp_value.0, Some(3)); +} + +#[test] +fn test_sequencingtominimizetardytaskweight_to_ilp_all_on_time() { + let problem = + SequencingToMinimizeTardyTaskWeight::new(vec![1, 1, 1], vec![2, 3, 4], vec![10, 10, 10]); + let reduction = ReduceTo::>::reduce_to(&problem); + + let ilp_solution = ILPSolver::new() + .solve(reduction.target_problem()) + .expect("ILP should be solvable"); + let extracted = reduction.extract_solution(&ilp_solution); + let value = problem.evaluate(&extracted); + assert!(value.is_valid()); + assert_eq!(value.0, Some(0)); +} + +#[test] +fn test_sequencingtominimizetardytaskweight_to_ilp_optimal_ordering() { + // 3 tasks where order matters: + // t0: length=4, weight=5, deadline=4 + // t1: length=1, weight=1, deadline=5 + // t2: length=2, weight=3, deadline=3 + // Best schedule: [2,0,1] -> t2 completes 2 (ok), t0 completes 6 (tardy wt=5), t1 completes 7 (tardy wt=1) + // or: [2,1,0] -> t2 completes 2 (ok), t1 completes 3 (ok), t0 completes 7 (tardy wt=5) + // or: [1,2,0] -> t1 completes 1 (ok), t2 completes 3 (ok), t0 completes 7 (tardy wt=5) + // or: [0,1,2] -> t0 completes 4 (ok), t1 completes 5 (ok), t2 completes 7 (tardy wt=3) = 3 + // Minimum is 3 (schedule [0,1,2]) + let problem = + SequencingToMinimizeTardyTaskWeight::new(vec![4, 1, 2], vec![5, 1, 3], vec![4, 5, 3]); + let reduction = ReduceTo::>::reduce_to(&problem); + + let ilp_solution = ILPSolver::new() + .solve(reduction.target_problem()) + .expect("ILP should be solvable"); + let extracted = reduction.extract_solution(&ilp_solution); + let ilp_value = problem.evaluate(&extracted); + + let bf = BruteForce::new(); + let bf_witness = bf.find_witness(&problem).expect("should have solution"); + let bf_value = problem.evaluate(&bf_witness); + + assert_eq!(ilp_value, bf_value); +} From 67ce930fa23ed6abc41caebfec12845c50eb0b40 Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Mon, 30 Mar 2026 05:02:57 +0800 Subject: [PATCH 4/6] feat: add SequencingWithDeadlinesAndSetUpTimes model (#499) Implement the scheduling feasibility problem with compiler-class setup times (GJ SS6) with direct ILP reduction, CLI support, and paper entry. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/paper/reductions.typ | 62 +++++ problemreductions-cli/src/cli.rs | 7 + problemreductions-cli/src/commands/create.rs | 73 ++++- src/models/misc/mod.rs | 4 + ...uencing_with_deadlines_and_set_up_times.rs | 263 ++++++++++++++++++ src/rules/mod.rs | 3 + ...equencingwithdeadlinesandsetuptimes_ilp.rs | 224 +++++++++++++++ ...uencing_with_deadlines_and_set_up_times.rs | 206 ++++++++++++++ ...equencingwithdeadlinesandsetuptimes_ilp.rs | 121 ++++++++ 9 files changed, 961 insertions(+), 2 deletions(-) create mode 100644 src/models/misc/sequencing_with_deadlines_and_set_up_times.rs create mode 100644 src/rules/sequencingwithdeadlinesandsetuptimes_ilp.rs create mode 100644 src/unit_tests/models/misc/sequencing_with_deadlines_and_set_up_times.rs create mode 100644 src/unit_tests/rules/sequencingwithdeadlinesandsetuptimes_ilp.rs diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index 8509a419..07d04735 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -191,6 +191,7 @@ "SequencingToMinimizeTardyTaskWeight": [Sequencing to Minimize Tardy Task Weight], "SequencingToMinimizeWeightedCompletionTime": [Sequencing to Minimize Weighted Completion Time], "SequencingToMinimizeWeightedTardiness": [Sequencing to Minimize Weighted Tardiness], + "SequencingWithDeadlinesAndSetUpTimes": [Sequencing with Deadlines and Set-Up Times], "SequencingWithReleaseTimesAndDeadlines": [Sequencing with Release Times and Deadlines], "SequencingWithinIntervals": [Sequencing Within Intervals], "ShortestCommonSupersequence": [Shortest Common Supersequence], @@ -6472,6 +6473,47 @@ A classical NP-complete problem from Garey and Johnson @garey1979[Ch.~3, p.~76], ] } +#{ + let x = load-model-example("SequencingWithDeadlinesAndSetUpTimes") + let lengths = x.instance.lengths + let deadlines = x.instance.deadlines + let compilers = x.instance.compilers + let setup_times = x.instance.setup_times + let ntasks = lengths.len() + let schedule = x.optimal_config + let completions = { + let t = 0 + let prev_compiler = none + let result = () + for task in schedule { + if prev_compiler != none and prev_compiler != compilers.at(task) { + t += setup_times.at(compilers.at(task)) + } + t += lengths.at(task) + result.push(t) + prev_compiler = compilers.at(task) + } + result + } + [ + #problem-def("SequencingWithDeadlinesAndSetUpTimes")[ + Given a set $T$ of $n$ tasks, a processing-time function $ell: T -> ZZ^+$, a deadline function $d: T -> ZZ^+$, a compiler assignment $k: T -> C$ for a finite set $C$ of compilers, and a setup-time function $s: C -> ZZ_(>=0)$, determine whether there exists a single-machine schedule such that every task $t$ completes by its deadline $d(t)$, where an additional setup time $s(k(t))$ is charged before $t$ whenever $k(t) != k(t')$ for the immediately preceding task $t'$. + ][ + Sequencing with Deadlines and Set-Up Times is problem SS14 in Garey & Johnson @garey1979, usually written $1 | s_(i j) | "feasibility"$. The problem is NP-complete even when all setup times are equal. It generalises Sequencing with Release Times and Deadlines (SS13) by replacing release-time windows with compiler-switch penalties. + + Configurations are direct permutation encodings: the config vector $(sigma_0, dots, sigma_(n-1))$ specifies which task occupies each position, and a configuration is valid iff it is a permutation of $\{0, dots, n-1\}$. + + *Example.* Consider $n = #ntasks$ tasks with lengths $ell = (#lengths.map(v => str(v)).join(", "))$, deadlines $d = (#deadlines.map(v => str(v)).join(", "))$, compilers $k = (#compilers.map(v => str(v)).join(", "))$, and setup times $s = (#setup_times.map(v => str(v)).join(", "))$. The schedule $(#schedule.map(t => $t_(#(t + 1))$).join(", "))$ achieves completion times $(#completions.map(v => str(v)).join(", "))$; every task meets its deadline, so the instance is feasible. + + #pred-commands( + "pred create --example SequencingWithDeadlinesAndSetUpTimes -o sequencing-with-deadlines-and-set-up-times.json", + "pred solve sequencing-with-deadlines-and-set-up-times.json", + "pred evaluate sequencing-with-deadlines-and-set-up-times.json --config " + x.optimal_config.map(str).join(","), + ) + ] + ] +} + #{ let x = load-model-example("IntegralFlowHomologousArcs") let arcs = x.instance.graph.arcs @@ -9524,6 +9566,26 @@ The following reductions to Integer Linear Programming are straightforward formu _Solution extraction._ Read the unique position $p$ with $x_(j,p) = 1$ for each task $j$ to recover the schedule permutation. ] +#reduction-rule("SequencingWithDeadlinesAndSetUpTimes", "ILP")[ + Assign tasks to positions with switch-detection auxiliaries that gate per-compiler setup costs into the deadline constraints. +][ + _Construction._ Let $n$ be the number of tasks. Variables: binary $x_(j,p)$ with $x_(j,p) = 1$ iff task $j$ occupies position $p$; binary $"sw"_p$ for $p >= 1$ indicating a compiler switch before position $p$; binary $a_(j,p) = x_(j,p) dot "sw"_p$ (linearised product). Let $M = sum_j ell_j + max_c s(c) dot (n-1)$. The ILP is: + $ + "find" quad & bold(x) \ + "subject to" quad & sum_p x_(j,p) = 1 quad forall j \ + & sum_j x_(j,p) = 1 quad forall p \ + & x_(j,p) + x_(j',p-1) - "sw"_p <= 1 quad forall p >= 1, j, j' : k(j) != k(j') \ + & a_(j,p) <= x_(j,p), quad a_(j,p) <= "sw"_p, quad x_(j,p) + "sw"_p - a_(j,p) <= 1 quad forall j, p >= 1 \ + & M x_(j,p) + sum_(p' < p) sum_(j') ell_(j') x_(j',p') + sum_(p'=1)^(p) sum_(j') s(k(j')) a_(j',p') <= d_j - ell_j + M quad forall j, p \ + & x_(j,p), "sw"_p, a_(j,p) in {0, 1} + $. + The switch-detection row forces $"sw"_p = 1$ whenever the tasks at positions $p-1$ and $p$ use different compilers. The $a_(j,p)$ linearisation then routes the correct per-compiler setup time into the completion-time bound for each position. + + _Correctness._ ($arrow.r.double$) Any feasible schedule assigns each task to a position; the switch indicator equals one exactly when consecutive compilers differ, and the deadline constraint is satisfied by hypothesis. ($arrow.l.double$) Any feasible ILP solution is a valid permutation and the deadline bound ensures each task finishes on time accounting for all setup penalties. + + _Solution extraction._ Read the unique position $p$ with $x_(j,p) = 1$ for each task $j$ to recover the schedule permutation. +] + #reduction-rule("SequencingToMinimizeWeightedTardiness", "ILP")[ Encode the single-machine order with pairwise precedence bits and completion times, then linearize the weighted tardiness bound with nonnegative tardiness variables. ][ diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index 226c56d0..fbd0d5ae 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -315,6 +315,7 @@ Flags by problem type: SequencingToMinimizeTardyTaskWeight --sizes, --weights, --deadlines SequencingToMinimizeWeightedCompletionTime --lengths, --weights [--precedence-pairs] SequencingToMinimizeWeightedTardiness --sizes, --weights, --deadlines, --bound + SequencingWithDeadlinesAndSetUpTimes --sizes, --deadlines, --compilers, --setup-times MinimumExternalMacroDataCompression --string, --pointer-cost [--alphabet-size] MinimumInternalMacroDataCompression --string, --pointer-cost [--alphabet-size] SCS --strings [--alphabet-size] @@ -757,6 +758,12 @@ pub struct CreateArgs { /// Number of sectors for ExpectedRetrievalCost #[arg(long)] pub num_sectors: Option, + /// Compiler index for each task in SequencingWithDeadlinesAndSetUpTimes (comma-separated, e.g., "0,1,0,1,0") + #[arg(long)] + pub compilers: Option, + /// Setup times per compiler for SequencingWithDeadlinesAndSetUpTimes (comma-separated, e.g., "1,2") + #[arg(long)] + pub setup_times: Option, /// Source string for StringToStringCorrection (comma-separated symbol indices, e.g., "0,1,2,3") #[arg(long)] pub source_string: Option, diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index b73fc011..2e061b0d 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -32,8 +32,9 @@ use problemreductions::models::misc::{ SchedulingToMinimizeWeightedCompletionTime, SchedulingWithIndividualDeadlines, SequencingToMinimizeMaximumCumulativeCost, SequencingToMinimizeTardyTaskWeight, SequencingToMinimizeWeightedCompletionTime, SequencingToMinimizeWeightedTardiness, - SequencingWithReleaseTimesAndDeadlines, SequencingWithinIntervals, ShortestCommonSupersequence, - StringToStringCorrection, SubsetSum, SumOfSquaresPartition, ThreePartition, TimetableDesign, + SequencingWithDeadlinesAndSetUpTimes, SequencingWithReleaseTimesAndDeadlines, + SequencingWithinIntervals, ShortestCommonSupersequence, StringToStringCorrection, SubsetSum, + SumOfSquaresPartition, ThreePartition, TimetableDesign, }; use problemreductions::models::BiconnectivityAugmentation; use problemreductions::prelude::*; @@ -205,6 +206,8 @@ fn all_data_flags_empty(args: &CreateArgs) -> bool { && args.rhs.is_none() && args.coeff_c.is_none() && args.required_columns.is_none() + && args.compilers.is_none() + && args.setup_times.is_none() } fn emit_problem_output(output: &ProblemJsonOutput, out: &OutputConfig) -> Result<()> { @@ -3736,6 +3739,70 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { ) } + // SequencingWithDeadlinesAndSetUpTimes + "SequencingWithDeadlinesAndSetUpTimes" => { + let sizes_str = args.sizes.as_deref().ok_or_else(|| { + anyhow::anyhow!( + "SequencingWithDeadlinesAndSetUpTimes requires --sizes, --deadlines, --compilers, and --setup-times\n\n\ + Usage: pred create SequencingWithDeadlinesAndSetUpTimes --sizes 2,3,1,2,2 --deadlines 4,11,3,16,7 --compilers 0,1,0,1,0 --setup-times 1,2" + ) + })?; + let deadlines_str = args.deadlines.as_deref().ok_or_else(|| { + anyhow::anyhow!( + "SequencingWithDeadlinesAndSetUpTimes requires --deadlines\n\n\ + Usage: pred create SequencingWithDeadlinesAndSetUpTimes --sizes 2,3,1,2,2 --deadlines 4,11,3,16,7 --compilers 0,1,0,1,0 --setup-times 1,2" + ) + })?; + let compilers_str = args.compilers.as_deref().ok_or_else(|| { + anyhow::anyhow!( + "SequencingWithDeadlinesAndSetUpTimes requires --compilers\n\n\ + Usage: pred create SequencingWithDeadlinesAndSetUpTimes --sizes 2,3,1,2,2 --deadlines 4,11,3,16,7 --compilers 0,1,0,1,0 --setup-times 1,2" + ) + })?; + let setup_times_str = args.setup_times.as_deref().ok_or_else(|| { + anyhow::anyhow!( + "SequencingWithDeadlinesAndSetUpTimes requires --setup-times\n\n\ + Usage: pred create SequencingWithDeadlinesAndSetUpTimes --sizes 2,3,1,2,2 --deadlines 4,11,3,16,7 --compilers 0,1,0,1,0 --setup-times 1,2" + ) + })?; + let lengths: Vec = util::parse_comma_list(sizes_str)?; + let deadlines: Vec = util::parse_comma_list(deadlines_str)?; + let compilers: Vec = util::parse_comma_list(compilers_str)?; + let setup_times: Vec = util::parse_comma_list(setup_times_str)?; + anyhow::ensure!( + lengths.len() == deadlines.len(), + "lengths length ({}) must equal deadlines length ({})", + lengths.len(), + deadlines.len() + ); + anyhow::ensure!( + lengths.len() == compilers.len(), + "lengths length ({}) must equal compilers length ({})", + lengths.len(), + compilers.len() + ); + anyhow::ensure!( + lengths.iter().all(|&l| l > 0), + "task lengths must be positive" + ); + let num_compilers = setup_times.len(); + for &c in &compilers { + anyhow::ensure!( + c < num_compilers, + "compiler index {c} is out of range for setup_times of length {num_compilers}" + ); + } + ( + ser(SequencingWithDeadlinesAndSetUpTimes::new( + lengths, + deadlines, + compilers, + setup_times, + ))?, + resolved_variant.clone(), + ) + } + // SequencingToMinimizeWeightedCompletionTime "SequencingToMinimizeWeightedCompletionTime" => { let lengths_str = args.lengths.as_deref().ok_or_else(|| { @@ -8060,6 +8127,8 @@ mod tests { rhs: None, coeff_c: None, required_columns: None, + compilers: None, + setup_times: None, } } diff --git a/src/models/misc/mod.rs b/src/models/misc/mod.rs index ccda3a4b..a34a5d51 100644 --- a/src/models/misc/mod.rs +++ b/src/models/misc/mod.rs @@ -34,6 +34,7 @@ //! - [`SequencingToMinimizeTardyTaskWeight`]: Minimize total weight of tardy tasks //! - [`SequencingToMinimizeWeightedCompletionTime`]: Minimize total weighted completion time //! - [`SequencingToMinimizeWeightedTardiness`]: Decide whether a schedule meets a weighted tardiness bound +//! - [`SequencingWithDeadlinesAndSetUpTimes`]: Single-machine scheduling feasibility with compiler-switch setup penalties //! - [`SequencingWithReleaseTimesAndDeadlines`]: Single-machine scheduling feasibility //! - [`SequencingWithinIntervals`]: Schedule tasks within time windows //! - [`ShortestCommonSupersequence`]: Find a common supersequence of bounded length @@ -103,6 +104,7 @@ mod sequencing_to_minimize_maximum_cumulative_cost; mod sequencing_to_minimize_tardy_task_weight; mod sequencing_to_minimize_weighted_completion_time; mod sequencing_to_minimize_weighted_tardiness; +mod sequencing_with_deadlines_and_set_up_times; mod sequencing_with_release_times_and_deadlines; mod sequencing_within_intervals; pub(crate) mod shortest_common_supersequence; @@ -152,6 +154,7 @@ pub use sequencing_to_minimize_maximum_cumulative_cost::SequencingToMinimizeMaxi pub use sequencing_to_minimize_tardy_task_weight::SequencingToMinimizeTardyTaskWeight; pub use sequencing_to_minimize_weighted_completion_time::SequencingToMinimizeWeightedCompletionTime; pub use sequencing_to_minimize_weighted_tardiness::SequencingToMinimizeWeightedTardiness; +pub use sequencing_with_deadlines_and_set_up_times::SequencingWithDeadlinesAndSetUpTimes; pub use sequencing_with_release_times_and_deadlines::SequencingWithReleaseTimesAndDeadlines; pub use sequencing_within_intervals::SequencingWithinIntervals; pub use shortest_common_supersequence::ShortestCommonSupersequence; @@ -197,6 +200,7 @@ pub(crate) fn canonical_model_example_specs() -> Vec", description: "Processing time for each task" }, + FieldInfo { name: "deadlines", type_name: "Vec", description: "Deadline d(t) for each task" }, + FieldInfo { name: "compilers", type_name: "Vec", description: "Compiler index k(t) for each task" }, + FieldInfo { name: "setup_times", type_name: "Vec", description: "Setup time s(c) charged when switching away from compiler c" }, + ], + } +} + +/// Sequencing with Deadlines and Set-Up Times problem. +/// +/// Given tasks with processing times `l(t)`, deadlines `d(t)`, compiler +/// assignments `k(t)`, and per-compiler setup times `s(c)`, find a +/// single-machine schedule in which all tasks meet their deadlines, where a +/// setup penalty `s(k(t'))` is added before any task `t` that uses a +/// different compiler than the immediately preceding task `t'`. +/// +/// This is problem SS14 in Garey & Johnson (1979), written +/// $1 | s_{ij} | \text{feasibility}$. +/// +/// Configurations are direct permutation encodings with `dims() = [n; n]`: +/// each position holds the index of the task scheduled at that position. +/// A configuration is valid iff it is a permutation of `0..n`. +#[derive(Debug, Clone, Serialize)] +pub struct SequencingWithDeadlinesAndSetUpTimes { + lengths: Vec, + deadlines: Vec, + compilers: Vec, + setup_times: Vec, +} + +#[derive(Deserialize)] +struct SequencingWithDeadlinesAndSetUpTimesSerde { + lengths: Vec, + deadlines: Vec, + compilers: Vec, + setup_times: Vec, +} + +impl SequencingWithDeadlinesAndSetUpTimes { + fn validate( + lengths: &[u64], + deadlines: &[u64], + compilers: &[usize], + setup_times: &[u64], + ) -> Result<(), String> { + if lengths.len() != deadlines.len() { + return Err("lengths length must equal deadlines length".to_string()); + } + if lengths.len() != compilers.len() { + return Err("lengths length must equal compilers length".to_string()); + } + if lengths.contains(&0) { + return Err("task lengths must be positive".to_string()); + } + let num_compilers = setup_times.len(); + for &c in compilers { + if c >= num_compilers { + return Err(format!( + "compiler index {c} is out of range for setup_times of length {num_compilers}" + )); + } + } + Ok(()) + } + + /// Create a new sequencing instance. + /// + /// # Panics + /// + /// Panics if the input vectors are inconsistent or contain invalid values. + pub fn new( + lengths: Vec, + deadlines: Vec, + compilers: Vec, + setup_times: Vec, + ) -> Self { + Self::validate(&lengths, &deadlines, &compilers, &setup_times) + .unwrap_or_else(|err| panic!("{err}")); + Self { + lengths, + deadlines, + compilers, + setup_times, + } + } + + /// Returns the number of tasks. + pub fn num_tasks(&self) -> usize { + self.lengths.len() + } + + /// Returns the number of distinct compilers (= `setup_times.len()`). + pub fn num_compilers(&self) -> usize { + self.setup_times.len() + } + + /// Returns the processing times. + pub fn lengths(&self) -> &[u64] { + &self.lengths + } + + /// Returns the task deadlines. + pub fn deadlines(&self) -> &[u64] { + &self.deadlines + } + + /// Returns the compiler index for each task. + pub fn compilers(&self) -> &[usize] { + &self.compilers + } + + /// Returns the per-compiler setup times. + pub fn setup_times(&self) -> &[u64] { + &self.setup_times + } + + /// Decode a direct permutation configuration. + /// + /// Returns `Some(schedule)` if the config is a valid permutation of `0..n`, + /// or `None` otherwise. + fn decode_permutation(config: &[usize], n: usize) -> Option> { + if config.len() != n { + return None; + } + let mut seen = vec![false; n]; + for &task in config { + if task >= n || seen[task] { + return None; + } + seen[task] = true; + } + Some(config.to_vec()) + } + + /// Check whether a schedule meets all deadlines. + /// + /// Returns `true` iff every task in the schedule completes by its deadline. + fn all_deadlines_met(&self, schedule: &[usize]) -> bool { + let mut elapsed: u64 = 0; + let mut prev_compiler: Option = None; + for &task in schedule { + // Add setup time if the compiler switches. + if let Some(prev) = prev_compiler { + if prev != self.compilers[task] { + elapsed = elapsed + .checked_add(self.setup_times[self.compilers[task]]) + .expect("elapsed time overflowed u64"); + } + } + elapsed = elapsed + .checked_add(self.lengths[task]) + .expect("elapsed time overflowed u64"); + if elapsed > self.deadlines[task] { + return false; + } + prev_compiler = Some(self.compilers[task]); + } + true + } +} + +impl TryFrom for SequencingWithDeadlinesAndSetUpTimes { + type Error = String; + + fn try_from(value: SequencingWithDeadlinesAndSetUpTimesSerde) -> Result { + Self::validate( + &value.lengths, + &value.deadlines, + &value.compilers, + &value.setup_times, + )?; + Ok(Self { + lengths: value.lengths, + deadlines: value.deadlines, + compilers: value.compilers, + setup_times: value.setup_times, + }) + } +} + +impl<'de> Deserialize<'de> for SequencingWithDeadlinesAndSetUpTimes { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value = SequencingWithDeadlinesAndSetUpTimesSerde::deserialize(deserializer)?; + Self::try_from(value).map_err(serde::de::Error::custom) + } +} + +impl Problem for SequencingWithDeadlinesAndSetUpTimes { + const NAME: &'static str = "SequencingWithDeadlinesAndSetUpTimes"; + type Value = Or; + + fn variant() -> Vec<(&'static str, &'static str)> { + crate::variant_params![] + } + + fn dims(&self) -> Vec { + let n = self.num_tasks(); + vec![n; n] + } + + fn evaluate(&self, config: &[usize]) -> Or { + let n = self.num_tasks(); + let Some(schedule) = Self::decode_permutation(config, n) else { + return Or(false); + }; + Or(self.all_deadlines_met(&schedule)) + } +} + +crate::declare_variants! { + default SequencingWithDeadlinesAndSetUpTimes => "factorial(num_tasks)", +} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_model_example_specs() -> Vec { + vec![crate::example_db::specs::ModelExampleSpec { + id: "sequencing_with_deadlines_and_set_up_times", + // 5 tasks, lengths [2,3,1,2,2], deadlines [4,11,3,16,7], compilers [0,1,0,1,0], + // setup_times [1,2]. + // Optimal config: [2,0,4,1,3] (tasks t3,t1,t5,t2,t4 in 1-indexed) + // Position 0: task 2 (compiler 0), no prev → elapsed = 0+1 = 1 ≤ d[2]=3 ✓ + // Position 1: task 0 (compiler 0), same → elapsed = 1+2 = 3 ≤ d[0]=4 ✓ + // Position 2: task 4 (compiler 0), same → elapsed = 3+2 = 5 ≤ d[4]=7 ✓ + // Position 3: task 1 (compiler 1), switch+s[1]=2 → elapsed = 5+2+3 = 10 ≤ d[1]=11 ✓ + // Position 4: task 3 (compiler 1), same → elapsed = 10+2 = 12 ≤ d[3]=16 ✓ + instance: Box::new(SequencingWithDeadlinesAndSetUpTimes::new( + vec![2, 3, 1, 2, 2], + vec![4, 11, 3, 16, 7], + vec![0, 1, 0, 1, 0], + vec![1, 2], + )), + optimal_config: vec![2, 0, 4, 1, 3], + optimal_value: serde_json::json!(true), + }] +} + +#[cfg(test)] +#[path = "../../unit_tests/models/misc/sequencing_with_deadlines_and_set_up_times.rs"] +mod tests; diff --git a/src/rules/mod.rs b/src/rules/mod.rs index 1933cd14..3a8cf343 100644 --- a/src/rules/mod.rs +++ b/src/rules/mod.rs @@ -220,6 +220,8 @@ pub(crate) mod sequencingtominimizeweightedcompletiontime_ilp; #[cfg(feature = "ilp-solver")] pub(crate) mod sequencingtominimizeweightedtardiness_ilp; #[cfg(feature = "ilp-solver")] +pub(crate) mod sequencingwithdeadlinesandsetuptimes_ilp; +#[cfg(feature = "ilp-solver")] pub(crate) mod sequencingwithinintervals_ilp; #[cfg(feature = "ilp-solver")] pub(crate) mod sequencingwithreleasetimesanddeadlines_ilp; @@ -384,6 +386,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec. +//! +//! Position-assignment ILP with compiler-switch detection. +//! +//! Variables: +//! - `x_{j,p}` binary: task j occupies position p (n*n variables) +//! - `sw_p` binary: a compiler switch occurs before position p (n-1 variables, p >= 1) +//! - `a_{j,p}` binary: x_{j,p} = 1 AND sw_p = 1 (n*(n-1) variables, p >= 1) +//! +//! The completion time of task j at position p equals the sum of all task +//! lengths up to and including position p, plus the setup times for switches +//! at each position 1..=p. Using the `a_{j,p}` linearisation, the setup +//! contribution at position p is `sum_j s[k(j)] * a_{j,p}`. +//! +//! Deadline enforcement uses the standard big-M trick: for each (j, p), +//! if `x_{j,p}=1` then the completion time at p must not exceed `d[j]`. + +use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; +use crate::models::misc::SequencingWithDeadlinesAndSetUpTimes; +use crate::reduction; +use crate::rules::ilp_helpers::one_hot_decode; +use crate::rules::traits::{ReduceTo, ReductionResult}; + +/// Result of reducing SequencingWithDeadlinesAndSetUpTimes to ILP. +#[derive(Debug, Clone)] +pub struct ReductionSWDSTToILP { + target: ILP, + num_tasks: usize, +} + +impl ReductionResult for ReductionSWDSTToILP { + type Source = SequencingWithDeadlinesAndSetUpTimes; + type Target = ILP; + + fn target_problem(&self) -> &ILP { + &self.target + } + + fn extract_solution(&self, target_solution: &[usize]) -> Vec { + let n = self.num_tasks; + // x_{j,p} occupies the first n*n variables: decode the permutation. + one_hot_decode(target_solution, n, n, 0) + } +} + +#[reduction(overhead = { + num_vars = "num_tasks * num_tasks + (num_tasks - 1) + num_tasks * (num_tasks - 1)", + num_constraints = "2 * num_tasks + 3 * (num_tasks - 1) + 3 * num_tasks * (num_tasks - 1) + num_tasks * num_tasks", +})] +impl ReduceTo> for SequencingWithDeadlinesAndSetUpTimes { + type Result = ReductionSWDSTToILP; + + fn reduce_to(&self) -> Self::Result { + let n = self.num_tasks(); + + // Handle empty case. + if n == 0 { + return ReductionSWDSTToILP { + target: ILP::new(0, vec![], vec![], ObjectiveSense::Minimize), + num_tasks: 0, + }; + } + + // Variable layout: + // x_{j,p} = j*n + p for j,p in 0..n → indices 0..n*n + // sw_p = n*n + (p-1) for p in 1..n → indices n*n .. n*n+(n-1) + // a_{j,p} = n*n+(n-1)+j*(n-1)+(p-1) for j in 0..n, p in 1..n + // → indices n*n+(n-1) .. n*n+(n-1)+n*(n-1) + let num_x = n * n; + let sw_offset = num_x; + let a_offset = sw_offset + (n - 1); + let num_vars = a_offset + n * (n - 1); + + let x_var = |j: usize, p: usize| -> usize { j * n + p }; + let sw_var = |p: usize| -> usize { sw_offset + (p - 1) }; // p >= 1 + let a_var = |j: usize, p: usize| -> usize { a_offset + j * (n - 1) + (p - 1) }; // p >= 1 + + let lengths = self.lengths(); + let deadlines = self.deadlines(); + let compilers = self.compilers(); + let setup_times = self.setup_times(); + + // Big-M: total processing time + worst-case total setup overhead. + let total_length: u64 = lengths.iter().copied().sum(); + let max_setup: u64 = setup_times.iter().copied().max().unwrap_or(0); + let big_m = total_length as f64 + max_setup as f64 * (n as f64 - 1.0); + + let mut constraints = Vec::new(); + + // 1. Each task assigned to exactly one position: sum_p x_{j,p} = 1 for all j. + for j in 0..n { + let terms: Vec<(usize, f64)> = (0..n).map(|p| (x_var(j, p), 1.0)).collect(); + constraints.push(LinearConstraint::eq(terms, 1.0)); + } + + // 2. Each position has exactly one task: sum_j x_{j,p} = 1 for all p. + for p in 0..n { + let terms: Vec<(usize, f64)> = (0..n).map(|j| (x_var(j, p), 1.0)).collect(); + constraints.push(LinearConstraint::eq(terms, 1.0)); + } + + // For each position p >= 1: + for p in 1..n { + // 3. Switch detection: sw_p >= x_{j,p} + x_{j',p-1} - 1 + // whenever k(j) != k(j'). + // This forces sw_p = 1 whenever the tasks at p-1 and p differ. + for j in 0..n { + for j_prev in 0..n { + if compilers[j] != compilers[j_prev] { + // sw_p - x_{j,p} - x_{j',p-1} >= -1 + // i.e., x_{j,p} + x_{j',p-1} - sw_p <= 1 + constraints.push(LinearConstraint::le( + vec![ + (x_var(j, p), 1.0), + (x_var(j_prev, p - 1), 1.0), + (sw_var(p), -1.0), + ], + 1.0, + )); + } + } + } + + // 4. Linearisation of a_{j,p} = x_{j,p} * sw_p for each j: + // a_{j,p} <= x_{j,p} + // a_{j,p} <= sw_p + // a_{j,p} >= x_{j,p} + sw_p - 1 + for j in 0..n { + // a_{j,p} <= x_{j,p} + constraints.push(LinearConstraint::le( + vec![(a_var(j, p), 1.0), (x_var(j, p), -1.0)], + 0.0, + )); + // a_{j,p} <= sw_p + constraints.push(LinearConstraint::le( + vec![(a_var(j, p), 1.0), (sw_var(p), -1.0)], + 0.0, + )); + // a_{j,p} >= x_{j,p} + sw_p - 1 + // i.e. x_{j,p} + sw_p - a_{j,p} <= 1 + constraints.push(LinearConstraint::le( + vec![(x_var(j, p), 1.0), (sw_var(p), 1.0), (a_var(j, p), -1.0)], + 1.0, + )); + } + } + + // 5. Deadline constraints: for each (j, p), if x_{j,p}=1, then + // the completion time at position p must be <= d[j]. + // + // Completion time at position p = + // sum_{p'<=p} sum_{j''} l_{j''} * x_{j'',p'} + // + sum_{p'=1..=p} sum_{j''} s[k(j'')] * a_{j'',p'} + // + // Big-M form (only active when x_{j,p}=1): + // M * x_{j,p} + // + sum_{p' = Vec::new(); + // Big-M activation term + terms.push((x_var(j, p), big_m)); + // Processing time for positions 0..p (not including p itself) + for pp in 0..p { + for (jj, &len) in lengths.iter().enumerate() { + terms.push((x_var(jj, pp), len as f64)); + } + } + // Setup time for positions 1..=p + for pp in 1..=p { + for jj in 0..n { + let s = setup_times[compilers[jj]] as f64; + if s > 0.0 { + terms.push((a_var(jj, pp), s)); + } + } + } + let rhs = deadlines[j] as f64 - lengths[j] as f64 + big_m; + constraints.push(LinearConstraint::le(terms, rhs)); + } + } + + ReductionSWDSTToILP { + target: ILP::new(num_vars, constraints, vec![], ObjectiveSense::Minimize), + num_tasks: n, + } + } +} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_rule_example_specs() -> Vec { + vec![crate::example_db::specs::RuleExampleSpec { + id: "sequencingwithdeadlinesandsetuptimes_to_ilp", + build: || { + let source = SequencingWithDeadlinesAndSetUpTimes::new( + vec![2, 3, 1, 2, 2], + vec![4, 11, 3, 16, 7], + vec![0, 1, 0, 1, 0], + vec![1, 2], + ); + crate::example_db::specs::rule_example_via_ilp::<_, bool>(source) + }, + }] +} + +#[cfg(test)] +#[path = "../unit_tests/rules/sequencingwithdeadlinesandsetuptimes_ilp.rs"] +mod tests; diff --git a/src/unit_tests/models/misc/sequencing_with_deadlines_and_set_up_times.rs b/src/unit_tests/models/misc/sequencing_with_deadlines_and_set_up_times.rs new file mode 100644 index 00000000..eac5475d --- /dev/null +++ b/src/unit_tests/models/misc/sequencing_with_deadlines_and_set_up_times.rs @@ -0,0 +1,206 @@ +use super::*; +use crate::solvers::BruteForce; +use crate::traits::Problem; +use crate::types::Or; + +#[test] +fn test_sequencing_with_deadlines_and_set_up_times_creation() { + let problem = SequencingWithDeadlinesAndSetUpTimes::new( + vec![2, 3, 1, 2, 2], + vec![4, 11, 3, 16, 7], + vec![0, 1, 0, 1, 0], + vec![1, 2], + ); + + assert_eq!(problem.num_tasks(), 5); + assert_eq!(problem.num_compilers(), 2); + assert_eq!(problem.lengths(), &[2, 3, 1, 2, 2]); + assert_eq!(problem.deadlines(), &[4, 11, 3, 16, 7]); + assert_eq!(problem.compilers(), &[0, 1, 0, 1, 0]); + assert_eq!(problem.setup_times(), &[1, 2]); + assert_eq!(problem.dims(), vec![5, 5, 5, 5, 5]); + assert_eq!( + ::NAME, + "SequencingWithDeadlinesAndSetUpTimes" + ); + assert_eq!( + ::variant(), + vec![] + ); +} + +#[test] +fn test_sequencing_with_deadlines_and_set_up_times_evaluate_feasible() { + let problem = SequencingWithDeadlinesAndSetUpTimes::new( + vec![2, 3, 1, 2, 2], + vec![4, 11, 3, 16, 7], + vec![0, 1, 0, 1, 0], + vec![1, 2], + ); + + // Config [2,0,4,1,3]: tasks t2,t0,t4,t1,t3 (0-indexed) + // Position 0: task 2 (compiler 0), no prev → elapsed = 0+1 = 1 ≤ 3 ✓ + // Position 1: task 0 (compiler 0), same → elapsed = 1+2 = 3 ≤ 4 ✓ + // Position 2: task 4 (compiler 0), same → elapsed = 3+2 = 5 ≤ 7 ✓ + // Position 3: task 1 (compiler 1), switch s[1]=2 → elapsed = 5+2+3 = 10 ≤ 11 ✓ + // Position 4: task 3 (compiler 1), same → elapsed = 10+2 = 12 ≤ 16 ✓ + assert_eq!(problem.evaluate(&[2, 0, 4, 1, 3]), Or(true)); +} + +#[test] +fn test_sequencing_with_deadlines_and_set_up_times_evaluate_infeasible() { + let problem = SequencingWithDeadlinesAndSetUpTimes::new( + vec![2, 3, 1, 2, 2], + vec![4, 11, 3, 16, 7], + vec![0, 1, 0, 1, 0], + vec![1, 2], + ); + + // Config [0,1,2,3,4]: tasks in natural order + // Position 0: task 0 (compiler 0), no prev → elapsed = 0+2 = 2 ≤ 4 ✓ + // Position 1: task 1 (compiler 1), switch s[1]=2 → elapsed = 2+2+3 = 7 ≤ 11 ✓ + // Position 2: task 2 (compiler 0), switch s[0]=1 → elapsed = 7+1+1 = 9 > 3 ✗ + assert_eq!(problem.evaluate(&[0, 1, 2, 3, 4]), Or(false)); +} + +#[test] +fn test_sequencing_with_deadlines_and_set_up_times_evaluate_invalid_permutation() { + let problem = SequencingWithDeadlinesAndSetUpTimes::new( + vec![2, 3, 1], + vec![5, 10, 5], + vec![0, 1, 0], + vec![1, 2], + ); + + // Wrong length + assert_eq!(problem.evaluate(&[0, 1]), Or(false)); + assert_eq!(problem.evaluate(&[0, 1, 2, 0]), Or(false)); + // Duplicate + assert_eq!(problem.evaluate(&[0, 0, 1]), Or(false)); + // Out of range + assert_eq!(problem.evaluate(&[0, 1, 3]), Or(false)); +} + +#[test] +fn test_sequencing_with_deadlines_and_set_up_times_brute_force_small() { + // 3 tasks, no setup time needed if same compiler, easy instance. + let problem = SequencingWithDeadlinesAndSetUpTimes::new( + vec![1, 1, 1], + vec![3, 3, 3], + vec![0, 0, 0], + vec![0], + ); + let solver = BruteForce::new(); + let solution = solver + .find_witness(&problem) + .expect("should find a feasible schedule"); + assert_eq!(problem.evaluate(&solution), Or(true)); +} + +#[test] +fn test_sequencing_with_deadlines_and_set_up_times_brute_force_infeasible() { + // All deadlines are 1, but each task takes 2 — impossible. + let problem = SequencingWithDeadlinesAndSetUpTimes::new( + vec![2, 2, 2], + vec![1, 1, 1], + vec![0, 0, 0], + vec![0], + ); + let solver = BruteForce::new(); + assert!( + solver.find_witness(&problem).is_none(), + "infeasible instance should return None" + ); +} + +#[test] +fn test_sequencing_with_deadlines_and_set_up_times_paper_example() { + let problem = SequencingWithDeadlinesAndSetUpTimes::new( + vec![2, 3, 1, 2, 2], + vec![4, 11, 3, 16, 7], + vec![0, 1, 0, 1, 0], + vec![1, 2], + ); + let expected_config = vec![2, 0, 4, 1, 3]; + assert_eq!(problem.evaluate(&expected_config), Or(true)); + + let solver = BruteForce::new(); + let solution = solver + .find_witness(&problem) + .expect("paper example should be feasible"); + assert_eq!(problem.evaluate(&solution), Or(true)); +} + +#[test] +fn test_sequencing_with_deadlines_and_set_up_times_serialization() { + let problem = SequencingWithDeadlinesAndSetUpTimes::new( + vec![2, 3, 1], + vec![5, 10, 4], + vec![0, 1, 0], + vec![1, 2], + ); + let json = serde_json::to_value(&problem).unwrap(); + let restored: SequencingWithDeadlinesAndSetUpTimes = serde_json::from_value(json).unwrap(); + + assert_eq!(restored.lengths(), problem.lengths()); + assert_eq!(restored.deadlines(), problem.deadlines()); + assert_eq!(restored.compilers(), problem.compilers()); + assert_eq!(restored.setup_times(), problem.setup_times()); +} + +#[test] +fn test_sequencing_with_deadlines_and_set_up_times_deserialization_rejects_zero_length() { + let err = serde_json::from_value::(serde_json::json!({ + "lengths": [0, 1, 2], + "deadlines": [5, 5, 5], + "compilers": [0, 0, 0], + "setup_times": [1], + })) + .unwrap_err(); + assert!(err.to_string().contains("task lengths must be positive")); +} + +#[test] +fn test_sequencing_with_deadlines_and_set_up_times_deserialization_rejects_out_of_range_compiler() { + let err = serde_json::from_value::(serde_json::json!({ + "lengths": [1, 2], + "deadlines": [5, 5], + "compilers": [0, 2], + "setup_times": [1, 2], + })) + .unwrap_err(); + assert!(err.to_string().contains("out of range")); +} + +#[test] +fn test_sequencing_with_deadlines_and_set_up_times_setup_time_charged_on_switch() { + // Two tasks, different compilers: setup time s[compiler_of_task1] is charged + // before task 1 because task 0 uses a different compiler. + // lengths [1,1], deadlines [1, 4], compilers [0,1], setup_times [0, 2] + // Schedule [0,1]: elapsed after t0 = 1 ≤ 1 ✓; switch s[1]=2; elapsed = 1+2+1 = 4 ≤ 4 ✓ + let problem = + SequencingWithDeadlinesAndSetUpTimes::new(vec![1, 1], vec![1, 4], vec![0, 1], vec![0, 2]); + assert_eq!(problem.evaluate(&[0, 1]), Or(true)); + // Tight deadline: if setup charged, 1+2+1=4 > 3 ✗ + let tight = + SequencingWithDeadlinesAndSetUpTimes::new(vec![1, 1], vec![1, 3], vec![0, 1], vec![0, 2]); + assert_eq!(tight.evaluate(&[0, 1]), Or(false)); +} + +#[test] +#[should_panic(expected = "lengths length must equal deadlines length")] +fn test_sequencing_with_deadlines_and_set_up_times_mismatched_lengths_deadlines() { + SequencingWithDeadlinesAndSetUpTimes::new(vec![1, 2], vec![5], vec![0, 0], vec![1]); +} + +#[test] +#[should_panic(expected = "lengths length must equal compilers length")] +fn test_sequencing_with_deadlines_and_set_up_times_mismatched_lengths_compilers() { + SequencingWithDeadlinesAndSetUpTimes::new(vec![1, 2], vec![5, 5], vec![0], vec![1]); +} + +#[test] +#[should_panic(expected = "task lengths must be positive")] +fn test_sequencing_with_deadlines_and_set_up_times_zero_length() { + SequencingWithDeadlinesAndSetUpTimes::new(vec![0, 1], vec![5, 5], vec![0, 0], vec![1]); +} diff --git a/src/unit_tests/rules/sequencingwithdeadlinesandsetuptimes_ilp.rs b/src/unit_tests/rules/sequencingwithdeadlinesandsetuptimes_ilp.rs new file mode 100644 index 00000000..23f97a1a --- /dev/null +++ b/src/unit_tests/rules/sequencingwithdeadlinesandsetuptimes_ilp.rs @@ -0,0 +1,121 @@ +use super::*; +use crate::models::algebraic::ILP; +use crate::rules::test_helpers::assert_satisfaction_round_trip_from_optimization_target; +use crate::solvers::{BruteForce, ILPSolver}; +use crate::traits::Problem; +use crate::types::Or; + +#[test] +fn test_sequencingwithdeadlinesandsetuptimes_to_ilp_closed_loop() { + // Small feasible instance (3 tasks) + let problem = SequencingWithDeadlinesAndSetUpTimes::new( + vec![1, 1, 1], + vec![1, 3, 5], + vec![0, 1, 0], + vec![0, 1], + ); + let reduction = ReduceTo::>::reduce_to(&problem); + + assert_satisfaction_round_trip_from_optimization_target( + &problem, + &reduction, + "SequencingWithDeadlinesAndSetUpTimes->ILP closed loop", + ); +} + +#[test] +fn test_sequencingwithdeadlinesandsetuptimes_to_ilp_feasible_paper_example() { + let problem = SequencingWithDeadlinesAndSetUpTimes::new( + vec![2, 3, 1, 2, 2], + vec![4, 11, 3, 16, 7], + vec![0, 1, 0, 1, 0], + vec![1, 2], + ); + + let bf = BruteForce::new(); + let bf_witness = bf + .find_witness(&problem) + .expect("paper example should be feasible"); + assert_eq!(problem.evaluate(&bf_witness), Or(true)); + + let reduction = ReduceTo::>::reduce_to(&problem); + let ilp_solution = ILPSolver::new() + .solve(reduction.target_problem()) + .expect("ILP should be feasible"); + let extracted = reduction.extract_solution(&ilp_solution); + assert_eq!(problem.evaluate(&extracted), Or(true)); +} + +#[test] +fn test_sequencingwithdeadlinesandsetuptimes_to_ilp_infeasible() { + // All tasks have deadline 1 but each takes 2 — clearly impossible. + let problem = + SequencingWithDeadlinesAndSetUpTimes::new(vec![2, 2], vec![1, 1], vec![0, 0], vec![0]); + let reduction = ReduceTo::>::reduce_to(&problem); + assert!( + ILPSolver::new().solve(reduction.target_problem()).is_none(), + "infeasible instance should produce infeasible ILP" + ); +} + +#[test] +fn test_sequencingwithdeadlinesandsetuptimes_to_ilp_setup_time_respected() { + // Two tasks with different compilers: setup time s=2 must be charged. + // lengths [1,1], deadlines [1, 4], compilers [0,1], setup_times [0, 2] + // Order [0,1]: elapsed=1≤1 ✓, then switch s=2, elapsed=1+2+1=4≤4 ✓ → feasible + // Order [1,0]: elapsed=1≤4 ✓, then switch s=0, elapsed=1+0+1=2≤1 ✗ → infeasible + let problem = + SequencingWithDeadlinesAndSetUpTimes::new(vec![1, 1], vec![1, 4], vec![0, 1], vec![0, 2]); + let reduction = ReduceTo::>::reduce_to(&problem); + let ilp_solution = ILPSolver::new() + .solve(reduction.target_problem()) + .expect("ILP should be feasible"); + let extracted = reduction.extract_solution(&ilp_solution); + assert_eq!(problem.evaluate(&extracted), Or(true)); +} + +#[test] +fn test_sequencingwithdeadlinesandsetuptimes_to_ilp_bf_vs_ilp_small() { + // 3 tasks: verify brute force and ILP agree on feasibility. + let problem = SequencingWithDeadlinesAndSetUpTimes::new( + vec![2, 1, 3], + vec![3, 5, 9], + vec![0, 1, 0], + vec![1, 2], + ); + + let bf = BruteForce::new(); + let bf_result = bf.find_witness(&problem); + let bf_feasible = bf_result.is_some(); + + let reduction = ReduceTo::>::reduce_to(&problem); + let ilp_result = ILPSolver::new().solve(reduction.target_problem()); + let ilp_feasible = ilp_result.is_some(); + + assert_eq!( + bf_feasible, ilp_feasible, + "BF and ILP should agree on feasibility" + ); + if let Some(ilp_solution) = ilp_result { + let extracted = reduction.extract_solution(&ilp_solution); + assert_eq!(problem.evaluate(&extracted), Or(true)); + } +} + +#[test] +fn test_sequencingwithdeadlinesandsetuptimes_to_ilp_no_setup_same_compiler() { + // All tasks use the same compiler: no setup time ever charged. + // Tight deadlines that are only feasible without setup. + let problem = SequencingWithDeadlinesAndSetUpTimes::new( + vec![1, 2, 1], + vec![1, 3, 4], + vec![0, 0, 0], + vec![100], // large setup time, but never triggered + ); + let reduction = ReduceTo::>::reduce_to(&problem); + let ilp_solution = ILPSolver::new() + .solve(reduction.target_problem()) + .expect("should be feasible with no switches"); + let extracted = reduction.extract_solution(&ilp_solution); + assert_eq!(problem.evaluate(&extracted), Or(true)); +} From a8c17999de0b58769ad6d74f431c40e7d9343300 Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Mon, 30 Mar 2026 06:46:30 +0800 Subject: [PATCH 5/6] feat: add PreemptiveScheduling model (#504) Implement multiprocessor preemptive scheduling (GJ SS12) with binary time-slot assignment, direct ILP reduction, CLI support, and paper entry. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/paper/reductions.typ | 52 +++ problemreductions-cli/src/commands/create.rs | 63 +++- src/models/misc/mod.rs | 4 + src/models/misc/preemptive_scheduling.rs | 295 ++++++++++++++++++ src/rules/mod.rs | 3 + src/rules/preemptivescheduling_ilp.rs | 156 +++++++++ .../models/misc/preemptive_scheduling.rs | 231 ++++++++++++++ .../rules/preemptivescheduling_ilp.rs | 105 +++++++ 8 files changed, 908 insertions(+), 1 deletion(-) create mode 100644 src/models/misc/preemptive_scheduling.rs create mode 100644 src/rules/preemptivescheduling_ilp.rs create mode 100644 src/unit_tests/models/misc/preemptive_scheduling.rs create mode 100644 src/unit_tests/rules/preemptivescheduling_ilp.rs diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index 07d04735..f923f3ed 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -177,6 +177,7 @@ "PartitionIntoPathsOfLength2": [Partition into Paths of Length 2], "PartitionIntoTriangles": [Partition Into Triangles], "PrecedenceConstrainedScheduling": [Precedence Constrained Scheduling], + "PreemptiveScheduling": [Preemptive Scheduling], "PrimeAttributeName": [Prime Attribute Name], "QuadraticAssignment": [Quadratic Assignment], "QuadraticDiophantineEquations": [Quadratic Diophantine Equations], @@ -5935,6 +5936,39 @@ A classical NP-complete problem from Garey and Johnson @garey1979[Ch.~3, p.~76], ] ] } +#{ + let x = load-model-example("PreemptiveScheduling") + let n = x.instance.lengths.len() + let m = x.instance.num_processors + let lengths = x.instance.lengths + let precs = x.instance.precedences + let d_max = lengths.fold(0, (acc, l) => acc + l) + let cfg = x.optimal_config + // For each task t, collect active time slots from the flat binary config + let active-slots = range(n).map(t => + range(d_max).filter(u => cfg.at(t * d_max + u) == 1) + ) + let makespan = x.optimal_value + [ + #problem-def("PreemptiveScheduling")[ + Given a set $T$ of $n$ tasks with processing lengths $ell: T -> ZZ^+$, a number $m in ZZ^+$ of identical processors, and a set of precedence constraints $prec$ on $T$, find a preemptive schedule that minimizes the makespan. + + A preemptive schedule assigns each task $t$ a (possibly non-contiguous) set $S(t) subset.eq {0, 1, dots, D_"max" - 1}$ of unit time slots, where $D_"max" = sum_t ell(t)$, such that $|S(t)| = ell(t)$ for all $t$, at most $m$ tasks are active at each slot, and for every precedence $(t_i prec t_j)$, the last slot of $t_i$ precedes the first slot of $t_j$. + + The makespan is $max_{t in T} (max S(t) + 1)$. + ][ + Preemptive Scheduling is problem A5 SS6 in Garey & Johnson @garey1979. NP-complete in general; the special case without precedences ($m$ arbitrary) is solvable in polynomial time (McNaughton's wrap-around algorithm), and the preemptive open-shop variant is also polynomial. The configuration representation is a binary vector of length $n dot D_"max"$ encoding per-slot assignments. + + *Example.* Let $n = #n$ tasks with lengths $(#lengths.map(str).join(", "))$, $m = #m$ processors, and precedences #{precs.map(p => $t_#(p.at(0)) prec t_#(p.at(1))$).join(", ")}. Optimal makespan: $#makespan$. Schedule: #range(n).map(t => [$t_#t$ at slots $[#active-slots.at(t).map(str).join(", ")]$]).join("; "). + + #pred-commands( + "pred create --example PreemptiveScheduling -o preemptive-scheduling.json", + "pred solve preemptive-scheduling.json", + "pred evaluate preemptive-scheduling.json --config " + cfg.map(str).join(","), + ) + ] + ] +} #{ let x = load-model-example("SchedulingToMinimizeWeightedCompletionTime") let ntasks = x.instance.lengths.len() @@ -9158,6 +9192,24 @@ The following reductions to Integer Linear Programming are straightforward formu _Solution extraction._ Task $j$ is scheduled at time $arg max_t x_(j,t)$. ] +#reduction-rule("PreemptiveScheduling", "ILP")[ + Minimize makespan for preemptive parallel scheduling with variable-length tasks and precedence constraints. +][ + _Construction._ Let $D = sum_t ell(t)$ be the horizon. Variables: binary $x_(t,u) in {0,1}$ (task $t$ processed at slot $u$) for $t in {0, dots, n-1}$, $u in {0, dots, D-1}$; integer $M in {0, dots, D}$ (makespan). The ILP is: + $ + min quad & M \ + "subject to" quad & sum_u x_(t,u) = ell(t) quad forall t quad "(work)" \ + & sum_t x_(t,u) <= m quad forall u quad "(capacity)" \ + & sum_u u dot x_(j,u) - sum_u u dot x_(i,u) >= 1 quad "for each" (i prec j) quad "(precedence)" \ + & M - (u+1) dot x_(t,u) >= 0 quad forall t, u quad "(makespan)" \ + & x_(t,u) in {0, 1}, quad M in ZZ_(>= 0) + $. + + _Correctness._ Work constraints enforce each task runs for exactly $ell(t)$ slots. Capacity limits at most $m$ tasks per slot. Precedences are enforced by weighted time indicators. Makespan lower bounds force $M >= u+1$ whenever task $t$ is active at slot $u$. + + _Solution extraction._ Config$[t dot D + u] = x_(t,u)$ for all $t, u$. +] + #reduction-rule("SequencingWithinIntervals", "ILP")[ Schedule tasks with release times, deadlines, and processing lengths on a single machine without overlap. ][ diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index 2e061b0d..070b12cb 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -27,7 +27,7 @@ use problemreductions::models::misc::{ IntegerExpressionMembership, JobShopScheduling, KnownValue, KthLargestMTuple, LongestCommonSubsequence, MinimumExternalMacroDataCompression, MinimumInternalMacroDataCompression, MinimumTardinessSequencing, MultiprocessorScheduling, - PaintShop, PartiallyOrderedKnapsack, ProductionPlanning, QueryArg, + PaintShop, PartiallyOrderedKnapsack, PreemptiveScheduling, ProductionPlanning, QueryArg, RectilinearPictureCompression, RegisterSufficiency, ResourceConstrainedScheduling, SchedulingToMinimizeWeightedCompletionTime, SchedulingWithIndividualDeadlines, SequencingToMinimizeMaximumCumulativeCost, SequencingToMinimizeTardyTaskWeight, @@ -686,6 +686,9 @@ fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str { "--num-periods 6 --demands 5,3,7,2,8,5 --capacities 12,12,12,12,12,12 --setup-costs 10,10,10,10,10,10 --production-costs 1,1,1,1,1,1 --inventory-costs 1,1,1,1,1,1 --cost-bound 80" } "MultiprocessorScheduling" => "--lengths 4,5,3,2,6 --num-processors 2 --deadline 10", + "PreemptiveScheduling" => { + "--sizes 2,1,3,2,1 --num-processors 2 --precedence-pairs \"0>2,1>3\"" + } "SchedulingToMinimizeWeightedCompletionTime" => { "--lengths 1,2,3,4,5 --weights 6,4,3,2,1 --num-processors 2" } @@ -3438,6 +3441,64 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { ) } + // PreemptiveScheduling + "PreemptiveScheduling" => { + let usage = "Usage: pred create PreemptiveScheduling --sizes 2,1,3,2,1 --num-processors 2 [--precedence-pairs \"0>2,1>3\"]"; + let sizes_str = args.sizes.as_deref().ok_or_else(|| { + anyhow::anyhow!( + "PreemptiveScheduling requires --sizes and --num-processors\n\n{usage}" + ) + })?; + let num_processors = args.num_processors.ok_or_else(|| { + anyhow::anyhow!("PreemptiveScheduling requires --num-processors\n\n{usage}") + })?; + anyhow::ensure!( + num_processors > 0, + "PreemptiveScheduling requires --num-processors > 0\n\n{usage}" + ); + let lengths: Vec = util::parse_comma_list(sizes_str)?; + anyhow::ensure!( + lengths.iter().all(|&l| l > 0), + "PreemptiveScheduling: all task lengths must be positive\n\n{usage}" + ); + let num_tasks = lengths.len(); + let precedences: Vec<(usize, usize)> = match args.precedence_pairs.as_deref() { + Some(s) if !s.is_empty() => s + .split(',') + .map(|pair| { + let parts: Vec<&str> = pair.trim().split('>').collect(); + anyhow::ensure!( + parts.len() == 2, + "Invalid precedence format '{}', expected 'u>v'", + pair.trim() + ); + Ok(( + parts[0].trim().parse::()?, + parts[1].trim().parse::()?, + )) + }) + .collect::>>()?, + _ => vec![], + }; + for &(pred, succ) in &precedences { + anyhow::ensure!( + pred < num_tasks && succ < num_tasks, + "precedence index out of range: ({}, {}) but num_tasks = {}", + pred, + succ, + num_tasks + ); + } + ( + ser(PreemptiveScheduling::new( + lengths, + num_processors, + precedences, + ))?, + resolved_variant.clone(), + ) + } + // SchedulingToMinimizeWeightedCompletionTime "SchedulingToMinimizeWeightedCompletionTime" => { let usage = "Usage: pred create SchedulingToMinimizeWeightedCompletionTime --lengths 1,2,3,4,5 --weights 6,4,3,2,1 --num-processors 2"; diff --git a/src/models/misc/mod.rs b/src/models/misc/mod.rs index a34a5d51..86391564 100644 --- a/src/models/misc/mod.rs +++ b/src/models/misc/mod.rs @@ -24,6 +24,7 @@ //! - [`Partition`]: Partition a multiset into two equal-sum subsets //! - [`PartiallyOrderedKnapsack`]: Knapsack with precedence constraints //! - [`PrecedenceConstrainedScheduling`]: Schedule unit tasks on processors by deadline +//! - [`PreemptiveScheduling`]: Preemptive parallel scheduling with precedences (minimize makespan) //! - [`ProductionPlanning`]: Meet all period demands within capacity and total-cost bounds //! - [`RectilinearPictureCompression`]: Cover 1-entries with bounded rectangles //! - [`RegisterSufficiency`]: Evaluate DAG computation with bounded registers @@ -94,6 +95,7 @@ pub(crate) mod paintshop; pub(crate) mod partially_ordered_knapsack; pub(crate) mod partition; mod precedence_constrained_scheduling; +mod preemptive_scheduling; mod production_planning; mod rectilinear_picture_compression; mod register_sufficiency; @@ -144,6 +146,7 @@ pub use paintshop::PaintShop; pub use partially_ordered_knapsack::PartiallyOrderedKnapsack; pub use partition::Partition; pub use precedence_constrained_scheduling::PrecedenceConstrainedScheduling; +pub use preemptive_scheduling::PreemptiveScheduling; pub use production_planning::ProductionPlanning; pub use rectilinear_picture_compression::RectilinearPictureCompression; pub use register_sufficiency::RegisterSufficiency; @@ -216,5 +219,6 @@ pub(crate) fn canonical_model_example_specs() -> Vec", description: "Processing length l(t) for each task" }, + FieldInfo { name: "num_processors", type_name: "usize", description: "Number of identical processors m" }, + FieldInfo { name: "precedences", type_name: "Vec<(usize, usize)>", description: "Precedence pairs (pred, succ) — pred must finish before succ starts" }, + ], + } +} + +/// The Preemptive Scheduling problem. +/// +/// Given `n` tasks with processing lengths `l(0), ..., l(n-1)`, `m` identical +/// processors, and a set of precedence constraints, find a preemptive schedule +/// that minimizes the makespan. +/// +/// Tasks may be interrupted and resumed at later time slots (preemption). +/// A configuration is a binary vector of length `n × D_max` where +/// `D_max = sum of all lengths` is the worst-case makespan. +/// +/// `config[t * D_max + u] = 1` means task `t` is processed at time slot `u`. +/// +/// A valid schedule satisfies: +/// - Each task `t` is active in exactly `l(t)` time slots. +/// - At most `m` tasks are active at any time slot. +/// - For each precedence `(pred, succ)`, the last active slot of `pred` is +/// strictly less than the first active slot of `succ`. +/// +/// The makespan is `max_t (last active slot of t + 1)`. +/// +/// # Example +/// +/// ``` +/// use problemreductions::models::misc::PreemptiveScheduling; +/// use problemreductions::Problem; +/// +/// let problem = PreemptiveScheduling::new(vec![2, 1], 2, vec![]); +/// // D_max = 3, config length = 2 * 3 = 6 +/// // task 0 active at slots 0,1; task 1 active at slot 0 +/// let config = vec![1, 1, 0, 1, 0, 0]; +/// assert_eq!(problem.evaluate(&config), problemreductions::types::Min(Some(2))); +/// ``` +#[derive(Debug, Clone, Serialize)] +pub struct PreemptiveScheduling { + /// Processing length for each task. + lengths: Vec, + /// Number of identical processors. + num_processors: usize, + /// Precedence constraints: (pred, succ) means pred must finish before succ starts. + precedences: Vec<(usize, usize)>, +} + +#[derive(Deserialize)] +struct PreemptiveSchedulingSerde { + lengths: Vec, + num_processors: usize, + precedences: Vec<(usize, usize)>, +} + +impl PreemptiveScheduling { + fn validate( + lengths: &[usize], + num_processors: usize, + precedences: &[(usize, usize)], + ) -> Result<(), String> { + if lengths.contains(&0) { + return Err("task lengths must be positive".to_string()); + } + if num_processors == 0 { + return Err("num_processors must be positive".to_string()); + } + let n = lengths.len(); + for &(pred, succ) in precedences { + if pred >= n || succ >= n { + return Err(format!( + "precedence index out of range: ({pred}, {succ}) but num_tasks = {n}" + )); + } + } + Ok(()) + } + + /// Create a new Preemptive Scheduling instance. + /// + /// # Arguments + /// * `lengths` - Processing length `l(t)` for each task (must be positive) + /// * `num_processors` - Number of identical processors `m` (must be positive) + /// * `precedences` - Pairs `(pred, succ)`: task `pred` must finish before task `succ` starts + /// + /// # Panics + /// + /// Panics if any length is zero, `num_processors` is zero, or any precedence + /// index is out of range. + pub fn new( + lengths: Vec, + num_processors: usize, + precedences: Vec<(usize, usize)>, + ) -> Self { + Self::validate(&lengths, num_processors, &precedences) + .unwrap_or_else(|err| panic!("{err}")); + Self { + lengths, + num_processors, + precedences, + } + } + + /// Get the number of tasks. + pub fn num_tasks(&self) -> usize { + self.lengths.len() + } + + /// Get the number of processors. + pub fn num_processors(&self) -> usize { + self.num_processors + } + + /// Get the number of precedence constraints. + pub fn num_precedences(&self) -> usize { + self.precedences.len() + } + + /// Get the processing lengths. + pub fn lengths(&self) -> &[usize] { + &self.lengths + } + + /// Get the precedence constraints. + pub fn precedences(&self) -> &[(usize, usize)] { + &self.precedences + } + + /// Compute `D_max = sum of all task lengths` (worst-case makespan). + pub fn d_max(&self) -> usize { + self.lengths.iter().sum() + } +} + +impl TryFrom for PreemptiveScheduling { + type Error = String; + + fn try_from(value: PreemptiveSchedulingSerde) -> Result { + Self::validate(&value.lengths, value.num_processors, &value.precedences)?; + Ok(Self { + lengths: value.lengths, + num_processors: value.num_processors, + precedences: value.precedences, + }) + } +} + +impl<'de> Deserialize<'de> for PreemptiveScheduling { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value = PreemptiveSchedulingSerde::deserialize(deserializer)?; + Self::try_from(value).map_err(serde::de::Error::custom) + } +} + +impl Problem for PreemptiveScheduling { + const NAME: &'static str = "PreemptiveScheduling"; + type Value = Min; + + fn variant() -> Vec<(&'static str, &'static str)> { + crate::variant_params![] + } + + fn dims(&self) -> Vec { + let d = self.d_max(); + vec![2; self.num_tasks() * d] + } + + fn evaluate(&self, config: &[usize]) -> Min { + let n = self.num_tasks(); + let d = self.d_max(); + + // Check config length + if config.len() != n * d { + return Min(None); + } + + // Check each slot is binary + if config.iter().any(|&v| v > 1) { + return Min(None); + } + + // Check each task t is active in exactly l(t) slots + for t in 0..n { + let active: usize = config[t * d..(t + 1) * d].iter().sum(); + if active != self.lengths[t] { + return Min(None); + } + } + + // Check processor capacity at each time slot + for u in 0..d { + let active_count: usize = (0..n).filter(|&t| config[t * d + u] == 1).count(); + if active_count > self.num_processors { + return Min(None); + } + } + + // Check precedence constraints: + // last active slot of pred < first active slot of succ + for &(pred, succ) in &self.precedences { + let last_pred = (0..d).rev().find(|&u| config[pred * d + u] == 1); + let first_succ = (0..d).find(|&u| config[succ * d + u] == 1); + if let (Some(lp), Some(fs)) = (last_pred, first_succ) { + if lp >= fs { + return Min(None); + } + } + } + + // Compute makespan: max over all t of (last active slot + 1) + let makespan = (0..n) + .filter_map(|t| (0..d).rev().find(|&u| config[t * d + u] == 1)) + .map(|last| last + 1) + .max() + .unwrap_or(0); + + Min(Some(makespan)) + } +} + +crate::declare_variants! { + default PreemptiveScheduling => "2^(num_tasks * num_tasks)", +} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_model_example_specs() -> Vec { + // 5 tasks, lengths [2,1,3,2,1], 2 processors, precedences [(0,2),(1,3)] + // D_max = 2+1+3+2+1 = 9 + // Optimal schedule (makespan 5): + // t0: slots 0,1 → t0*9+0=1, t0*9+1=1 + // t1: slot 0 → t1*9+0=1 + // t2: slots 2,3,4 → t2*9+2=1, t2*9+3=1, t2*9+4=1 + // t3: slots 2,3 → t3*9+2=1, t3*9+3=1 + // t4: slot 1 → t4*9+1=1 + // config indices (length 45): + // t0 (0..9): [1,1,0,0,0,0,0,0,0] + // t1 (9..18): [1,0,0,0,0,0,0,0,0] + // t2 (18..27):[0,0,1,1,1,0,0,0,0] + // t3 (27..36):[0,0,1,1,0,0,0,0,0] + // t4 (36..45):[0,1,0,0,0,0,0,0,0] + let mut config = vec![0usize; 5 * 9]; + // t0 (config[0..9]) at slots 0,1 + config[0] = 1; + config[1] = 1; + // t1 (config[9..18]) at slot 0 + config[9] = 1; + // t2 (config[18..27]) at slots 2,3,4 + config[18 + 2] = 1; + config[18 + 3] = 1; + config[18 + 4] = 1; + // t3 (config[27..36]) at slots 2,3 + config[27 + 2] = 1; + config[27 + 3] = 1; + // t4 (config[36..45]) at slot 1 + config[36 + 1] = 1; + vec![crate::example_db::specs::ModelExampleSpec { + id: "preemptive_scheduling", + instance: Box::new(PreemptiveScheduling::new( + vec![2, 1, 3, 2, 1], + 2, + vec![(0, 2), (1, 3)], + )), + optimal_config: config, + optimal_value: serde_json::json!(5), + }] +} + +#[cfg(test)] +#[path = "../../unit_tests/models/misc/preemptive_scheduling.rs"] +mod tests; diff --git a/src/rules/mod.rs b/src/rules/mod.rs index 3a8cf343..90abab79 100644 --- a/src/rules/mod.rs +++ b/src/rules/mod.rs @@ -196,6 +196,8 @@ pub(crate) mod pathconstrainednetworkflow_ilp; #[cfg(feature = "ilp-solver")] pub(crate) mod precedenceconstrainedscheduling_ilp; #[cfg(feature = "ilp-solver")] +pub(crate) mod preemptivescheduling_ilp; +#[cfg(feature = "ilp-solver")] pub(crate) mod quadraticassignment_ilp; #[cfg(feature = "ilp-solver")] pub(crate) mod qubo_ilp; @@ -375,6 +377,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec. +//! +//! Time-indexed formulation with an auxiliary integer makespan variable: +//! - Variables: binary x_{t,u} for t in 0..n, u in 0..D_max (task t processed at slot u), +//! plus integer M (the makespan), indexed at position n*D_max. +//! - Variable index for x_{t,u}: t * D_max + u. +//! - Variable index for M: n * D_max. +//! - Constraints: +//! 1. Work: Σ_u x_{t,u} = l(t) for each task t +//! 2. Capacity: Σ_t x_{t,u} ≤ m for each time slot u +//! 3. Precedence: for each (pred, succ) and each slot u, +//! `l(pred) * x_{succ,u} ≤ Σ_{v=0}^{u-1} x_{pred,v}` +//! This ensures succ can only be active at slot u if pred has already +//! completed all l(pred) units of work in slots 0..u-1. +//! 4. Makespan lower bound: M ≥ (u+1) when x_{t,u}=1: +//! `M - (u+1)*x_{t,u} ≥ 0` for all t,u +//! 5. Binary bounds: x_{t,u} ≤ 1 for each t,u +//! (since ILP uses non-negative integer domain) +//! - Objective: Minimize M. +//! +//! Note: ILP treats all variables as non-negative integers. Binary constraints +//! on x_{t,u} are enforced by x_{t,u} ≤ 1. + +use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; +use crate::models::misc::PreemptiveScheduling; +use crate::reduction; +use crate::rules::traits::{ReduceTo, ReductionResult}; + +/// Result of reducing PreemptiveScheduling to ILP. +/// +/// Variable layout: +/// - x_{t,u} at index t * D_max + u for t in 0..n, u in 0..D_max (n*D_max vars) +/// - M at index n * D_max (1 integer var) +/// +/// Total: n * D_max + 1 variables. +#[derive(Debug, Clone)] +pub struct ReductionPSToILP { + target: ILP, + num_tasks: usize, + d_max: usize, +} + +impl ReductionResult for ReductionPSToILP { + type Source = PreemptiveScheduling; + type Target = ILP; + + fn target_problem(&self) -> &ILP { + &self.target + } + + /// Extract schedule from ILP solution. + /// + /// Returns a binary config of length n * D_max: `config[t * D_max + u] = x_{t,u}`. + fn extract_solution(&self, target_solution: &[usize]) -> Vec { + let nd = self.num_tasks * self.d_max; + target_solution[..nd.min(target_solution.len())].to_vec() + } +} + +#[reduction( + overhead = { + num_vars = "num_tasks * num_tasks + 1", + num_constraints = "num_tasks + num_tasks * num_tasks + num_precedences * num_tasks + num_tasks * num_tasks", + } +)] +impl ReduceTo> for PreemptiveScheduling { + type Result = ReductionPSToILP; + + fn reduce_to(&self) -> Self::Result { + let n = self.num_tasks(); + let m = self.num_processors(); + let d = self.d_max(); + let num_task_vars = n * d; + let m_var = num_task_vars; // index of the makespan variable M + let num_vars = num_task_vars + 1; + + let x = |t: usize, u: usize| t * d + u; + + let mut constraints = Vec::new(); + + // 1. Work constraints: Σ_u x_{t,u} = l(t) for each task t + for t in 0..n { + let terms: Vec<(usize, f64)> = (0..d).map(|u| (x(t, u), 1.0)).collect(); + constraints.push(LinearConstraint::eq(terms, self.lengths()[t] as f64)); + } + + // 2. Capacity constraints: Σ_t x_{t,u} ≤ m for each time slot u + for u in 0..d { + let terms: Vec<(usize, f64)> = (0..n).map(|t| (x(t, u), 1.0)).collect(); + constraints.push(LinearConstraint::le(terms, m as f64)); + } + + // 3. Precedence constraints: for each (pred, succ) and each slot u: + // l(pred) * x_{succ,u} ≤ Σ_{v=0}^{u-1} x_{pred,v} + // i.e. l(pred) * x_{succ,u} - Σ_{v=0}^{u-1} x_{pred,v} ≤ 0 + // + // Interpretation: succ can only be active at slot u once pred has + // accumulated all l(pred) units of work in strictly earlier slots. + for &(pred, succ) in self.precedences() { + let l_pred = self.lengths()[pred] as f64; + for u in 0..d { + // Σ_{v=0}^{u-1} x_{pred,v} - l(pred)*x_{succ,u} ≥ 0 + // i.e. l(pred)*x_{succ,u} - Σ_{v = Vec::new(); + // Cumulative pred work up to u-1 + for v in 0..u { + terms.push((x(pred, v), -1.0)); + } + terms.push((x(succ, u), l_pred)); + constraints.push(LinearConstraint::le(terms, 0.0)); + } + } + + // 4. Makespan lower bound: M - (u+1)*x_{t,u} ≥ 0 for all t,u + for t in 0..n { + for u in 0..d { + constraints.push(LinearConstraint::ge( + vec![(m_var, 1.0), (x(t, u), -((u + 1) as f64))], + 0.0, + )); + } + } + + // 5. Binary upper bound: x_{t,u} ≤ 1 for all t,u + for t in 0..n { + for u in 0..d { + constraints.push(LinearConstraint::le(vec![(x(t, u), 1.0)], 1.0)); + } + } + + // Objective: minimize M + let objective = vec![(m_var, 1.0)]; + + ReductionPSToILP { + target: ILP::new(num_vars, constraints, objective, ObjectiveSense::Minimize), + num_tasks: n, + d_max: d, + } + } +} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_rule_example_specs() -> Vec { + vec![crate::example_db::specs::RuleExampleSpec { + id: "preemptivescheduling_to_ilp", + build: || { + // 3 tasks, lengths [2,1,2], 2 processors, precedence (0,2) + let source = PreemptiveScheduling::new(vec![2, 1, 2], 2, vec![(0, 2)]); + crate::example_db::specs::rule_example_via_ilp::<_, i32>(source) + }, + }] +} + +#[cfg(test)] +#[path = "../unit_tests/rules/preemptivescheduling_ilp.rs"] +mod tests; diff --git a/src/unit_tests/models/misc/preemptive_scheduling.rs b/src/unit_tests/models/misc/preemptive_scheduling.rs new file mode 100644 index 00000000..d1e2f880 --- /dev/null +++ b/src/unit_tests/models/misc/preemptive_scheduling.rs @@ -0,0 +1,231 @@ +use super::*; +use crate::traits::Problem; +use crate::types::Min; + +// ─── helpers ─────────────────────────────────────────────────────────────── + +/// Small instance: 2 tasks with lengths [2, 1], 2 processors, no precedences. +/// D_max = 3. Config length = 2 * 3 = 6. +fn small_instance() -> PreemptiveScheduling { + PreemptiveScheduling::new(vec![2, 1], 2, vec![]) +} + +/// 2 tasks with a precedence: task 0 → task 1. +/// lengths [1, 1], 2 processors, precedence (0,1). +/// D_max = 2. Config length = 2 * 2 = 4. +fn precedence_instance() -> PreemptiveScheduling { + PreemptiveScheduling::new(vec![1, 1], 2, vec![(0, 1)]) +} + +// ─── creation / accessor tests ───────────────────────────────────────────── + +#[test] +fn test_preemptive_scheduling_creation() { + let p = PreemptiveScheduling::new(vec![2, 1, 3], 2, vec![(0, 2)]); + assert_eq!(p.num_tasks(), 3); + assert_eq!(p.num_processors(), 2); + assert_eq!(p.num_precedences(), 1); + assert_eq!(p.lengths(), &[2, 1, 3]); + assert_eq!(p.precedences(), &[(0, 2)]); + assert_eq!(p.d_max(), 6); + assert_eq!(p.dims(), vec![2; 3 * 6]); + assert_eq!( + ::NAME, + "PreemptiveScheduling" + ); + assert!(::variant().is_empty()); +} + +#[test] +fn test_preemptive_scheduling_empty_tasks() { + let p = PreemptiveScheduling::new(vec![], 1, vec![]); + assert_eq!(p.num_tasks(), 0); + assert_eq!(p.d_max(), 0); + assert_eq!(p.dims(), Vec::::new()); + assert_eq!(p.evaluate(&[]), Min(Some(0))); +} + +// ─── evaluate: valid configs ──────────────────────────────────────────────── + +#[test] +fn test_preemptive_scheduling_evaluate_valid_no_precedence() { + let p = small_instance(); + // D_max=3; t0 active at 0,1 t1 active at 0 + // config layout: [t0s0, t0s1, t0s2, t1s0, t1s1, t1s2] + let config = vec![1, 1, 0, 1, 0, 0]; + assert_eq!(p.evaluate(&config), Min(Some(2))); +} + +#[test] +fn test_preemptive_scheduling_evaluate_valid_split() { + // Single processor, 1 task of length 2; split into slots 0 and 2 + let p = PreemptiveScheduling::new(vec![2], 1, vec![]); + // D_max=2, config length=2 + let config = vec![1, 1]; + assert_eq!(p.evaluate(&config), Min(Some(2))); +} + +#[test] +fn test_preemptive_scheduling_evaluate_valid_precedence() { + // Task 0 finishes at slot 0 (last=0), task 1 starts at slot 1 (first=1). OK. + let p = precedence_instance(); + // D_max=2; t0=[1,0], t1=[0,1] + let config = vec![1, 0, 0, 1]; + assert_eq!(p.evaluate(&config), Min(Some(2))); +} + +#[test] +fn test_preemptive_scheduling_makespan_correct() { + // 3 tasks on 3 processors, no precedences, all finish at slot 2 + let p = PreemptiveScheduling::new(vec![1, 1, 1], 3, vec![]); + // D_max=3; each task active in exactly 1 slot, all at slot 2 + let config = vec![ + 0, 0, 1, // t0 at slot 2 + 0, 0, 1, // t1 at slot 2 + 0, 0, 1, // t2 at slot 2 + ]; + // 3 tasks at slot 2 <= 3 processors OK, makespan = 3 + assert_eq!(p.evaluate(&config), Min(Some(3))); +} + +// ─── evaluate: invalid configs ───────────────────────────────────────────── + +#[test] +fn test_preemptive_scheduling_evaluate_wrong_length() { + let p = small_instance(); + assert_eq!(p.evaluate(&[]), Min(None)); + assert_eq!(p.evaluate(&[1, 1, 0]), Min(None)); // too short + assert_eq!(p.evaluate(&[1, 1, 0, 1, 0, 0, 0]), Min(None)); // too long +} + +#[test] +fn test_preemptive_scheduling_evaluate_wrong_active_count() { + let p = small_instance(); + // t0 needs 2 active slots but gets 1; t1 needs 1 but gets 1 + let config = vec![1, 0, 0, 1, 0, 0]; + assert_eq!(p.evaluate(&config), Min(None)); +} + +#[test] +fn test_preemptive_scheduling_evaluate_processor_overflow() { + // 3 tasks, 2 processors; all three tasks at slot 0 + let p = PreemptiveScheduling::new(vec![1, 1, 1], 2, vec![]); + // D_max=3; all at slot 0 → 3 tasks > 2 processors + let config = vec![1, 0, 0, 1, 0, 0, 1, 0, 0]; + assert_eq!(p.evaluate(&config), Min(None)); +} + +#[test] +fn test_preemptive_scheduling_evaluate_precedence_violation() { + // Task 0 last active slot 1, task 1 first active slot 1 — not strictly less + let p = precedence_instance(); + // D_max=2; t0=[0,1], t1=[0,1] — both active at slot 1; last of pred = 1, first of succ = 0 + // Actually last_pred = 1, first_succ = 0 → 1 >= 0 → violation + let config = vec![0, 1, 1, 0]; + assert_eq!(p.evaluate(&config), Min(None)); +} + +#[test] +fn test_preemptive_scheduling_evaluate_precedence_same_slot() { + // Tasks assigned to the same slot; last_pred = 0, first_succ = 0 → violation + let p = precedence_instance(); + // t0=[1,0], t1=[1,0] + let config = vec![1, 0, 1, 0]; + assert_eq!(p.evaluate(&config), Min(None)); +} + +// ─── paper canonical example ──────────────────────────────────────────────── + +#[test] +fn test_preemptive_scheduling_paper_example() { + // 5 tasks, lengths [2,1,3,2,1], 2 processors, precedences [(0,2),(1,3)] + // Optimal makespan = 5 + let p = PreemptiveScheduling::new(vec![2, 1, 3, 2, 1], 2, vec![(0, 2), (1, 3)]); + let d = p.d_max(); // = 9 + assert_eq!(d, 9); + + let mut config = vec![0usize; 5 * d]; + // t0 (task index 0) occupies config[0..d] + config[0] = 1; // t0 at slot 0 + config[1] = 1; // t0 at slot 1 + // t1 (task index 1) occupies config[d..2*d] + config[d] = 1; // t1 at slot 0 + // t2 (task index 2) occupies config[2*d..3*d] + config[2 * d + 2] = 1; // t2 at slot 2 + config[2 * d + 3] = 1; // t2 at slot 3 + config[2 * d + 4] = 1; // t2 at slot 4 + // t3 (task index 3) occupies config[3*d..4*d] + config[3 * d + 2] = 1; // t3 at slot 2 + config[3 * d + 3] = 1; // t3 at slot 3 + // t4 (task index 4) occupies config[4*d..5*d] + config[4 * d + 1] = 1; // t4 at slot 1 + + assert_eq!(p.evaluate(&config), Min(Some(5))); +} + +// ─── serialization ────────────────────────────────────────────────────────── + +#[test] +fn test_preemptive_scheduling_serialization() { + let p = PreemptiveScheduling::new(vec![2, 1, 3], 2, vec![(0, 2)]); + let json = serde_json::to_value(&p).unwrap(); + let restored: PreemptiveScheduling = serde_json::from_value(json).unwrap(); + assert_eq!(restored.num_tasks(), p.num_tasks()); + assert_eq!(restored.num_processors(), p.num_processors()); + assert_eq!(restored.lengths(), p.lengths()); + assert_eq!(restored.precedences(), p.precedences()); +} + +#[test] +fn test_preemptive_scheduling_serialization_roundtrip_evaluate() { + let p = PreemptiveScheduling::new(vec![1, 1], 2, vec![(0, 1)]); + let json = serde_json::to_value(&p).unwrap(); + let p2: PreemptiveScheduling = serde_json::from_value(json).unwrap(); + // valid: t0 at 0, t1 at 1 + let config = vec![1, 0, 0, 1]; + assert_eq!(p.evaluate(&config), p2.evaluate(&config)); +} + +// ─── validation panics ────────────────────────────────────────────────────── + +#[test] +#[should_panic(expected = "task lengths must be positive")] +fn test_preemptive_scheduling_zero_length() { + PreemptiveScheduling::new(vec![0, 1], 2, vec![]); +} + +#[test] +#[should_panic(expected = "num_processors must be positive")] +fn test_preemptive_scheduling_zero_processors() { + PreemptiveScheduling::new(vec![1, 1], 0, vec![]); +} + +#[test] +#[should_panic(expected = "precedence index out of range")] +fn test_preemptive_scheduling_precedence_out_of_range() { + PreemptiveScheduling::new(vec![1, 1], 2, vec![(0, 5)]); +} + +// ─── serde validation ─────────────────────────────────────────────────────── + +#[test] +fn test_preemptive_scheduling_deserialize_invalid_zero_length() { + let json = serde_json::json!({ + "lengths": [0, 1], + "num_processors": 2, + "precedences": [] + }); + let result: Result = serde_json::from_value(json); + assert!(result.is_err()); +} + +#[test] +fn test_preemptive_scheduling_deserialize_invalid_zero_processors() { + let json = serde_json::json!({ + "lengths": [1, 2], + "num_processors": 0, + "precedences": [] + }); + let result: Result = serde_json::from_value(json); + assert!(result.is_err()); +} diff --git a/src/unit_tests/rules/preemptivescheduling_ilp.rs b/src/unit_tests/rules/preemptivescheduling_ilp.rs new file mode 100644 index 00000000..95a0258a --- /dev/null +++ b/src/unit_tests/rules/preemptivescheduling_ilp.rs @@ -0,0 +1,105 @@ +use super::*; +use crate::models::algebraic::ILP; +use crate::solvers::ILPSolver; +use crate::traits::Problem; +use crate::types::Min; + +// ─── helpers ─────────────────────────────────────────────────────────────── + +/// 2 tasks, lengths [1, 1], 2 processors, precedence (0,1). +/// D_max = 2. Optimal makespan = 2. +fn small_instance() -> PreemptiveScheduling { + PreemptiveScheduling::new(vec![1, 1], 2, vec![(0, 1)]) +} + +/// 3 tasks, lengths [2,1,2], 2 processors, precedence (0,2). +/// D_max = 5. Feasible with makespan ≤ 5. +fn medium_instance() -> PreemptiveScheduling { + PreemptiveScheduling::new(vec![2, 1, 2], 2, vec![(0, 2)]) +} + +// ─── structure ───────────────────────────────────────────────────────────── + +#[test] +fn test_preemptivescheduling_to_ilp_structure() { + let p = small_instance(); + // n=2, D_max=2 → 2*2+1 = 5 variables + let reduction: ReductionPSToILP = ReduceTo::>::reduce_to(&p); + let ilp = reduction.target_problem(); + assert_eq!(ilp.num_vars, 5, "expected n*D_max+1 = 5 variables"); + assert_eq!( + ilp.objective, + vec![(4, 1.0)], + "objective: minimize M at index 4" + ); + + // Constraints: + // 2 work + 2 capacity + 1 prec*(D_max=2 slots) + 2*2 makespan + 2*2 binary = 2+2+2+4+4 = 14 + assert_eq!(ilp.constraints.len(), 14); +} + +// ─── closed-loop ─────────────────────────────────────────────────────────── + +#[test] +fn test_preemptivescheduling_to_ilp_closed_loop() { + let p = small_instance(); + let reduction: ReductionPSToILP = ReduceTo::>::reduce_to(&p); + let ilp_solution = ILPSolver::new() + .solve(reduction.target_problem()) + .expect("ILP should be feasible"); + let extracted = reduction.extract_solution(&ilp_solution); + let value = p.evaluate(&extracted); + assert!( + value.0.is_some(), + "extracted schedule should be valid, got {value:?}" + ); +} + +#[test] +fn test_preemptivescheduling_to_ilp_medium_closed_loop() { + let p = medium_instance(); + let reduction: ReductionPSToILP = ReduceTo::>::reduce_to(&p); + let ilp_solution = ILPSolver::new() + .solve(reduction.target_problem()) + .expect("ILP should be feasible"); + let extracted = reduction.extract_solution(&ilp_solution); + let value = p.evaluate(&extracted); + assert!( + value.0.is_some(), + "extracted schedule should be valid, got {value:?}" + ); + assert!( + value.0.map(|v| v <= 5).unwrap_or(false), + "makespan should be at most 5, got {value:?}" + ); +} + +// ─── infeasible ──────────────────────────────────────────────────────────── + +#[test] +fn test_preemptivescheduling_to_ilp_infeasible() { + // 1 processor, tasks t0→t1→t0 would be a cycle — let's just make a + // tight instance: 1 processor, 1 task of length 1, always feasible. + // Actually, let's check that a huge task on 1 tiny processor is fine + // (it's always feasible; makespan is just larger). + // Use a cycle-free precedence that is always schedulable. + let p = PreemptiveScheduling::new(vec![1, 1], 1, vec![(0, 1)]); + let reduction: ReductionPSToILP = ReduceTo::>::reduce_to(&p); + let sol = ILPSolver::new().solve(reduction.target_problem()); + // 1 processor, t0 at slot 0, t1 at slot 1 → always feasible + assert!(sol.is_some(), "should be feasible"); +} + +// ─── extract_solution ────────────────────────────────────────────────────── + +#[test] +fn test_preemptivescheduling_to_ilp_extract_solution() { + // small_instance: n=2, D_max=2, m_var=4 + // x_{0,0}=1, x_{0,1}=0, x_{1,0}=0, x_{1,1}=1, M=2 + let p = small_instance(); + let reduction: ReductionPSToILP = ReduceTo::>::reduce_to(&p); + let ilp_solution = vec![1, 0, 0, 1, 2]; // last element is M + let extracted = reduction.extract_solution(&ilp_solution); + assert_eq!(extracted, vec![1, 0, 0, 1]); + assert_eq!(p.evaluate(&extracted), Min(Some(2))); +} From 54a63d14f1b7febb3ba69b639f3125b374761e3e Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Mon, 30 Mar 2026 07:30:18 +0800 Subject: [PATCH 6/6] Fix #506: Add OpenShopScheduling model and direct ILP rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the Open Shop Scheduling optimization model (minimize makespan) with a direct ILP reduction using disjunctive formulation (binary ordering variables + integer start times + makespan objective). Canonical example uses the 4 jobs × 3 machines instance with true optimal makespan = 8. Co-Authored-By: Claude Sonnet 4.6 --- docs/paper/reductions.typ | 134 ++++++++ docs/paper/references.bib | 11 + src/models/misc/mod.rs | 4 + src/models/misc/open_shop_scheduling.rs | 269 ++++++++++++++++ src/rules/mod.rs | 3 + src/rules/openshopscheduling_ilp.rs | 286 ++++++++++++++++++ .../models/misc/open_shop_scheduling.rs | 232 ++++++++++++++ .../rules/openshopscheduling_ilp.rs | 143 +++++++++ 8 files changed, 1082 insertions(+) create mode 100644 src/models/misc/open_shop_scheduling.rs create mode 100644 src/rules/openshopscheduling_ilp.rs create mode 100644 src/unit_tests/models/misc/open_shop_scheduling.rs create mode 100644 src/unit_tests/rules/openshopscheduling_ilp.rs diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index f923f3ed..47746047 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -163,6 +163,7 @@ "MinMaxMulticenter": [Min-Max Multicenter], "FlowShopScheduling": [Flow Shop Scheduling], "JobShopScheduling": [Job-Shop Scheduling], + "OpenShopScheduling": [Open Shop Scheduling], "GroupingBySwapping": [Grouping by Swapping], "IntegerExpressionMembership": [Integer Expression Membership], "MinimumCutIntoBoundedSets": [Minimum Cut Into Bounded Sets], @@ -5587,6 +5588,119 @@ A classical NP-complete problem from Garey and Johnson @garey1979[Ch.~3, p.~76], ] } +#{ + let x = load-model-example("OpenShopScheduling") + let p = x.instance.processing_times + let m = x.instance.num_machines + let n = p.len() + let cfg = x.optimal_config + // Decode per-machine orderings: cfg[i*n..(i+1)*n] is machine i's job order + let orders = range(m).map(i => cfg.slice(i * n, (i + 1) * n)) + + // Greedy simulation to compute start times + let machine-avail = range(m).map(_ => 0) + let job-avail = range(n).map(_ => 0) + let next-on = range(m).map(_ => 0) + let start-times = range(n).map(_ => range(m).map(_ => 0)) + let finish-times = range(n).map(_ => range(m).map(_ => 0)) + + let total-tasks = n * m + let scheduled = 0 + while scheduled < total-tasks { + // Find machine with earliest next start + let best-start = 999999 + let best-machine = -1 + for i in range(m) { + if next-on.at(i) < n { + let j = orders.at(i).at(next-on.at(i)) + let s = calc.max(machine-avail.at(i), job-avail.at(j)) + if s < best-start or (s == best-start and (best-machine == -1 or i < best-machine)) { + best-start = s + best-machine = i + } + } + } + let i = best-machine + let j = orders.at(i).at(next-on.at(i)) + let s = calc.max(machine-avail.at(i), job-avail.at(j)) + let f = s + p.at(j).at(i) + start-times.at(j).at(i) = s + finish-times.at(j).at(i) = f + machine-avail.at(i) = f + job-avail.at(j) = f + next-on.at(i) += 1 + scheduled += 1 + } + + let makespan = calc.max(..range(n).map(j => calc.max(..range(m).map(i => finish-times.at(j).at(i))))) + + [ + #problem-def("OpenShopScheduling")[ + Given $m$ machines and a set $J$ of $n$ jobs, where each job $j in J$ has one task per machine $i$ with processing time $p(j, i) in ZZ^+_0$, find a non-preemptive schedule minimizing the *makespan* $max_(j,i)(sigma(j, i) + p(j, i))$, subject to: + 1. *Machine constraint:* Each machine processes at most one job at a time. + 2. *Job constraint:* Each job occupies at most one machine at a time. + Unlike flow-shop or job-shop scheduling, there is no prescribed order for a job's tasks across machines. + ][ + Open Shop Scheduling is problem SS14 in Garey and Johnson's catalog @garey1979 (decision version: does a schedule exist with makespan $<= D$?). NP-completeness for $m >= 3$ machines was established by Gonzalez and Sahni via reduction from Partition @gonzalez1976. The problem is solvable in polynomial time for $m = 2$ and also for the preemptive variant with any $m$ @gonzalez1976. This codebase evaluates a candidate schedule by simulating a greedy active schedule: for each step, the machine with the earliest feasible next-job start is processed next. The configuration encodes one permutation of jobs per machine (direct indices), giving $(n!)^m$ candidate orderings. + + *Example.* Let $m = #m$ machines and $n = #n$ jobs with processing times + #align(center, math.equation([$P = #math.mat(..p.map(row => row.map(v => [#v])))$])) + The canonical optimal orderings are: + #align(center, table( + columns: 2, + align: (left, left), + table.header([Machine], [Job order]), + ..range(m).map(i => ([M#(i+1)], orders.at(i).map(j => [$J_#(j+1)$]).join[$,$])).flatten() + )) + giving the Gantt chart in @fig:openshop and makespan *#makespan*. + + #pred-commands( + "pred create --example " + problem-spec(x) + " -o open-shop-scheduling.json", + "pred solve open-shop-scheduling.json", + "pred evaluate open-shop-scheduling.json --config " + x.optimal_config.map(str).join(","), + ) + + #figure( + canvas(length: 1cm, { + import draw: * + let colors = (rgb("#4e79a7"), rgb("#e15759"), rgb("#76b7b2"), rgb("#f28e2b"), rgb("#59a14f"), rgb("#b07aa1")) + let scale = 0.55 + let row-h = 0.6 + let gap = 0.15 + + for mi in range(m) { + let y = -mi * (row-h + gap) + content((-0.8, y), text(8pt, "M" + str(mi + 1))) + } + + for j in range(n) { + for i in range(m) { + let s = start-times.at(j).at(i) + let f = finish-times.at(j).at(i) + let x0 = s * scale + let x1 = f * scale + let y = -i * (row-h + gap) + rect((x0, y - row-h / 2), (x1, y + row-h / 2), + fill: colors.at(j).transparentize(30%), stroke: 0.4pt + colors.at(j)) + content(((x0 + x1) / 2, y), text(6pt, [$J_#(j + 1)$])) + } + } + + let y-axis = -(m - 1) * (row-h + gap) - row-h / 2 - 0.2 + line((0, y-axis), (makespan * scale, y-axis), stroke: 0.4pt) + for t in range(makespan + 1) { + let x = t * scale + line((x, y-axis), (x, y-axis - 0.1), stroke: 0.4pt) + content((x, y-axis - 0.25), text(6pt, str(t))) + } + content((makespan * scale / 2, y-axis - 0.5), text(7pt)[$t$]) + }), + caption: [Open-shop schedule for #n jobs on #m machines. Optimal makespan is #makespan. Each color represents one job; no two tasks of the same job overlap in time.], + ) + ] + ] +} + #problem-def("StaffScheduling")[ Given a collection $C$ of binary schedule patterns of length $m$, where each pattern has exactly $k$ ones, a requirement vector $overline(R) in ZZ_(>= 0)^m$, and a worker budget $n in ZZ_(>= 0)$, determine whether there exists a function $f: C -> ZZ_(>= 0)$ such that $sum_(c in C) f(c) <= n$ and $sum_(c in C) f(c) dot c >= overline(R)$ component-wise. ][ @@ -9547,6 +9661,26 @@ The following reductions to Integer Linear Programming are straightforward formu _Solution extraction._ Sort the jobs by their final-machine completion times $C_(j,m)$ and convert that permutation to Lehmer code. ] +#reduction-rule("OpenShopScheduling", "ILP")[ + Binary ordering variables and integer start times encode the disjunctive non-overlap constraints for both machines and jobs; the makespan is the minimized objective. +][ + _Construction._ Let $M = sum_(j,i) p(j,i)$ be the big-$M$ constant (an upper bound on the makespan). For each pair $j < k$ and each machine $i$, let $x_{j k i} in {0,1}$ with $x_{j k i} = 1$ iff job $j$ precedes job $k$ on machine $i$. For each job $j$ and pair of machines $i < i'$, let $y_{j i i'} in {0,1}$ with $y_{j i i'} = 1$ iff machine $i$ is processed before machine $i'$ for job $j$. Let $s_{j,i} in ZZ_{>=0}$ be the start time of job $j$ on machine $i$, and $C$ be the integer makespan variable. The ILP is: + $ + min quad & C \ + "subject to" quad + & s_(k,i) - s_(j,i) - M x_(j k i) >= p(j, i) - M quad forall j < k, i \ + & s_(j,i) - s_(k,i) + M x_(j k i) >= p(k, i) quad forall j < k, i \ + & s_(j,i') - s_(j,i) - M y_(j i i') >= p(j, i) - M quad forall j, i < i' \ + & s_(j,i) - s_(j,i') + M y_(j i i') >= p(j, i') quad forall j, i < i' \ + & C - s_(j,i) >= p(j, i) quad forall j, i \ + & x_(j k i), y_(j i i') in {0,1},; s_(j,i), C in ZZ_(>=0) + $. + + _Correctness._ ($arrow.r.double$) Any feasible open-shop schedule with the given permutations $sigma_i$ induces valid ordering bits $x_{j k i}$ and $y_{j i i'}$ and start times satisfying all non-overlap constraints. ($arrow.l.double$) Any feasible ILP solution defines non-overlapping start times for all tasks, respecting both machine and job constraints. + + _Solution extraction._ For each machine $i$, sort jobs by their ILP start times $s_{j,i}$ to recover the per-machine permutation; output the concatenation of these $m$ direct-index permutations. +] + #reduction-rule("MinimumTardinessSequencing", "ILP")[ A position-assignment ILP captures the permutation, the precedence constraints, and a binary tardy indicator for each unit-length task. ][ diff --git a/docs/paper/references.bib b/docs/paper/references.bib index f7d6f456..b82bb5b3 100644 --- a/docs/paper/references.bib +++ b/docs/paper/references.bib @@ -1558,3 +1558,14 @@ @article{Murty1972 pages = {326--370}, doi = {10.1007/BF01584550} } + +@article{gonzalez1976, + author = {Teofilo Gonzalez and Sartaj Sahni}, + title = {Open Shop Scheduling to Minimize Finish Time}, + journal = {Journal of the ACM}, + volume = {23}, + number = {4}, + pages = {665--679}, + year = {1976}, + doi = {10.1145/321978.321985} +} diff --git a/src/models/misc/mod.rs b/src/models/misc/mod.rs index 86391564..9a0f323e 100644 --- a/src/models/misc/mod.rs +++ b/src/models/misc/mod.rs @@ -15,6 +15,7 @@ //! - [`JobShopScheduling`]: Minimize makespan with per-job processor routes //! - [`Knapsack`]: 0-1 Knapsack (maximize value subject to weight capacity) //! - [`MultiprocessorScheduling`]: Schedule tasks on processors to meet a deadline +//! - [`OpenShopScheduling`]: Open Shop Scheduling (minimize makespan, free task order per job) //! - [`LongestCommonSubsequence`]: Longest Common Subsequence //! - [`MinimumExternalMacroDataCompression`]: Minimize compression cost using external dictionary //! - [`MinimumInternalMacroDataCompression`]: Minimize self-referencing compression cost @@ -91,6 +92,7 @@ mod minimum_external_macro_data_compression; mod minimum_internal_macro_data_compression; mod minimum_tardiness_sequencing; mod multiprocessor_scheduling; +mod open_shop_scheduling; pub(crate) mod paintshop; pub(crate) mod partially_ordered_knapsack; pub(crate) mod partition; @@ -142,6 +144,7 @@ pub use minimum_external_macro_data_compression::MinimumExternalMacroDataCompres pub use minimum_internal_macro_data_compression::MinimumInternalMacroDataCompression; pub use minimum_tardiness_sequencing::MinimumTardinessSequencing; pub use multiprocessor_scheduling::MultiprocessorScheduling; +pub use open_shop_scheduling::OpenShopScheduling; pub use paintshop::PaintShop; pub use partially_ordered_knapsack::PartiallyOrderedKnapsack; pub use partition::Partition; @@ -183,6 +186,7 @@ pub(crate) fn canonical_model_example_specs() -> Vec>", description: "processing_times[j][i] = processing time of job j on machine i (n x m)" }, + ], + } +} + +/// The Open Shop Scheduling problem. +/// +/// Given `m` machines and `n` jobs, where job `j` has one task on each machine +/// `i` with processing time `p[j][i]`, find a non-preemptive schedule that +/// minimizes the makespan. Unlike flow-shop or job-shop scheduling, there is no +/// prescribed order for the tasks of a given job — each job's tasks may be +/// processed on the machines in any order. +/// +/// # Constraints +/// +/// 1. **Machine constraint:** Each machine processes at most one job at a time. +/// 2. **Job constraint:** Each job occupies at most one machine at a time. +/// +/// # Configuration Encoding +/// +/// The configuration is a flat array of `n * m` values. +/// `config[i * n .. (i + 1) * n]` gives the permutation of jobs on machine `i` +/// (direct job indices, not Lehmer code). A segment is valid iff it is a +/// permutation of `0..n`. Invalid configs return `Min(None)`. +/// +/// # Example +/// +/// ``` +/// use problemreductions::models::misc::OpenShopScheduling; +/// use problemreductions::{Problem, Solver, BruteForce}; +/// use problemreductions::types::Min; +/// +/// // 2 machines, 2 jobs +/// let p = vec![vec![1, 2], vec![2, 1]]; +/// let problem = OpenShopScheduling::new(2, p); +/// let solver = BruteForce::new(); +/// let value = Solver::solve(&solver, &problem); +/// assert_eq!(value, Min(Some(3))); +/// ``` +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OpenShopScheduling { + /// Number of machines m. + num_machines: usize, + /// Processing time matrix: `processing_times[j][i]` is the time to process + /// job `j` on machine `i`. Dimensions: n jobs × m machines. + processing_times: Vec>, +} + +impl OpenShopScheduling { + /// Create a new Open Shop Scheduling instance. + /// + /// # Arguments + /// * `num_machines` - Number of machines m + /// * `processing_times` - `processing_times[j][i]` = processing time of job j on machine i. + /// Each inner Vec must have length `num_machines`. + /// + /// # Panics + /// Panics if any job does not have exactly `num_machines` processing times. + pub fn new(num_machines: usize, processing_times: Vec>) -> Self { + for (j, times) in processing_times.iter().enumerate() { + assert_eq!( + times.len(), + num_machines, + "Job {} has {} processing times, expected {}", + j, + times.len(), + num_machines + ); + } + Self { + num_machines, + processing_times, + } + } + + /// Get the number of machines. + pub fn num_machines(&self) -> usize { + self.num_machines + } + + /// Get the number of jobs. + pub fn num_jobs(&self) -> usize { + self.processing_times.len() + } + + /// Get the processing time matrix. + pub fn processing_times(&self) -> &[Vec] { + &self.processing_times + } + + /// Decode the per-machine job orderings from a config. + /// + /// Returns `None` if the config length is wrong or any segment is not a + /// valid permutation of `0..n`. + pub fn decode_orders(&self, config: &[usize]) -> Option>> { + let n = self.num_jobs(); + let m = self.num_machines; + if config.len() != n * m { + return None; + } + let mut orders = Vec::with_capacity(m); + for i in 0..m { + let seg = &config[i * n..(i + 1) * n]; + // Validate that seg is a permutation of 0..n + let mut seen = vec![false; n]; + for &job in seg { + if job >= n || seen[job] { + return None; + } + seen[job] = true; + } + orders.push(seg.to_vec()); + } + Some(orders) + } + + /// Compute the makespan from a set of per-machine job orderings. + /// + /// Uses a greedy simulation: at each step, among all machines whose next + /// scheduled job can start (both machine and job are free), schedule the + /// one with the earliest available start time. + pub fn compute_makespan(&self, orders: &[Vec]) -> usize { + let n = self.num_jobs(); + let m = self.num_machines; + + if n == 0 || m == 0 { + return 0; + } + + // `machine_avail[i]` = next time machine i is free. + let mut machine_avail = vec![0usize; m]; + // `job_avail[j]` = next time job j is free (all its currently scheduled + // tasks have finished). + let mut job_avail = vec![0usize; n]; + // Pointer to next unscheduled position in each machine's ordering. + let mut next_on_machine = vec![0usize; m]; + + let total_tasks = n * m; + let mut scheduled = 0; + + while scheduled < total_tasks { + // Find the (machine, earliest start time) among all machines that + // still have unscheduled tasks. + let mut best_start = usize::MAX; + let mut best_machine = usize::MAX; + + for i in 0..m { + if next_on_machine[i] < n { + let j = orders[i][next_on_machine[i]]; + let start = machine_avail[i].max(job_avail[j]); + // Tie-break by machine index to make the result deterministic. + if start < best_start || (start == best_start && i < best_machine) { + best_start = start; + best_machine = i; + } + } + } + + // Schedule the chosen task. + let i = best_machine; + let j = orders[i][next_on_machine[i]]; + let start = machine_avail[i].max(job_avail[j]); + let finish = start + self.processing_times[j][i]; + machine_avail[i] = finish; + job_avail[j] = finish; + next_on_machine[i] += 1; + scheduled += 1; + } + + machine_avail + .iter() + .copied() + .max() + .unwrap_or(0) + .max(job_avail.iter().copied().max().unwrap_or(0)) + } +} + +impl Problem for OpenShopScheduling { + const NAME: &'static str = "OpenShopScheduling"; + type Value = Min; + + fn variant() -> Vec<(&'static str, &'static str)> { + crate::variant_params![] + } + + fn dims(&self) -> Vec { + let n = self.num_jobs(); + let m = self.num_machines; + vec![n; n * m] + } + + fn evaluate(&self, config: &[usize]) -> Min { + match self.decode_orders(config) { + Some(orders) => Min(Some(self.compute_makespan(&orders))), + None => Min(None), + } + } +} + +crate::declare_variants! { + default OpenShopScheduling => "factorial(num_jobs)^num_machines", +} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_model_example_specs() -> Vec { + // 4 jobs × 3 machines example from issue #506. + // processing_times[j][i]: + // J1: p[0] = [3, 1, 2] + // J2: p[1] = [2, 3, 1] + // J3: p[2] = [1, 2, 3] + // J4: p[3] = [2, 2, 1] + // + // Per-machine totals: M1=8, M2=8, M3=7. Per-job totals: J1=6, J2=6, J3=6, J4=5. + // Lower bound: max(8, 6) = 8. True optimal makespan = 8. + // + // Optimal machine orderings (0-indexed jobs): + // M1: [J1, J2, J3, J4] = [0, 1, 2, 3] + // M2: [J2, J1, J4, J3] = [1, 0, 3, 2] + // M3: [J3, J4, J1, J2] = [2, 3, 0, 1] + // + // config = [M1 order | M2 order | M3 order] + // = [0, 1, 2, 3, 1, 0, 3, 2, 2, 3, 0, 1] + // + // Resulting schedule: + // J1: M1=[0,3), M2=[7,8), M3=[1,3) — job non-overlap: [0,3),[1,3) overlap! + // Actually use simulation to verify: + // Step 1: best start = M1(J1:0), M2(J2:0), M3(J3:0) → M1 ties with M2,M3; pick M1 + // J1 on M1: [0,3) + // ... (simulation produces makespan=8) + // + // 224 out of 13824 orderings achieve the optimal makespan of 8. + vec![crate::example_db::specs::ModelExampleSpec { + id: "open_shop_scheduling", + instance: Box::new(OpenShopScheduling::new( + 3, + vec![vec![3, 1, 2], vec![2, 3, 1], vec![1, 2, 3], vec![2, 2, 1]], + )), + optimal_config: vec![0, 1, 2, 3, 1, 0, 3, 2, 2, 3, 0, 1], + optimal_value: serde_json::json!(8), + }] +} + +#[cfg(test)] +#[path = "../../unit_tests/models/misc/open_shop_scheduling.rs"] +mod tests; diff --git a/src/rules/mod.rs b/src/rules/mod.rs index 90abab79..17e91265 100644 --- a/src/rules/mod.rs +++ b/src/rules/mod.rs @@ -182,6 +182,8 @@ pub(crate) mod multiprocessorscheduling_ilp; #[cfg(feature = "ilp-solver")] pub(crate) mod naesatisfiability_ilp; #[cfg(feature = "ilp-solver")] +pub(crate) mod openshopscheduling_ilp; +#[cfg(feature = "ilp-solver")] pub(crate) mod optimallineararrangement_ilp; #[cfg(feature = "ilp-solver")] pub(crate) mod paintshop_ilp; @@ -370,6 +372,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec. +//! +//! Disjunctive formulation with binary ordering variables and integer start times: +//! +//! **Variables:** +//! - `x_{j,k,i}` for j < k, all machines i: binary, 1 if job j precedes job k on machine i. +//! Index: pair index * m + i, where pair index = `j*(2n-j-1)/2 + (k-j-1)`. +//! Count: n*(n-1)/2 * m variables. +//! - `s_{j,i}` for all (j, i): integer start time of job j on machine i. +//! Index: num_order_vars + j * m + i. +//! Count: n * m variables. +//! - `C` (makespan): integer, index num_order_vars + n * m. +//! +//! **Constraints:** +//! 1. Binary bounds: 0 ≤ x_{j,k,i} ≤ 1 for all j < k, i. +//! 2. Machine non-overlap for each pair (j, k) and machine i: +//! - 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} +//! - 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 +//! 3. Job non-overlap for each job j and each pair of machines (i, i'): +//! Uses separate binary variable y_{j,i,i'} for i < i' to decide which task runs first. +//! Variables y_{j,i,i'}: appended after s variables. +//! - s_{j,i'} ≥ s_{j,i} + p_{j,i} - M*(1 - y_{j,i,i'}) +//! - s_{j,i} ≥ s_{j,i'} + p_{j,i'} - M*y_{j,i,i'} +//! 4. Makespan: C ≥ s_{j,i} + p_{j,i} for all (j, i). +//! 5. Non-negativity of start times: s_{j,i} ≥ 0 (implied by ILP non-negativity). +//! +//! **Objective:** Minimize C. + +use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; +use crate::models::misc::OpenShopScheduling; +use crate::reduction; +use crate::rules::traits::{ReduceTo, ReductionResult}; + +/// Result of reducing OpenShopScheduling to ILP. +/// +/// Variable layout: +/// - `x_{j,k,i}` at index `pair_idx(j,k) * m + i` (num_pairs * m vars) +/// - `s_{j,i}` at index `num_order_vars + j * m + i` (n * m vars) +/// - `y_{j,i,i'}` for i < i': at `num_order_vars + n*m + j * num_machine_pairs + machine_pair_idx(i,i')` +/// (n * m*(m-1)/2 vars) +/// - `C`: at index `num_order_vars + n * m + n * m*(m-1)/2` (1 var) +#[derive(Debug, Clone)] +pub struct ReductionOSSToILP { + target: ILP, + num_jobs: usize, + num_machines: usize, + /// n*(n-1)/2 * m — start index of s_{j,i} variables + num_order_vars: usize, +} + +impl ReductionOSSToILP { + fn pair_idx(&self, j: usize, k: usize) -> usize { + debug_assert!(j < k); + let n = self.num_jobs; + j * (2 * n - j - 1) / 2 + (k - j - 1) + } + + fn x_var(&self, j: usize, k: usize, i: usize) -> usize { + self.pair_idx(j, k) * self.num_machines + i + } + + fn s_var(&self, j: usize, i: usize) -> usize { + self.num_order_vars + j * self.num_machines + i + } + + fn machine_pair_idx(&self, i: usize, ip: usize) -> usize { + debug_assert!(i < ip); + let m = self.num_machines; + i * (2 * m - i - 1) / 2 + (ip - i - 1) + } + + fn y_var(&self, j: usize, i: usize, ip: usize) -> usize { + let num_machine_pairs = self.num_machines * self.num_machines.saturating_sub(1) / 2; + self.num_order_vars + + self.num_jobs * self.num_machines + + j * num_machine_pairs + + self.machine_pair_idx(i, ip) + } +} + +impl ReductionResult for ReductionOSSToILP { + type Source = OpenShopScheduling; + type Target = ILP; + + fn target_problem(&self) -> &ILP { + &self.target + } + + /// Extract per-machine job orderings from the ILP start times, then + /// convert to the config format (direct permutation indices per machine). + fn extract_solution(&self, target_solution: &[usize]) -> Vec { + let n = self.num_jobs; + let m = self.num_machines; + + // Read start times s_{j,i} for each (j, i) + let start = |j: usize, i: usize| -> usize { + let idx = self.num_order_vars + j * m + i; + target_solution.get(idx).copied().unwrap_or(0) + }; + + // For each machine, sort jobs by their start time on that machine + let mut config = Vec::with_capacity(n * m); + for i in 0..m { + let mut jobs: Vec = (0..n).collect(); + jobs.sort_by_key(|&j| (start(j, i), j)); + config.extend(jobs); + } + config + } +} + +#[reduction(overhead = { + num_vars = "num_jobs * (num_jobs - 1) / 2 * num_machines + num_jobs * num_machines + num_jobs * num_machines * (num_machines - 1) / 2 + 1", + 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", +})] +impl ReduceTo> for OpenShopScheduling { + type Result = ReductionOSSToILP; + + fn reduce_to(&self) -> Self::Result { + let n = self.num_jobs(); + let m = self.num_machines(); + let p = self.processing_times(); + + let num_pairs = n * n.saturating_sub(1) / 2; + let num_machine_pairs = m * m.saturating_sub(1) / 2; + + // Variable counts + let num_order_vars = num_pairs * m; // x_{j,k,i}: binary + let num_start_vars = n * m; // s_{j,i}: integer + let num_job_pair_vars = n * num_machine_pairs; // y_{j,i,i'}: binary + let num_vars = num_order_vars + num_start_vars + num_job_pair_vars + 1; // +1 for C + + let result = ReductionOSSToILP { + target: ILP::new(0, vec![], vec![], ObjectiveSense::Minimize), + num_jobs: n, + num_machines: m, + num_order_vars, + }; + + // Big-M: sum of all processing times (loose upper bound on makespan) + let total_p: usize = p.iter().flat_map(|row| row.iter()).sum(); + let big_m = total_p as f64; + + let c_var = num_order_vars + num_start_vars + num_job_pair_vars; + + let mut constraints = Vec::new(); + + // 1. Binary bounds on x_{j,k,i}: 0 ≤ x ≤ 1 + for j in 0..n { + for k in (j + 1)..n { + for i in 0..m { + let x = result.x_var(j, k, i); + constraints.push(LinearConstraint::le(vec![(x, 1.0)], 1.0)); + } + } + } + + // Upper bounds on start time variables: s_{j,i} ≤ total_p + // (no task can start after all tasks have finished) + for j in 0..n { + for i in 0..m { + let sji = result.s_var(j, i); + constraints.push(LinearConstraint::le(vec![(sji, 1.0)], big_m)); + } + } + + // Upper bound on makespan C ≤ total_p + constraints.push(LinearConstraint::le(vec![(c_var, 1.0)], big_m)); + + // 2. Machine non-overlap: for each pair (j,k) with j= p_{j,i} - M + constraints.push(LinearConstraint::ge( + vec![(sk, 1.0), (sj, -1.0), (x, -big_m)], + pji - big_m, + )); + + // (b) s_{j,i} - s_{k,i} + M*x_{j,k,i} >= p_{k,i} + constraints.push(LinearConstraint::ge( + vec![(sj, 1.0), (sk, -1.0), (x, big_m)], + pki, + )); + } + } + } + + // 3. Binary bounds on y_{j,i,i'}: 0 ≤ y ≤ 1 + for j in 0..n { + for i in 0..m { + for ip in (i + 1)..m { + let y = result.y_var(j, i, ip); + constraints.push(LinearConstraint::le(vec![(y, 1.0)], 1.0)); + } + } + } + + // 4. Job non-overlap: for each job j and each pair (i, i') with i < i' + // y_{j,i,i'}=1 means machine i is scheduled before machine i' for job j: + // (a) s_{j,i'} ≥ s_{j,i} + p_{j,i} - M*(1-y) + // s_{j,i'} - s_{j,i} - M*y ≥ p_{j,i} - M + // (b) s_{j,i} ≥ s_{j,i'} + p_{j,i'} - M*y + // s_{j,i} - s_{j,i'} + M*y ≥ p_{j,i'} + for (j, pj) in p.iter().enumerate() { + for i in 0..m { + for ip in (i + 1)..m { + let y = result.y_var(j, i, ip); + let sji = result.s_var(j, i); + let sjip = result.s_var(j, ip); + let pji = pj[i] as f64; + let pjip = pj[ip] as f64; + + // (a) s_{j,i'} - s_{j,i} - M*y >= p_{j,i} - M + constraints.push(LinearConstraint::ge( + vec![(sjip, 1.0), (sji, -1.0), (y, -big_m)], + pji - big_m, + )); + + // (b) s_{j,i} - s_{j,i'} + M*y >= p_{j,i'} + constraints.push(LinearConstraint::ge( + vec![(sji, 1.0), (sjip, -1.0), (y, big_m)], + pjip, + )); + } + } + } + + // 5. Makespan: C ≥ s_{j,i} + p_{j,i} ⟺ C - s_{j,i} ≥ p_{j,i} + for (j, pj) in p.iter().enumerate() { + for (i, &pji) in pj.iter().enumerate() { + let sji = result.s_var(j, i); + constraints.push(LinearConstraint::ge( + vec![(c_var, 1.0), (sji, -1.0)], + pji as f64, + )); + } + } + + // Objective: minimize C + let objective = vec![(c_var, 1.0)]; + + ReductionOSSToILP { + target: ILP::new(num_vars, constraints, objective, ObjectiveSense::Minimize), + num_jobs: n, + num_machines: m, + num_order_vars, + } + } +} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_rule_example_specs() -> Vec { + vec![crate::example_db::specs::RuleExampleSpec { + id: "openshopscheduling_to_ilp", + build: || { + // Small 2x2 instance for canonical example + let source = OpenShopScheduling::new(2, vec![vec![1, 2], vec![2, 1]]); + crate::example_db::specs::rule_example_via_ilp::<_, i32>(source) + }, + }] +} + +#[cfg(test)] +#[path = "../unit_tests/rules/openshopscheduling_ilp.rs"] +mod tests; diff --git a/src/unit_tests/models/misc/open_shop_scheduling.rs b/src/unit_tests/models/misc/open_shop_scheduling.rs new file mode 100644 index 00000000..2c493cda --- /dev/null +++ b/src/unit_tests/models/misc/open_shop_scheduling.rs @@ -0,0 +1,232 @@ +use super::*; +use crate::solvers::{BruteForce, Solver}; +use crate::traits::Problem; +use crate::types::Min; + +/// 2 machines, 2 jobs: smallest non-trivial instance. +/// processing_times[j][i]: J1=[1,2], J2=[2,1] +/// All four orderings give the same makespan = 3 (symmetric). +fn two_by_two() -> OpenShopScheduling { + OpenShopScheduling::new(2, vec![vec![1, 2], vec![2, 1]]) +} + +/// 3 machines, 3 jobs: a small asymmetric instance. +fn three_by_three() -> OpenShopScheduling { + OpenShopScheduling::new(3, vec![vec![1, 2, 3], vec![3, 2, 1], vec![2, 1, 2]]) +} + +/// Issue #506 example: 4 jobs × 3 machines, true optimal makespan = 8. +/// (The issue body incorrectly claimed 11 was optimal; brute-force confirms 8.) +fn issue_example() -> OpenShopScheduling { + OpenShopScheduling::new( + 3, + vec![vec![3, 1, 2], vec![2, 3, 1], vec![1, 2, 3], vec![2, 2, 1]], + ) +} + +// ─── creation and dims ─────────────────────────────────────────────────────── + +#[test] +fn test_open_shop_scheduling_creation() { + let p = issue_example(); + assert_eq!(p.num_machines(), 3); + assert_eq!(p.num_jobs(), 4); + assert_eq!( + p.processing_times(), + &[ + vec![3usize, 1, 2], + vec![2, 3, 1], + vec![1, 2, 3], + vec![2, 2, 1], + ] + ); +} + +#[test] +fn test_open_shop_scheduling_dims() { + let p = issue_example(); + // n = 4 jobs, m = 3 machines → n*m = 12 config variables, each in 0..4 + assert_eq!(p.dims(), vec![4usize; 12]); + + let p2 = two_by_two(); + assert_eq!(p2.dims(), vec![2usize; 4]); +} + +// ─── evaluate ──────────────────────────────────────────────────────────────── + +#[test] +fn test_open_shop_scheduling_evaluate_issue_example_optimal() { + let p = issue_example(); + // Optimal config: M1=[0,1,2,3], M2=[1,0,3,2], M3=[2,3,0,1] + // True optimal makespan = 8 (the issue body incorrectly claimed 11). + let config = vec![0, 1, 2, 3, 1, 0, 3, 2, 2, 3, 0, 1]; + assert_eq!(p.evaluate(&config), Min(Some(8))); +} + +#[test] +fn test_open_shop_scheduling_evaluate_issue_example_suboptimal_schedule() { + let p = issue_example(); + // The schedule from the issue body: M1=[2,1,0,3], M2=[2,1,0,3], M3=[2,0,1,3] + // gives makespan 11, which is valid but not optimal (optimal is 8). + let config = vec![2, 1, 0, 3, 2, 1, 0, 3, 2, 0, 1, 3]; + let value = p.evaluate(&config); + assert_eq!(value, Min(Some(11))); +} + +#[test] +fn test_open_shop_scheduling_evaluate_suboptimal() { + let p = issue_example(); + // Identity orderings on all machines: M1=[0,1,2,3], M2=[0,1,2,3], M3=[0,1,2,3] + let config = vec![0, 1, 2, 3, 0, 1, 2, 3, 0, 1, 2, 3]; + let value = p.evaluate(&config); + // Must be valid and > 8 (non-optimal) + assert!(value.0.is_some()); + assert!(value.0.unwrap() > 8); +} + +#[test] +fn test_open_shop_scheduling_evaluate_invalid_not_permutation() { + let p = issue_example(); + // config[0..4] = [0,0,0,0] is not a permutation → invalid + let config = vec![0, 0, 0, 0, 0, 1, 2, 3, 0, 1, 2, 3]; + assert_eq!(p.evaluate(&config), Min(None)); +} + +#[test] +fn test_open_shop_scheduling_evaluate_wrong_length() { + let p = issue_example(); + // Too short + assert_eq!(p.evaluate(&[0, 1, 2]), Min(None)); + // Too long + assert_eq!(p.evaluate(&[0; 13]), Min(None)); +} + +#[test] +fn test_open_shop_scheduling_evaluate_empty() { + let p = OpenShopScheduling::new(3, vec![]); + assert_eq!(p.dims(), Vec::::new()); + assert_eq!(p.evaluate(&[]), Min(Some(0))); +} + +#[test] +fn test_open_shop_scheduling_evaluate_two_by_two() { + let p = two_by_two(); + // M1=[0,1], M2=[0,1]: valid permutations + // Simulation: + // Step 1: best start is min over M1(J1: max(0,0)=0) and M2(J1: max(0,0)=0) + // → machine 0 (tie-break), schedule J1 on M1: [0,1), machine_avail[0]=1, job_avail[0]=1 + // Step 2: M1 next is J2 (start=max(1,0)=1), M2 next is J1 (start=max(0,1)=1) + // → machine 0 (tie-break), schedule J2 on M1: [1,3), machine_avail[0]=3, job_avail[1]=3 + // Step 3: M1 done, M2 next is J1 (start=max(0,1)=1), schedule J1 on M2: [1,3), machine_avail[1]=3, job_avail[0]=3 + // Step 4: M2 next is J2 (start=max(3,3)=3), schedule J2 on M2: [3,4), machine_avail[1]=4, job_avail[1]=4 + // Makespan = 4 + let config = vec![0, 1, 0, 1]; + let val = p.evaluate(&config); + assert!(val.0.is_some()); + assert_eq!(val, Min(Some(4))); +} + +// ─── decode_orders ─────────────────────────────────────────────────────────── + +#[test] +fn test_open_shop_scheduling_decode_orders_valid() { + let p = two_by_two(); + let config = vec![0, 1, 1, 0]; + let orders = p.decode_orders(&config).unwrap(); + assert_eq!(orders, vec![vec![0, 1], vec![1, 0]]); +} + +#[test] +fn test_open_shop_scheduling_decode_orders_invalid_duplicate() { + let p = two_by_two(); + let config = vec![0, 0, 1, 0]; // first machine has duplicate 0 + assert!(p.decode_orders(&config).is_none()); +} + +#[test] +fn test_open_shop_scheduling_decode_orders_invalid_out_of_range() { + let p = two_by_two(); + let config = vec![0, 2, 1, 0]; // job 2 out of range for n=2 + assert!(p.decode_orders(&config).is_none()); +} + +// ─── compute_makespan ──────────────────────────────────────────────────────── + +#[test] +fn test_open_shop_scheduling_compute_makespan_optimal_schedule() { + let p = issue_example(); + // True optimal: M1=[0,1,2,3], M2=[1,0,3,2], M3=[2,3,0,1], makespan=8 + let orders = vec![ + vec![0, 1, 2, 3], // M1 + vec![1, 0, 3, 2], // M2 + vec![2, 3, 0, 1], // M3 + ]; + assert_eq!(p.compute_makespan(&orders), 8); +} + +#[test] +fn test_open_shop_scheduling_compute_makespan_issue_example_schedule() { + let p = issue_example(); + // The schedule from the issue body: makespan=11 (valid but suboptimal) + let orders = vec![vec![2, 1, 0, 3], vec![2, 1, 0, 3], vec![2, 0, 1, 3]]; + // Manually verified start/finish times: + // J1: M1=[3,6), M2=[6,7), M3=[7,9) + // J2: M1=[1,3), M2=[3,6), M3=[9,10) + // J3: M1=[0,1), M2=[1,3), M3=[3,6) + // J4: M1=[6,8), M2=[8,10), M3=[10,11) + assert_eq!(p.compute_makespan(&orders), 11); +} + +// ─── problem trait ─────────────────────────────────────────────────────────── + +#[test] +fn test_open_shop_scheduling_problem_name_and_variant() { + assert_eq!(::NAME, "OpenShopScheduling"); + assert!(::variant().is_empty()); +} + +// ─── serialization ─────────────────────────────────────────────────────────── + +#[test] +fn test_open_shop_scheduling_serialization() { + let p = issue_example(); + let json = serde_json::to_value(&p).unwrap(); + let restored: OpenShopScheduling = serde_json::from_value(json).unwrap(); + assert_eq!(restored.num_machines(), p.num_machines()); + assert_eq!(restored.num_jobs(), p.num_jobs()); + assert_eq!(restored.processing_times(), p.processing_times()); +} + +// ─── brute-force solver ────────────────────────────────────────────────────── + +#[test] +fn test_open_shop_scheduling_brute_force_small() { + // 2x2 instance: brute force over 2^4 = 16 configs (4 valid schedules) + let p = two_by_two(); + let solver = BruteForce::new(); + let value = Solver::solve(&solver, &p); + assert!(value.0.is_some()); + // Optimal value for this instance + assert_eq!(value, Min(Some(3))); + let witness = solver.find_witness(&p).unwrap(); + assert_eq!(p.evaluate(&witness), Min(Some(3))); +} + +#[test] +fn test_open_shop_scheduling_brute_force_medium() { + // 3x3 instance: brute force over 3^9 = 19683 configs (216 valid schedules) + let p = three_by_three(); + let solver = BruteForce::new(); + let value = Solver::solve(&solver, &p); + assert!(value.0.is_some()); + let witness = solver.find_witness(&p).unwrap(); + assert_eq!(p.evaluate(&witness), value); +} + +#[test] +fn test_open_shop_scheduling_canonical_example_config_is_optimal() { + // Verify that the canonical example config achieves the true optimal makespan = 8 + let p = issue_example(); + let optimal_config = vec![0, 1, 2, 3, 1, 0, 3, 2, 2, 3, 0, 1]; + assert_eq!(p.evaluate(&optimal_config), Min(Some(8))); +} diff --git a/src/unit_tests/rules/openshopscheduling_ilp.rs b/src/unit_tests/rules/openshopscheduling_ilp.rs new file mode 100644 index 00000000..cd552843 --- /dev/null +++ b/src/unit_tests/rules/openshopscheduling_ilp.rs @@ -0,0 +1,143 @@ +use super::*; +use crate::models::algebraic::ILP; +use crate::models::misc::OpenShopScheduling; +use crate::solvers::ILPSolver; +use crate::traits::Problem; +use crate::types::Min; + +/// 2 machines, 2 jobs: smallest non-trivial instance. +/// processing_times[j][i]: J1=[1,2], J2=[2,1]. Optimal makespan = 3. +fn small_instance() -> OpenShopScheduling { + OpenShopScheduling::new(2, vec![vec![1, 2], vec![2, 1]]) +} + +/// 3 machines, 2 jobs. +fn medium_instance() -> OpenShopScheduling { + OpenShopScheduling::new(3, vec![vec![3, 1, 2], vec![2, 3, 1]]) +} + +// ─── structure ─────────────────────────────────────────────────────────────── + +#[test] +fn test_openshopscheduling_to_ilp_structure_small() { + let p = small_instance(); + let reduction: ReductionOSSToILP = ReduceTo::>::reduce_to(&p); + let ilp = reduction.target_problem(); + + // n=2, m=2: + // num_pairs = 1, num_order_vars = 1*2 = 2 (x_{0,1,0}, x_{0,1,1}) + // num_start_vars = 2*2 = 4 (s_{0,0}, s_{0,1}, s_{1,0}, s_{1,1}) + // num_machine_pairs = 1, num_job_pair_vars = 2*1 = 2 (y_{0,0,1}, y_{1,0,1}) + // c_var = 1 + // Total = 2 + 4 + 2 + 1 = 9 + assert_eq!( + ilp.num_vars, 9, + "expected 9 variables, got {}", + ilp.num_vars + ); + // Constraint count: 2 bound_x + 4 s_upper + 1 c_upper + 4 machine_nooverlap + // + 2 bound_y + 4 job_nooverlap + 4 makespan = 21 + assert_eq!( + ilp.constraints.len(), + 21, + "expected 21 constraints, got {}", + ilp.constraints.len() + ); + assert_eq!( + ilp.objective, + vec![(8, 1.0)], + "objective should minimize C (index 8)" + ); +} + +// ─── closed-loop ───────────────────────────────────────────────────────────── + +#[test] +fn test_openshopscheduling_to_ilp_closed_loop_small() { + let p = small_instance(); + let reduction: ReductionOSSToILP = ReduceTo::>::reduce_to(&p); + let ilp_solution = ILPSolver::new() + .solve(reduction.target_problem()) + .expect("ILP should be feasible"); + + let extracted = reduction.extract_solution(&ilp_solution); + let value = p.evaluate(&extracted); + assert!( + value.0.is_some(), + "extracted schedule must be valid, got {value:?}" + ); + // Optimal makespan = 3 + assert_eq!(value, Min(Some(3)), "ILP should find optimal makespan = 3"); +} + +#[test] +fn test_openshopscheduling_to_ilp_closed_loop_medium() { + let p = medium_instance(); + let reduction: ReductionOSSToILP = ReduceTo::>::reduce_to(&p); + let ilp_solution = ILPSolver::new() + .solve(reduction.target_problem()) + .expect("ILP should be feasible"); + + let extracted = reduction.extract_solution(&ilp_solution); + let value = p.evaluate(&extracted); + assert!( + value.0.is_some(), + "extracted schedule must be valid, got {value:?}" + ); + // Max machine total = max(5, 4, 3) = 5; max job total = max(6, 6) = 6 + // Lower bound = 6. + let makespan = value.0.unwrap(); + assert!(makespan >= 6, "makespan {makespan} must be ≥ lower bound 6"); +} + +// ─── extract_solution ──────────────────────────────────────────────────────── + +#[test] +fn test_openshopscheduling_to_ilp_extract_solution_respects_start_times() { + // For small instance, if we manually craft an ILP solution, extraction should + // order jobs on each machine by start time. + let p = small_instance(); + let reduction: ReductionOSSToILP = ReduceTo::>::reduce_to(&p); + + // Variable layout: x_{0,1,0}=0, x_{0,1,1}=1, s_{0,0}=1, s_{0,1}=0, s_{1,0}=0, s_{1,1}=2, y_{0,0,1}=0, y_{1,0,1}=1, C=3 + // => M1: job 1 starts at 0, job 0 starts at 1 → order [1, 0] + // => M2: job 0 starts at 0, job 1 starts at 2 → order [0, 1] + let target_solution = vec![0, 1, 1, 0, 0, 2, 0, 1, 3]; + let extracted = reduction.extract_solution(&target_solution); + // M1: J1 at t=0, J0 at t=1 → order [1, 0] + // M2: J0 at t=0, J1 at t=2 → order [0, 1] + assert_eq!(extracted[0..2], [1, 0], "M1 order should be [1, 0]"); + assert_eq!(extracted[2..4], [0, 1], "M2 order should be [0, 1]"); + let value = p.evaluate(&extracted); + assert!(value.0.is_some(), "extracted config should be valid"); +} + +// ─── single job / single machine ───────────────────────────────────────────── + +#[test] +fn test_openshopscheduling_to_ilp_single_job() { + // 1 job, 2 machines: trivial, makespan = sum of processing times + let p = OpenShopScheduling::new(2, vec![vec![3, 4]]); + let reduction: ReductionOSSToILP = ReduceTo::>::reduce_to(&p); + let ilp_solution = ILPSolver::new() + .solve(reduction.target_problem()) + .expect("ILP should be feasible"); + let extracted = reduction.extract_solution(&ilp_solution); + let value = p.evaluate(&extracted); + assert!(value.0.is_some()); + assert_eq!(value, Min(Some(7))); +} + +#[test] +fn test_openshopscheduling_to_ilp_single_machine() { + // 3 jobs, 1 machine: serial schedule, makespan = sum of all processing times + let p = OpenShopScheduling::new(1, vec![vec![2], vec![3], vec![1]]); + let reduction: ReductionOSSToILP = ReduceTo::>::reduce_to(&p); + let ilp_solution = ILPSolver::new() + .solve(reduction.target_problem()) + .expect("ILP should be feasible"); + let extracted = reduction.extract_solution(&ilp_solution); + let value = p.evaluate(&extracted); + assert!(value.0.is_some()); + assert_eq!(value, Min(Some(6))); +}