diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index 141a7fe42..de79d7edb 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -159,6 +159,7 @@ "IntegralFlowWithMultipliers": [Integral Flow With Multipliers], "MinMaxMulticenter": [Min-Max Multicenter], "FlowShopScheduling": [Flow Shop Scheduling], + "JobShopScheduling": [Job-Shop Scheduling], "GroupingBySwapping": [Grouping by Swapping], "MinimumCutIntoBoundedSets": [Minimum Cut Into Bounded Sets], "MinimumDummyActivitiesPert": [Minimum Dummy Activities in PERT Networks], @@ -5122,6 +5123,163 @@ A classical NP-complete problem from Garey and Johnson @garey1979[Ch.~3, p.~76], ] } +#{ + let x = load-model-example("JobShopScheduling") + let jobs = x.instance.jobs + let m = x.instance.num_processors + let n = jobs.len() + let lehmer = x.optimal_config + + // Flatten tasks: build per-machine task lists and lengths + let task-lengths = () + let task-job = () // which job each flat task belongs to + let task-index = () // which task within the job + let machine-tasks = range(m).map(_ => ()) + let tid = 0 + for (ji, job) in jobs.enumerate() { + for (ki, op) in job.enumerate() { + let (mi, len) = op + task-lengths.push(len) + task-job.push(ji) + task-index.push(ki) + machine-tasks.at(mi).push(tid) + tid += 1 + } + } + let T = task-lengths.len() + + // Decode per-machine Lehmer codes into machine orders + let offset = 0 + let machine-orders = () + for mi in range(m) { + let mt = machine-tasks.at(mi) + let k = mt.len() + let seg = lehmer.slice(offset, offset + k) + let avail = range(k) + let order = () + for c in seg { + order.push(mt.at(avail.at(c))) + avail = avail.enumerate().filter(((i, v)) => i != c).map(((i, v)) => v) + } + machine-orders.push(order) + offset += k + } + + // Build DAG edges (job precedence + machine order) + let successors = range(T).map(_ => ()) + let indegree = range(T).map(_ => 0) + // Job precedence edges + let job-task-start = 0 + for job in jobs { + for i in range(job.len() - 1) { + let u = job-task-start + i + let v = job-task-start + i + 1 + successors.at(u).push(v) + indegree.at(v) += 1 + } + job-task-start += job.len() + } + // Machine order edges + for order in machine-orders { + for i in range(order.len() - 1) { + let u = order.at(i) + let v = order.at(i + 1) + successors.at(u).push(v) + indegree.at(v) += 1 + } + } + + // Topological sort + longest-path to compute start times + let start-times = range(T).map(_ => 0) + let queue = () + for t in range(T) { + if indegree.at(t) == 0 { queue.push(t) } + } + while queue.len() > 0 { + let u = queue.remove(0) + let finish = start-times.at(u) + task-lengths.at(u) + for v in successors.at(u) { + if finish > start-times.at(v) { start-times.at(v) = finish } + indegree.at(v) -= 1 + if indegree.at(v) == 0 { queue.push(v) } + } + } + + // Build Gantt blocks: (machine, job, task-within-job, start, end) + let blocks = () + for t in range(T) { + let (mi, _len) = jobs.at(task-job.at(t)).at(task-index.at(t)) + blocks.push((mi, task-job.at(t), task-index.at(t), start-times.at(t), start-times.at(t) + task-lengths.at(t))) + } + let makespan = calc.max(..range(T).map(t => start-times.at(t) + task-lengths.at(t))) + [ + #problem-def("JobShopScheduling")[ + Given a positive integer $m$, a set $J$ of jobs, where each job $j in J$ consists of an ordered list of tasks $t_1[j], dots, t_(n_j)[j]$ with processor assignments $p(t_k[j]) in {1, dots, m}$, processing lengths $ell(t_k[j]) in ZZ^+_0$, and consecutive-processor constraint $p(t_k[j]) != p(t_(k+1)[j])$, find start times $sigma(t_k[j]) in ZZ^+_0$ such that tasks sharing a processor do not overlap, each job respects $sigma(t_(k+1)[j]) >= sigma(t_k[j]) + ell(t_k[j])$, and the makespan $max_(j in J) (sigma(t_(n_j)[j]) + ell(t_(n_j)[j]))$ is minimized. + ][ + Job-Shop Scheduling is the classical disjunctive scheduling problem SS18 in Garey & Johnson; Garey, Johnson, and Sethi proved it strongly NP-hard already for two machines @garey1976. Unlike Flow Shop Scheduling, each job carries its own machine route, so the difficulty lies in choosing a compatible relative order on every machine and then finding the schedule with minimum makespan. This implementation follows the original Garey-Johnson formulation, including the requirement that consecutive tasks of the same job use different processors, and evaluates a witness by orienting the machine-order edges and propagating longest paths through the resulting precedence DAG. The registered baseline therefore exposes a factorial upper bound over task orders#footnote[The auto-generated complexity table records the concrete upper bound used by the Rust implementation; no sharper exact bound is cited here.]. + + *Example.* The canonical fixture has #m machines and #n jobs + $ + #for (ji, job) in jobs.enumerate() { + $J_#(ji+1) = (#job.map(((mi, len)) => $(M_#(mi+1), #len)$).join($,$))$ + if ji < n - 1 [$,$] else [.] + } + $ + The witness stored in the example DB orders the six tasks on $M_1$ as $(J_1^1, J_2^2, J_3^1, J_4^2, J_5^1, J_5^3)$ and the six tasks on $M_2$ as $(J_2^1, J_4^1, J_1^2, J_3^2, J_5^2, J_2^3)$. Taking the earliest schedule consistent with those machine orders yields the Gantt chart in @fig:jobshop, whose makespan is $#makespan$. + + #pred-commands( + "pred create --example " + problem-spec(x) + " -o job-shop-scheduling.json", + "pred solve job-shop-scheduling.json --solver brute-force", + "pred evaluate job-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")) + let scale = 0.38 + 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 block in blocks { + let (mi, ji, ti, s, e) = block + let x0 = s * scale + let x1 = e * scale + let y = -mi * (row-h + gap) + rect( + (x0, y - row-h / 2), + (x1, y + row-h / 2), + fill: colors.at(ji).transparentize(30%), + stroke: 0.4pt + colors.at(ji), + ) + content(((x0 + x1) / 2, y), text(6pt, "j" + str(ji + 1) + "." + str(ti + 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(calc.ceil(makespan / 5) + 1).map(i => calc.min(i * 5, makespan)) { + 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))) + } + if calc.rem(makespan, 5) != 0 { + let x = makespan * scale + line((x, y-axis), (x, y-axis - 0.1), stroke: 0.4pt) + content((x, y-axis - 0.25), text(6pt, str(makespan))) + } + content((makespan * scale / 2, y-axis - 0.5), text(7pt)[$t$]) + }), + caption: [Job-shop schedule induced by the canonical machine-order witness. The optimal makespan is #makespan.], + ) + ] + ] +} + #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. ][ diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index 0721f3058..fe0b7bd35 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -298,6 +298,7 @@ Flags by problem type: PartiallyOrderedKnapsack --sizes, --values, --capacity, --precedences QAP --matrix (cost), --distance-matrix StrongConnectivityAugmentation --arcs, --candidate-arcs, --bound [--num-vertices] + JobShopScheduling --job-tasks [--num-processors] FlowShopScheduling --task-lengths, --deadline [--num-processors] StaffScheduling --schedules, --requirements, --num-workers, --k TimetableDesign --num-periods, --num-craftsmen, --num-tasks, --craftsman-avail, --task-avail, --requirements @@ -643,10 +644,13 @@ pub struct CreateArgs { /// Task lengths for FlowShopScheduling (semicolon-separated rows: "3,4,2;2,3,5;4,1,3") #[arg(long)] pub task_lengths: Option, + /// Job tasks for JobShopScheduling (semicolon-separated jobs, comma-separated processor:length tasks, e.g., "0:3,1:4;1:2,0:3,1:2") + #[arg(long)] + pub job_tasks: Option, /// Deadline for FlowShopScheduling, MultiprocessorScheduling, or ResourceConstrainedScheduling #[arg(long)] pub deadline: Option, - /// Number of processors/machines for FlowShopScheduling, MultiprocessorScheduling, ResourceConstrainedScheduling, or SchedulingWithIndividualDeadlines + /// Number of processors/machines for FlowShopScheduling, JobShopScheduling, MultiprocessorScheduling, ResourceConstrainedScheduling, or SchedulingWithIndividualDeadlines #[arg(long)] pub num_processors: Option, /// Binary schedule patterns for StaffScheduling (semicolon-separated rows, e.g., "1,1,0;0,1,1") @@ -883,7 +887,7 @@ mod tests { )); assert!( help.contains( - "Number of processors/machines for FlowShopScheduling, MultiprocessorScheduling, ResourceConstrainedScheduling, or SchedulingWithIndividualDeadlines" + "Number of processors/machines for FlowShopScheduling, JobShopScheduling, MultiprocessorScheduling, ResourceConstrainedScheduling, or SchedulingWithIndividualDeadlines" ), "create help should describe --num-processors for both scheduling models" ); diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index efc099f4b..d295670c6 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -22,14 +22,14 @@ use problemreductions::models::graph::{ use problemreductions::models::misc::{ AdditionalKey, BinPacking, BoyceCoddNormalFormViolation, CapacityAssignment, CbqRelation, ConjunctiveBooleanQuery, ConsistencyOfDatabaseFrequencyTables, EnsembleComputation, - ExpectedRetrievalCost, FlowShopScheduling, FrequencyTable, GroupingBySwapping, KnownValue, - LongestCommonSubsequence, MinimumTardinessSequencing, MultiprocessorScheduling, PaintShop, - PartiallyOrderedKnapsack, QueryArg, RectilinearPictureCompression, - ResourceConstrainedScheduling, SchedulingWithIndividualDeadlines, - SequencingToMinimizeMaximumCumulativeCost, SequencingToMinimizeWeightedCompletionTime, - SequencingToMinimizeWeightedTardiness, SequencingWithReleaseTimesAndDeadlines, - SequencingWithinIntervals, ShortestCommonSupersequence, StringToStringCorrection, SubsetSum, - SumOfSquaresPartition, ThreePartition, TimetableDesign, + ExpectedRetrievalCost, FlowShopScheduling, FrequencyTable, GroupingBySwapping, + JobShopScheduling, KnownValue, LongestCommonSubsequence, MinimumTardinessSequencing, + MultiprocessorScheduling, PaintShop, PartiallyOrderedKnapsack, QueryArg, + RectilinearPictureCompression, ResourceConstrainedScheduling, + SchedulingWithIndividualDeadlines, SequencingToMinimizeMaximumCumulativeCost, + SequencingToMinimizeWeightedCompletionTime, SequencingToMinimizeWeightedTardiness, + SequencingWithReleaseTimesAndDeadlines, SequencingWithinIntervals, ShortestCommonSupersequence, + StringToStringCorrection, SubsetSum, SumOfSquaresPartition, ThreePartition, TimetableDesign, }; use problemreductions::models::BiconnectivityAugmentation; use problemreductions::prelude::*; @@ -149,6 +149,7 @@ fn all_data_flags_empty(args: &CreateArgs) -> bool { && args.resource_bounds.is_none() && args.resource_requirements.is_none() && args.task_lengths.is_none() + && args.job_tasks.is_none() && args.deadline.is_none() && args.num_processors.is_none() && args.schedules.is_none() @@ -448,6 +449,51 @@ fn parse_precedence_pairs(raw: Option<&str>) -> Result> { .unwrap_or_else(|| Ok(vec![])) } +fn parse_job_shop_jobs(raw: &str) -> Result>> { + let raw = raw.trim(); + if raw.is_empty() { + return Ok(vec![]); + } + + raw.split(';') + .enumerate() + .map(|(job_index, job_str)| { + let job_str = job_str.trim(); + anyhow::ensure!( + !job_str.is_empty(), + "Invalid --job-tasks value: empty job at position {}", + job_index + ); + + job_str + .split(',') + .map(|task_str| { + let task_str = task_str.trim(); + let (processor, length) = task_str.split_once(':').ok_or_else(|| { + anyhow::anyhow!( + "Invalid --job-tasks operation '{}': expected 'processor:length'", + task_str + ) + })?; + let processor = processor.trim().parse::().map_err(|_| { + anyhow::anyhow!( + "Invalid --job-tasks operation '{}': processor must be a nonnegative integer", + task_str + ) + })?; + let length = length.trim().parse::().map_err(|_| { + anyhow::anyhow!( + "Invalid --job-tasks operation '{}': length must be a nonnegative integer", + task_str + ) + })?; + Ok((processor, length)) + }) + .collect() + }) + .collect() +} + fn validate_precedence_pairs(precedences: &[(usize, usize)], num_tasks: usize) -> Result<()> { for &(pred, succ) in precedences { anyhow::ensure!( @@ -618,6 +664,9 @@ fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str { "--capacities 1,2,3 --cost-matrix \"1,3,6;2,4,7;1,2,5\" --delay-matrix \"8,4,1;7,3,1;6,3,1\" --delay-budget 12" } "MultiprocessorScheduling" => "--lengths 4,5,3,2,6 --num-processors 2 --deadline 10", + "JobShopScheduling" => { + "--job-tasks \"0:3,1:4;1:2,0:3,1:2;0:4,1:3;1:5,0:2;0:2,1:3,0:1\" --num-processors 2" + } "MinimumMultiwayCut" => "--graph 0-1,1-2,2-3 --terminals 0,2 --edge-weights 1,1,1", "ExpectedRetrievalCost" => EXPECTED_RETRIEVAL_COST_EXAMPLE_ARGS, "SequencingWithinIntervals" => "--release-times 0,0,5 --deadlines 11,11,6 --lengths 3,1,1", @@ -734,9 +783,11 @@ fn help_flag_name(canonical: &str, field_name: &str) -> String { ("BoundedComponentSpanningForest", "max_components") => return "k".to_string(), ("BoundedComponentSpanningForest", "max_weight") => return "bound".to_string(), ("FlowShopScheduling", "num_processors") + | ("JobShopScheduling", "num_processors") | ("SchedulingWithIndividualDeadlines", "num_processors") => { return "num-processors/--m".to_string(); } + ("JobShopScheduling", "jobs") => return "job-tasks".to_string(), ("LengthBoundedDisjointPaths", "max_length") => return "bound".to_string(), ("RectilinearPictureCompression", "bound") => return "bound".to_string(), ("PrimeAttributeName", "num_attributes") => return "universe".to_string(), @@ -3450,6 +3501,55 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { ) } + // JobShopScheduling + "JobShopScheduling" => { + let usage = "Usage: pred create JobShopScheduling --job-tasks \"0:3,1:4;1:2,0:3,1:2;0:4,1:3\" --num-processors 2"; + let job_tasks = args.job_tasks.as_deref().ok_or_else(|| { + anyhow::anyhow!("JobShopScheduling requires --job-tasks\n\n{usage}") + })?; + let jobs = parse_job_shop_jobs(job_tasks)?; + let inferred_processors = jobs + .iter() + .flat_map(|job| job.iter().map(|(processor, _)| *processor)) + .max() + .map(|processor| processor + 1); + let num_processors = resolve_processor_count_flags( + "JobShopScheduling", + usage, + args.num_processors, + args.m, + )? + .or(inferred_processors) + .ok_or_else(|| { + anyhow::anyhow!( + "Cannot infer num_processors from empty job list; use --num-processors" + ) + })?; + anyhow::ensure!( + num_processors > 0, + "JobShopScheduling requires --num-processors > 0\n\n{usage}" + ); + for (job_index, job) in jobs.iter().enumerate() { + for (task_index, &(processor, _)) in job.iter().enumerate() { + anyhow::ensure!( + processor < num_processors, + "job {job_index} task {task_index} uses processor {processor}, but num_processors = {num_processors}" + ); + } + for (task_index, pair) in job.windows(2).enumerate() { + anyhow::ensure!( + pair[0].0 != pair[1].0, + "job {job_index} tasks {task_index} and {} must use different processors\n\n{usage}", + task_index + 1 + ); + } + } + ( + ser(JobShopScheduling::new(num_processors, jobs))?, + resolved_variant.clone(), + ) + } + // StaffScheduling "StaffScheduling" => { let usage = "Usage: pred create StaffScheduling --schedules \"1,1,1,1,1,0,0;0,1,1,1,1,1,0;0,0,1,1,1,1,1;1,0,0,1,1,1,1;1,1,0,0,1,1,1\" --requirements 2,2,2,3,3,2,1 --num-workers 4 --k 5"; @@ -7206,6 +7306,7 @@ mod tests { deadlines: None, precedence_pairs: None, task_lengths: None, + job_tasks: None, resource_bounds: None, resource_requirements: None, deadline: None, @@ -7274,6 +7375,13 @@ mod tests { assert!(!all_data_flags_empty(&args)); } + #[test] + fn test_all_data_flags_empty_treats_job_tasks_as_input() { + let mut args = empty_args(); + args.job_tasks = Some("0:1,1:1;1:1,0:1".to_string()); + assert!(!all_data_flags_empty(&args)); + } + #[test] fn test_parse_potential_edges() { let mut args = empty_args(); @@ -7592,6 +7700,95 @@ mod tests { let _ = std::fs::remove_file(output_path); } + #[test] + fn test_create_job_shop_scheduling_json() { + use crate::dispatch::ProblemJsonOutput; + use problemreductions::models::misc::JobShopScheduling; + use problemreductions::traits::Problem; + use problemreductions::types::Min; + + let mut args = empty_args(); + args.problem = Some("JobShopScheduling".to_string()); + args.job_tasks = Some("0:3,1:4;1:2,0:3,1:2;0:4,1:3;1:5,0:2;0:2,1:3,0:1".to_string()); + + let output_path = + std::env::temp_dir().join(format!("job-shop-scheduling-{}.json", std::process::id())); + let out = OutputConfig { + output: Some(output_path.clone()), + quiet: true, + json: false, + auto_json: false, + }; + + create(&args, &out).unwrap(); + + let json = std::fs::read_to_string(&output_path).unwrap(); + let created: ProblemJsonOutput = serde_json::from_str(&json).unwrap(); + assert_eq!(created.problem_type, "JobShopScheduling"); + assert!(created.variant.is_empty()); + + let problem: JobShopScheduling = serde_json::from_value(created.data).unwrap(); + assert_eq!(problem.num_processors(), 2); + assert_eq!(problem.num_jobs(), 5); + assert_eq!( + problem.evaluate(&[0, 0, 0, 0, 0, 0, 1, 3, 0, 1, 1, 0]), + Min(Some(19)) + ); + + let _ = std::fs::remove_file(output_path); + } + + #[test] + fn test_create_job_shop_scheduling_requires_job_tasks() { + let mut args = empty_args(); + args.problem = Some("JobShopScheduling".to_string()); + args.num_processors = Some(2); + + let out = OutputConfig { + output: None, + quiet: true, + json: false, + auto_json: false, + }; + + let err = create(&args, &out).unwrap_err().to_string(); + assert!(err.contains("JobShopScheduling requires --job-tasks")); + } + + #[test] + fn test_create_job_shop_scheduling_rejects_malformed_operation() { + let mut args = empty_args(); + args.problem = Some("JobShopScheduling".to_string()); + args.job_tasks = Some("0-3,1:4".to_string()); + + let out = OutputConfig { + output: None, + quiet: true, + json: false, + auto_json: false, + }; + + let err = create(&args, &out).unwrap_err().to_string(); + assert!(err.contains("expected 'processor:length'")); + } + + #[test] + fn test_create_job_shop_scheduling_rejects_consecutive_same_processor() { + let mut args = empty_args(); + args.problem = Some("JobShopScheduling".to_string()); + args.job_tasks = Some("0:1,0:1".to_string()); + + let out = OutputConfig { + output: None, + quiet: true, + json: false, + auto_json: false, + }; + + let err = create(&args, &out).unwrap_err().to_string(); + assert!(err.contains("must use different processors")); + } + #[test] fn test_create_rooted_tree_storage_assignment_json() { let mut args = empty_args(); diff --git a/src/lib.rs b/src/lib.rs index 7193925fe..ef9a7e1ac 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -72,14 +72,14 @@ pub mod prelude { AdditionalKey, BinPacking, BoyceCoddNormalFormViolation, CapacityAssignment, CbqRelation, ConjunctiveBooleanQuery, ConjunctiveQueryFoldability, ConsistencyOfDatabaseFrequencyTables, EnsembleComputation, ExpectedRetrievalCost, Factoring, FlowShopScheduling, - GroupingBySwapping, Knapsack, LongestCommonSubsequence, MinimumTardinessSequencing, - MultiprocessorScheduling, PaintShop, Partition, QueryArg, RectilinearPictureCompression, - ResourceConstrainedScheduling, SchedulingWithIndividualDeadlines, - SequencingToMinimizeMaximumCumulativeCost, SequencingToMinimizeWeightedCompletionTime, - SequencingToMinimizeWeightedTardiness, SequencingWithReleaseTimesAndDeadlines, - SequencingWithinIntervals, ShortestCommonSupersequence, StackerCrane, StaffScheduling, - StringToStringCorrection, SubsetSum, SumOfSquaresPartition, Term, ThreePartition, - TimetableDesign, + GroupingBySwapping, JobShopScheduling, Knapsack, LongestCommonSubsequence, + MinimumTardinessSequencing, MultiprocessorScheduling, PaintShop, Partition, QueryArg, + RectilinearPictureCompression, ResourceConstrainedScheduling, + SchedulingWithIndividualDeadlines, SequencingToMinimizeMaximumCumulativeCost, + SequencingToMinimizeWeightedCompletionTime, SequencingToMinimizeWeightedTardiness, + SequencingWithReleaseTimesAndDeadlines, SequencingWithinIntervals, + ShortestCommonSupersequence, StackerCrane, StaffScheduling, StringToStringCorrection, + SubsetSum, SumOfSquaresPartition, Term, ThreePartition, TimetableDesign, }; pub use crate::models::set::{ ComparativeContainment, ConsecutiveSets, ExactCoverBy3Sets, MaximumSetPacking, diff --git a/src/models/misc/flow_shop_scheduling.rs b/src/models/misc/flow_shop_scheduling.rs index 02f55fb87..d29937b8a 100644 --- a/src/models/misc/flow_shop_scheduling.rs +++ b/src/models/misc/flow_shop_scheduling.rs @@ -167,27 +167,14 @@ impl Problem for FlowShopScheduling { } fn dims(&self) -> Vec { - let n = self.num_jobs(); - (0..n).rev().map(|i| i + 1).collect() + super::lehmer_dims(self.num_jobs()) } fn evaluate(&self, config: &[usize]) -> crate::types::Or { crate::types::Or({ - let n = self.num_jobs(); - if config.len() != n { + let Some(job_order) = super::decode_lehmer(config, self.num_jobs()) else { return crate::types::Or(false); - } - - // Decode Lehmer code into a permutation. - // config[i] must be < n - i (the domain size for position i). - let mut available: Vec = (0..n).collect(); - let mut job_order = Vec::with_capacity(n); - for &c in config.iter() { - if c >= available.len() { - return crate::types::Or(false); - } - job_order.push(available.remove(c)); - } + }; let makespan = self.compute_makespan(&job_order); makespan <= self.deadline diff --git a/src/models/misc/job_shop_scheduling.rs b/src/models/misc/job_shop_scheduling.rs new file mode 100644 index 000000000..be2dbb4bf --- /dev/null +++ b/src/models/misc/job_shop_scheduling.rs @@ -0,0 +1,263 @@ +//! Job Shop Scheduling problem implementation. +//! +//! Given `m` processors and a set of jobs, each job consisting of an ordered +//! sequence of processor-length tasks, find a schedule that minimizes the +//! makespan (completion time of the last task) while respecting both within-job +//! precedence and single-processor capacity constraints. + +use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::traits::Problem; +use crate::types::Min; +use serde::{Deserialize, Serialize}; +use std::collections::VecDeque; + +inventory::submit! { + ProblemSchemaEntry { + name: "JobShopScheduling", + display_name: "Job-Shop Scheduling", + aliases: &[], + dimensions: &[], + module_path: module_path!(), + description: "Minimize the makespan of a job-shop schedule", + fields: &[ + FieldInfo { name: "num_processors", type_name: "usize", description: "Number of processors m" }, + FieldInfo { name: "jobs", type_name: "Vec>", description: "jobs[j][k] = (processor, length) for the k-th task of job j" }, + ], + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct JobShopScheduling { + num_processors: usize, + jobs: Vec>, +} + +struct FlattenedTasks { + job_task_ids: Vec>, + machine_task_ids: Vec>, + lengths: Vec, +} + +impl JobShopScheduling { + pub fn new(num_processors: usize, jobs: Vec>) -> Self { + let num_tasks: usize = jobs.iter().map(Vec::len).sum(); + if num_tasks > 0 { + assert!( + num_processors > 0, + "num_processors must be positive when tasks are present" + ); + } + + for (job_index, job) in jobs.iter().enumerate() { + for (task_index, &(processor, _length)) in job.iter().enumerate() { + assert!( + processor < num_processors, + "job {job_index} task {task_index} uses processor {processor}, but num_processors = {num_processors}" + ); + } + + for (task_index, pair) in job.windows(2).enumerate() { + assert_ne!( + pair[0].0, + pair[1].0, + "job {job_index} tasks {task_index} and {} must use different processors", + task_index + 1 + ); + } + } + + Self { + num_processors, + jobs, + } + } + + pub fn num_processors(&self) -> usize { + self.num_processors + } + + pub fn jobs(&self) -> &[Vec<(usize, u64)>] { + &self.jobs + } + + pub fn num_jobs(&self) -> usize { + self.jobs.len() + } + + pub fn num_tasks(&self) -> usize { + self.jobs.iter().map(Vec::len).sum() + } + + fn flatten_tasks(&self) -> FlattenedTasks { + let mut job_task_ids = Vec::with_capacity(self.jobs.len()); + let mut machine_task_ids = vec![Vec::new(); self.num_processors]; + let mut lengths = Vec::with_capacity(self.num_tasks()); + let mut task_id = 0usize; + + for job in &self.jobs { + let mut ids = Vec::with_capacity(job.len()); + for &(processor, length) in job { + ids.push(task_id); + machine_task_ids[processor].push(task_id); + lengths.push(length); + task_id += 1; + } + job_task_ids.push(ids); + } + + FlattenedTasks { + job_task_ids, + machine_task_ids, + lengths, + } + } + + fn decode_machine_orders( + &self, + config: &[usize], + flattened: &FlattenedTasks, + ) -> Option>> { + if config.len() != flattened.lengths.len() { + return None; + } + + let mut offset = 0usize; + let mut orders = Vec::with_capacity(flattened.machine_task_ids.len()); + + for machine_tasks in &flattened.machine_task_ids { + let k = machine_tasks.len(); + let perm = super::decode_lehmer(&config[offset..offset + k], k)?; + orders.push(perm.into_iter().map(|i| machine_tasks[i]).collect()); + offset += k; + } + + Some(orders) + } + + /// Compute start times from a Lehmer-code config. Returns `None` if the + /// config is invalid or induces a cycle in the precedence DAG. + pub fn schedule_from_config(&self, config: &[usize]) -> Option> { + self.schedule_from_config_inner(config, &self.flatten_tasks()) + } + + fn schedule_from_config_inner( + &self, + config: &[usize], + flattened: &FlattenedTasks, + ) -> Option> { + let machine_orders = self.decode_machine_orders(config, flattened)?; + let num_tasks = flattened.lengths.len(); + + if num_tasks == 0 { + return Some(Vec::new()); + } + + let mut adjacency = vec![Vec::::new(); num_tasks]; + let mut indegree = vec![0usize; num_tasks]; + + for job_ids in &flattened.job_task_ids { + for pair in job_ids.windows(2) { + adjacency[pair[0]].push(pair[1]); + indegree[pair[1]] += 1; + } + } + + for machine_order in &machine_orders { + for pair in machine_order.windows(2) { + adjacency[pair[0]].push(pair[1]); + indegree[pair[1]] += 1; + } + } + + let mut queue = VecDeque::new(); + for (task_id, °ree) in indegree.iter().enumerate() { + if degree == 0 { + queue.push_back(task_id); + } + } + + let mut start_times = vec![0u64; num_tasks]; + let mut processed = 0usize; + + while let Some(task_id) = queue.pop_front() { + processed += 1; + let finish = start_times[task_id].checked_add(flattened.lengths[task_id])?; + + for &next_task in &adjacency[task_id] { + start_times[next_task] = start_times[next_task].max(finish); + indegree[next_task] -= 1; + if indegree[next_task] == 0 { + queue.push_back(next_task); + } + } + } + + if processed != num_tasks { + return None; + } + + Some(start_times) + } +} + +impl Problem for JobShopScheduling { + const NAME: &'static str = "JobShopScheduling"; + type Value = Min; + + fn variant() -> Vec<(&'static str, &'static str)> { + crate::variant_params![] + } + + fn dims(&self) -> Vec { + self.flatten_tasks() + .machine_task_ids + .into_iter() + .flat_map(|machine_tasks| super::lehmer_dims(machine_tasks.len())) + .collect() + } + + fn evaluate(&self, config: &[usize]) -> Min { + let flattened = self.flatten_tasks(); + match self.schedule_from_config_inner(config, &flattened) { + Some(start_times) => { + let makespan = start_times + .iter() + .enumerate() + .map(|(i, &s)| s + flattened.lengths[i]) + .max() + .unwrap_or(0); + Min(Some(makespan)) + } + None => Min(None), + } + } +} + +crate::declare_variants! { + default JobShopScheduling => "factorial(num_tasks)", +} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_model_example_specs() -> Vec { + vec![crate::example_db::specs::ModelExampleSpec { + id: "job_shop_scheduling", + instance: Box::new(JobShopScheduling::new( + 2, + vec![ + vec![(0, 3), (1, 4)], + vec![(1, 2), (0, 3), (1, 2)], + vec![(0, 4), (1, 3)], + vec![(1, 5), (0, 2)], + vec![(0, 2), (1, 3), (0, 1)], + ], + )), + // Machine 0 order [0,3,5,8,9,11] => [0,0,0,0,0,0] + // Machine 1 order [2,7,1,6,10,4] => [1,3,0,1,1,0] + optimal_config: vec![0, 0, 0, 0, 0, 0, 1, 3, 0, 1, 1, 0], + optimal_value: serde_json::json!(19), + }] +} + +#[cfg(test)] +#[path = "../../unit_tests/models/misc/job_shop_scheduling.rs"] +mod tests; diff --git a/src/models/misc/minimum_tardiness_sequencing.rs b/src/models/misc/minimum_tardiness_sequencing.rs index 17a7fe3d9..eba25bb02 100644 --- a/src/models/misc/minimum_tardiness_sequencing.rs +++ b/src/models/misc/minimum_tardiness_sequencing.rs @@ -132,26 +132,14 @@ impl Problem for MinimumTardinessSequencing { } fn dims(&self) -> Vec { - let n = self.num_tasks; - (0..n).rev().map(|i| i + 1).collect() + super::lehmer_dims(self.num_tasks) } fn evaluate(&self, config: &[usize]) -> Min { let n = self.num_tasks; - if config.len() != n { + let Some(schedule) = super::decode_lehmer(config, n) else { return Min(None); - } - - // Decode Lehmer code into a permutation. - // config[i] must be < n - i (the domain size for position i). - let mut available: Vec = (0..n).collect(); - let mut schedule = Vec::with_capacity(n); - for &c in config.iter() { - if c >= available.len() { - return Min(None); - } - schedule.push(available.remove(c)); - } + }; // schedule[i] = the task scheduled at position i. // We need sigma(task) = position, i.e., the inverse permutation. diff --git a/src/models/misc/mod.rs b/src/models/misc/mod.rs index dd17c5bb7..491950d37 100644 --- a/src/models/misc/mod.rs +++ b/src/models/misc/mod.rs @@ -11,6 +11,7 @@ //! - [`Factoring`]: Integer factorization //! - [`FlowShopScheduling`]: Flow Shop Scheduling (meet deadline on m processors) //! - [`GroupingBySwapping`]: Group equal symbols into contiguous blocks by adjacent swaps +//! - [`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 //! - [`LongestCommonSubsequence`]: Longest Common Subsequence @@ -35,6 +36,31 @@ //! - [`SumOfSquaresPartition`]: Partition integers into K groups minimizing sum of squared group sums pub(crate) mod additional_key; + +/// Decode a Lehmer code into a permutation of `0..n`. +/// +/// Each element of `config` selects from the remaining items: +/// `config[i]` must be `< n - i`. Returns `None` if the config is +/// invalid (wrong length or out-of-range digit). +pub(crate) fn decode_lehmer(config: &[usize], n: usize) -> Option> { + if config.len() != n { + return None; + } + let mut available: Vec = (0..n).collect(); + let mut schedule = Vec::with_capacity(n); + for &digit in config { + if digit >= available.len() { + return None; + } + schedule.push(available.remove(digit)); + } + Some(schedule) +} + +/// Return the Lehmer-code dimension vector `[n, n-1, ..., 1]`. +pub(crate) fn lehmer_dims(n: usize) -> Vec { + (0..n).rev().map(|i| i + 1).collect() +} mod bin_packing; mod boyce_codd_normal_form_violation; mod capacity_assignment; @@ -46,6 +72,7 @@ pub(crate) mod expected_retrieval_cost; pub(crate) mod factoring; mod flow_shop_scheduling; mod grouping_by_swapping; +mod job_shop_scheduling; mod knapsack; mod longest_common_subsequence; mod minimum_tardiness_sequencing; @@ -85,6 +112,7 @@ pub use expected_retrieval_cost::ExpectedRetrievalCost; pub use factoring::Factoring; pub use flow_shop_scheduling::FlowShopScheduling; pub use grouping_by_swapping::GroupingBySwapping; +pub use job_shop_scheduling::JobShopScheduling; pub use knapsack::Knapsack; pub use longest_common_subsequence::LongestCommonSubsequence; pub use minimum_tardiness_sequencing::MinimumTardinessSequencing; @@ -143,6 +171,7 @@ pub(crate) fn canonical_model_example_specs() -> Vec Option> { - let n = self.num_tasks(); - if config.len() != n { - return None; - } - - let mut available: Vec = (0..n).collect(); - let mut schedule = Vec::with_capacity(n); - for &digit in config { - if digit >= available.len() { - return None; - } - schedule.push(available.remove(digit)); - } - Some(schedule) + super::decode_lehmer(config, self.num_tasks()) } } @@ -147,8 +134,7 @@ impl Problem for SequencingToMinimizeMaximumCumulativeCost { } fn dims(&self) -> Vec { - let n = self.num_tasks(); - (0..n).rev().map(|i| i + 1).collect() + super::lehmer_dims(self.num_tasks()) } fn evaluate(&self, config: &[usize]) -> crate::types::Min { diff --git a/src/models/misc/sequencing_to_minimize_weighted_completion_time.rs b/src/models/misc/sequencing_to_minimize_weighted_completion_time.rs index 76cd0962f..62073c83e 100644 --- a/src/models/misc/sequencing_to_minimize_weighted_completion_time.rs +++ b/src/models/misc/sequencing_to_minimize_weighted_completion_time.rs @@ -130,20 +130,7 @@ impl SequencingToMinimizeWeightedCompletionTime { } fn decode_schedule(&self, config: &[usize]) -> Option> { - let n = self.num_tasks(); - if config.len() != n { - return None; - } - - let mut available: Vec = (0..n).collect(); - let mut schedule = Vec::with_capacity(n); - for &digit in config { - if digit >= available.len() { - return None; - } - schedule.push(available.remove(digit)); - } - Some(schedule) + super::decode_lehmer(config, self.num_tasks()) } fn weighted_completion_time(&self, schedule: &[usize]) -> Min { @@ -214,8 +201,7 @@ impl Problem for SequencingToMinimizeWeightedCompletionTime { } fn dims(&self) -> Vec { - let n = self.num_tasks(); - (0..n).rev().map(|i| i + 1).collect() + super::lehmer_dims(self.num_tasks()) } fn evaluate(&self, config: &[usize]) -> Min { diff --git a/src/models/misc/sequencing_to_minimize_weighted_tardiness.rs b/src/models/misc/sequencing_to_minimize_weighted_tardiness.rs index 8d1daa326..946ae2140 100644 --- a/src/models/misc/sequencing_to_minimize_weighted_tardiness.rs +++ b/src/models/misc/sequencing_to_minimize_weighted_tardiness.rs @@ -114,20 +114,7 @@ impl SequencingToMinimizeWeightedTardiness { } fn decode_schedule(&self, config: &[usize]) -> Option> { - let n = self.num_tasks(); - if config.len() != n { - return None; - } - - let mut available: Vec = (0..n).collect(); - let mut schedule = Vec::with_capacity(n); - for &digit in config { - if digit >= available.len() { - return None; - } - schedule.push(available.remove(digit)); - } - Some(schedule) + super::decode_lehmer(config, self.num_tasks()) } fn schedule_weighted_tardiness(&self, schedule: &[usize]) -> Option { @@ -160,8 +147,7 @@ impl Problem for SequencingToMinimizeWeightedTardiness { } fn dims(&self) -> Vec { - let n = self.num_tasks(); - (0..n).rev().map(|i| i + 1).collect() + super::lehmer_dims(self.num_tasks()) } fn evaluate(&self, config: &[usize]) -> crate::types::Or { diff --git a/src/models/misc/sequencing_with_release_times_and_deadlines.rs b/src/models/misc/sequencing_with_release_times_and_deadlines.rs index f81bce5fe..35c7c9607 100644 --- a/src/models/misc/sequencing_with_release_times_and_deadlines.rs +++ b/src/models/misc/sequencing_with_release_times_and_deadlines.rs @@ -113,26 +113,14 @@ impl Problem for SequencingWithReleaseTimesAndDeadlines { } fn dims(&self) -> Vec { - let n = self.num_tasks(); - (0..n).rev().map(|i| i + 1).collect() + super::lehmer_dims(self.num_tasks()) } fn evaluate(&self, config: &[usize]) -> crate::types::Or { crate::types::Or({ - let n = self.num_tasks(); - if config.len() != n { + let Some(schedule) = super::decode_lehmer(config, self.num_tasks()) else { return crate::types::Or(false); - } - - // Decode Lehmer code into a permutation of task indices. - let mut available: Vec = (0..n).collect(); - let mut schedule = Vec::with_capacity(n); - for &c in config.iter() { - if c >= available.len() { - return crate::types::Or(false); - } - schedule.push(available.remove(c)); - } + }; // Schedule tasks left-to-right: each task starts at max(release_time, current_time). let mut current_time: u64 = 0; diff --git a/src/models/mod.rs b/src/models/mod.rs index dbace3877..a3e77f0a1 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -38,9 +38,9 @@ pub use misc::PartiallyOrderedKnapsack; pub use misc::{ AdditionalKey, BinPacking, CapacityAssignment, CbqRelation, ConjunctiveBooleanQuery, ConjunctiveQueryFoldability, ConsistencyOfDatabaseFrequencyTables, EnsembleComputation, - ExpectedRetrievalCost, Factoring, FlowShopScheduling, GroupingBySwapping, Knapsack, - LongestCommonSubsequence, MinimumTardinessSequencing, MultiprocessorScheduling, PaintShop, - Partition, PrecedenceConstrainedScheduling, QueryArg, RectilinearPictureCompression, + ExpectedRetrievalCost, Factoring, FlowShopScheduling, GroupingBySwapping, JobShopScheduling, + Knapsack, LongestCommonSubsequence, MinimumTardinessSequencing, MultiprocessorScheduling, + PaintShop, Partition, PrecedenceConstrainedScheduling, QueryArg, RectilinearPictureCompression, ResourceConstrainedScheduling, SchedulingWithIndividualDeadlines, SequencingToMinimizeMaximumCumulativeCost, SequencingToMinimizeWeightedCompletionTime, SequencingToMinimizeWeightedTardiness, SequencingWithReleaseTimesAndDeadlines, diff --git a/src/unit_tests/example_db.rs b/src/unit_tests/example_db.rs index e07d63cf9..993080941 100644 --- a/src/unit_tests/example_db.rs +++ b/src/unit_tests/example_db.rs @@ -112,6 +112,24 @@ fn test_find_model_example_multiprocessor_scheduling() { ); } +#[test] +fn test_find_model_example_job_shop_scheduling() { + let problem = ProblemRef { + name: "JobShopScheduling".to_string(), + variant: BTreeMap::new(), + }; + + let example = find_model_example(&problem).expect("JobShopScheduling example exists"); + assert_eq!(example.problem, "JobShopScheduling"); + assert_eq!(example.variant, problem.variant); + assert_eq!(example.instance["num_processors"], 2); + assert!(example.instance["jobs"].is_array()); + assert_eq!( + example.optimal_config, + vec![0, 0, 0, 0, 0, 0, 1, 3, 0, 1, 1, 0] + ); +} + #[test] fn test_find_model_example_integral_flow_bundles() { let problem = ProblemRef { diff --git a/src/unit_tests/models/misc/job_shop_scheduling.rs b/src/unit_tests/models/misc/job_shop_scheduling.rs new file mode 100644 index 000000000..4b252f1d5 --- /dev/null +++ b/src/unit_tests/models/misc/job_shop_scheduling.rs @@ -0,0 +1,93 @@ +use super::*; +use crate::solvers::{BruteForce, Solver}; +use crate::traits::Problem; +use crate::types::Min; + +fn issue_example() -> JobShopScheduling { + JobShopScheduling::new( + 2, + vec![ + vec![(0, 3), (1, 4)], + vec![(1, 2), (0, 3), (1, 2)], + vec![(0, 4), (1, 3)], + vec![(1, 5), (0, 2)], + vec![(0, 2), (1, 3), (0, 1)], + ], + ) +} + +fn small_two_job_instance() -> JobShopScheduling { + JobShopScheduling::new(2, vec![vec![(0, 1), (1, 1)], vec![(1, 1), (0, 1)]]) +} + +#[test] +fn test_job_shop_scheduling_creation_and_dims() { + let problem = issue_example(); + assert_eq!(problem.num_processors(), 2); + assert_eq!(problem.num_jobs(), 5); + assert_eq!(problem.num_tasks(), 12); + assert_eq!(problem.dims(), vec![6, 5, 4, 3, 2, 1, 6, 5, 4, 3, 2, 1]); +} + +#[test] +fn test_job_shop_scheduling_evaluate_issue_example() { + let problem = issue_example(); + let config = vec![0, 0, 0, 0, 0, 0, 1, 3, 0, 1, 1, 0]; + assert_eq!(problem.evaluate(&config), Min(Some(19))); +} + +#[test] +fn test_job_shop_scheduling_paper_example_schedule() { + let problem = issue_example(); + let config = vec![0, 0, 0, 0, 0, 0, 1, 3, 0, 1, 1, 0]; + let start_times = problem.schedule_from_config(&config).unwrap(); + assert_eq!(start_times, vec![0, 7, 0, 3, 17, 6, 11, 2, 10, 12, 14, 17]); + + let makespan = start_times + .iter() + .zip( + problem + .jobs() + .iter() + .flat_map(|job| job.iter().map(|(_, length)| *length)), + ) + .map(|(&start, length)| start + length) + .max() + .unwrap(); + assert_eq!(makespan, 19); +} + +#[test] +fn test_job_shop_scheduling_rejects_cyclic_machine_orders() { + let problem = small_two_job_instance(); + let config = vec![1, 0, 0, 0]; + assert_eq!(problem.evaluate(&config), Min(None)); +} + +#[test] +fn test_job_shop_scheduling_invalid_config_and_serialization() { + let problem = small_two_job_instance(); + assert_eq!(problem.evaluate(&[2, 0, 0, 0]), Min(None)); + assert_eq!(problem.evaluate(&[0, 0, 0]), Min(None)); + + let json = serde_json::to_value(&problem).unwrap(); + let restored: JobShopScheduling = serde_json::from_value(json).unwrap(); + assert_eq!(restored.num_processors(), problem.num_processors()); + assert_eq!(restored.jobs(), problem.jobs()); +} + +#[test] +fn test_job_shop_scheduling_problem_name_and_variant() { + assert_eq!(::NAME, "JobShopScheduling"); + assert!(::variant().is_empty()); +} + +#[test] +fn test_job_shop_scheduling_brute_force_solver_small_instance() { + let problem = small_two_job_instance(); + let solver = BruteForce::new(); + let value = Solver::solve(&solver, &problem); + assert_eq!(value, Min(Some(2))); + let witness = solver.find_witness(&problem).unwrap(); + assert_eq!(problem.evaluate(&witness), Min(Some(2))); +} diff --git a/src/unit_tests/trait_consistency.rs b/src/unit_tests/trait_consistency.rs index 40e36fd60..e7f89c40f 100644 --- a/src/unit_tests/trait_consistency.rs +++ b/src/unit_tests/trait_consistency.rs @@ -198,6 +198,10 @@ fn test_all_problems_implement_trait_correctly() { &FlowShopScheduling::new(2, vec![vec![1, 2], vec![3, 4]], 10), "FlowShopScheduling", ); + check_problem_trait( + &JobShopScheduling::new(2, vec![vec![(0, 1), (1, 1)], vec![(1, 1), (0, 1)]], 2), + "JobShopScheduling", + ); check_problem_trait( &SequencingToMinimizeWeightedTardiness::new(vec![3, 4, 2], vec![2, 3, 1], vec![5, 8, 4], 4), "SequencingToMinimizeWeightedTardiness",