diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index 5b94df3f..4c402841 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -145,6 +145,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], @@ -5126,6 +5127,97 @@ A classical NP-complete problem from Garey and Johnson @garey1979[Ch.~3, p.~76], ] } +#{ + let x = load-model-example("JobShopScheduling") + let D = x.instance.deadline + let blocks = ( + (0, 0, 0, 0, 3), + (0, 1, 1, 3, 6), + (0, 2, 0, 6, 10), + (0, 3, 1, 10, 12), + (0, 4, 0, 12, 14), + (0, 4, 2, 17, 18), + (1, 1, 0, 0, 2), + (1, 3, 0, 2, 7), + (1, 0, 1, 7, 11), + (1, 2, 1, 11, 14), + (1, 4, 1, 14, 17), + (1, 1, 2, 17, 19), + ) + let makespan = 19 + [ + #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$, consecutive-processor constraint $p(t_k[j]) != p(t_(k+1)[j])$, and a deadline $D in ZZ^+$, 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 every job finishes by time $D$. + ][ + Job-Shop Scheduling is the classical disjunctive scheduling problem SS18 in Garey & Johnson; Garey, Johnson, and Sethi proved it strongly NP-complete 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 checking whether those local orders admit global start times. 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 two machines, deadline $D = #D$, and five jobs + $ + J_1 = ((M_1, 3), (M_2, 4)), + J_2 = ((M_2, 2), (M_1, 3), (M_2, 2)), + J_3 = ((M_1, 4), (M_2, 3)), + J_4 = ((M_2, 5), (M_1, 2)), + J_5 = ((M_1, 2), (M_2, 3), (M_1, 1)). + $ + 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 last completion time is $#makespan <= #D$, so the verifier returns YES. + + #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(2) { + 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 = -(2 - 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$]) + + let dl-x = D * scale + line((dl-x, row-h / 2 + 0.1), (dl-x, y-axis), stroke: (paint: red, thickness: 0.8pt, dash: "dashed")) + content((dl-x, row-h / 2 + 0.25), text(6pt, fill: red)[$D = #D$]) + }), + caption: [Job-shop schedule induced by the canonical machine-order witness. The final completion time is #makespan, which stays to the left of the deadline marker $D = #D$.], + ) + ] + ] +} + #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 54dcf8ee..ef8c3440 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, --deadline [--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 @@ -646,10 +647,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, - /// Deadline for FlowShopScheduling, MultiprocessorScheduling, or ResourceConstrainedScheduling + /// 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, JobShopScheduling, 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") diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index 9c65f926..f05ab659 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, 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, TimetableDesign, }; use problemreductions::models::BiconnectivityAugmentation; use problemreductions::prelude::*; @@ -150,6 +150,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() @@ -449,6 +450,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!( @@ -620,6 +666,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\" --cost-budget 10 --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\" --deadline 20 --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", @@ -735,9 +784,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(), @@ -3560,6 +3611,51 @@ 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\" --deadline 20 --num-processors 2"; + let job_tasks = args.job_tasks.as_deref().ok_or_else(|| { + anyhow::anyhow!("JobShopScheduling requires --job-tasks and --deadline\n\n{usage}") + })?; + let deadline = args.deadline.ok_or_else(|| { + anyhow::anyhow!("JobShopScheduling requires --deadline\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}" + ); + } + } + ( + ser(JobShopScheduling::new(num_processors, jobs, deadline))?, + 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"; @@ -7311,6 +7407,7 @@ mod tests { deadlines: None, precedence_pairs: None, task_lengths: None, + job_tasks: None, resource_bounds: None, resource_requirements: None, deadline: None, @@ -7379,6 +7476,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(); @@ -7713,6 +7817,75 @@ mod tests { assert!(err.contains("ExpectedRetrievalCost requires --latency-bound")); } + #[test] + fn test_create_job_shop_scheduling_json() { + use crate::dispatch::ProblemJsonOutput; + use problemreductions::models::misc::JobShopScheduling; + + 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()); + args.deadline = Some(20); + + 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!(problem.evaluate(&[0, 0, 0, 0, 0, 0, 1, 3, 0, 1, 1, 0])); + + 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.deadline = Some(20); + + 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()); + args.deadline = Some(20); + + 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_rooted_tree_storage_assignment_json() { let mut args = empty_args(); diff --git a/problemreductions-cli/src/problem_name.rs b/problemreductions-cli/src/problem_name.rs index 94817e75..ea0d8a20 100644 --- a/problemreductions-cli/src/problem_name.rs +++ b/problemreductions-cli/src/problem_name.rs @@ -20,6 +20,9 @@ pub fn resolve_alias(input: &str) -> String { if input.eq_ignore_ascii_case("GroupingBySwapping") { return "GroupingBySwapping".to_string(); } + if input.eq_ignore_ascii_case("JobShopScheduling") { + return "JobShopScheduling".to_string(); + } if let Some(pt) = problemreductions::registry::find_problem_type_by_alias(input) { return pt.canonical_name.to_string(); } diff --git a/src/lib.rs b/src/lib.rs index 56b6902e..2e21f45c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -72,13 +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, 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, TimetableDesign, }; pub use crate::models::set::{ ComparativeContainment, ConsecutiveSets, ExactCoverBy3Sets, MaximumSetPacking, diff --git a/src/models/misc/job_shop_scheduling.rs b/src/models/misc/job_shop_scheduling.rs new file mode 100644 index 00000000..e8acf831 --- /dev/null +++ b/src/models/misc/job_shop_scheduling.rs @@ -0,0 +1,267 @@ +//! Job Shop Scheduling problem implementation. +//! +//! Given `m` processors and a set of jobs, each job consisting of an ordered +//! sequence of processor-length tasks, determine whether the tasks can be +//! scheduled to finish by a global deadline while respecting both within-job +//! precedence and single-processor capacity constraints. + +use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::traits::{Problem, SatisfactionProblem}; +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: "Determine whether a job-shop schedule meets a global deadline", + 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" }, + FieldInfo { name: "deadline", type_name: "u64", description: "Global deadline D" }, + ], + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct JobShopScheduling { + num_processors: usize, + jobs: Vec>, + deadline: u64, +} + +struct FlattenedTasks { + job_task_ids: Vec>, + machine_task_ids: Vec>, + lengths: Vec, +} + +impl JobShopScheduling { + pub fn new(num_processors: usize, jobs: Vec>, deadline: u64) -> 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, + deadline, + } + } + + pub fn num_processors(&self) -> usize { + self.num_processors + } + + pub fn jobs(&self) -> &[Vec<(usize, u64)>] { + &self.jobs + } + + pub fn deadline(&self) -> u64 { + self.deadline + } + + 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 next_offset = offset + machine_tasks.len(); + let segment = &config[offset..next_offset]; + offset = next_offset; + + let mut available = machine_tasks.clone(); + let mut order = Vec::with_capacity(machine_tasks.len()); + for &digit in segment { + if digit >= available.len() { + return None; + } + order.push(available.remove(digit)); + } + orders.push(order); + } + + Some(orders) + } + + fn schedule_from_config(&self, config: &[usize]) -> Option> { + let flattened = self.flatten_tasks(); + 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; + } + + for (task_id, &start) in start_times.iter().enumerate() { + let finish = start.checked_add(flattened.lengths[task_id])?; + if finish > self.deadline { + return None; + } + } + + Some(start_times) + } +} + +impl Problem for JobShopScheduling { + const NAME: &'static str = "JobShopScheduling"; + type Metric = bool; + + 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| (0..machine_tasks.len()).rev().map(|i| i + 1)) + .collect() + } + + fn evaluate(&self, config: &[usize]) -> bool { + self.schedule_from_config(config).is_some() + } +} + +impl SatisfactionProblem for JobShopScheduling {} + +crate::declare_variants! { + default sat 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)], + ], + 20, + )), + // 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!(true), + }] +} + +#[cfg(test)] +#[path = "../../unit_tests/models/misc/job_shop_scheduling.rs"] +mod tests; diff --git a/src/models/misc/mod.rs b/src/models/misc/mod.rs index 2d07dc9b..db6ac731 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`]: Meet a deadline 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 @@ -46,6 +47,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; @@ -84,6 +86,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; @@ -141,6 +144,7 @@ pub(crate) fn canonical_model_example_specs() -> Vec 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)], + ], + 20, + ) +} + +fn small_two_job_instance() -> JobShopScheduling { + JobShopScheduling::new(2, vec![vec![(0, 1), (1, 1)], vec![(1, 1), (0, 1)]], 2) +} + +#[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!(problem.evaluate(&config)); +} + +#[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!(!problem.evaluate(&config)); +} + +#[test] +fn test_job_shop_scheduling_invalid_config_and_serialization() { + let problem = small_two_job_instance(); + assert!(!problem.evaluate(&[2, 0, 0, 0])); + assert!(!problem.evaluate(&[0, 0, 0])); + + 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()); + assert_eq!(restored.deadline(), problem.deadline()); +} + +#[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 solution = solver.find_satisfying(&problem); + assert!(solution.is_some()); + assert!(problem.evaluate(&solution.unwrap())); +} diff --git a/src/unit_tests/trait_consistency.rs b/src/unit_tests/trait_consistency.rs index f45862e0..9e40f051 100644 --- a/src/unit_tests/trait_consistency.rs +++ b/src/unit_tests/trait_consistency.rs @@ -201,6 +201,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",