diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index c60bbcde..57c56040 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -137,6 +137,7 @@ "MinimumFeedbackVertexSet": [Minimum Feedback Vertex Set], "ConjunctiveBooleanQuery": [Conjunctive Boolean Query], "ConsecutiveBlockMinimization": [Consecutive Block Minimization], + "ConsecutiveOnesMatrixAugmentation": [Consecutive Ones Matrix Augmentation], "ConsecutiveOnesSubmatrix": [Consecutive Ones Submatrix], "SparseMatrixCompression": [Sparse Matrix Compression], "DirectedTwoCommodityIntegralFlow": [Directed Two-Commodity Integral Flow], @@ -6106,6 +6107,55 @@ A classical NP-complete problem from Garey and Johnson @garey1979[Ch.~3, p.~76], ] } +#{ + let x = load-model-example("ConsecutiveOnesMatrixAugmentation") + let A = x.instance.matrix + let m = A.len() + let n = if m > 0 { A.at(0).len() } else { 0 } + let K = x.instance.bound + let perm = x.optimal_config + let A-int = A.map(row => row.map(v => if v { 1 } else { 0 })) + let reordered = A.map(row => perm.map(c => if row.at(c) { 1 } else { 0 })) + let total-flips = 0 + for row in reordered { + let first = none + let last = none + let count = 0 + for (j, value) in row.enumerate() { + if value == 1 { + if first == none { + first = j + } + last = j + count += 1 + } + } + if first != none and last != none { + total-flips += last - first + 1 - count + } + } + [ + #problem-def("ConsecutiveOnesMatrixAugmentation")[ + Given an $m times n$ binary matrix $A$ and a nonnegative integer $K$, determine whether there exists a matrix $A'$, obtained from $A$ by changing at most $K$ zero entries to one, such that some permutation of the columns of $A'$ has the consecutive ones property. + ][ + Consecutive Ones Matrix Augmentation is problem SR16 in Garey & Johnson @garey1979. It asks whether a binary matrix can be repaired by a bounded number of augmenting flips so that every row's 1-entries become contiguous after reordering the columns. This setting appears in information retrieval and DNA physical mapping, where matrices close to the consecutive ones property can still encode useful interval structure. Booth and Lueker showed that testing whether a matrix already has the consecutive ones property is polynomial-time via PQ-trees @booth1976, but allowing bounded augmentation makes the decision problem NP-complete @booth1975. The direct exhaustive search tries all $n!$ column permutations and, for each one, computes the minimum augmentation cost by filling the holes between the first and last 1 in every row#footnote[No algorithm improving on brute-force permutation enumeration is known for the general problem in this repository's supported setting.]. + + *Example.* Consider the $#m times #n$ matrix $A = mat(#A-int.map(row => row.map(v => str(v)).join(", ")).join("; "))$ with $K = #K$. Under the permutation $pi = (#perm.map(p => str(p)).join(", "))$, the reordered rows are #reordered.enumerate().map(((i, row)) => [$r_#(i + 1) = (#row.map(v => str(v)).join(", "))$]).join(", "). The first row becomes $(1, 0, 1, 0, 1)$, so filling the two interior gaps yields $(1, 1, 1, 1, 1)$. The other three rows already have consecutive 1-entries under the same order, so the total augmentation cost is #total-flips and #total-flips $<= #K$, making the instance satisfiable. + + #pred-commands( + "pred create --example ConsecutiveOnesMatrixAugmentation -o consecutive-ones-matrix-augmentation.json", + "pred solve consecutive-ones-matrix-augmentation.json --solver brute-force", + "pred evaluate consecutive-ones-matrix-augmentation.json --config " + x.optimal_config.map(str).join(","), + ) + + #figure( + align(center, math.equation([$A = #math.mat(..A-int.map(row => row.map(v => [#v])))$])), + caption: [The canonical $#m times #n$ example matrix for Consecutive Ones Matrix Augmentation. The permutation $pi = (#perm.map(p => str(p)).join(", "))$ makes only the first row need augmentation, and exactly two zero-to-one flips suffice.], + ) + ] + ] +} + #{ let x = load-model-example("ConsecutiveOnesSubmatrix") let A = x.instance.matrix diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index af87c708..6702dab7 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -268,6 +268,7 @@ Flags by problem type: PartialFeedbackEdgeSet --graph, --budget, --max-cycle-length [--num-vertices] BMF --matrix (0/1), --rank ConsecutiveBlockMinimization --matrix (JSON 2D bool), --bound-k + ConsecutiveOnesMatrixAugmentation --matrix (0/1), --bound ConsecutiveOnesSubmatrix --matrix (0/1), --k SparseMatrixCompression --matrix (0/1), --bound SteinerTree --graph, --edge-weights, --terminals diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index 3829c601..0dbdec6e 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -8,8 +8,8 @@ use crate::util; use anyhow::{bail, Context, Result}; use problemreductions::export::{ModelExample, ProblemRef, ProblemSide, RuleExample}; use problemreductions::models::algebraic::{ - ClosestVectorProblem, ConsecutiveBlockMinimization, ConsecutiveOnesSubmatrix, - SparseMatrixCompression, BMF, + ClosestVectorProblem, ConsecutiveBlockMinimization, ConsecutiveOnesMatrixAugmentation, + ConsecutiveOnesSubmatrix, SparseMatrixCompression, BMF, }; use problemreductions::models::formula::Quantifier; use problemreductions::models::graph::{ @@ -693,6 +693,9 @@ fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str { "ConsecutiveBlockMinimization" => { "--matrix '[[true,false,true],[false,true,true]]' --bound 2" } + "ConsecutiveOnesMatrixAugmentation" => { + "--matrix \"1,0,0,1,1;1,1,0,0,0;0,1,1,0,1;0,0,1,1,0\" --bound 2" + } "SparseMatrixCompression" => { "--matrix \"1,0,0,1;0,1,0,0;0,0,1,0;1,0,0,0\" --bound 2" } @@ -739,6 +742,7 @@ fn help_flag_name(canonical: &str, field_name: &str) -> String { ("PrimeAttributeName", "dependencies") => return "deps".to_string(), ("PrimeAttributeName", "query_attribute") => return "query".to_string(), ("MixedChinesePostman", "arc_weights") => return "arc-costs".to_string(), + ("ConsecutiveOnesMatrixAugmentation", "bound") => return "bound".to_string(), ("ConsecutiveOnesSubmatrix", "bound") => return "bound".to_string(), ("SparseMatrixCompression", "bound_k") => return "bound".to_string(), ("StackerCrane", "edges") => return "graph".to_string(), @@ -824,6 +828,9 @@ fn help_flag_hint( ("PathConstrainedNetworkFlow", "paths") => { "semicolon-separated arc-index paths: \"0,2,5,8;1,4,7,9\"" } + ("ConsecutiveOnesMatrixAugmentation", "matrix") => { + "semicolon-separated 0/1 rows: \"1,0;0,1\"" + } ("ConsecutiveOnesSubmatrix", "matrix") => "semicolon-separated 0/1 rows: \"1,0;0,1\"", ("SparseMatrixCompression", "matrix") => "semicolon-separated 0/1 rows: \"1,0;0,1\"", ("TimetableDesign", "craftsman_avail") | ("TimetableDesign", "task_avail") => { @@ -2817,6 +2824,22 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { ) } + // ConsecutiveOnesMatrixAugmentation + "ConsecutiveOnesMatrixAugmentation" => { + let matrix = parse_bool_matrix(args)?; + let bound = args.bound.ok_or_else(|| { + anyhow::anyhow!( + "ConsecutiveOnesMatrixAugmentation requires --matrix and --bound\n\n\ + Usage: pred create ConsecutiveOnesMatrixAugmentation --matrix \"1,0,0,1,1;1,1,0,0,0;0,1,1,0,1;0,0,1,1,0\" --bound 2" + ) + })?; + ( + ser(ConsecutiveOnesMatrixAugmentation::try_new(matrix, bound) + .map_err(|e| anyhow::anyhow!(e))?)?, + resolved_variant.clone(), + ) + } + // SparseMatrixCompression "SparseMatrixCompression" => { let matrix = parse_bool_matrix(args)?; @@ -7994,4 +8017,80 @@ mod tests { let err = create(&args, &out).unwrap_err().to_string(); assert!(err.contains("bound >= 1")); } + + #[test] + fn test_create_consecutive_ones_matrix_augmentation_json() { + use crate::dispatch::ProblemJsonOutput; + + let mut args = empty_args(); + args.problem = Some("ConsecutiveOnesMatrixAugmentation".to_string()); + args.matrix = Some("1,0,0,1,1;1,1,0,0,0;0,1,1,0,1;0,0,1,1,0".to_string()); + args.bound = Some(2); + + let output_path = + std::env::temp_dir().join(format!("coma-create-{}.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, "ConsecutiveOnesMatrixAugmentation"); + assert!(created.variant.is_empty()); + assert_eq!( + created.data, + serde_json::json!({ + "matrix": [ + [true, false, false, true, true], + [true, true, false, false, false], + [false, true, true, false, true], + [false, false, true, true, false], + ], + "bound": 2, + }) + ); + + let _ = std::fs::remove_file(output_path); + } + + #[test] + fn test_create_consecutive_ones_matrix_augmentation_requires_bound() { + let mut args = empty_args(); + args.problem = Some("ConsecutiveOnesMatrixAugmentation".to_string()); + args.matrix = Some("1,0;0,1".to_string()); + + let out = OutputConfig { + output: None, + quiet: true, + json: false, + auto_json: false, + }; + + let err = create(&args, &out).unwrap_err().to_string(); + assert!(err.contains("ConsecutiveOnesMatrixAugmentation requires --matrix and --bound")); + assert!(err.contains("Usage: pred create ConsecutiveOnesMatrixAugmentation")); + } + + #[test] + fn test_create_consecutive_ones_matrix_augmentation_negative_bound() { + let mut args = empty_args(); + args.problem = Some("ConsecutiveOnesMatrixAugmentation".to_string()); + args.matrix = Some("1,0;0,1".to_string()); + args.bound = Some(-1); + + let out = OutputConfig { + output: None, + quiet: true, + json: false, + auto_json: false, + }; + + let err = create(&args, &out).unwrap_err().to_string(); + assert!(err.contains("nonnegative")); + } } diff --git a/src/lib.rs b/src/lib.rs index 30c0d472..2933fc2c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -42,7 +42,9 @@ pub mod variant; /// Prelude module for convenient imports. pub mod prelude { // Problem types - pub use crate::models::algebraic::{QuadraticAssignment, SparseMatrixCompression, BMF, QUBO}; + pub use crate::models::algebraic::{ + ConsecutiveOnesMatrixAugmentation, QuadraticAssignment, SparseMatrixCompression, BMF, QUBO, + }; pub use crate::models::formula::{ CNFClause, CircuitSAT, KSatisfiability, NAESatisfiability, QuantifiedBooleanFormulas, Satisfiability, diff --git a/src/models/algebraic/consecutive_ones_matrix_augmentation.rs b/src/models/algebraic/consecutive_ones_matrix_augmentation.rs new file mode 100644 index 00000000..aa5d187a --- /dev/null +++ b/src/models/algebraic/consecutive_ones_matrix_augmentation.rs @@ -0,0 +1,163 @@ +//! Consecutive Ones Matrix Augmentation problem implementation. +//! +//! Given an m x n binary matrix A and a nonnegative integer K, determine +//! whether there exists a permutation of the columns and at most K zero-to-one +//! augmentations such that every row has consecutive 1s. + +use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::traits::{Problem, SatisfactionProblem}; +use serde::{Deserialize, Serialize}; + +inventory::submit! { + ProblemSchemaEntry { + name: "ConsecutiveOnesMatrixAugmentation", + display_name: "Consecutive Ones Matrix Augmentation", + aliases: &[], + dimensions: &[], + module_path: module_path!(), + description: "Augment a binary matrix with at most K zero-to-one flips so some column permutation has the consecutive ones property", + fields: &[ + FieldInfo { name: "matrix", type_name: "Vec>", description: "m x n binary matrix A" }, + FieldInfo { name: "bound", type_name: "i64", description: "Upper bound K on zero-to-one augmentations" }, + ], + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ConsecutiveOnesMatrixAugmentation { + matrix: Vec>, + bound: i64, +} + +impl ConsecutiveOnesMatrixAugmentation { + pub fn new(matrix: Vec>, bound: i64) -> Self { + Self::try_new(matrix, bound).unwrap_or_else(|err| panic!("{err}")) + } + + pub fn try_new(matrix: Vec>, bound: i64) -> Result { + let num_cols = matrix.first().map_or(0, Vec::len); + if matrix.iter().any(|row| row.len() != num_cols) { + return Err("all matrix rows must have the same length".to_string()); + } + if bound < 0 { + return Err("bound must be nonnegative".to_string()); + } + Ok(Self { matrix, bound }) + } + + pub fn matrix(&self) -> &[Vec] { + &self.matrix + } + + pub fn bound(&self) -> i64 { + self.bound + } + + pub fn num_rows(&self) -> usize { + self.matrix.len() + } + + pub fn num_cols(&self) -> usize { + self.matrix.first().map_or(0, Vec::len) + } + + fn validate_permutation(&self, config: &[usize]) -> bool { + if config.len() != self.num_cols() { + return false; + } + + let mut seen = vec![false; self.num_cols()]; + for &col in config { + if col >= self.num_cols() || seen[col] { + return false; + } + seen[col] = true; + } + true + } + + fn row_augmentation_cost(row: &[bool], config: &[usize]) -> usize { + let mut first_one = None; + let mut last_one = None; + let mut one_count = 0usize; + + for (position, &col) in config.iter().enumerate() { + if row[col] { + first_one.get_or_insert(position); + last_one = Some(position); + one_count += 1; + } + } + + match (first_one, last_one) { + (Some(first), Some(last)) => last - first + 1 - one_count, + _ => 0, + } + } + + fn total_augmentation_cost(&self, config: &[usize]) -> Option { + if !self.validate_permutation(config) { + return None; + } + + let mut total = 0usize; + for row in &self.matrix { + total += Self::row_augmentation_cost(row, config); + if total > self.bound as usize { + return Some(total); + } + } + + Some(total) + } +} + +impl Problem for ConsecutiveOnesMatrixAugmentation { + const NAME: &'static str = "ConsecutiveOnesMatrixAugmentation"; + type Metric = bool; + + fn dims(&self) -> Vec { + vec![self.num_cols(); self.num_cols()] + } + + fn evaluate(&self, config: &[usize]) -> bool { + self.total_augmentation_cost(config) + .is_some_and(|cost| cost <= self.bound as usize) + } + + fn variant() -> Vec<(&'static str, &'static str)> { + crate::variant_params![] + } + + fn num_variables(&self) -> usize { + self.num_cols() + } +} + +impl SatisfactionProblem for ConsecutiveOnesMatrixAugmentation {} + +crate::declare_variants! { + default sat ConsecutiveOnesMatrixAugmentation => "factorial(num_cols) * num_rows * num_cols", +} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_model_example_specs() -> Vec { + vec![crate::example_db::specs::ModelExampleSpec { + id: "consecutive_ones_matrix_augmentation", + instance: Box::new(ConsecutiveOnesMatrixAugmentation::new( + vec![ + vec![true, false, false, true, true], + vec![true, true, false, false, false], + vec![false, true, true, false, true], + vec![false, false, true, true, false], + ], + 2, + )), + optimal_config: vec![0, 1, 4, 2, 3], + optimal_value: serde_json::json!(true), + }] +} + +#[cfg(test)] +#[path = "../../unit_tests/models/algebraic/consecutive_ones_matrix_augmentation.rs"] +mod tests; diff --git a/src/models/algebraic/mod.rs b/src/models/algebraic/mod.rs index f5aaff91..d341270c 100644 --- a/src/models/algebraic/mod.rs +++ b/src/models/algebraic/mod.rs @@ -13,6 +13,7 @@ pub(crate) mod bmf; pub(crate) mod closest_vector_problem; pub(crate) mod consecutive_block_minimization; +pub(crate) mod consecutive_ones_matrix_augmentation; pub(crate) mod consecutive_ones_submatrix; pub(crate) mod ilp; pub(crate) mod quadratic_assignment; @@ -22,6 +23,7 @@ pub(crate) mod sparse_matrix_compression; pub use bmf::BMF; pub use closest_vector_problem::{ClosestVectorProblem, VarBounds}; pub use consecutive_block_minimization::ConsecutiveBlockMinimization; +pub use consecutive_ones_matrix_augmentation::ConsecutiveOnesMatrixAugmentation; pub use consecutive_ones_submatrix::ConsecutiveOnesSubmatrix; pub use ilp::{Comparison, LinearConstraint, ObjectiveSense, VariableDomain, ILP}; pub use quadratic_assignment::QuadraticAssignment; @@ -36,6 +38,7 @@ pub(crate) fn canonical_model_example_specs() -> Vec Vec> { + vec![ + vec![true, false, false, true, true], + vec![true, true, false, false, false], + vec![false, true, true, false, true], + vec![false, false, true, true, false], + ] +} + +fn issue_no_matrix() -> Vec> { + vec![ + vec![true, false, true, false], + vec![false, true, false, true], + vec![true, true, false, true], + vec![false, true, true, false], + ] +} + +#[test] +fn test_consecutive_ones_matrix_augmentation_basic() { + let problem = ConsecutiveOnesMatrixAugmentation::new(issue_yes_matrix(), 2); + + assert_eq!(problem.num_rows(), 4); + assert_eq!(problem.num_cols(), 5); + assert_eq!(problem.bound(), 2); + assert_eq!(problem.num_variables(), 5); + assert_eq!(problem.dims(), vec![5; 5]); + assert_eq!( + ::NAME, + "ConsecutiveOnesMatrixAugmentation" + ); + assert_eq!( + ::variant(), + Vec::<(&'static str, &'static str)>::new() + ); +} + +#[test] +fn test_consecutive_ones_matrix_augmentation_yes_instance() { + let problem = ConsecutiveOnesMatrixAugmentation::new(issue_yes_matrix(), 2); + + assert!(problem.evaluate(&[0, 1, 4, 2, 3])); +} + +#[test] +fn test_consecutive_ones_matrix_augmentation_no_instance() { + let problem = ConsecutiveOnesMatrixAugmentation::new(issue_no_matrix(), 0); + + let solver = BruteForce::new(); + assert!(solver.find_satisfying(&problem).is_none()); +} + +#[test] +fn test_consecutive_ones_matrix_augmentation_invalid_permutations() { + let problem = ConsecutiveOnesMatrixAugmentation::new(issue_yes_matrix(), 2); + + assert!(!problem.evaluate(&[0, 1, 4, 2])); + assert!(!problem.evaluate(&[0, 1, 4, 2, 5])); + assert!(!problem.evaluate(&[0, 1, 4, 2, 2])); +} + +#[test] +fn test_consecutive_ones_matrix_augmentation_serialization() { + let problem = ConsecutiveOnesMatrixAugmentation::new(issue_yes_matrix(), 2); + let json = serde_json::to_value(&problem).unwrap(); + + assert_eq!( + json, + serde_json::json!({ + "matrix": [ + [true, false, false, true, true], + [true, true, false, false, false], + [false, true, true, false, true], + [false, false, true, true, false], + ], + "bound": 2, + }) + ); + + let restored: ConsecutiveOnesMatrixAugmentation = serde_json::from_value(json).unwrap(); + assert_eq!(restored.matrix(), problem.matrix()); + assert_eq!(restored.bound(), problem.bound()); +} + +#[test] +fn test_consecutive_ones_matrix_augmentation_complexity_metadata() { + use crate::registry::VariantEntry; + + let entry = inventory::iter::() + .find(|entry| entry.name == "ConsecutiveOnesMatrixAugmentation") + .expect("variant entry should exist"); + + assert_eq!( + entry.complexity, + "factorial(num_cols) * num_rows * num_cols" + ); +} + +#[cfg(feature = "example-db")] +#[test] +fn test_consecutive_ones_matrix_augmentation_has_canonical_example() { + let specs = canonical_model_example_specs(); + + assert_eq!(specs.len(), 1); + assert_eq!(specs[0].id, "consecutive_ones_matrix_augmentation"); +} + +#[test] +fn test_consecutive_ones_matrix_augmentation_all_zero_row() { + let problem = ConsecutiveOnesMatrixAugmentation::new( + vec![ + vec![true, false, true], + vec![false, false, false], + vec![false, true, false], + ], + 0, + ); + + // Permutation [0, 1, 2] — row 0 has gap, row 1 has no 1s (0 cost), row 2 is fine + assert!(!problem.evaluate(&[0, 1, 2])); + // Permutation [0, 2, 1] — row 0: [1,1,0] consecutive, row 1: all zeros (0 cost), row 2: [0,0,1] consecutive + assert!(problem.evaluate(&[0, 2, 1])); +} + +#[test] +#[should_panic(expected = "same length")] +fn test_consecutive_ones_matrix_augmentation_rejects_ragged_matrix() { + ConsecutiveOnesMatrixAugmentation::new(vec![vec![true, false], vec![true]], 1); +} + +#[test] +#[should_panic(expected = "nonnegative")] +fn test_consecutive_ones_matrix_augmentation_rejects_negative_bound() { + ConsecutiveOnesMatrixAugmentation::new(issue_yes_matrix(), -1); +} diff --git a/tests/main.rs b/tests/main.rs index 4c93d3f9..6a7cc0c0 100644 --- a/tests/main.rs +++ b/tests/main.rs @@ -1,3 +1,5 @@ +#[path = "suites/consecutive_ones_matrix_augmentation.rs"] +mod consecutive_ones_matrix_augmentation; #[path = "suites/examples.rs"] mod examples; #[path = "suites/integration.rs"] diff --git a/tests/suites/consecutive_ones_matrix_augmentation.rs b/tests/suites/consecutive_ones_matrix_augmentation.rs new file mode 100644 index 00000000..f14c861e --- /dev/null +++ b/tests/suites/consecutive_ones_matrix_augmentation.rs @@ -0,0 +1,17 @@ +use problemreductions::models::algebraic::ConsecutiveOnesMatrixAugmentation; +use problemreductions::Problem; + +#[test] +fn test_consecutive_ones_matrix_augmentation_yes_instance() { + let problem = ConsecutiveOnesMatrixAugmentation::new( + vec![ + vec![true, false, false, true, true], + vec![true, true, false, false, false], + vec![false, true, true, false, true], + vec![false, false, true, true, false], + ], + 2, + ); + + assert!(problem.evaluate(&[0, 1, 4, 2, 3])); +}