diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index 01372f7c..da5b4844 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -112,6 +112,7 @@ "MinimumTardinessSequencing": [Minimum Tardiness Sequencing], "SequencingWithinIntervals": [Sequencing Within Intervals], "DirectedTwoCommodityIntegralFlow": [Directed Two-Commodity Integral Flow], + "RectilinearPictureCompression": [Rectilinear Picture Compression], ) // Definition label: "def:" — each definition block must have a matching label @@ -1879,6 +1880,60 @@ NP-completeness was established by Garey, Johnson, and Stockmeyer @gareyJohnsonS *Example.* Let $n = 4$ items with weights $(2, 3, 4, 5)$, values $(3, 4, 5, 7)$, and capacity $C = 7$. Selecting $S = {1, 2}$ (items with weights 3 and 4) gives total weight $3 + 4 = 7 lt.eq C$ and total value $4 + 5 = 9$. Selecting $S = {0, 3}$ (weights 2 and 5) gives weight $2 + 5 = 7 lt.eq C$ and value $3 + 7 = 10$, which is optimal. ] +#{ + let x = load-model-example("RectilinearPictureCompression") + let mat = x.instance.matrix + let m = mat.len() + let n = mat.at(0).len() + let K = x.instance.bound_k + // Convert bool matrix to int for display + let M = mat.map(row => row.map(v => if v { 1 } else { 0 })) + [ + #problem-def("RectilinearPictureCompression")[ + Given an $m times n$ binary matrix $M$ and a nonnegative integer $K$, + determine whether there exists a collection of at most $K$ + axis-aligned rectangles that covers precisely the 1-entries of $M$. + Each rectangle is a quadruple $(a, b, c, d)$ with $a lt.eq b$ and $c lt.eq d$, + covering entries $M_(i j)$ for $a lt.eq i lt.eq b$ and $c lt.eq j lt.eq d$, + where every covered entry must satisfy $M_(i j) = 1$. + ][ + Rectilinear Picture Compression is a classical NP-complete problem from Garey & Johnson (A4 SR25, p.~232) @garey1979. It arises naturally in image compression, DNA microarray design, integrated circuit manufacturing, and access control list minimization. NP-completeness was established by Masek (1978) via transformation from 3SAT. A straightforward exact baseline, including the brute-force solver in this crate, enumerates subsets of the maximal all-1 rectangles. If an instance has $R$ such rectangles, this gives an $O^*(2^R)$ exact search, so the worst-case behavior remains exponential in the instance size. + + *Example.* Let $M = mat(#M.map(row => row.map(v => str(v)).join(", ")).join("; "))$ (a $#m times #n$ matrix) and $K = #K$. The two maximal all-1 rectangles cover rows $0..1$, columns $0..1$ and rows $2..3$, columns $2..3$. Selecting both gives $|{R_1, R_2}| = 2 lt.eq K = #K$ and their union covers all eight 1-entries, so the answer is YES. + + #figure( + { + let cell-size = 0.5 + let blue = graph-colors.at(0) + let teal = rgb("#76b7b2") + // Rectangle covers: R1 covers rows 0..1, cols 0..1; R2 covers rows 2..3, cols 2..3 + let rect-color(r, c) = { + if r <= 1 and c <= 1 { blue.transparentize(40%) } + else if r >= 2 and c >= 2 { teal.transparentize(40%) } + else { white } + } + align(center, grid( + columns: n, + column-gutter: 0pt, + row-gutter: 0pt, + ..range(m).map(r => + range(n).map(c => { + let val = M.at(r).at(c) + let fill = if val == 1 { rect-color(r, c) } else { white } + box(width: cell-size * 1cm, height: cell-size * 1cm, + fill: fill, stroke: 0.4pt + luma(180), + align(center + horizon, text(8pt, weight: if val == 1 { "bold" } else { "regular" }, + if val == 1 { "1" } else { "0" }))) + }) + ).flatten(), + )) + }, + caption: [Rectilinear Picture Compression: matrix $M$ covered by two rectangles $R_1$ (blue, top-left $2 times 2$) and $R_2$ (teal, bottom-right $2 times 2$), with $|{R_1, R_2}| = 2 lt.eq K = #K$.], + ) + ] + ] +} + #problem-def("RuralPostman")[ Given an undirected graph $G = (V, E)$ with edge lengths $l: E -> ZZ_(gt.eq 0)$, a subset $E' subset.eq E$ of required edges, and a bound $B in ZZ^+$, determine whether there exists a circuit (closed walk) in $G$ that traverses every edge in $E'$ and has total length at most $B$. ][ diff --git a/docs/src/cli.md b/docs/src/cli.md index 5c595e90..915bc257 100644 --- a/docs/src/cli.md +++ b/docs/src/cli.md @@ -291,6 +291,8 @@ pred create QUBO --matrix "1,0.5;0.5,2" -o qubo.json pred create KColoring --k 3 --graph 0-1,1-2,2-0 -o kcol.json pred create SpinGlass --graph 0-1,1-2 -o sg.json pred create MaxCut --graph 0-1,1-2,2-0 -o maxcut.json +pred create RectilinearPictureCompression --matrix "1,1,0,0;1,1,0,0;0,0,1,1;0,0,1,1" --k 2 -o rpc.json +pred solve rpc.json --solver brute-force pred create SteinerTree --graph 0-1,0-3,1-2,1-3,2-3,2-4,3-4 --edge-weights 2,5,2,1,5,6,1 --terminals 0,2,4 -o steiner.json pred create UndirectedTwoCommodityIntegralFlow --graph 0-2,1-2,2-3 --capacities 1,1,2 --source-1 0 --sink-1 3 --source-2 1 --sink-2 3 --requirement-1 1 --requirement-2 1 -o utcif.json pred create LengthBoundedDisjointPaths --graph 0-1,1-6,0-2,2-3,3-6,0-4,4-5,5-6 --source 0 --sink 6 --num-paths-required 2 --bound 3 -o lbdp.json diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index e71a9e42..6743a9af 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -249,6 +249,7 @@ Flags by problem type: FVS --arcs [--weights] [--num-vertices] FlowShopScheduling --task-lengths, --deadline [--num-processors] MinimumTardinessSequencing --n, --deadlines [--precedence-pairs] + RectilinearPictureCompression --matrix (0/1), --k SCS --strings, --bound [--alphabet-size] D2CIF --arcs, --capacities, --source-1, --sink-1, --source-2, --sink-2, --requirement-1, --requirement-2 ILP, CircuitSAT (via reduction only) @@ -321,10 +322,10 @@ pub struct CreateArgs { /// Number of variables (for SAT/KSAT) #[arg(long)] pub num_vars: Option, - /// Matrix for QUBO (semicolon-separated rows, e.g., "1,0.5;0.5,2") + /// Matrix input (semicolon-separated rows; use `pred create ` for problem-specific formats) #[arg(long)] pub matrix: Option, - /// Number of colors for KColoring + /// Shared integer parameter (use `pred create ` for the problem-specific meaning) #[arg(long)] pub k: Option, /// Generate a random instance (graph-based problems only) diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index 03220f4f..1e1e29af 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -14,7 +14,8 @@ use problemreductions::models::graph::{ }; use problemreductions::models::misc::{ BinPacking, FlowShopScheduling, LongestCommonSubsequence, MinimumTardinessSequencing, - PaintShop, SequencingWithinIntervals, ShortestCommonSupersequence, SubsetSum, + PaintShop, RectilinearPictureCompression, SequencingWithinIntervals, + ShortestCommonSupersequence, SubsetSum, }; use problemreductions::prelude::*; use problemreductions::registry::collect_schemas; @@ -233,7 +234,6 @@ fn type_format_hint(type_name: &str, graph_type: Option<&str>) -> &'static str { "Vec>" => "semicolon-separated groups: \"0,1;2,3\"", "usize" => "integer", "u64" => "integer", - "Vec" => "comma-separated integers: 0,0,5", "i64" => "integer", "BigUint" => "nonnegative decimal integer", "Vec" => "comma-separated nonnegative decimal integers: 3,7,1,8", @@ -293,6 +293,9 @@ fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str { "--arcs \"0>1,0>2,1>3,2>3,1>4,3>5,4>5,2>4\" --weights 3,2,4,1,2,3,1,3 --partition \"0,1;2,3;4,7;5,6\" --bound 10" } "SubgraphIsomorphism" => "--graph 0-1,1-2,2-0 --pattern 0-1", + "RectilinearPictureCompression" => { + "--matrix \"1,1,0,0;1,1,0,0;0,0,1,1;0,0,1,1\" --k 2" + } "SubsetSum" => "--sizes 3,7,1,8,2,4 --target 11", "SetBasis" => "--universe 4 --sets \"0,1;1,2;0,2;0,1,2\" --k 3", "ShortestCommonSupersequence" => "--strings \"0,1,2;1,2,0\" --bound 4", @@ -305,6 +308,8 @@ fn help_flag_name(canonical: &str, field_name: &str) -> String { match (canonical, field_name) { ("BoundedComponentSpanningForest", "max_components") => return "k".to_string(), ("BoundedComponentSpanningForest", "max_weight") => return "bound".to_string(), + ("LengthBoundedDisjointPaths", "max_length") => return "bound".to_string(), + ("RectilinearPictureCompression", "bound_k") => return "k".to_string(), _ => {} } // General field-name overrides (previously in cli_flag_name) @@ -367,12 +372,7 @@ fn print_problem_help(canonical: &str, graph_type: Option<&str>) -> Result<()> { eprintln!(" --{:<16} {} ({})", flag_name, field.description, hint); } else { let hint = help_flag_hint(canonical, &field.name, &field.type_name, graph_type); - eprintln!( - " --{:<16} {} ({})", - help_flag_name(canonical, &field.name), - field.description, - hint - ); + eprintln!(" --{:<16} {} ({})", flag_name, field.description, hint); } } } else { @@ -406,10 +406,7 @@ fn problem_help_flag_name( if field_type == "DirectedGraph" { return "arcs".to_string(); } - if canonical == "LengthBoundedDisjointPaths" && field_name == "max_length" { - return "bound".to_string(); - } - field_name.replace('_', "-") + help_flag_name(canonical, field_name) } fn lbdp_validation_error(message: &str, usage: Option<&str>) -> anyhow::Error { @@ -1107,6 +1104,21 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { (ser(BMF::new(matrix, rank))?, resolved_variant.clone()) } + // RectilinearPictureCompression + "RectilinearPictureCompression" => { + let matrix = parse_bool_matrix(args)?; + let k = args.k.ok_or_else(|| { + anyhow::anyhow!( + "RectilinearPictureCompression requires --matrix and --k\n\n\ + Usage: pred create RectilinearPictureCompression --matrix \"1,1,0,0;1,1,0,0;0,0,1,1;0,0,1,1\" --k 2" + ) + })?; + ( + ser(RectilinearPictureCompression::new(matrix, k))?, + resolved_variant.clone(), + ) + } + // LongestCommonSubsequence "LongestCommonSubsequence" => { let strings_str = args.strings.as_deref().ok_or_else(|| { @@ -2052,7 +2064,7 @@ fn parse_bool_matrix(args: &CreateArgs) -> Result>> { .matrix .as_deref() .ok_or_else(|| anyhow::anyhow!("This problem requires --matrix (e.g., \"1,0;0,1;1,1\")"))?; - matrix_str + let matrix: Vec> = matrix_str .split(';') .map(|row| { row.trim() @@ -2067,7 +2079,16 @@ fn parse_bool_matrix(args: &CreateArgs) -> Result>> { }) .collect() }) - .collect() + .collect::>()?; + + if let Some(expected_width) = matrix.first().map(Vec::len) { + anyhow::ensure!( + matrix.iter().all(|row| row.len() == expected_width), + "All rows in --matrix must have the same length" + ); + } + + Ok(matrix) } /// Parse `--matrix` as semicolon-separated rows of comma-separated f64 values. diff --git a/problemreductions-cli/src/commands/inspect.rs b/problemreductions-cli/src/commands/inspect.rs index 3a5d37ca..5155c658 100644 --- a/problemreductions-cli/src/commands/inspect.rs +++ b/problemreductions-cli/src/commands/inspect.rs @@ -41,7 +41,12 @@ fn inspect_problem(pj: &ProblemJson, out: &OutputConfig) -> Result<()> { text.push_str(&format!("Variables: {}\n", problem.num_variables_dyn())); // Solvers - text.push_str("Solvers: ilp (default), brute-force\n"); + let solvers = problem.available_solvers(); + if solvers.first() == Some(&"ilp") { + text.push_str("Solvers: ilp (default), brute-force\n"); + } else { + text.push_str("Solvers: brute-force\n"); + } // Reductions let outgoing = graph.outgoing_reductions(name); @@ -56,7 +61,7 @@ fn inspect_problem(pj: &ProblemJson, out: &OutputConfig) -> Result<()> { "variant": variant, "size_fields": size_fields, "num_variables": problem.num_variables_dyn(), - "solvers": ["ilp", "brute-force"], + "solvers": solvers, "reduces_to": targets, }); diff --git a/problemreductions-cli/src/dispatch.rs b/problemreductions-cli/src/dispatch.rs index 938d8643..0ba13977 100644 --- a/problemreductions-cli/src/dispatch.rs +++ b/problemreductions-cli/src/dispatch.rs @@ -47,14 +47,12 @@ impl LoadedProblem { Ok(SolveResult { config, evaluation }) } - /// Solve using the ILP solver. If the problem is not ILP, auto-reduce to ILP first. - pub fn solve_with_ilp(&self) -> Result { + fn best_ilp_path(&self) -> Option { let name = self.problem_name(); if name == "ILP" { - return solve_ilp(self.as_any()); + return Some(problemreductions::rules::ReductionPath { steps: Vec::new() }); } - // Auto-reduce to ILP, solve, and map solution back let source_variant = self.variant_map(); let graph = ReductionGraph::new(); let ilp_variants = graph.variants_for("ILP"); @@ -79,13 +77,37 @@ impl LoadedProblem { } } - let reduction_path = best_path.ok_or_else(|| { + best_path + } + + pub fn supports_ilp_solver(&self) -> bool { + self.best_ilp_path().is_some() + } + + pub fn available_solvers(&self) -> Vec<&'static str> { + if self.supports_ilp_solver() { + vec!["ilp", "brute-force"] + } else { + vec!["brute-force"] + } + } + + /// Solve using the ILP solver. If the problem is not ILP, auto-reduce to ILP first. + pub fn solve_with_ilp(&self) -> Result { + let name = self.problem_name(); + if name == "ILP" { + return solve_ilp(self.as_any()); + } + + // Auto-reduce to ILP, solve, and map solution back + let reduction_path = self.best_ilp_path().ok_or_else(|| { anyhow::anyhow!( "No reduction path from {} to ILP. Try `--solver brute-force`, or reduce to a problem that supports ILP.", name ) })?; + let graph = ReductionGraph::new(); let chain = graph .reduce_along_path(&reduction_path, self.as_any()) .ok_or_else(|| anyhow::anyhow!("Failed to execute reduction chain to ILP"))?; diff --git a/problemreductions-cli/tests/cli_tests.rs b/problemreductions-cli/tests/cli_tests.rs index 9c35fd00..c481bebc 100644 --- a/problemreductions-cli/tests/cli_tests.rs +++ b/problemreductions-cli/tests/cli_tests.rs @@ -951,6 +951,47 @@ fn test_solve_d2cif_default_solver_suggests_bruteforce() { std::fs::remove_file(&output_file).ok(); } +#[test] +fn test_inspect_rectilinear_picture_compression_lists_bruteforce_only() { + let output_file = std::env::temp_dir().join("pred_test_inspect_rpc.json"); + let create_output = pred() + .args([ + "-o", + output_file.to_str().unwrap(), + "create", + "RectilinearPictureCompression", + "--matrix", + "1,1;1,1", + "--k", + "1", + ]) + .output() + .unwrap(); + assert!( + create_output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&create_output.stderr) + ); + + let inspect_output = pred() + .args(["inspect", output_file.to_str().unwrap()]) + .output() + .unwrap(); + assert!( + inspect_output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&inspect_output.stderr) + ); + let stdout = String::from_utf8(inspect_output.stdout).unwrap(); + let json: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + assert!( + json["solvers"] == serde_json::json!(["brute-force"]), + "inspect should list only usable solvers, got: {json}" + ); + + std::fs::remove_file(&output_file).ok(); +} + #[test] fn test_create_x3c_rejects_duplicate_subset_elements() { let output = pred() @@ -2355,6 +2396,94 @@ fn test_create_set_basis_no_flags_uses_actual_cli_flag_names() { ); } +#[test] +fn test_create_rectilinear_picture_compression_help_uses_k_flag() { + let output = pred() + .args(["create", "RectilinearPictureCompression"]) + .output() + .unwrap(); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("--matrix"), + "expected '--matrix' in help output, got: {stderr}" + ); + assert!( + stderr.contains("--k"), + "expected '--k' in help output, got: {stderr}" + ); + assert!( + !stderr.contains("--bound-k"), + "help should advertise the actual CLI flag name, got: {stderr}" + ); +} + +#[test] +fn test_create_rectilinear_picture_compression_rejects_ragged_matrix() { + let output = pred() + .args([ + "create", + "RectilinearPictureCompression", + "--matrix", + "1,0;1", + "--k", + "1", + ]) + .output() + .unwrap(); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("All rows in --matrix must have the same length"), + "expected rectangular-matrix validation error, got: {stderr}" + ); + assert!( + !stderr.contains("panicked at"), + "ragged matrix should not crash the CLI, got: {stderr}" + ); +} + +#[test] +fn test_create_help_uses_generic_matrix_and_k_descriptions() { + let output = pred().args(["create", "--help"]).output().unwrap(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("Matrix input"), + "expected generic matrix help, got: {stdout}" + ); + assert!( + stdout.contains("Shared integer parameter"), + "expected generic k help, got: {stdout}" + ); + assert!( + !stdout.contains("Matrix for QUBO"), + "create --help should not imply --matrix is QUBO-only, got: {stdout}" + ); + assert!( + !stdout.contains("Number of colors for KColoring"), + "create --help should not imply --k is KColoring-only, got: {stdout}" + ); +} + +#[test] +fn test_create_length_bounded_disjoint_paths_help_uses_bound_flag() { + let output = pred() + .args(["create", "LengthBoundedDisjointPaths"]) + .output() + .unwrap(); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("--bound"), + "expected '--bound' in help output, got: {stderr}" + ); + assert!( + !stderr.contains("--max-length"), + "help should advertise the actual CLI flag name, got: {stderr}" + ); +} + #[test] fn test_create_kcoloring_missing_k() { let output = pred() diff --git a/src/example_db/fixtures/examples.json b/src/example_db/fixtures/examples.json index 6e52883b..4c13aebb 100644 --- a/src/example_db/fixtures/examples.json +++ b/src/example_db/fixtures/examples.json @@ -31,6 +31,7 @@ {"problem":"PaintShop","variant":{},"instance":{"car_labels":["A","B","C"],"is_first":[true,true,false,true,false,false],"num_cars":3,"sequence_indices":[0,1,0,2,1,2]},"samples":[{"config":[0,0,1],"metric":{"Valid":2}}],"optimal":[{"config":[0,0,1],"metric":{"Valid":2}},{"config":[0,1,1],"metric":{"Valid":2}},{"config":[1,0,0],"metric":{"Valid":2}},{"config":[1,1,0],"metric":{"Valid":2}}]}, {"problem":"PartitionIntoTriangles","variant":{"graph":"SimpleGraph"},"instance":{"graph":{"inner":{"edge_property":"undirected","edges":[[0,1,null],[0,2,null],[1,2,null],[3,4,null],[3,5,null],[4,5,null],[0,3,null]],"node_holes":[],"nodes":[null,null,null,null,null,null]}}},"samples":[{"config":[0,0,0,1,1,1],"metric":true}],"optimal":[{"config":[0,0,0,1,1,1],"metric":true},{"config":[1,1,1,0,0,0],"metric":true}]}, {"problem":"QUBO","variant":{"weight":"f64"},"instance":{"matrix":[[-1.0,2.0,0.0],[0.0,-1.0,2.0],[0.0,0.0,-1.0]],"num_vars":3},"samples":[{"config":[1,0,1],"metric":{"Valid":-2.0}}],"optimal":[{"config":[1,0,1],"metric":{"Valid":-2.0}}]}, + {"problem":"RectilinearPictureCompression","variant":{},"instance":{"bound_k":2,"matrix":[[true,true,false,false],[true,true,false,false],[false,false,true,true],[false,false,true,true]]},"samples":[{"config":[1,1],"metric":true},{"config":[0,0],"metric":false}],"optimal":[{"config":[1,1],"metric":true}]}, {"problem":"Satisfiability","variant":{},"instance":{"clauses":[{"literals":[1,2]},{"literals":[-1,3]},{"literals":[-2,-3]}],"num_vars":3},"samples":[{"config":[1,0,1],"metric":true}],"optimal":[{"config":[0,1,0],"metric":true},{"config":[1,0,1],"metric":true}]}, {"problem":"SequencingWithinIntervals","variant":{},"instance":{"deadlines":[11,11,11,11,6],"lengths":[3,1,2,4,1],"release_times":[0,0,0,0,5]},"samples":[{"config":[0,6,3,7,0],"metric":true}],"optimal":[{"config":[0,6,3,7,0],"metric":true},{"config":[0,10,3,6,0],"metric":true},{"config":[2,6,0,7,0],"metric":true},{"config":[2,10,0,6,0],"metric":true},{"config":[6,0,9,1,0],"metric":true},{"config":[6,4,9,0,0],"metric":true},{"config":[8,0,6,1,0],"metric":true},{"config":[8,4,6,0,0],"metric":true}]}, {"problem":"SetBasis","variant":{},"instance":{"collection":[[0,1],[1,2],[0,2],[0,1,2]],"k":3,"universe_size":4},"samples":[{"config":[1,0,0,0,0,1,0,0,0,0,1,0],"metric":true}],"optimal":[{"config":[0,0,1,0,0,1,0,0,1,0,0,0],"metric":true},{"config":[0,0,1,0,1,0,0,0,0,1,0,0],"metric":true},{"config":[0,1,0,0,0,0,1,0,1,0,0,0],"metric":true},{"config":[0,1,0,0,1,0,0,0,0,0,1,0],"metric":true},{"config":[0,1,1,0,1,0,1,0,1,1,0,0],"metric":true},{"config":[0,1,1,0,1,1,0,0,1,0,1,0],"metric":true},{"config":[1,0,0,0,0,0,1,0,0,1,0,0],"metric":true},{"config":[1,0,0,0,0,1,0,0,0,0,1,0],"metric":true},{"config":[1,0,1,0,0,1,1,0,1,1,0,0],"metric":true},{"config":[1,0,1,0,1,1,0,0,0,1,1,0],"metric":true},{"config":[1,1,0,0,0,1,1,0,1,0,1,0],"metric":true},{"config":[1,1,0,0,1,0,1,0,0,1,1,0],"metric":true}]}, diff --git a/src/lib.rs b/src/lib.rs index 42bc6c92..fc12ea21 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -58,8 +58,8 @@ pub mod prelude { }; pub use crate::models::misc::{ BinPacking, Factoring, FlowShopScheduling, Knapsack, LongestCommonSubsequence, - MinimumTardinessSequencing, PaintShop, SequencingWithinIntervals, - ShortestCommonSupersequence, SubsetSum, + MinimumTardinessSequencing, PaintShop, RectilinearPictureCompression, + SequencingWithinIntervals, ShortestCommonSupersequence, SubsetSum, }; pub use crate::models::set::{ ExactCoverBy3Sets, MaximumSetPacking, MinimumSetCovering, SetBasis, @@ -97,6 +97,9 @@ pub use inventory; #[path = "unit_tests/graph_models.rs"] mod test_graph_models; #[cfg(test)] +#[path = "unit_tests/prelude.rs"] +mod test_prelude; +#[cfg(test)] #[path = "unit_tests/property.rs"] mod test_property; #[cfg(test)] diff --git a/src/models/misc/mod.rs b/src/models/misc/mod.rs index c4b12527..76ec4b10 100644 --- a/src/models/misc/mod.rs +++ b/src/models/misc/mod.rs @@ -8,6 +8,7 @@ //! - [`LongestCommonSubsequence`]: Longest Common Subsequence //! - [`MinimumTardinessSequencing`]: Minimize tardy tasks in single-machine scheduling //! - [`PaintShop`]: Minimize color switches in paint shop scheduling +//! - [`RectilinearPictureCompression`]: Cover 1-entries with bounded rectangles //! - [`SequencingWithinIntervals`]: Schedule tasks within time windows //! - [`ShortestCommonSupersequence`]: Find a common supersequence of bounded length //! - [`SubsetSum`]: Find a subset summing to exactly a target value @@ -19,6 +20,7 @@ mod knapsack; mod longest_common_subsequence; mod minimum_tardiness_sequencing; pub(crate) mod paintshop; +mod rectilinear_picture_compression; mod sequencing_within_intervals; pub(crate) mod shortest_common_supersequence; mod subset_sum; @@ -30,6 +32,7 @@ pub use knapsack::Knapsack; pub use longest_common_subsequence::LongestCommonSubsequence; pub use minimum_tardiness_sequencing::MinimumTardinessSequencing; pub use paintshop::PaintShop; +pub use rectilinear_picture_compression::RectilinearPictureCompression; pub use sequencing_within_intervals::SequencingWithinIntervals; pub use shortest_common_supersequence::ShortestCommonSupersequence; pub use subset_sum::SubsetSum; @@ -39,6 +42,7 @@ pub(crate) fn canonical_model_example_specs() -> Vec>", description: "m x n binary matrix" }, + FieldInfo { name: "bound_k", type_name: "usize", description: "Maximum number of rectangles allowed" }, + ], + } +} + +/// The Rectilinear Picture Compression problem. +/// +/// Given an m x n binary matrix M and a nonnegative integer K, determine whether +/// there exists a collection of at most K axis-aligned all-1 rectangles that +/// covers precisely the 1-entries of M. +/// +/// # Representation +/// +/// The configuration space consists of the maximal all-1 rectangles in the +/// matrix. Each variable is binary: 1 if the rectangle is selected, 0 otherwise. +/// The problem is satisfiable iff the selected rectangles number at most K and +/// their union covers all 1-entries. +/// +/// # Example +/// +/// ``` +/// use problemreductions::models::misc::RectilinearPictureCompression; +/// use problemreductions::{Problem, Solver, BruteForce}; +/// +/// let matrix = vec![ +/// vec![true, true, false, false], +/// vec![true, true, false, false], +/// vec![false, false, true, true], +/// vec![false, false, true, true], +/// ]; +/// let problem = RectilinearPictureCompression::new(matrix, 2); +/// let solver = BruteForce::new(); +/// let solution = solver.find_satisfying(&problem); +/// assert!(solution.is_some()); +/// ``` +#[derive(Debug, Clone, Serialize)] +pub struct RectilinearPictureCompression { + matrix: Vec>, + bound_k: usize, + #[serde(skip)] + maximal_rects: Vec, +} + +impl<'de> Deserialize<'de> for RectilinearPictureCompression { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + struct Inner { + matrix: Vec>, + bound_k: usize, + } + let inner = Inner::deserialize(deserializer)?; + Ok(Self::new(inner.matrix, inner.bound_k)) + } +} + +impl RectilinearPictureCompression { + /// Create a new RectilinearPictureCompression instance. + /// + /// # Panics + /// + /// Panics if `matrix` is empty or has inconsistent row lengths. + pub fn new(matrix: Vec>, bound_k: usize) -> Self { + assert!(!matrix.is_empty(), "Matrix must not be empty"); + let cols = matrix[0].len(); + assert!(cols > 0, "Matrix must have at least one column"); + assert!( + matrix.iter().all(|row| row.len() == cols), + "All rows must have the same length" + ); + let mut instance = Self { + matrix, + bound_k, + maximal_rects: Vec::new(), + }; + instance.maximal_rects = instance.compute_maximal_rectangles(); + instance + } + + /// Returns the number of rows in the matrix. + pub fn num_rows(&self) -> usize { + self.matrix.len() + } + + /// Returns the number of columns in the matrix. + pub fn num_cols(&self) -> usize { + self.matrix[0].len() + } + + /// Returns the bound K. + pub fn bound_k(&self) -> usize { + self.bound_k + } + + /// Returns a reference to the binary matrix. + pub fn matrix(&self) -> &[Vec] { + &self.matrix + } + + /// Returns the precomputed maximal all-1 sub-rectangles. + /// + /// Each rectangle is `(r1, c1, r2, c2)` covering rows `r1..=r2` and + /// columns `c1..=c2`. + pub fn maximal_rectangles(&self) -> &[Rectangle] { + &self.maximal_rects + } + + fn build_prefix_sum(&self) -> Vec> { + let m = self.num_rows(); + let n = self.num_cols(); + let mut prefix_sum = vec![vec![0; n + 1]; m + 1]; + + for r in 0..m { + let mut row_sum = 0; + for c in 0..n { + row_sum += usize::from(self.matrix[r][c]); + prefix_sum[r + 1][c + 1] = prefix_sum[r][c + 1] + row_sum; + } + } + + prefix_sum + } + + fn range_is_all_ones( + prefix_sum: &[Vec], + r1: usize, + c1: usize, + r2: usize, + c2: usize, + ) -> bool { + let area = (r2 - r1 + 1) * (c2 - c1 + 1); + let sum = prefix_sum[r2 + 1][c2 + 1] + prefix_sum[r1][c1] + - prefix_sum[r1][c2 + 1] + - prefix_sum[r2 + 1][c1]; + sum == area + } + + /// Enumerate all maximal all-1 sub-rectangles in the matrix. + /// + /// A rectangle is maximal if it cannot be extended one step left, right, + /// up, or down while remaining all-1. The result is sorted lexicographically. + fn compute_maximal_rectangles(&self) -> Vec { + let m = self.num_rows(); + let n = self.num_cols(); + + // Step 1: Enumerate right-maximal candidate rectangles by fixing + // (r1, c1, r2) and taking the widest all-1 prefix common to rows r1..=r2. + let mut candidates = Vec::new(); + for r1 in 0..m { + for c1 in 0..n { + if !self.matrix[r1][c1] { + continue; + } + // Find the rightmost column from c1 that is all-1 in row r1. + let mut c_max = n; + for c in c1..n { + if !self.matrix[r1][c] { + c_max = c; + break; + } + } + // Extend downward row by row, narrowing column range. + let mut c_end = c_max; // exclusive upper bound on columns + for r2 in r1..m { + // Narrow c_end based on row r2. + let mut new_c_end = c1; + for c in c1..c_end { + if self.matrix[r2][c] { + new_c_end = c + 1; + } else { + break; + } + } + if new_c_end <= c1 { + break; + } + c_end = new_c_end; + candidates.push((r1, c1, r2, c_end - 1)); + } + } + } + + // Step 2: Remove duplicates. + candidates.sort(); + candidates.dedup(); + + // Step 3: Filter to keep only rectangles that cannot be extended in + // any cardinal direction. A 2D prefix sum makes each extension check O(1). + let prefix_sum = self.build_prefix_sum(); + candidates + .into_iter() + .filter(|&(r1, c1, r2, c2)| { + let can_extend_left = + c1 > 0 && Self::range_is_all_ones(&prefix_sum, r1, c1 - 1, r2, c1 - 1); + let can_extend_right = + c2 + 1 < n && Self::range_is_all_ones(&prefix_sum, r1, c2 + 1, r2, c2 + 1); + let can_extend_up = + r1 > 0 && Self::range_is_all_ones(&prefix_sum, r1 - 1, c1, r1 - 1, c2); + let can_extend_down = + r2 + 1 < m && Self::range_is_all_ones(&prefix_sum, r2 + 1, c1, r2 + 1, c2); + + !(can_extend_left || can_extend_right || can_extend_up || can_extend_down) + }) + .collect() + } +} + +impl Problem for RectilinearPictureCompression { + const NAME: &'static str = "RectilinearPictureCompression"; + type Metric = bool; + + fn variant() -> Vec<(&'static str, &'static str)> { + crate::variant_params![] + } + + fn dims(&self) -> Vec { + vec![2; self.maximal_rects.len()] + } + + fn evaluate(&self, config: &[usize]) -> bool { + let rects = &self.maximal_rects; + if config.len() != rects.len() { + return false; + } + if config.iter().any(|&v| v >= 2) { + return false; + } + + // Count selected rectangles. + let selected_count: usize = config.iter().sum(); + if selected_count > self.bound_k { + return false; + } + + // Check that all 1-entries are covered. + let m = self.num_rows(); + let n = self.num_cols(); + let mut covered = vec![vec![false; n]; m]; + for (i, &x) in config.iter().enumerate() { + if x == 1 { + let (r1, c1, r2, c2) = rects[i]; + for row in &mut covered[r1..=r2] { + for cell in &mut row[c1..=c2] { + *cell = true; + } + } + } + } + + for (row_m, row_c) in self.matrix.iter().zip(covered.iter()) { + for (&entry, &cov) in row_m.iter().zip(row_c.iter()) { + if entry && !cov { + return false; + } + } + } + + true + } +} + +impl SatisfactionProblem for RectilinearPictureCompression {} + +crate::declare_variants! { + default sat RectilinearPictureCompression => "2^(num_rows * num_cols)", +} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_model_example_specs() -> Vec { + vec![crate::example_db::specs::ModelExampleSpec { + id: "rectilinear_picture_compression", + build: || { + let matrix = vec![ + vec![true, true, false, false], + vec![true, true, false, false], + vec![false, false, true, true], + vec![false, false, true, true], + ]; + let problem = RectilinearPictureCompression::new(matrix, 2); + // Config: select both maximal rectangles (the two 2x2 blocks). + // The maximal rectangles for this matrix are exactly: + // (0,0,1,1) and (2,2,3,3), so config [1,1] selects both. + crate::example_db::specs::satisfaction_example(problem, vec![vec![1, 1], vec![0, 0]]) + }, + }] +} + +#[cfg(test)] +#[path = "../../unit_tests/models/misc/rectilinear_picture_compression.rs"] +mod tests; diff --git a/src/models/mod.rs b/src/models/mod.rs index 072034f0..3c722a13 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -22,7 +22,7 @@ pub use graph::{ }; pub use misc::{ BinPacking, Factoring, FlowShopScheduling, Knapsack, LongestCommonSubsequence, - MinimumTardinessSequencing, PaintShop, SequencingWithinIntervals, ShortestCommonSupersequence, - SubsetSum, + MinimumTardinessSequencing, PaintShop, RectilinearPictureCompression, + SequencingWithinIntervals, ShortestCommonSupersequence, SubsetSum, }; pub use set::{ExactCoverBy3Sets, MaximumSetPacking, MinimumSetCovering, SetBasis}; diff --git a/src/unit_tests/models/misc/rectilinear_picture_compression.rs b/src/unit_tests/models/misc/rectilinear_picture_compression.rs new file mode 100644 index 00000000..3b7d38bf --- /dev/null +++ b/src/unit_tests/models/misc/rectilinear_picture_compression.rs @@ -0,0 +1,230 @@ +use super::*; +use crate::solvers::{BruteForce, Solver}; +use crate::traits::Problem; + +fn two_block_matrix() -> Vec> { + vec![ + vec![true, true, false, false], + vec![true, true, false, false], + vec![false, false, true, true], + vec![false, false, true, true], + ] +} + +fn issue_matrix() -> Vec> { + // 6x6 matrix from the issue description + vec![ + vec![true, true, true, false, false, false], + vec![true, true, true, false, false, false], + vec![false, false, true, true, true, false], + vec![false, false, true, true, true, false], + vec![false, false, false, false, true, true], + vec![false, false, false, false, true, true], + ] +} + +#[test] +fn test_rectilinear_picture_compression_basic() { + let problem = RectilinearPictureCompression::new(two_block_matrix(), 2); + assert_eq!(problem.num_rows(), 4); + assert_eq!(problem.num_cols(), 4); + assert_eq!(problem.bound_k(), 2); + assert_eq!( + ::NAME, + "RectilinearPictureCompression" + ); + assert_eq!( + ::variant(), + vec![] + ); +} + +#[test] +fn test_rectilinear_picture_compression_maximal_rectangles_two_blocks() { + let problem = RectilinearPictureCompression::new(two_block_matrix(), 2); + let rects = problem.maximal_rectangles(); + // Two disjoint 2x2 blocks: (0,0,1,1) and (2,2,3,3) + assert_eq!(rects, vec![(0, 0, 1, 1), (2, 2, 3, 3)]); +} + +#[test] +fn test_rectilinear_picture_compression_dims() { + let problem = RectilinearPictureCompression::new(two_block_matrix(), 2); + // 2 maximal rectangles -> 2 binary variables + assert_eq!(problem.dims(), vec![2, 2]); +} + +#[test] +fn test_rectilinear_picture_compression_evaluate_satisfying() { + let problem = RectilinearPictureCompression::new(two_block_matrix(), 2); + // Select both maximal rectangles + assert!(problem.evaluate(&[1, 1])); +} + +#[test] +fn test_rectilinear_picture_compression_evaluate_unsatisfying_not_all_covered() { + let problem = RectilinearPictureCompression::new(two_block_matrix(), 2); + // Select only first rectangle - second block uncovered + assert!(!problem.evaluate(&[1, 0])); + // Select only second rectangle - first block uncovered + assert!(!problem.evaluate(&[0, 1])); + // Select none + assert!(!problem.evaluate(&[0, 0])); +} + +#[test] +fn test_rectilinear_picture_compression_evaluate_bound_exceeded() { + let problem = RectilinearPictureCompression::new(two_block_matrix(), 1); + // Both selected but bound is 1 + assert!(!problem.evaluate(&[1, 1])); +} + +#[test] +fn test_rectilinear_picture_compression_evaluate_wrong_config_length() { + let problem = RectilinearPictureCompression::new(two_block_matrix(), 2); + assert!(!problem.evaluate(&[1])); + assert!(!problem.evaluate(&[1, 1, 0])); +} + +#[test] +fn test_rectilinear_picture_compression_evaluate_invalid_variable_value() { + let problem = RectilinearPictureCompression::new(two_block_matrix(), 2); + assert!(!problem.evaluate(&[2, 0])); +} + +#[test] +fn test_rectilinear_picture_compression_issue_matrix_satisfiable() { + let problem = RectilinearPictureCompression::new(issue_matrix(), 3); + let solver = BruteForce::new(); + let solution = solver.find_satisfying(&problem); + assert!(solution.is_some()); + let sol = solution.unwrap(); + assert!(problem.evaluate(&sol)); +} + +#[test] +fn test_rectilinear_picture_compression_issue_matrix_unsatisfiable() { + let problem = RectilinearPictureCompression::new(issue_matrix(), 2); + let solver = BruteForce::new(); + let solution = solver.find_satisfying(&problem); + assert!(solution.is_none()); +} + +#[test] +fn test_rectilinear_picture_compression_brute_force() { + let problem = RectilinearPictureCompression::new(two_block_matrix(), 2); + let solver = BruteForce::new(); + let solution = solver + .find_satisfying(&problem) + .expect("should find a solution"); + assert!(problem.evaluate(&solution)); +} + +#[test] +fn test_rectilinear_picture_compression_brute_force_all() { + let problem = RectilinearPictureCompression::new(two_block_matrix(), 2); + let solver = BruteForce::new(); + let solutions = solver.find_all_satisfying(&problem); + // Two disjoint 2x2 blocks with K=2: exactly one satisfying config [1,1]. + assert_eq!(solutions.len(), 1); + for sol in &solutions { + assert!(problem.evaluate(sol)); + } +} + +#[test] +fn test_rectilinear_picture_compression_serialization() { + let problem = RectilinearPictureCompression::new(two_block_matrix(), 2); + let json = serde_json::to_value(&problem).unwrap(); + assert_eq!( + json, + serde_json::json!({ + "matrix": [ + [true, true, false, false], + [true, true, false, false], + [false, false, true, true], + [false, false, true, true], + ], + "bound_k": 2, + }) + ); + let restored: RectilinearPictureCompression = serde_json::from_value(json).unwrap(); + assert_eq!(restored.num_rows(), problem.num_rows()); + assert_eq!(restored.num_cols(), problem.num_cols()); + assert_eq!(restored.bound_k(), problem.bound_k()); + assert_eq!(restored.matrix(), problem.matrix()); +} + +#[test] +fn test_rectilinear_picture_compression_single_cell() { + // Single 1-entry matrix + let matrix = vec![vec![true]]; + let problem = RectilinearPictureCompression::new(matrix, 1); + let rects = problem.maximal_rectangles(); + assert_eq!(rects, vec![(0, 0, 0, 0)]); + assert_eq!(problem.dims(), vec![2]); + assert!(problem.evaluate(&[1])); + assert!(!problem.evaluate(&[0])); +} + +#[test] +fn test_rectilinear_picture_compression_all_zeros() { + // Matrix with no 1-entries: no maximal rectangles, always satisfiable + let matrix = vec![vec![false, false], vec![false, false]]; + let problem = RectilinearPictureCompression::new(matrix, 0); + let rects = problem.maximal_rectangles(); + assert!(rects.is_empty()); + assert_eq!(problem.dims(), Vec::::new()); + // Empty config satisfies (no 1-entries to cover) + assert!(problem.evaluate(&[])); +} + +#[test] +fn test_rectilinear_picture_compression_full_matrix() { + // 2x2 all-ones matrix: one maximal rectangle covers everything + let matrix = vec![vec![true, true], vec![true, true]]; + let problem = RectilinearPictureCompression::new(matrix, 1); + let rects = problem.maximal_rectangles(); + assert_eq!(rects, vec![(0, 0, 1, 1)]); + assert!(problem.evaluate(&[1])); + assert!(!problem.evaluate(&[0])); +} + +#[test] +fn test_rectilinear_picture_compression_overlapping_rectangles() { + // L-shaped region: requires multiple rectangles, some may overlap + let matrix = vec![vec![true, true], vec![true, false]]; + let problem = RectilinearPictureCompression::new(matrix, 2); + let rects = problem.maximal_rectangles(); + // Maximal rectangles: (0,0,1,0) vertical bar, (0,0,0,1) horizontal bar + assert!(rects.contains(&(0, 0, 1, 0))); + assert!(rects.contains(&(0, 0, 0, 1))); + let solver = BruteForce::new(); + let solution = solver.find_satisfying(&problem).unwrap(); + assert!(problem.evaluate(&solution)); +} + +#[test] +fn test_rectilinear_picture_compression_matrix_getter() { + let matrix = two_block_matrix(); + let problem = RectilinearPictureCompression::new(matrix.clone(), 2); + assert_eq!(problem.matrix(), &matrix); +} + +#[test] +#[should_panic(expected = "empty")] +fn test_rectilinear_picture_compression_empty_matrix_panics() { + RectilinearPictureCompression::new(vec![], 1); +} + +#[test] +#[should_panic(expected = "column")] +fn test_rectilinear_picture_compression_empty_row_panics() { + RectilinearPictureCompression::new(vec![vec![]], 1); +} + +#[test] +#[should_panic(expected = "same length")] +fn test_rectilinear_picture_compression_inconsistent_rows_panics() { + RectilinearPictureCompression::new(vec![vec![true, false], vec![true]], 1); +} diff --git a/src/unit_tests/prelude.rs b/src/unit_tests/prelude.rs new file mode 100644 index 00000000..fd89ef5c --- /dev/null +++ b/src/unit_tests/prelude.rs @@ -0,0 +1,7 @@ +use crate::prelude::*; + +#[test] +fn test_prelude_exports_rectilinear_picture_compression() { + let problem = RectilinearPictureCompression::new(vec![vec![true]], 1); + assert_eq!(problem.bound_k(), 1); +}