diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index c262979c7..720b777a1 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -131,6 +131,7 @@ "MultiprocessorScheduling": [Multiprocessor Scheduling], "PrecedenceConstrainedScheduling": [Precedence Constrained Scheduling], "MinimumTardinessSequencing": [Minimum Tardiness Sequencing], + "SequencingToMinimizeMaximumCumulativeCost": [Sequencing to Minimize Maximum Cumulative Cost], "ConsecutiveOnesSubmatrix": [Consecutive Ones Submatrix], "SumOfSquaresPartition": [Sum of Squares Partition], "SequencingWithinIntervals": [Sequencing Within Intervals], @@ -3498,6 +3499,80 @@ A classical NP-complete problem from Garey and Johnson @garey1979[Ch.~3, p.~76], ] } +#{ + let x = load-model-example("SequencingToMinimizeMaximumCumulativeCost") + let costs = x.instance.costs + let precs = x.instance.precedences + let bound = x.instance.bound + let ntasks = costs.len() + let lehmer = x.optimal_config + let schedule = { + let avail = range(ntasks) + let result = () + for c in lehmer { + result.push(avail.at(c)) + avail = avail.enumerate().filter(((i, v)) => i != c).map(((i, v)) => v) + } + result + } + let prefix-sums = { + let running = 0 + let result = () + for task in schedule { + running += costs.at(task) + result.push(running) + } + result + } + [ + #problem-def("SequencingToMinimizeMaximumCumulativeCost")[ + Given a set $T$ of $n$ tasks, a precedence relation $prec.eq$ on $T$, an integer cost function $c: T -> ZZ$ (negative values represent profits), and a bound $K in ZZ$, determine whether there exists a one-machine schedule $sigma: T -> {1, 2, dots, n}$ that respects the precedence constraints and satisfies + $sum_(sigma(t') lt.eq sigma(t)) c(t') lt.eq K$ + for every task $t in T$. + ][ + Sequencing to Minimize Maximum Cumulative Cost is the scheduling problem SS7 in Garey & Johnson @garey1979. It is NP-complete by transformation from Register Sufficiency, even when every task cost is in ${-1, 0, 1}$ @garey1979. The problem models precedence-constrained task systems with resource consumption and release, where a negative cost corresponds to a profit or resource refund accumulated as the schedule proceeds. + + When the precedence constraints form a series-parallel digraph, #cite(, form: "prose") gave a polynomial-time algorithm running in $O(n^2)$ time. #cite(, form: "prose") placed the problem in a broader family of sequencing objectives solvable efficiently on series-parallel precedence structures. The implementation here uses Lehmer-code enumeration of task orders, so the direct exact search induced by the model runs in $O(n!)$ time. + + *Example.* Consider $n = #ntasks$ tasks with costs $(#costs.map(c => str(c)).join(", "))$, precedence constraints #{precs.map(p => [$t_#(p.at(0) + 1) prec.eq t_#(p.at(1) + 1)$]).join(", ")}, and bound $K = #bound$. The sample schedule $(#schedule.map(t => $t_#(t + 1)$).join(", "))$ has cumulative sums $(#prefix-sums.map(v => str(v)).join(", "))$, so every prefix stays at or below $K = #bound$. + + #figure( + { + let pos = rgb("#f28e2b") + let neg = rgb("#76b7b2") + let zero = rgb("#bab0ab") + align(center, stack(dir: ttb, spacing: 0.35cm, + stack(dir: ltr, spacing: 0.08cm, + ..schedule.enumerate().map(((i, task)) => { + let cost = costs.at(task) + let fill = if cost > 0 { + pos.transparentize(70%) + } else if cost < 0 { + neg.transparentize(65%) + } else { + zero.transparentize(65%) + } + stack(dir: ttb, spacing: 0.05cm, + box(width: 1.0cm, height: 0.6cm, fill: fill, stroke: 0.4pt + luma(120), + align(center + horizon, text(8pt, weight: "bold")[$t_#(task + 1)$])), + text(6pt, if cost >= 0 { $+ #cost$ } else { $#cost$ }), + ) + }), + ), + stack(dir: ltr, spacing: 0.08cm, + ..prefix-sums.map(v => { + box(width: 1.0cm, align(center + horizon, text(7pt)[$#v$])) + }), + ), + text(7pt, [prefix sums after each scheduled task]), + )) + }, + caption: [A satisfying schedule for Sequencing to Minimize Maximum Cumulative Cost. Orange boxes add cost, teal boxes release cost, and the displayed prefix sums $(#prefix-sums.map(v => str(v)).join(", "))$ never exceed $K = #bound$.], + ) + ] + ] +} + #problem-def("DirectedTwoCommodityIntegralFlow")[ Given a directed graph $G = (V, A)$ with arc capacities $c: A -> ZZ^+$, two source-sink pairs $(s_1, t_1)$ and $(s_2, t_2)$, and requirements $R_1, R_2 in ZZ^+$, determine whether there exist two integral flow functions $f_1, f_2: A -> ZZ_(>= 0)$ such that (1) $f_1(a) + f_2(a) <= c(a)$ for all $a in A$, (2) flow $f_i$ is conserved at every vertex except $s_1, s_2, t_1, t_2$, and (3) the net flow into $t_i$ under $f_i$ is at least $R_i$ for $i in {1, 2}$. ][ diff --git a/docs/paper/references.bib b/docs/paper/references.bib index 5d589fe33..5f663d807 100644 --- a/docs/paper/references.bib +++ b/docs/paper/references.bib @@ -137,6 +137,28 @@ @article{evenItaiShamir1976 doi = {10.1137/0205048} } +@article{abdelWahabKameda1978, + author = {H. M. Abdel-Wahab and T. Kameda}, + title = {Scheduling to Minimize Maximum Cumulative Cost Subject to Series-Parallel Precedence Constraints}, + journal = {Operations Research}, + volume = {26}, + number = {1}, + pages = {141--158}, + year = {1978}, + doi = {10.1287/opre.26.1.141} +} + +@article{monmaSidney1979, + author = {Clyde L. Monma and Jeffrey B. Sidney}, + title = {Sequencing with Series-Parallel Precedence Constraints}, + journal = {Mathematics of Operations Research}, + volume = {4}, + number = {3}, + pages = {215--224}, + year = {1979}, + doi = {10.1287/moor.4.3.215} +} + @article{evenTarjan1976, author = {Shimon Even and Robert Endre Tarjan}, title = {A Combinatorial Problem Which Is Complete in Polynomial Space}, diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index bf3b22ea5..bf3d7583d 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -269,6 +269,7 @@ Flags by problem type: FlowShopScheduling --task-lengths, --deadline [--num-processors] StaffScheduling --schedules, --requirements, --num-workers, --k MinimumTardinessSequencing --n, --deadlines [--precedence-pairs] + SequencingToMinimizeMaximumCumulativeCost --costs, --bound [--precedence-pairs] RectilinearPictureCompression --matrix (0/1), --k SCS --strings, --bound [--alphabet-size] StringToStringCorrection --source-string, --target-string, --bound [--alphabet-size] @@ -476,6 +477,9 @@ pub struct CreateArgs { /// Input strings for LCS (e.g., "ABAC;BACA" or "0,1,0;1,0,1") or SCS (e.g., "0,1,2;1,2,0") #[arg(long)] pub strings: Option, + /// Task costs for SequencingToMinimizeMaximumCumulativeCost (comma-separated, e.g., "2,-1,3,-2,1,-3") + #[arg(long, allow_hyphen_values = true)] + pub costs: Option, /// Directed arcs for directed graph problems (e.g., 0>1,1>2,2>0) #[arg(long)] pub arcs: Option, diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index 465817dd3..e8d89a26b 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -17,8 +17,9 @@ use problemreductions::models::misc::{ BinPacking, CbqRelation, ConjunctiveBooleanQuery, FlowShopScheduling, LongestCommonSubsequence, MinimumTardinessSequencing, MultiprocessorScheduling, PaintShop, PartiallyOrderedKnapsack, QueryArg, RectilinearPictureCompression, ResourceConstrainedScheduling, - SequencingWithReleaseTimesAndDeadlines, SequencingWithinIntervals, ShortestCommonSupersequence, - StringToStringCorrection, SubsetSum, SumOfSquaresPartition, + SequencingToMinimizeMaximumCumulativeCost, SequencingWithReleaseTimesAndDeadlines, + SequencingWithinIntervals, ShortestCommonSupersequence, StringToStringCorrection, SubsetSum, + SumOfSquaresPartition, }; use problemreductions::models::BiconnectivityAugmentation; use problemreductions::prelude::*; @@ -85,6 +86,7 @@ fn all_data_flags_empty(args: &CreateArgs) -> bool { && args.bound.is_none() && args.pattern.is_none() && args.strings.is_none() + && args.costs.is_none() && args.arcs.is_none() && args.source.is_none() && args.sink.is_none() @@ -218,6 +220,50 @@ fn resolve_rule_example( }) } +fn parse_precedence_pairs(raw: Option<&str>) -> Result> { + raw.filter(|s| !s.is_empty()) + .map(|s| { + s.split(',') + .map(|pair| { + let pair = pair.trim(); + let (pred, succ) = pair.split_once('>').ok_or_else(|| { + anyhow::anyhow!( + "Invalid --precedence-pairs value '{}': expected 'u>v'", + pair + ) + })?; + let pred = pred.trim().parse::().map_err(|_| { + anyhow::anyhow!( + "Invalid --precedence-pairs value '{}': expected 'u>v' with nonnegative integer indices", + pair + ) + })?; + let succ = succ.trim().parse::().map_err(|_| { + anyhow::anyhow!( + "Invalid --precedence-pairs value '{}': expected 'u>v' with nonnegative integer indices", + pair + ) + })?; + Ok((pred, succ)) + }) + .collect() + }) + .unwrap_or_else(|| Ok(vec![])) +} + +fn validate_precedence_pairs(precedences: &[(usize, usize)], num_tasks: usize) -> Result<()> { + 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 + ); + } + Ok(()) +} + fn create_from_example(args: &CreateArgs, out: &OutputConfig) -> Result<()> { let example_spec = args .example @@ -371,6 +417,9 @@ fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str { "--num-attributes 6 --dependencies \"0,1>2;0,2>3;1,3>4;2,4>5\" --k 2" } "ShortestCommonSupersequence" => "--strings \"0,1,2;1,2,0\" --bound 4", + "SequencingToMinimizeMaximumCumulativeCost" => { + "--costs 2,-1,3,-2,1,-3 --precedence-pairs \"0>2,1>2,1>3,2>4,3>5,4>5\" --bound 4" + } "ConjunctiveQueryFoldability" => "(use --example ConjunctiveQueryFoldability)", "ConjunctiveBooleanQuery" => { "--domain-size 6 --relations \"2:0,3|1,3|2,4;3:0,1,5|1,2,5\" --conjuncts-spec \"0:v0,c3;0:v1,c3;1:v0,v1,c5\"" @@ -470,6 +519,41 @@ fn parse_nonnegative_usize_bound(bound: i64, problem_name: &str, usage: &str) -> .map_err(|_| anyhow::anyhow!("{problem_name} requires nonnegative --bound\n\n{usage}")) } +fn validate_sequencing_within_intervals_inputs( + release_times: &[u64], + deadlines: &[u64], + lengths: &[u64], + usage: &str, +) -> Result<()> { + if release_times.len() != deadlines.len() { + bail!("release_times and deadlines must have the same length\n\n{usage}"); + } + if release_times.len() != lengths.len() { + bail!("release_times and lengths must have the same length\n\n{usage}"); + } + + for (i, ((&release_time, &deadline), &length)) in release_times + .iter() + .zip(deadlines.iter()) + .zip(lengths.iter()) + .enumerate() + { + let end = release_time.checked_add(length).ok_or_else(|| { + anyhow::anyhow!("Task {i}: overflow computing r(i) + l(i)\n\n{usage}") + })?; + if end > deadline { + bail!( + "Task {i}: r({}) + l({}) > d({}), time window is empty\n\n{usage}", + release_time, + length, + deadline + ); + } + } + + Ok(()) +} + fn print_problem_help(canonical: &str, graph_type: Option<&str>) -> Result<()> { let is_geometry = matches!( graph_type, @@ -1800,39 +1884,14 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { ) })?; let deadlines: Vec = util::parse_comma_list(deadlines_str)?; - 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![], - }; + 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 ); - 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 - ); - } + validate_precedence_pairs(&precedences, num_tasks)?; ( ser(MinimumTardinessSequencing::new( num_tasks, @@ -1843,6 +1902,33 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { ) } + // SequencingToMinimizeMaximumCumulativeCost + "SequencingToMinimizeMaximumCumulativeCost" => { + let costs_str = args.costs.as_deref().ok_or_else(|| { + anyhow::anyhow!( + "SequencingToMinimizeMaximumCumulativeCost requires --costs\n\n\ + Usage: pred create SequencingToMinimizeMaximumCumulativeCost --costs 2,-1,3,-2,1,-3 --precedence-pairs \"0>2,1>2,1>3,2>4,3>5,4>5\" --bound 4" + ) + })?; + let bound = args.bound.ok_or_else(|| { + anyhow::anyhow!( + "SequencingToMinimizeMaximumCumulativeCost requires --bound\n\n\ + Usage: pred create SequencingToMinimizeMaximumCumulativeCost --costs 2,-1,3,-2,1,-3 --precedence-pairs \"0>2,1>2,1>3,2>4,3>5,4>5\" --bound 4" + ) + })?; + let costs: Vec = util::parse_comma_list(costs_str)?; + let precedences = parse_precedence_pairs(args.precedence_pairs.as_deref())?; + validate_precedence_pairs(&precedences, costs.len())?; + ( + ser(SequencingToMinimizeMaximumCumulativeCost::new( + costs, + precedences, + bound, + ))?, + resolved_variant.clone(), + ) + } + // SequencingWithinIntervals "SequencingWithinIntervals" => { let usage = "Usage: pred create SequencingWithinIntervals --release-times 0,0,5 --deadlines 11,11,6 --lengths 3,1,1"; @@ -1858,6 +1944,12 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { let release_times: Vec = util::parse_comma_list(rt_str)?; let deadlines: Vec = util::parse_comma_list(dl_str)?; let lengths: Vec = util::parse_comma_list(len_str)?; + validate_sequencing_within_intervals_inputs( + &release_times, + &deadlines, + &lengths, + usage, + )?; ( ser(SequencingWithinIntervals::new( release_times, @@ -4266,6 +4358,9 @@ mod tests { domain_size: None, relations: None, conjuncts_spec: None, + costs: None, + cut_bound: None, + size_bound: None, } } diff --git a/problemreductions-cli/tests/cli_tests.rs b/problemreductions-cli/tests/cli_tests.rs index 9f6a8f134..dcc6e1e11 100644 --- a/problemreductions-cli/tests/cli_tests.rs +++ b/problemreductions-cli/tests/cli_tests.rs @@ -4988,6 +4988,150 @@ fn test_create_factoring_missing_bits() { ); } +#[test] +fn test_create_sequencing_to_minimize_maximum_cumulative_cost() { + let output = pred() + .args([ + "create", + "SequencingToMinimizeMaximumCumulativeCost", + "--costs", + "2,-1,3,-2,1,-3", + "--precedence-pairs", + "0>2,1>2,1>3,2>4,3>5,4>5", + "--bound", + "4", + ]) + .output() + .unwrap(); + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8(output.stdout).unwrap(); + let json: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + assert_eq!(json["type"], "SequencingToMinimizeMaximumCumulativeCost"); + assert_eq!( + json["data"]["costs"], + serde_json::json!([2, -1, 3, -2, 1, -3]) + ); + assert_eq!( + json["data"]["precedences"], + serde_json::json!([[0, 2], [1, 2], [1, 3], [2, 4], [3, 5], [4, 5]]) + ); + assert_eq!(json["data"]["bound"], 4); +} + +#[test] +fn test_create_sequencing_to_minimize_maximum_cumulative_cost_no_flags_shows_help() { + let output = pred() + .args(["create", "SequencingToMinimizeMaximumCumulativeCost"]) + .output() + .unwrap(); + assert!( + !output.status.success(), + "should exit non-zero when showing help without data flags" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("--costs"), + "expected '--costs' in help output, got: {stderr}" + ); + assert!( + stderr.contains("--bound"), + "expected '--bound' in help output, got: {stderr}" + ); +} + +#[test] +fn test_create_sequencing_to_minimize_maximum_cumulative_cost_missing_costs() { + let output = pred() + .args([ + "create", + "SequencingToMinimizeMaximumCumulativeCost", + "--bound", + "4", + ]) + .output() + .unwrap(); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("requires --costs"), + "expected missing --costs message, got: {stderr}" + ); +} + +#[test] +fn test_create_sequencing_to_minimize_maximum_cumulative_cost_bad_precedence() { + let output = pred() + .args([ + "create", + "SequencingToMinimizeMaximumCumulativeCost", + "--costs", + "1,-1,2", + "--precedence-pairs", + "0>3", + "--bound", + "2", + ]) + .output() + .unwrap(); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("precedence"), + "expected precedence validation error, got: {stderr}" + ); +} + +#[test] +fn test_create_sequencing_to_minimize_maximum_cumulative_cost_invalid_precedence_pair() { + let output = pred() + .args([ + "create", + "SequencingToMinimizeMaximumCumulativeCost", + "--costs", + "1,-1,2", + "--precedence-pairs", + "a>b", + "--bound", + "2", + ]) + .output() + .unwrap(); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("--precedence-pairs"), + "expected flag-specific precedence parse error, got: {stderr}" + ); +} + +#[test] +fn test_create_sequencing_to_minimize_maximum_cumulative_cost_allows_negative_values() { + let output = pred() + .args([ + "create", + "SequencingToMinimizeMaximumCumulativeCost", + "--costs", + "-1,2,-3", + "--bound", + "-1", + ]) + .output() + .unwrap(); + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8(output.stdout).unwrap(); + let json: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + assert_eq!(json["data"]["costs"], serde_json::json!([-1, 2, -3])); + assert_eq!(json["data"]["bound"], -1); +} + #[test] fn test_evaluate_multiprocessor_scheduling_rejects_zero_processors_json() { let problem_file = @@ -5943,4 +6087,67 @@ fn test_create_sequencing_within_intervals_rejects_empty_window() { .output() .unwrap(); assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + !stderr.contains("panicked at"), + "expected graceful CLI error, got panic: {stderr}" + ); + assert!( + stderr.contains("time window is empty"), + "expected empty-window validation error, got: {stderr}" + ); +} + +#[test] +fn test_create_sequencing_within_intervals_rejects_mismatched_lengths() { + let output = pred() + .args([ + "create", + "SequencingWithinIntervals", + "--release-times", + "0,1", + "--deadlines", + "2", + "--lengths", + "1,1", + ]) + .output() + .unwrap(); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + !stderr.contains("panicked at"), + "expected graceful CLI error, got panic: {stderr}" + ); + assert!( + stderr.contains("must have the same length"), + "expected length validation error, got: {stderr}" + ); +} + +#[test] +fn test_create_sequencing_within_intervals_rejects_overflow() { + let output = pred() + .args([ + "create", + "SequencingWithinIntervals", + "--release-times", + "18446744073709551615", + "--deadlines", + "18446744073709551615", + "--lengths", + "1", + ]) + .output() + .unwrap(); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + !stderr.contains("panicked at"), + "expected graceful CLI error, got panic: {stderr}" + ); + assert!( + stderr.contains("overflow computing r(i) + l(i)"), + "expected overflow validation error, got: {stderr}" + ); } diff --git a/src/lib.rs b/src/lib.rs index e00906145..3e0aa179a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -63,9 +63,10 @@ pub mod prelude { BinPacking, CbqRelation, ConjunctiveBooleanQuery, ConjunctiveQueryFoldability, Factoring, FlowShopScheduling, Knapsack, LongestCommonSubsequence, MinimumTardinessSequencing, MultiprocessorScheduling, PaintShop, QueryArg, RectilinearPictureCompression, - ResourceConstrainedScheduling, SequencingWithReleaseTimesAndDeadlines, - SequencingWithinIntervals, ShortestCommonSupersequence, StaffScheduling, - StringToStringCorrection, SubsetSum, SumOfSquaresPartition, Term, + ResourceConstrainedScheduling, SequencingToMinimizeMaximumCumulativeCost, + SequencingWithReleaseTimesAndDeadlines, SequencingWithinIntervals, + ShortestCommonSupersequence, StaffScheduling, StringToStringCorrection, SubsetSum, + SumOfSquaresPartition, Term, }; pub use crate::models::set::{ ComparativeContainment, ConsecutiveSets, ExactCoverBy3Sets, MaximumSetPacking, diff --git a/src/models/misc/mod.rs b/src/models/misc/mod.rs index cbfae31cc..679c07e5f 100644 --- a/src/models/misc/mod.rs +++ b/src/models/misc/mod.rs @@ -15,6 +15,7 @@ //! - [`PrecedenceConstrainedScheduling`]: Schedule unit tasks on processors by deadline //! - [`RectilinearPictureCompression`]: Cover 1-entries with bounded rectangles //! - [`ResourceConstrainedScheduling`]: Schedule unit-length tasks on processors with resource constraints +//! - [`SequencingToMinimizeMaximumCumulativeCost`]: Keep every cumulative schedule cost prefix under a bound //! - [`SequencingWithReleaseTimesAndDeadlines`]: Single-machine scheduling feasibility //! - [`SequencingWithinIntervals`]: Schedule tasks within time windows //! - [`ShortestCommonSupersequence`]: Find a common supersequence of bounded length @@ -36,6 +37,7 @@ pub(crate) mod partially_ordered_knapsack; mod precedence_constrained_scheduling; mod rectilinear_picture_compression; pub(crate) mod resource_constrained_scheduling; +mod sequencing_to_minimize_maximum_cumulative_cost; mod sequencing_with_release_times_and_deadlines; mod sequencing_within_intervals; pub(crate) mod shortest_common_supersequence; @@ -58,6 +60,7 @@ pub use partially_ordered_knapsack::PartiallyOrderedKnapsack; pub use precedence_constrained_scheduling::PrecedenceConstrainedScheduling; pub use rectilinear_picture_compression::RectilinearPictureCompression; pub use resource_constrained_scheduling::ResourceConstrainedScheduling; +pub use sequencing_to_minimize_maximum_cumulative_cost::SequencingToMinimizeMaximumCumulativeCost; pub use sequencing_with_release_times_and_deadlines::SequencingWithReleaseTimesAndDeadlines; pub use sequencing_within_intervals::SequencingWithinIntervals; pub use shortest_common_supersequence::ShortestCommonSupersequence; @@ -83,6 +86,7 @@ pub(crate) fn canonical_model_example_specs() -> Vec", description: "Task costs in schedule order-independent indexing" }, + FieldInfo { name: "precedences", type_name: "Vec<(usize, usize)>", description: "Precedence pairs (predecessor, successor)" }, + FieldInfo { name: "bound", type_name: "i64", description: "Upper bound on every cumulative cost prefix" }, + ], + } +} + +/// Sequencing to Minimize Maximum Cumulative Cost. +/// +/// Given a set of tasks `T`, a cost `c(t) in Z` for each task, a partial order +/// on the tasks, and a bound `K`, determine whether there exists a schedule that +/// respects the precedences and whose running cumulative cost never exceeds `K`. +/// +/// # Representation +/// +/// Configurations use Lehmer-code dimensions `[n, n-1, ..., 1]` to encode a +/// permutation of the task indices. +#[derive(Debug, Clone, Serialize)] +pub struct SequencingToMinimizeMaximumCumulativeCost { + costs: Vec, + precedences: Vec<(usize, usize)>, + bound: i64, +} + +#[derive(Debug, Deserialize)] +struct SequencingToMinimizeMaximumCumulativeCostUnchecked { + costs: Vec, + precedences: Vec<(usize, usize)>, + bound: i64, +} + +impl SequencingToMinimizeMaximumCumulativeCost { + /// Create a new instance. + /// + /// # Panics + /// + /// Panics if any precedence endpoint is out of range. + pub fn new(costs: Vec, precedences: Vec<(usize, usize)>, bound: i64) -> Self { + validate_precedences(&precedences, costs.len()); + Self { + costs, + precedences, + bound, + } + } + + /// Return the task costs. + pub fn costs(&self) -> &[i64] { + &self.costs + } + + /// Return the precedence constraints. + pub fn precedences(&self) -> &[(usize, usize)] { + &self.precedences + } + + /// Return the cumulative-cost bound. + pub fn bound(&self) -> i64 { + self.bound + } + + /// Return the number of tasks. + pub fn num_tasks(&self) -> usize { + self.costs.len() + } + + /// Return the number of precedence constraints. + pub fn num_precedences(&self) -> usize { + self.precedences.len() + } + + 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) + } +} + +impl<'de> Deserialize<'de> for SequencingToMinimizeMaximumCumulativeCost { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let unchecked = + SequencingToMinimizeMaximumCumulativeCostUnchecked::deserialize(deserializer)?; + if let Some(message) = + precedence_validation_error(&unchecked.precedences, unchecked.costs.len()) + { + return Err(D::Error::custom(message)); + } + Ok(Self { + costs: unchecked.costs, + precedences: unchecked.precedences, + bound: unchecked.bound, + }) + } +} + +fn validate_precedences(precedences: &[(usize, usize)], num_tasks: usize) { + if let Some(message) = precedence_validation_error(precedences, num_tasks) { + panic!("{message}"); + } +} + +fn precedence_validation_error(precedences: &[(usize, usize)], num_tasks: usize) -> Option { + for &(pred, succ) in precedences { + if pred >= num_tasks { + return Some(format!( + "predecessor index {} out of range (num_tasks = {})", + pred, num_tasks + )); + } + if succ >= num_tasks { + return Some(format!( + "successor index {} out of range (num_tasks = {})", + succ, num_tasks + )); + } + } + None +} + +impl Problem for SequencingToMinimizeMaximumCumulativeCost { + const NAME: &'static str = "SequencingToMinimizeMaximumCumulativeCost"; + type Metric = bool; + + fn variant() -> Vec<(&'static str, &'static str)> { + crate::variant_params![] + } + + fn dims(&self) -> Vec { + let n = self.num_tasks(); + (0..n).rev().map(|i| i + 1).collect() + } + + fn evaluate(&self, config: &[usize]) -> bool { + let Some(schedule) = self.decode_schedule(config) else { + return false; + }; + + let mut positions = vec![0usize; self.num_tasks()]; + for (position, &task) in schedule.iter().enumerate() { + positions[task] = position; + } + for &(pred, succ) in &self.precedences { + if positions[pred] >= positions[succ] { + return false; + } + } + + let mut cumulative = 0i64; + for &task in &schedule { + cumulative += self.costs[task]; + if cumulative > self.bound { + return false; + } + } + true + } +} + +impl SatisfactionProblem for SequencingToMinimizeMaximumCumulativeCost {} + +crate::declare_variants! { + default sat SequencingToMinimizeMaximumCumulativeCost => "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_maximum_cumulative_cost", + instance: Box::new(SequencingToMinimizeMaximumCumulativeCost::new( + vec![2, -1, 3, -2, 1, -3], + vec![(0, 2), (1, 2), (1, 3), (2, 4), (3, 5), (4, 5)], + 4, + )), + optimal_config: vec![1, 0, 1, 0, 0, 0], + optimal_value: serde_json::json!(true), + }] +} + +#[cfg(test)] +#[path = "../../unit_tests/models/misc/sequencing_to_minimize_maximum_cumulative_cost.rs"] +mod tests; diff --git a/src/models/mod.rs b/src/models/mod.rs index 78883344a..fd316527d 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -31,8 +31,9 @@ pub use misc::{ FlowShopScheduling, Knapsack, LongestCommonSubsequence, MinimumTardinessSequencing, MultiprocessorScheduling, PaintShop, PrecedenceConstrainedScheduling, QueryArg, RectilinearPictureCompression, ResourceConstrainedScheduling, - SequencingWithReleaseTimesAndDeadlines, SequencingWithinIntervals, ShortestCommonSupersequence, - StaffScheduling, StringToStringCorrection, SubsetSum, SumOfSquaresPartition, Term, + SequencingToMinimizeMaximumCumulativeCost, SequencingWithReleaseTimesAndDeadlines, + SequencingWithinIntervals, ShortestCommonSupersequence, StaffScheduling, + StringToStringCorrection, SubsetSum, SumOfSquaresPartition, Term, }; pub use set::{ ComparativeContainment, ConsecutiveSets, ExactCoverBy3Sets, MaximumSetPacking, diff --git a/src/unit_tests/models/misc/sequencing_to_minimize_maximum_cumulative_cost.rs b/src/unit_tests/models/misc/sequencing_to_minimize_maximum_cumulative_cost.rs new file mode 100644 index 000000000..fa6a4823f --- /dev/null +++ b/src/unit_tests/models/misc/sequencing_to_minimize_maximum_cumulative_cost.rs @@ -0,0 +1,143 @@ +use super::*; +use crate::solvers::{BruteForce, Solver}; +use crate::traits::Problem; + +fn issue_example(bound: i64) -> SequencingToMinimizeMaximumCumulativeCost { + SequencingToMinimizeMaximumCumulativeCost::new( + vec![2, -1, 3, -2, 1, -3], + vec![(0, 2), (1, 2), (1, 3), (2, 4), (3, 5), (4, 5)], + bound, + ) +} + +#[test] +fn test_sequencing_to_minimize_maximum_cumulative_cost_creation() { + let problem = issue_example(4); + assert_eq!(problem.costs(), &[2, -1, 3, -2, 1, -3]); + assert_eq!( + problem.precedences(), + &[(0, 2), (1, 2), (1, 3), (2, 4), (3, 5), (4, 5)] + ); + assert_eq!(problem.bound(), 4); + assert_eq!(problem.num_tasks(), 6); + assert_eq!(problem.num_precedences(), 6); + assert_eq!(problem.dims(), vec![6, 5, 4, 3, 2, 1]); + assert_eq!( + ::NAME, + "SequencingToMinimizeMaximumCumulativeCost" + ); + assert_eq!( + ::variant(), + vec![] + ); +} + +#[test] +fn test_sequencing_to_minimize_maximum_cumulative_cost_evaluate_satisfying_issue_order() { + let problem = issue_example(4); + + // Task order [1, 0, 3, 2, 4, 5]: + // available [0,1,2,3,4,5] -> pick 1 + // available [0,2,3,4,5] -> pick 0 + // available [2,3,4,5] -> pick 1 + // available [2,4,5] -> pick 0 + // available [4,5] -> pick 0 + // available [5] -> pick 0 + let config = vec![1, 0, 1, 0, 0, 0]; + assert!(problem.evaluate(&config)); +} + +#[test] +fn test_sequencing_to_minimize_maximum_cumulative_cost_evaluate_tight_bound() { + let problem = issue_example(3); + + // Identity order [0,1,2,3,4,5] reaches prefix sums 2,1,4,... and should fail for K = 3. + assert!(!problem.evaluate(&[0, 0, 0, 0, 0, 0])); +} + +#[test] +fn test_sequencing_to_minimize_maximum_cumulative_cost_precedence_violation() { + let problem = issue_example(10); + + // Task order [2, 0, 1, 3, 4, 5] violates precedence 0 -> 2. + assert!(!problem.evaluate(&[2, 0, 0, 0, 0, 0])); +} + +#[test] +fn test_sequencing_to_minimize_maximum_cumulative_cost_invalid_config() { + let problem = issue_example(4); + assert!(!problem.evaluate(&[6, 0, 0, 0, 0, 0])); + assert!(!problem.evaluate(&[1, 0, 1, 0, 0])); + assert!(!problem.evaluate(&[1, 0, 1, 0, 0, 0, 0])); +} + +#[test] +fn test_sequencing_to_minimize_maximum_cumulative_cost_brute_force_solver() { + let problem = issue_example(4); + let solver = BruteForce::new(); + let solution = solver + .find_satisfying(&problem) + .expect("should find a satisfying schedule"); + assert!(problem.evaluate(&solution)); +} + +#[test] +fn test_sequencing_to_minimize_maximum_cumulative_cost_unsatisfiable_cycle() { + let problem = SequencingToMinimizeMaximumCumulativeCost::new( + vec![1, -1, 2], + vec![(0, 1), (1, 2), (2, 0)], + 10, + ); + let solver = BruteForce::new(); + assert!(solver.find_satisfying(&problem).is_none()); +} + +#[test] +fn test_sequencing_to_minimize_maximum_cumulative_cost_paper_example() { + let problem = issue_example(4); + let sample_config = vec![1, 0, 1, 0, 0, 0]; + assert!(problem.evaluate(&sample_config)); + + let satisfying = BruteForce::new().find_all_satisfying(&problem); + assert_eq!(satisfying.len(), 5); + assert!(satisfying.iter().any(|config| config == &sample_config)); +} + +#[test] +fn test_sequencing_to_minimize_maximum_cumulative_cost_empty_instance() { + let problem = SequencingToMinimizeMaximumCumulativeCost::new(vec![], vec![], 0); + assert_eq!(problem.num_tasks(), 0); + assert_eq!(problem.dims(), Vec::::new()); + assert!(problem.evaluate(&[])); +} + +#[test] +#[should_panic(expected = "predecessor index 4 out of range")] +fn test_sequencing_to_minimize_maximum_cumulative_cost_invalid_precedence_endpoint() { + SequencingToMinimizeMaximumCumulativeCost::new(vec![1, -1, 2], vec![(4, 0)], 2); +} + +#[test] +fn test_sequencing_to_minimize_maximum_cumulative_cost_serialization() { + let problem = issue_example(4); + let json = serde_json::to_value(&problem).unwrap(); + let restored: SequencingToMinimizeMaximumCumulativeCost = serde_json::from_value(json).unwrap(); + assert_eq!(restored.costs(), problem.costs()); + assert_eq!(restored.precedences(), problem.precedences()); + assert_eq!(restored.bound(), problem.bound()); +} + +#[test] +fn test_sequencing_to_minimize_maximum_cumulative_cost_deserialize_rejects_invalid_precedence() { + let result: Result = + serde_json::from_value(serde_json::json!({ + "costs": [1, -1, 2], + "precedences": [[4, 0]], + "bound": 2 + })); + let err = result.unwrap_err().to_string(); + assert!( + err.contains("predecessor index 4 out of range"), + "got: {err}" + ); +}