diff --git a/Cargo.toml b/Cargo.toml index 561947083..3f592bd45 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,7 +31,7 @@ serde_json = "1.0" thiserror = "2.0" num-bigint = "0.4" num-traits = "0.2" -good_lp = { version = "1.8", default-features = false, optional = true } +good_lp = { version = "=1.14.2", default-features = false, optional = true } inventory = "0.3" ordered-float = "5.0" rand = "0.10" diff --git a/problemreductions-cli/src/test_support.rs b/problemreductions-cli/src/test_support.rs index 81d3d33c8..3b4e55d8f 100644 --- a/problemreductions-cli/src/test_support.rs +++ b/problemreductions-cli/src/test_support.rs @@ -180,6 +180,7 @@ problemreductions::inventory::submit! { }), capabilities: EdgeCapabilities::aggregate_only(), overhead_eval_fn: |_| ProblemSize::new(vec![]), + source_size_fn: |_| ProblemSize::new(vec![]), } } @@ -202,6 +203,7 @@ problemreductions::inventory::submit! { }), capabilities: EdgeCapabilities::aggregate_only(), overhead_eval_fn: |_| ProblemSize::new(vec![]), + source_size_fn: |_| ProblemSize::new(vec![]), } } diff --git a/problemreductions-macros/src/lib.rs b/problemreductions-macros/src/lib.rs index cd9d66b8f..e82983283 100644 --- a/problemreductions-macros/src/lib.rs +++ b/problemreductions-macros/src/lib.rs @@ -252,6 +252,47 @@ fn generate_overhead_eval_fn( }) } +/// Generate a function that extracts the source problem's size fields from `&dyn Any`. +/// +/// Collects all variable names referenced in the overhead expressions, generates +/// getter calls for each, and returns a `ProblemSize`. +fn generate_source_size_fn( + fields: &[(String, String)], + source_type: &Type, +) -> syn::Result { + let src_ident = syn::Ident::new("__src", proc_macro2::Span::call_site()); + + // Collect all unique variable names from overhead expressions + let mut var_names = std::collections::BTreeSet::new(); + for (_, expr_str) in fields { + let parsed = parser::parse_expr(expr_str).map_err(|e| { + syn::Error::new( + proc_macro2::Span::call_site(), + format!("error parsing overhead expression \"{expr_str}\": {e}"), + ) + })?; + for v in parsed.variables() { + var_names.insert(v.to_string()); + } + } + + let getter_tokens: Vec<_> = var_names + .iter() + .map(|var| { + let getter = syn::Ident::new(var, proc_macro2::Span::call_site()); + let name_lit = var.as_str(); + quote! { (#name_lit, #src_ident.#getter() as usize) } + }) + .collect(); + + Ok(quote! { + |__any_src: &dyn std::any::Any| -> crate::types::ProblemSize { + let #src_ident = __any_src.downcast_ref::<#source_type>().unwrap(); + crate::types::ProblemSize::new(vec![#(#getter_tokens),*]) + } + }) +} + /// Generate the reduction entry code fn generate_reduction_entry( attrs: &ReductionAttrs, @@ -288,8 +329,8 @@ fn generate_reduction_entry( let source_variant_body = make_variant_fn_body(source_type, &type_generics)?; let target_variant_body = make_variant_fn_body(&target_type, &type_generics)?; - // Generate overhead and eval fn - let (overhead, overhead_eval_fn) = match &attrs.overhead { + // Generate overhead, eval fn, and source size fn + let (overhead, overhead_eval_fn, source_size_fn) = match &attrs.overhead { Some(OverheadSpec::Legacy(tokens)) => { let eval_fn = quote! { |_: &dyn std::any::Any| -> crate::types::ProblemSize { @@ -297,12 +338,18 @@ fn generate_reduction_entry( migrate to parsed syntax: field = \"expression\"") } }; - (tokens.clone(), eval_fn) + let size_fn = quote! { + |_: &dyn std::any::Any| -> crate::types::ProblemSize { + crate::types::ProblemSize::new(vec![]) + } + }; + (tokens.clone(), eval_fn, size_fn) } Some(OverheadSpec::Parsed(fields)) => { let overhead_tokens = generate_parsed_overhead(fields)?; let eval_fn = generate_overhead_eval_fn(fields, source_type)?; - (overhead_tokens, eval_fn) + let size_fn = generate_source_size_fn(fields, source_type)?; + (overhead_tokens, eval_fn, size_fn) } None => { return Err(syn::Error::new( @@ -337,6 +384,7 @@ fn generate_reduction_entry( reduce_aggregate_fn: None, capabilities: #capabilities, overhead_eval_fn: #overhead_eval_fn, + source_size_fn: #source_size_fn, } } diff --git a/src/rules/cost.rs b/src/rules/cost.rs index 0cbf1bf14..7678d4d87 100644 --- a/src/rules/cost.rs +++ b/src/rules/cost.rs @@ -27,6 +27,39 @@ impl PathCostFn for MinimizeSteps { } } +/// Minimize total output size (sum of all output field values). +/// +/// Prefers reduction paths that produce smaller intermediate and final problems. +/// Breaks ties that `MinimizeSteps` cannot resolve (e.g., two 2-step paths +/// where one produces 144 ILP variables and the other 1,332). +pub struct MinimizeOutputSize; + +impl PathCostFn for MinimizeOutputSize { + fn edge_cost(&self, overhead: &ReductionOverhead, size: &ProblemSize) -> f64 { + let output = overhead.evaluate_output_size(size); + output.total() as f64 + } +} + +/// Minimize steps first, then use output size as tiebreaker. +/// +/// Each edge has a primary cost of `STEP_WEIGHT` (ensuring fewer-step paths +/// always win) plus a small overhead-based cost that breaks ties between +/// equal-step paths. +pub struct MinimizeStepsThenOverhead; + +impl PathCostFn for MinimizeStepsThenOverhead { + fn edge_cost(&self, overhead: &ReductionOverhead, size: &ProblemSize) -> f64 { + // Use a large step weight to ensure step count dominates. + // The overhead tiebreaker uses log1p to compress the range, + // keeping it far smaller than STEP_WEIGHT for any realistic problem size. + const STEP_WEIGHT: f64 = 1e9; + let output = overhead.evaluate_output_size(size); + let overhead_tiebreaker = (1.0 + output.total() as f64).ln(); + STEP_WEIGHT + overhead_tiebreaker + } +} + /// Custom cost function from closure. pub struct CustomCost(pub F); diff --git a/src/rules/graph.rs b/src/rules/graph.rs index 303cbb698..3143924d5 100644 --- a/src/rules/graph.rs +++ b/src/rules/graph.rs @@ -904,6 +904,54 @@ impl ReductionGraph { result } + /// Evaluate the cumulative output size along a reduction path. + /// + /// Walks the path from start to end, applying each edge's overhead + /// expressions to transform the problem size at each step. + /// Returns `None` if any edge in the path cannot be found. + pub fn evaluate_path_overhead( + &self, + path: &ReductionPath, + input_size: &ProblemSize, + ) -> Option { + let mut current_size = input_size.clone(); + for pair in path.steps.windows(2) { + let src = self.lookup_node(&pair[0].name, &pair[0].variant)?; + let dst = self.lookup_node(&pair[1].name, &pair[1].variant)?; + let edge_idx = self.graph.find_edge(src, dst)?; + let edge = &self.graph[edge_idx]; + current_size = edge.overhead.evaluate_output_size(¤t_size); + } + Some(current_size) + } + + /// Compute the source problem's size from a type-erased instance. + /// + /// Iterates over all registered reduction entries with a matching source name + /// and merges their `source_size_fn` results to capture all size fields. + /// Different entries may reference different getter methods (e.g., one uses + /// `num_vertices` while another also uses `num_edges`). + pub fn compute_source_size(name: &str, instance: &dyn Any) -> ProblemSize { + let mut merged: Vec<(String, usize)> = Vec::new(); + let mut seen: HashSet = HashSet::new(); + + for entry in inventory::iter:: { + if entry.source_name == name { + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + (entry.source_size_fn)(instance) + })); + if let Ok(size) = result { + for (k, v) in size.components { + if seen.insert(k.clone()) { + merged.push((k, v)); + } + } + } + } + } + ProblemSize { components: merged } + } + /// Get all incoming reductions to a problem (across all its variants). pub fn incoming_reductions(&self, name: &str) -> Vec { let Some(indices) = self.name_to_nodes.get(name) else { diff --git a/src/rules/hamiltoniancircuit_biconnectivityaugmentation.rs b/src/rules/hamiltoniancircuit_biconnectivityaugmentation.rs new file mode 100644 index 000000000..cc3e75aae --- /dev/null +++ b/src/rules/hamiltoniancircuit_biconnectivityaugmentation.rs @@ -0,0 +1,167 @@ +//! Reduction from HamiltonianCircuit to BiconnectivityAugmentation. +//! +//! Based on the Eswaran & Tarjan (1976) approach: +//! +//! Given a Hamiltonian Circuit instance G = (V, E) with n vertices, construct a +//! BiconnectivityAugmentation instance as follows: +//! +//! 1. Start with an edgeless graph on n vertices. +//! 2. For each pair (u, v) with u < v, create a potential edge with: +//! - weight 1 if {u, v} is in E +//! - weight 2 if {u, v} is not in E +//! 3. Set budget B = n. +//! +//! G has a Hamiltonian circuit iff there exists a biconnectivity augmentation of +//! cost exactly n using only weight-1 edges (i.e., original edges). +//! +//! The selected weight-1 edges form a Hamiltonian cycle in G, which is necessarily +//! biconnected. Any augmentation using a weight-2 edge would cost at least n+1, +//! exceeding the budget of n (since at least n edges are needed for biconnectivity). + +use crate::models::graph::{BiconnectivityAugmentation, HamiltonianCircuit}; +use crate::reduction; +use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::topology::{Graph, SimpleGraph}; + +/// Result of reducing HamiltonianCircuit to BiconnectivityAugmentation. +/// +/// Stores the target problem and the mapping from potential edge indices to +/// vertex pairs for solution extraction. +#[derive(Debug, Clone)] +pub struct ReductionHamiltonianCircuitToBiconnectivityAugmentation { + target: BiconnectivityAugmentation, + /// Number of vertices in the original graph. + num_vertices: usize, + /// Potential edges as (u, v) pairs, in the same order as the target's potential_weights. + potential_edges: Vec<(usize, usize)>, +} + +impl ReductionResult for ReductionHamiltonianCircuitToBiconnectivityAugmentation { + type Source = HamiltonianCircuit; + type Target = BiconnectivityAugmentation; + + fn target_problem(&self) -> &Self::Target { + &self.target + } + + fn extract_solution(&self, target_solution: &[usize]) -> Vec { + let n = self.num_vertices; + if n < 3 { + return vec![0; n]; + } + + // Collect selected edges (those with config value 1) + let mut adj: Vec> = vec![vec![]; n]; + for (i, &(u, v)) in self.potential_edges.iter().enumerate() { + if i < target_solution.len() && target_solution[i] == 1 { + adj[u].push(v); + adj[v].push(u); + } + } + + // Check that every vertex has exactly degree 2 (Hamiltonian cycle) + if adj.iter().any(|neighbors| neighbors.len() != 2) { + return vec![0; n]; + } + + // Walk the cycle starting from vertex 0 + let mut circuit = Vec::with_capacity(n); + circuit.push(0); + let mut prev = 0; + let mut current = adj[0][0]; + while current != 0 { + circuit.push(current); + let next = if adj[current][0] == prev { + adj[current][1] + } else { + adj[current][0] + }; + prev = current; + current = next; + + // Safety: if we've visited more than n vertices, something is wrong + if circuit.len() > n { + return vec![0; n]; + } + } + + if circuit.len() == n { + circuit + } else { + vec![0; n] + } + } +} + +#[reduction( + overhead = { + num_vertices = "num_vertices", + num_edges = "0", + num_potential_edges = "num_vertices * (num_vertices - 1) / 2", + } +)] +impl ReduceTo> for HamiltonianCircuit { + type Result = ReductionHamiltonianCircuitToBiconnectivityAugmentation; + + fn reduce_to(&self) -> Self::Result { + let n = self.num_vertices(); + let graph = self.graph(); + + // Edgeless initial graph + let initial_graph = SimpleGraph::empty(n); + + // Create potential edges for all pairs (u, v) with u < v + let mut potential_weights = Vec::new(); + let mut potential_edges = Vec::new(); + for u in 0..n { + for v in (u + 1)..n { + let weight = if graph.has_edge(u, v) { 1 } else { 2 }; + potential_weights.push((u, v, weight)); + potential_edges.push((u, v)); + } + } + + // Budget = n (exactly enough for n weight-1 edges) + let budget = n as i32; + + let target = BiconnectivityAugmentation::new(initial_graph, potential_weights, budget); + + ReductionHamiltonianCircuitToBiconnectivityAugmentation { + target, + num_vertices: n, + potential_edges, + } + } +} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_rule_example_specs() -> Vec { + use crate::export::SolutionPair; + + vec![crate::example_db::specs::RuleExampleSpec { + id: "hamiltoniancircuit_to_biconnectivityaugmentation", + build: || { + // Square graph (4-cycle): 0-1-2-3-0 + let source = HamiltonianCircuit::new(SimpleGraph::cycle(4)); + // Potential edges for 4 vertices (indices 0..5): + // 0: (0,1) w=1, 1: (0,2) w=2, 2: (0,3) w=1, + // 3: (1,2) w=1, 4: (1,3) w=2, 5: (2,3) w=1 + // HC 0-1-2-3-0 selects edges (0,1),(1,2),(2,3),(0,3) => indices 0,3,5,2 + // Config: [1, 0, 1, 1, 0, 1] + crate::example_db::specs::rule_example_with_witness::< + _, + BiconnectivityAugmentation, + >( + source, + SolutionPair { + source_config: vec![0, 1, 2, 3], + target_config: vec![1, 0, 1, 1, 0, 1], + }, + ) + }, + }] +} + +#[cfg(test)] +#[path = "../unit_tests/rules/hamiltoniancircuit_biconnectivityaugmentation.rs"] +mod tests; diff --git a/src/rules/hamiltoniancircuit_quadraticassignment.rs b/src/rules/hamiltoniancircuit_quadraticassignment.rs new file mode 100644 index 000000000..f366b4770 --- /dev/null +++ b/src/rules/hamiltoniancircuit_quadraticassignment.rs @@ -0,0 +1,103 @@ +//! Reduction from HamiltonianCircuit to QuadraticAssignment. +//! +//! Uses the Sahni & Gonzalez (1976) construction. The cost matrix encodes +//! cycle adjacency on positions: c[i][j] = 1 if j = (i+1) mod n, else 0. +//! The distance matrix encodes graph connectivity: d[k][l] = 1 if {k,l} ∈ E, +//! else ω = n+1 (penalty). A Hamiltonian circuit exists iff the QAP optimum +//! equals n. + +use crate::models::algebraic::QuadraticAssignment; +use crate::models::graph::HamiltonianCircuit; +use crate::reduction; +use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::topology::{Graph, SimpleGraph}; + +/// Result of reducing HamiltonianCircuit to QuadraticAssignment. +#[derive(Debug, Clone)] +pub struct ReductionHamiltonianCircuitToQuadraticAssignment { + target: QuadraticAssignment, +} + +impl ReductionResult for ReductionHamiltonianCircuitToQuadraticAssignment { + type Source = HamiltonianCircuit; + type Target = QuadraticAssignment; + + fn target_problem(&self) -> &Self::Target { + &self.target + } + + fn extract_solution(&self, target_solution: &[usize]) -> Vec { + // QAP config is a permutation γ mapping positions to vertices, + // which is directly the Hamiltonian circuit visit order. + target_solution.to_vec() + } +} + +#[reduction( + overhead = { + num_facilities = "num_vertices", + num_locations = "num_vertices", + } +)] +impl ReduceTo for HamiltonianCircuit { + type Result = ReductionHamiltonianCircuitToQuadraticAssignment; + + fn reduce_to(&self) -> Self::Result { + let n = self.num_vertices(); + let omega = (n + 1) as i64; + + // Cost matrix C: cycle adjacency on positions. + // c[i][j] = 1 if j == (i+1) mod n, else 0. + let cost_matrix: Vec> = (0..n) + .map(|i| { + (0..n) + .map(|j| if j == (i + 1) % n { 1 } else { 0 }) + .collect() + }) + .collect(); + + // Distance matrix D: graph connectivity. + // d[k][l] = 1 if {k,l} ∈ E, else ω (penalty) for k ≠ l; d[k][k] = 0. + let distance_matrix: Vec> = (0..n) + .map(|k| { + (0..n) + .map(|l| { + if k == l { + 0 + } else if self.graph().has_edge(k, l) { + 1 + } else { + omega + } + }) + .collect() + }) + .collect(); + + let target = QuadraticAssignment::new(cost_matrix, distance_matrix); + ReductionHamiltonianCircuitToQuadraticAssignment { target } + } +} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_rule_example_specs() -> Vec { + use crate::export::SolutionPair; + + vec![crate::example_db::specs::RuleExampleSpec { + id: "hamiltoniancircuit_to_quadraticassignment", + build: || { + let source = HamiltonianCircuit::new(SimpleGraph::cycle(4)); + crate::example_db::specs::rule_example_with_witness::<_, QuadraticAssignment>( + source, + SolutionPair { + source_config: vec![0, 1, 2, 3], + target_config: vec![0, 1, 2, 3], + }, + ) + }, + }] +} + +#[cfg(test)] +#[path = "../unit_tests/rules/hamiltoniancircuit_quadraticassignment.rs"] +mod tests; diff --git a/src/rules/hamiltoniancircuit_ruralpostman.rs b/src/rules/hamiltoniancircuit_ruralpostman.rs new file mode 100644 index 000000000..277b1aa18 --- /dev/null +++ b/src/rules/hamiltoniancircuit_ruralpostman.rs @@ -0,0 +1,177 @@ +//! Reduction from HamiltonianCircuit to RuralPostman. +//! +//! Vertex-splitting construction inspired by Lenstra & Rinnooy Kan (1976). +//! +//! # Construction +//! +//! Given a graph G = (V, E) with n vertices and m edges: +//! - Split each vertex v_i into v_i^a (vertex 2i) and v_i^b (vertex 2i+1). +//! - Add a required edge {v_i^a, v_i^b} with weight 1 for each vertex (n required edges). +//! - For each edge {v_i, v_j} in E, add two non-required connectivity edges: +//! {v_i^b, v_j^a} and {v_j^b, v_i^a}, each with weight 1. +//! +//! The target graph has 2n vertices, n + 2m edges, and n required edges. +//! +//! # Correctness +//! +//! G has a Hamiltonian circuit iff the optimal RPP cost equals 2n: +//! - If G has HC (v_{p_0}, ..., v_{p_{n-1}}): the RPP tour traverses +//! v_{p_0}^a -> v_{p_0}^b -> v_{p_1}^a -> v_{p_1}^b -> ... -> v_{p_0}^a, +//! using n required edges (cost n) and n connectivity edges (cost n), total 2n. +//! - If G has no HC: every valid RPP tour covering all required edges needs +//! strictly more than n connectivity edges (the bipartite graph between +//! b-vertices and a-vertices does not admit a perfect matching corresponding +//! to a Hamiltonian circuit), so cost > 2n. + +use crate::models::graph::{HamiltonianCircuit, RuralPostman}; +use crate::reduction; +use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::topology::{Graph, SimpleGraph}; + +/// Result of reducing HamiltonianCircuit to RuralPostman. +#[derive(Debug, Clone)] +pub struct ReductionHamiltonianCircuitToRuralPostman { + target: RuralPostman, + /// Number of vertices in the original graph. + n: usize, + /// Edges of the original graph (for solution extraction). + source_edges: Vec<(usize, usize)>, +} + +impl ReductionResult for ReductionHamiltonianCircuitToRuralPostman { + type Source = HamiltonianCircuit; + type Target = RuralPostman; + + fn target_problem(&self) -> &Self::Target { + &self.target + } + + fn extract_solution(&self, target_solution: &[usize]) -> Vec { + // The target solution is edge multiplicities. + // Required edges are indices 0..n (the {v_i^a, v_i^b} edges). + // Connectivity edges start at index n. + // For each source edge (v_i, v_j) at source index k: + // target edge n + 2*k is {v_i^b, v_j^a} + // target edge n + 2*k + 1 is {v_j^b, v_i^a} + // + // A connectivity edge {v_i^b, v_j^a} used with multiplicity 1 means + // the tour goes from vertex i to vertex j (j follows i in the HC). + + let n = self.n; + + // Build successor map from connectivity edges used exactly once + let mut successor = vec![usize::MAX; n]; + for (k, &(vi, vj)) in self.source_edges.iter().enumerate() { + let fwd_idx = n + 2 * k; // {v_i^b, v_j^a} + let bwd_idx = n + 2 * k + 1; // {v_j^b, v_i^a} + + let fwd_mult = target_solution.get(fwd_idx).copied().unwrap_or(0); + let bwd_mult = target_solution.get(bwd_idx).copied().unwrap_or(0); + + // In an optimal HC solution, each connectivity edge is used 0 or 1 times. + // Each vertex should have exactly one outgoing connectivity edge. + if fwd_mult > 0 && successor[vi] == usize::MAX { + successor[vi] = vj; + } + if bwd_mult > 0 && successor[vj] == usize::MAX { + successor[vj] = vi; + } + } + + // Walk the successor chain starting from vertex 0 + let mut cycle = Vec::with_capacity(n); + let mut current = 0; + for _ in 0..n { + cycle.push(current); + let next = successor[current]; + if next == usize::MAX { + // No valid successor found; return fallback + return vec![0; n]; + } + current = next; + } + + cycle + } +} + +#[reduction( + overhead = { + num_vertices = "2 * num_vertices", + num_edges = "num_vertices + 2 * num_edges", + num_required_edges = "num_vertices", + } +)] +impl ReduceTo> for HamiltonianCircuit { + type Result = ReductionHamiltonianCircuitToRuralPostman; + + fn reduce_to(&self) -> Self::Result { + let n = self.num_vertices(); + let source_edges: Vec<(usize, usize)> = self.graph().edges(); + let m = source_edges.len(); + + // Build target graph with 2n vertices + let num_target_edges = n + 2 * m; + let mut target_edges = Vec::with_capacity(num_target_edges); + let mut edge_weights = Vec::with_capacity(num_target_edges); + let mut required_edges = Vec::with_capacity(n); + + // Required edges: {v_i^a, v_i^b} = {2i, 2i+1} with weight 1 + for i in 0..n { + target_edges.push((2 * i, 2 * i + 1)); + edge_weights.push(1); + required_edges.push(i); // edge index i is required + } + + // Connectivity edges for each source edge {v_i, v_j}: + // {v_i^b, v_j^a} = {2i+1, 2j} with weight 1 + // {v_j^b, v_i^a} = {2j+1, 2i} with weight 1 + for &(vi, vj) in &source_edges { + target_edges.push((2 * vi + 1, 2 * vj)); + edge_weights.push(1); + target_edges.push((2 * vj + 1, 2 * vi)); + edge_weights.push(1); + } + + let target_graph = SimpleGraph::new(2 * n, target_edges); + let target = RuralPostman::new(target_graph, edge_weights, required_edges); + + ReductionHamiltonianCircuitToRuralPostman { + target, + n, + source_edges, + } + } +} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_rule_example_specs() -> Vec { + use crate::export::SolutionPair; + + vec![crate::example_db::specs::RuleExampleSpec { + id: "hamiltoniancircuit_to_ruralpostman", + build: || { + // Triangle graph: 3 vertices, 3 edges, HC = [0, 1, 2] + let source = HamiltonianCircuit::new(SimpleGraph::new(3, vec![(0, 1), (1, 2), (0, 2)])); + + // Target graph has 6 vertices, 3 + 6 = 9 edges, 3 required edges. + // HC [0, 1, 2] uses connectivity edges: + // 0->1: fwd edge of source edge 0=(0,1), idx=3 + // 1->2: fwd edge of source edge 1=(1,2), idx=5 + // 2->0: bwd edge of source edge 2=(0,2), idx=8 + // Required edges all have multiplicity 1. + // target_config = [1, 1, 1, 1, 0, 1, 0, 0, 1] + crate::example_db::specs::rule_example_with_witness::<_, RuralPostman>( + source, + SolutionPair { + source_config: vec![0, 1, 2], + target_config: vec![1, 1, 1, 1, 0, 1, 0, 0, 1], + }, + ) + }, + }] +} + +#[cfg(test)] +#[path = "../unit_tests/rules/hamiltoniancircuit_ruralpostman.rs"] +mod tests; diff --git a/src/rules/hamiltoniancircuit_stackercrane.rs b/src/rules/hamiltoniancircuit_stackercrane.rs new file mode 100644 index 000000000..88bdaf207 --- /dev/null +++ b/src/rules/hamiltoniancircuit_stackercrane.rs @@ -0,0 +1,100 @@ +//! Reduction from HamiltonianCircuit to StackerCrane. +//! +//! Based on the vertex-splitting construction of Frederickson, Hecht & Kim (1978). +//! Each vertex v_i is split into v_i^in (= 2i) and v_i^out (= 2i+1). A mandatory +//! directed arc (v_i^in → v_i^out) of length 1 is added for each vertex. For each +//! undirected edge {v_i, v_j} in the source graph, two undirected connector edges +//! {v_i^out, v_j^in} and {v_j^out, v_i^in} of length 0 are added. +//! +//! The source graph has a Hamiltonian circuit iff the optimal Stacker Crane tour +//! cost equals n (the number of vertices), since each arc contributes cost 1 and +//! each zero-cost connector edge links consecutive arcs for free. + +use crate::models::graph::HamiltonianCircuit; +use crate::models::misc::StackerCrane; +use crate::reduction; +use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::topology::{Graph, SimpleGraph}; + +/// Result of reducing HamiltonianCircuit to StackerCrane. +#[derive(Debug, Clone)] +pub struct ReductionHamiltonianCircuitToStackerCrane { + target: StackerCrane, +} + +impl ReductionResult for ReductionHamiltonianCircuitToStackerCrane { + type Source = HamiltonianCircuit; + type Target = StackerCrane; + + fn target_problem(&self) -> &Self::Target { + &self.target + } + + fn extract_solution(&self, target_solution: &[usize]) -> Vec { + // The target config is a permutation of arc indices. + // Arc i corresponds to original vertex i (arc from 2i to 2i+1). + // The permutation order directly gives the Hamiltonian circuit vertex order. + target_solution.to_vec() + } +} + +#[reduction( + overhead = { + num_vertices = "2 * num_vertices", + num_arcs = "num_vertices", + num_edges = "2 * num_edges", + } +)] +impl ReduceTo for HamiltonianCircuit { + type Result = ReductionHamiltonianCircuitToStackerCrane; + + fn reduce_to(&self) -> Self::Result { + let n = self.num_vertices(); + + // Each vertex i becomes two vertices: 2i (in) and 2i+1 (out). + let target_num_vertices = 2 * n; + + // One mandatory arc per original vertex: (2i, 2i+1) with length 1. + let arcs: Vec<(usize, usize)> = (0..n).map(|i| (2 * i, 2 * i + 1)).collect(); + let arc_lengths: Vec = vec![1; n]; + + // For each original edge {u, v}, add two undirected connector edges: + // {u^out, v^in} = {2u+1, 2v} with length 0 + // {v^out, u^in} = {2v+1, 2u} with length 0 + let mut edges = Vec::new(); + let mut edge_lengths = Vec::new(); + for (u, v) in self.graph().edges() { + edges.push((2 * u + 1, 2 * v)); + edge_lengths.push(0); + edges.push((2 * v + 1, 2 * u)); + edge_lengths.push(0); + } + + let target = StackerCrane::new(target_num_vertices, arcs, edges, arc_lengths, edge_lengths); + + ReductionHamiltonianCircuitToStackerCrane { target } + } +} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_rule_example_specs() -> Vec { + use crate::export::SolutionPair; + + vec![crate::example_db::specs::RuleExampleSpec { + id: "hamiltoniancircuit_to_stackercrane", + build: || { + let source = HamiltonianCircuit::new(SimpleGraph::cycle(4)); + crate::example_db::specs::rule_example_with_witness::<_, StackerCrane>( + source, + SolutionPair { + source_config: vec![0, 1, 2, 3], + target_config: vec![0, 1, 2, 3], + }, + ) + }, + }] +} + +#[cfg(test)] +#[path = "../unit_tests/rules/hamiltoniancircuit_stackercrane.rs"] +mod tests; diff --git a/src/rules/hamiltoniancircuit_strongconnectivityaugmentation.rs b/src/rules/hamiltoniancircuit_strongconnectivityaugmentation.rs new file mode 100644 index 000000000..e90842ca6 --- /dev/null +++ b/src/rules/hamiltoniancircuit_strongconnectivityaugmentation.rs @@ -0,0 +1,144 @@ +//! Reduction from HamiltonianCircuit to StrongConnectivityAugmentation. +//! +//! Based on the Eswaran & Tarjan (1976) construction: start with an arc-less +//! digraph on n vertices. For each ordered pair (u, v), create a candidate arc +//! with weight 1 if {u, v} is an edge in the source graph, and weight 2 +//! otherwise. Set the budget B = n. A Hamiltonian circuit exists in the source +//! graph if and only if a cost-n strong connectivity augmentation exists using +//! only weight-1 arcs. + +use crate::models::graph::{HamiltonianCircuit, StrongConnectivityAugmentation}; +use crate::reduction; +use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::topology::{DirectedGraph, Graph, SimpleGraph}; + +/// Result of reducing HamiltonianCircuit to StrongConnectivityAugmentation. +#[derive(Debug, Clone)] +pub struct ReductionHamiltonianCircuitToStrongConnectivityAugmentation { + target: StrongConnectivityAugmentation, + n: usize, +} + +impl ReductionResult for ReductionHamiltonianCircuitToStrongConnectivityAugmentation { + type Source = HamiltonianCircuit; + type Target = StrongConnectivityAugmentation; + + fn target_problem(&self) -> &Self::Target { + &self.target + } + + fn extract_solution(&self, target_solution: &[usize]) -> Vec { + let n = self.n; + if n == 0 { + return vec![]; + } + + // Build directed adjacency from selected arcs. + let candidate_arcs = self.target.candidate_arcs(); + let mut successors = vec![Vec::new(); n]; + for (idx, &selected) in target_solution.iter().enumerate() { + if selected == 1 { + let (u, v, _) = candidate_arcs[idx]; + successors[u].push(v); + } + } + + // Walk the directed cycle starting from vertex 0. + let mut order = Vec::with_capacity(n); + let mut current = 0; + let mut visited = vec![false; n]; + for _ in 0..n { + if visited[current] { + // Not a valid Hamiltonian cycle; return fallback. + return vec![0; n]; + } + visited[current] = true; + order.push(current); + if successors[current].len() != 1 { + return vec![0; n]; + } + current = successors[current][0]; + } + + order + } +} + +#[reduction( + overhead = { + num_vertices = "num_vertices", + num_arcs = "0", + num_potential_arcs = "num_vertices * (num_vertices - 1)", + } +)] +impl ReduceTo> for HamiltonianCircuit { + type Result = ReductionHamiltonianCircuitToStrongConnectivityAugmentation; + + fn reduce_to(&self) -> Self::Result { + let n = self.num_vertices(); + let graph = DirectedGraph::empty(n); + + // Generate all ordered pairs (u, v) with u != v as candidate arcs. + let mut candidate_arcs = Vec::with_capacity(n * (n - 1)); + for u in 0..n { + for v in 0..n { + if u != v { + let weight = if self.graph().has_edge(u, v) { 1 } else { 2 }; + candidate_arcs.push((u, v, weight)); + } + } + } + + let bound = n as i32; + let target = StrongConnectivityAugmentation::new(graph, candidate_arcs, bound); + + ReductionHamiltonianCircuitToStrongConnectivityAugmentation { target, n } + } +} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_rule_example_specs() -> Vec { + use crate::export::SolutionPair; + + vec![crate::example_db::specs::RuleExampleSpec { + id: "hamiltoniancircuit_to_strongconnectivityaugmentation", + build: || { + // 4-cycle: 0-1-2-3-0 + let source = HamiltonianCircuit::new(SimpleGraph::cycle(4)); + let reduction = ReduceTo::>::reduce_to(&source); + let target = reduction.target_problem(); + + // The HC permutation [0, 1, 2, 3] corresponds to the directed cycle + // 0->1->2->3->0. We need to find the indices of these arcs in the + // candidate list. Candidate arcs are ordered: for each u in 0..n, + // for each v in 0..n where u!=v, so arc (u,v) is at index + // u*(n-1) + (if v > u then v-1 else v). + let n = 4; + let mut target_config = vec![0usize; n * (n - 1)]; + let cycle_arcs = [(0, 1), (1, 2), (2, 3), (3, 0)]; + for (u, v) in cycle_arcs { + let idx = u * (n - 1) + if v > u { v - 1 } else { v }; + target_config[idx] = 1; + } + + // Verify the target config is valid + assert!( + target.is_valid_solution(&target_config), + "canonical target config must be a valid SCA solution" + ); + + crate::example_db::specs::assemble_rule_example( + &source, + target, + vec![SolutionPair { + source_config: vec![0, 1, 2, 3], + target_config, + }], + ) + }, + }] +} + +#[cfg(test)] +#[path = "../unit_tests/rules/hamiltoniancircuit_strongconnectivityaugmentation.rs"] +mod tests; diff --git a/src/rules/hamiltonianpath_consecutiveonessubmatrix.rs b/src/rules/hamiltonianpath_consecutiveonessubmatrix.rs new file mode 100644 index 000000000..4880aa86a --- /dev/null +++ b/src/rules/hamiltonianpath_consecutiveonessubmatrix.rs @@ -0,0 +1,181 @@ +//! Reduction from HamiltonianPath to ConsecutiveOnesSubmatrix. +//! +//! Given a Hamiltonian Path instance G = (V, E) with n vertices and m edges, +//! we construct a ConsecutiveOnesSubmatrix instance as follows (Booth 1975, +//! Garey & Johnson SR14): +//! +//! 1. Build the vertex-edge incidence matrix A of size n × m: +//! a_{i,j} = 1 iff vertex i is an endpoint of edge j. +//! 2. Set bound K = n − 1 (number of edges in a Hamiltonian path). +//! +//! G has a Hamiltonian path iff K columns of A can be permuted so that each +//! row has all its 1's consecutive (the consecutive ones property). +//! +//! Overhead: num_rows = num_vertices, num_cols = num_edges, bound = num_vertices − 1. + +use crate::models::algebraic::ConsecutiveOnesSubmatrix; +use crate::models::graph::HamiltonianPath; +use crate::reduction; +use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::topology::{Graph, SimpleGraph}; + +/// Result of reducing HamiltonianPath to ConsecutiveOnesSubmatrix. +/// +/// Stores the target problem, the original graph edge list (for solution +/// extraction), and the number of original vertices. +#[derive(Debug, Clone)] +pub struct ReductionHamiltonianPathToConsecutiveOnesSubmatrix { + target: ConsecutiveOnesSubmatrix, + /// Edges of the original graph, indexed the same as columns in the matrix. + edges: Vec<(usize, usize)>, + /// Number of vertices in the original graph. + num_vertices: usize, +} + +impl ReductionResult for ReductionHamiltonianPathToConsecutiveOnesSubmatrix { + type Source = HamiltonianPath; + type Target = ConsecutiveOnesSubmatrix; + + fn target_problem(&self) -> &Self::Target { + &self.target + } + + fn extract_solution(&self, target_solution: &[usize]) -> Vec { + let n = self.num_vertices; + if n == 0 { + return vec![]; + } + if n == 1 { + return vec![0]; + } + + // target_solution is a binary vector over columns (edges). + // Selected columns correspond to edges forming the Hamiltonian path. + // Guard: in the Tucker fallback branch, edges may be shorter than target columns. + let selected_edges: Vec<(usize, usize)> = target_solution + .iter() + .enumerate() + .filter(|(_, &v)| v == 1) + .filter_map(|(j, _)| self.edges.get(j).copied()) + .collect(); + + if selected_edges.len() != n - 1 { + return vec![0; n]; + } + + // Build adjacency list from selected edges. + let mut adj: Vec> = vec![vec![]; n]; + for &(u, v) in &selected_edges { + adj[u].push(v); + adj[v].push(u); + } + + // Find the path endpoints (degree-1 vertices in the selected subgraph). + let endpoints: Vec = (0..n).filter(|&v| adj[v].len() == 1).collect(); + if endpoints.len() != 2 { + // Not a valid path — fallback. + return vec![0; n]; + } + + // Walk the path from one endpoint. + let mut path = Vec::with_capacity(n); + let mut current = endpoints[0]; + let mut prev = usize::MAX; + for _ in 0..n { + path.push(current); + let next = adj[current].iter().copied().find(|&nb| nb != prev); + prev = current; + match next { + Some(nx) => current = nx, + None => break, + } + } + + if path.len() != n { + return vec![0; n]; + } + + path + } +} + +#[reduction( + overhead = { + num_rows = "num_vertices", + num_cols = "num_edges", + } +)] +impl ReduceTo for HamiltonianPath { + type Result = ReductionHamiltonianPathToConsecutiveOnesSubmatrix; + + fn reduce_to(&self) -> Self::Result { + let n = self.num_vertices(); + let edges = self.graph().edges(); + let m = edges.len(); + + // K = n - 1 (but at least 0 for degenerate cases). + let bound = if n > 0 { (n - 1) as i64 } else { 0 }; + + // If there are fewer edges than required (m < n-1), a Hamiltonian path + // is impossible. Construct a trivially unsatisfiable C1P instance: + // a 3×3 Tucker-style matrix with bound = 3 that has no valid column + // permutation satisfying C1P. + if n > 1 && m < n - 1 { + let tucker = vec![ + vec![true, true, false], + vec![true, false, true], + vec![false, true, true], + ]; + let target = ConsecutiveOnesSubmatrix::new(tucker, 3); + return ReductionHamiltonianPathToConsecutiveOnesSubmatrix { + target, + edges, + num_vertices: n, + }; + } + + // Build n × m vertex-edge incidence matrix. + let mut matrix = vec![vec![false; m]; n]; + for (j, &(u, v)) in edges.iter().enumerate() { + matrix[u][j] = true; + matrix[v][j] = true; + } + + let target = ConsecutiveOnesSubmatrix::new(matrix, bound); + + ReductionHamiltonianPathToConsecutiveOnesSubmatrix { + target, + edges, + num_vertices: n, + } + } +} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_rule_example_specs() -> Vec { + use crate::export::SolutionPair; + + vec![crate::example_db::specs::RuleExampleSpec { + id: "hamiltonianpath_to_consecutiveonessubmatrix", + build: || { + // Path graph: 0-1-2-3 (has a Hamiltonian path: 0,1,2,3) + let source = HamiltonianPath::new(SimpleGraph::new(4, vec![(0, 1), (1, 2), (2, 3)])); + + // Edges: [(0,1), (1,2), (2,3)] — all 3 edges are in the path. + // K = 3 = n-1, so target_config selects all columns. + let target_config = vec![1, 1, 1]; + + crate::example_db::specs::rule_example_with_witness::<_, ConsecutiveOnesSubmatrix>( + source, + SolutionPair { + source_config: vec![0, 1, 2, 3], + target_config, + }, + ) + }, + }] +} + +#[cfg(test)] +#[path = "../unit_tests/rules/hamiltonianpath_consecutiveonessubmatrix.rs"] +mod tests; diff --git a/src/rules/ksatisfiability_kclique.rs b/src/rules/ksatisfiability_kclique.rs new file mode 100644 index 000000000..0c5d1b16b --- /dev/null +++ b/src/rules/ksatisfiability_kclique.rs @@ -0,0 +1,150 @@ +//! Reduction from KSatisfiability (3-SAT) to KClique. +//! +//! Classical Karp (1972) reduction. Given a 3-SAT formula with `m` clauses over +//! `n` variables, construct a graph with `3m` vertices — one per literal position +//! in each clause. Connect two vertices `(j1, p1)` and `(j2, p2)` if and only if +//! they belong to different clauses (`j1 ≠ j2`) and their literals are not +//! contradictory (not `x_i` and `¬x_i`). Set `k = m`. +//! +//! A satisfying assignment selects one true literal per clause, forming a clique +//! of size `m`. Conversely, any `m`-clique picks one non-contradictory literal +//! per clause, which can be extended to a satisfying assignment. +//! +//! Reference: Karp, "Reducibility among combinatorial problems", 1972. + +use crate::models::formula::KSatisfiability; +use crate::models::graph::KClique; +use crate::reduction; +use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::topology::SimpleGraph; +use crate::variant::K3; + +/// Result of reducing KSatisfiability to KClique. +#[derive(Debug, Clone)] +pub struct Reduction3SATToKClique { + target: KClique, + /// Clauses from the source problem, needed for solution extraction. + source_clauses: Vec>, + source_num_vars: usize, +} + +impl ReductionResult for Reduction3SATToKClique { + type Source = KSatisfiability; + type Target = KClique; + + fn target_problem(&self) -> &Self::Target { + &self.target + } + + fn extract_solution(&self, target_solution: &[usize]) -> Vec { + let n = self.source_num_vars; + // Start with all variables unset (false = 0). + let mut assignment = vec![0usize; n]; + // Track which variables have been explicitly set by a clique vertex. + let mut set = vec![false; n]; + + for (v, &val) in target_solution.iter().enumerate() { + if val != 1 { + continue; + } + // Vertex v corresponds to clause j, position p. + let j = v / 3; + let p = v % 3; + let lit = self.source_clauses[j][p]; + let var_idx = (lit.unsigned_abs() as usize) - 1; // 0-indexed + if !set[var_idx] { + assignment[var_idx] = if lit > 0 { 1 } else { 0 }; + set[var_idx] = true; + } + } + assignment + } +} + +/// Check whether two literals are contradictory (one is the negation of the other). +fn literals_contradict(lit1: i32, lit2: i32) -> bool { + lit1 == -lit2 +} + +#[reduction( + overhead = { + num_vertices = "3 * num_clauses", + num_edges = "9 * num_clauses * (num_clauses - 1) / 2", + k = "num_clauses", + } +)] +impl ReduceTo> for KSatisfiability { + type Result = Reduction3SATToKClique; + + fn reduce_to(&self) -> Self::Result { + let m = self.num_clauses(); + let num_verts = 3 * m; + + // Collect literals for each clause for easy access. + let clause_lits: Vec> = + self.clauses().iter().map(|c| c.literals.clone()).collect(); + + // Build edges: connect (j1,p1) and (j2,p2) if j1 != j2 and literals + // are not contradictory. + let mut edges = Vec::new(); + for j1 in 0..m { + for j2 in (j1 + 1)..m { + for p1 in 0..3 { + for p2 in 0..3 { + let lit1 = clause_lits[j1][p1]; + let lit2 = clause_lits[j2][p2]; + if !literals_contradict(lit1, lit2) { + let v1 = 3 * j1 + p1; + let v2 = 3 * j2 + p2; + edges.push((v1, v2)); + } + } + } + } + } + + let graph = SimpleGraph::new(num_verts, edges); + let target = KClique::new(graph, m); + + Reduction3SATToKClique { + target, + source_clauses: clause_lits, + source_num_vars: self.num_vars(), + } + } +} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_rule_example_specs() -> Vec { + use crate::export::SolutionPair; + use crate::models::formula::CNFClause; + + vec![crate::example_db::specs::RuleExampleSpec { + id: "ksatisfiability_to_kclique", + build: || { + // (x1 ∨ x2 ∨ x3) ∧ (¬x1 ∨ ¬x2 ∨ x3), n=3, m=2 + let source = KSatisfiability::::new( + 3, + vec![ + CNFClause::new(vec![1, 2, 3]), + CNFClause::new(vec![-1, -2, 3]), + ], + ); + // x1=F, x2=F, x3=T satisfies both clauses. + // Clause 0: pick literal x3 (position 2) → vertex 2 + // Clause 1: pick literal ¬x1 (position 0) → vertex 3 + // Target config: 6 vertices, vertices 2 and 3 selected. + crate::example_db::specs::rule_example_with_witness::<_, KClique>( + source, + SolutionPair { + source_config: vec![0, 0, 1], + target_config: vec![0, 0, 1, 1, 0, 0], + }, + ) + }, + }] +} + +#[cfg(test)] +#[path = "../unit_tests/rules/ksatisfiability_kclique.rs"] +mod tests; diff --git a/src/rules/ksatisfiability_minimumvertexcover.rs b/src/rules/ksatisfiability_minimumvertexcover.rs new file mode 100644 index 000000000..9e881dd4e --- /dev/null +++ b/src/rules/ksatisfiability_minimumvertexcover.rs @@ -0,0 +1,150 @@ +//! Reduction from KSatisfiability (3-SAT) to MinimumVertexCover. +//! +//! Classical Garey & Johnson reduction (Theorem 3.3). For each variable u_i, +//! add two vertices {u_i, not-u_i} connected by a truth-setting edge. For each +//! clause c_j, add 3 vertices forming a satisfaction-testing triangle. For each +//! literal l_k in clause c_j, add a communication edge from the triangle vertex +//! j_k to the literal vertex l_k. +//! +//! The resulting graph has a vertex cover of size n + 2m if and only if the +//! 3-SAT formula is satisfiable (n = num_vars, m = num_clauses). +//! +//! Reference: Garey & Johnson, "Computers and Intractability", 1979, Theorem 3.3 + +use crate::models::formula::KSatisfiability; +use crate::models::graph::MinimumVertexCover; +use crate::reduction; +use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::topology::SimpleGraph; +use crate::variant::K3; + +/// Result of reducing KSatisfiability to MinimumVertexCover. +#[derive(Debug, Clone)] +pub struct Reduction3SATToMVC { + target: MinimumVertexCover, + source_num_vars: usize, +} + +impl ReductionResult for Reduction3SATToMVC { + type Source = KSatisfiability; + type Target = MinimumVertexCover; + + fn target_problem(&self) -> &Self::Target { + &self.target + } + + /// Extract a SAT assignment from a vertex cover solution. + /// + /// Vertex layout: indices 0..2n are literal vertices (even = positive, + /// odd = negated). For variable i, vertex 2*i is u_i and vertex 2*i+1 + /// is not-u_i. Each truth-setting edge forces exactly one of these two + /// into any minimum vertex cover. If u_i is in the cover, set x_i = 1; + /// if not-u_i is in the cover, set x_i = 0. + fn extract_solution(&self, target_solution: &[usize]) -> Vec { + (0..self.source_num_vars) + .map(|i| { + // u_i is at index 2*i, not-u_i is at index 2*i+1 + if target_solution[2 * i] == 1 { + 1 + } else { + 0 + } + }) + .collect() + } +} + +#[reduction( + overhead = { + num_vertices = "2 * num_vars + 3 * num_clauses", + num_edges = "num_vars + 6 * num_clauses", + } +)] +impl ReduceTo> for KSatisfiability { + type Result = Reduction3SATToMVC; + + fn reduce_to(&self) -> Self::Result { + let n = self.num_vars(); + let m = self.num_clauses(); + let total_vertices = 2 * n + 3 * m; + let mut edges: Vec<(usize, usize)> = Vec::with_capacity(n + 6 * m); + + // Step 1: Truth-setting components. + // For each variable i, add edge (2*i, 2*i+1) connecting u_i and not-u_i. + for i in 0..n { + edges.push((2 * i, 2 * i + 1)); + } + + // Step 2: Satisfaction-testing components (triangles) and communication edges. + // For each clause j, triangle vertices are at indices 2*n + 3*j, 2*n + 3*j + 1, 2*n + 3*j + 2. + for (j, clause) in self.clauses().iter().enumerate() { + let base = 2 * n + 3 * j; + + // Triangle edges within clause j + edges.push((base, base + 1)); + edges.push((base + 1, base + 2)); + edges.push((base, base + 2)); + + // Communication edges: connect triangle vertex k to the literal vertex + for (k, &lit) in clause.literals.iter().enumerate() { + let var_idx = lit.unsigned_abs() as usize - 1; // 0-indexed variable + let literal_vertex = if lit > 0 { + 2 * var_idx // positive literal vertex + } else { + 2 * var_idx + 1 // negated literal vertex + }; + edges.push((base + k, literal_vertex)); + } + } + + let graph = SimpleGraph::new(total_vertices, edges); + let weights = vec![1i32; total_vertices]; + let target = MinimumVertexCover::new(graph, weights); + + Reduction3SATToMVC { + target, + source_num_vars: n, + } + } +} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_rule_example_specs() -> Vec { + use crate::export::SolutionPair; + use crate::models::formula::CNFClause; + + vec![crate::example_db::specs::RuleExampleSpec { + id: "ksatisfiability_to_minimumvertexcover", + build: || { + let source = KSatisfiability::::new( + 3, + vec![ + CNFClause::new(vec![1, 2, 3]), + CNFClause::new(vec![-1, -2, 3]), + ], + ); + crate::example_db::specs::rule_example_with_witness::< + _, + MinimumVertexCover, + >( + source, + SolutionPair { + // x1=0, x2=0, x3=1 satisfies both clauses + source_config: vec![0, 0, 1], + // Literal vertices: u1(0), ~u1(1), u2(2), ~u2(3), u3(4), ~u3(5) + // Clause 0 triangle: v6, v7, v8 (literals x1, x2, x3) + // Clause 1 triangle: v9, v10, v11 (literals ~x1, ~x2, x3) + // VC: from truth-setting, pick ~u1(1), ~u2(3), u3(4) + // Clause 0: u1,u2 not in cover -> pick v6,v7; u3 in cover -> v8 free + // Clause 1: ~u1,~u2,u3 all in cover -> pick any 2: v9,v10 + // Total cover size = 3 + 2 + 2 = 7 = n + 2m + target_config: vec![0, 1, 0, 1, 1, 0, 1, 1, 0, 1, 1, 0], + }, + ) + }, + }] +} + +#[cfg(test)] +#[path = "../unit_tests/rules/ksatisfiability_minimumvertexcover.rs"] +mod tests; diff --git a/src/rules/maximumindependentset_integralflowbundles.rs b/src/rules/maximumindependentset_integralflowbundles.rs new file mode 100644 index 000000000..6928d7336 --- /dev/null +++ b/src/rules/maximumindependentset_integralflowbundles.rs @@ -0,0 +1,159 @@ +//! Reduction from MaximumIndependentSet to IntegralFlowBundles (Sahni 1974). +//! +//! For a graph G = (V, E) with n = |V| vertices and m = |E| edges: +//! +//! 1. Create a directed graph with n + 2 vertices: source s, intermediate +//! vertices w_0, ..., w_{n-1}, and sink t. +//! 2. For each vertex v_i, create two arcs: arc_in_i = (s, w_i) and +//! arc_out_i = (w_i, t). Arc indices: arc_in_i = 2i, arc_out_i = 2i + 1. +//! 3. For each edge {v_i, v_j}, create bundle {arc_out_i, arc_out_j} with +//! capacity 1 (conflict constraint: at most one endpoint selected). +//! 4. For each vertex v_i, create bundle {arc_in_i, arc_out_i} with capacity 2. +//! Flow conservation at w_i forces arc_in_i = arc_out_i = f_i, so the +//! bundle sum is 2*f_i <= 2, giving f_i in {0, 1}. This bundle also +//! ensures every arc is covered by at least one bundle. +//! 5. Set requirement R = 1 (any non-empty independent set gives a feasible flow). +//! +//! An independent set of size k corresponds to a feasible flow of value k. +//! The bundle constraints ensure only independent sets produce valid flows. + +use crate::models::graph::{IntegralFlowBundles, MaximumIndependentSet}; +use crate::reduction; +use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::topology::{Graph, SimpleGraph}; + +#[cfg(feature = "example-db")] +use crate::solvers::BruteForce; + +/// Result of reducing MaximumIndependentSet to IntegralFlowBundles. +#[derive(Debug, Clone)] +pub struct ReductionMISToIFB { + target: IntegralFlowBundles, + /// Number of vertices in the source graph. + num_source_vertices: usize, +} + +impl ReductionResult for ReductionMISToIFB { + type Source = MaximumIndependentSet; + type Target = IntegralFlowBundles; + + fn target_problem(&self) -> &Self::Target { + &self.target + } + + /// Extract solution: vertex i is selected iff arc_out_i (index 2i + 1) + /// has nonzero flow. + fn extract_solution(&self, target_solution: &[usize]) -> Vec { + (0..self.num_source_vertices) + .map(|i| { + if target_solution.get(2 * i + 1).copied().unwrap_or(0) > 0 { + 1 + } else { + 0 + } + }) + .collect() + } +} + +#[reduction( + overhead = { + num_vertices = "num_vertices + 2", + num_arcs = "2 * num_vertices", + num_bundles = "num_edges + num_vertices", + } +)] +impl ReduceTo for MaximumIndependentSet { + type Result = ReductionMISToIFB; + + fn reduce_to(&self) -> Self::Result { + let n = self.graph().num_vertices(); + let edges = self.graph().edges(); + + // Set requirement = 1: any independent set of size >= 1 maps to a feasible flow. + // The bundle constraints ensure only independent sets produce valid flows. + let requirement = 1u64; + + // Vertices: s = 0, w_i = i + 1 (for i in 0..n), t = n + 1 + let source_vertex = 0; + let sink_vertex = n + 1; + + // Arcs: arc_in_i = (s, w_i), arc_out_i = (w_i, t) + let mut arcs = Vec::with_capacity(2 * n); + for i in 0..n { + arcs.push((source_vertex, i + 1)); // arc_in_i at index 2*i + arcs.push((i + 1, sink_vertex)); // arc_out_i at index 2*i + 1 + } + + let directed_graph = crate::topology::DirectedGraph::new(n + 2, arcs); + + // Bundles: edge bundles + vertex bundles + let mut bundles = Vec::with_capacity(edges.len() + n); + let mut bundle_capacities = Vec::with_capacity(edges.len() + n); + + // Edge bundles: for each edge {v_i, v_j}, bundle {arc_out_i, arc_out_j} with cap 1 + for &(u, v) in &edges { + bundles.push(vec![2 * u + 1, 2 * v + 1]); + bundle_capacities.push(1); + } + + // Vertex bundles: for each vertex v_i, bundle {arc_in_i, arc_out_i} with cap 2. + // Flow conservation forces arc_in_i = arc_out_i = f_i, so bundle sum = + // 2*f_i <= 2, hence f_i in {0, 1}. This also ensures every arc is in + // at least one bundle (required by IntegralFlowBundles). + for i in 0..n { + bundles.push(vec![2 * i, 2 * i + 1]); + bundle_capacities.push(2); + } + + let target = IntegralFlowBundles::new( + directed_graph, + source_vertex, + sink_vertex, + bundles, + bundle_capacities, + requirement, + ); + + ReductionMISToIFB { + target, + num_source_vertices: n, + } + } +} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_rule_example_specs() -> Vec { + use crate::export::SolutionPair; + + vec![crate::example_db::specs::RuleExampleSpec { + id: "maximumindependentset_to_integralflowbundles", + build: || { + // Path graph: 0-1-2-3, unit weights + // Optimal MIS = {0, 2} or {1, 3} or {0, 3}, size = 2 + let source = MaximumIndependentSet::new( + SimpleGraph::new(4, vec![(0, 1), (1, 2), (2, 3)]), + vec![1i32; 4], + ); + let reduction = ReduceTo::::reduce_to(&source); + let target = reduction.target_problem(); + + let target_witness = BruteForce::new() + .find_witness(target) + .expect("target should have a feasible solution"); + let source_witness = reduction.extract_solution(&target_witness); + + crate::example_db::specs::rule_example_with_witness::<_, IntegralFlowBundles>( + source, + SolutionPair { + source_config: source_witness, + target_config: target_witness, + }, + ) + }, + }] +} + +#[cfg(test)] +#[path = "../unit_tests/rules/maximumindependentset_integralflowbundles.rs"] +mod tests; diff --git a/src/rules/minimumvertexcover_minimumfeedbackarcset.rs b/src/rules/minimumvertexcover_minimumfeedbackarcset.rs new file mode 100644 index 000000000..b616f2b77 --- /dev/null +++ b/src/rules/minimumvertexcover_minimumfeedbackarcset.rs @@ -0,0 +1,123 @@ +//! Reduction from MinimumVertexCover to MinimumFeedbackArcSet. +//! +//! Each vertex v is split into v^in and v^out connected by an internal arc +//! (v^in → v^out) with weight w(v). For each edge {u,v}, two crossing arcs +//! (u^out → v^in) and (v^out → u^in) are added with a large penalty weight +//! M = 1 + Σ w(v). The penalty ensures no optimal FAS includes crossing arcs. +//! +//! A vertex cover of the source maps to a feedback arc set of internal arcs: +//! if vertex i is in the cover, remove internal arc i. + +use crate::models::graph::{MinimumFeedbackArcSet, MinimumVertexCover}; +use crate::reduction; +use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::topology::{DirectedGraph, Graph, SimpleGraph}; + +/// Result of reducing MinimumVertexCover to MinimumFeedbackArcSet. +#[derive(Debug, Clone)] +pub struct ReductionVCToFAS { + target: MinimumFeedbackArcSet, + /// Number of vertices in the source graph (= number of internal arcs). + num_source_vertices: usize, +} + +impl ReductionResult for ReductionVCToFAS { + type Source = MinimumVertexCover; + type Target = MinimumFeedbackArcSet; + + fn target_problem(&self) -> &Self::Target { + &self.target + } + + /// Extract solution: internal arcs are at positions 0..n in the FAS config. + /// If internal arc i is in the FAS (config[i] = 1), vertex i is in the cover. + fn extract_solution(&self, target_solution: &[usize]) -> Vec { + target_solution[..self.num_source_vertices].to_vec() + } +} + +#[reduction( + overhead = { + num_vertices = "2 * num_vertices", + num_arcs = "num_vertices + 2 * num_edges", + } +)] +impl ReduceTo> for MinimumVertexCover { + type Result = ReductionVCToFAS; + + fn reduce_to(&self) -> Self::Result { + let n = self.graph().num_vertices(); + let edges = self.graph().edges(); + + // Vertex splitting: vertex v → v^in (index v) and v^out (index n + v) + // Internal arcs: (v^in → v^out) for each vertex v, with weight w(v) + // Crossing arcs: for each edge {u,v}, add (u^out → v^in) and (v^out → u^in) with weight M + + let weight_sum: i32 = self.weights().iter().sum::(); + let big_m: i32 = weight_sum + .checked_add(1) + .expect("penalty M = 1 + sum(weights) overflows i32"); + + let mut arcs = Vec::with_capacity(n + 2 * edges.len()); + let mut weights = Vec::with_capacity(n + 2 * edges.len()); + + // Internal arcs first (indices 0..n) + for v in 0..n { + arcs.push((v, n + v)); // v^in → v^out + weights.push(self.weights()[v]); + } + + // Crossing arcs for each edge + for (u, v) in &edges { + arcs.push((n + u, *v)); // u^out → v^in + weights.push(big_m); + arcs.push((n + v, *u)); // v^out → u^in + weights.push(big_m); + } + + let graph = DirectedGraph::new(2 * n, arcs); + let target = MinimumFeedbackArcSet::new(graph, weights); + + ReductionVCToFAS { + target, + num_source_vertices: n, + } + } +} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_rule_example_specs() -> Vec { + use crate::export::SolutionPair; + use crate::solvers::BruteForce; + + vec![crate::example_db::specs::RuleExampleSpec { + id: "minimumvertexcover_to_minimumfeedbackarcset", + build: || { + // Triangle graph: 0-1-2-0, unit weights + // MVC optimal = 2 vertices (e.g., {0, 1}) + let source = MinimumVertexCover::new( + SimpleGraph::new(3, vec![(0, 1), (1, 2), (2, 0)]), + vec![1i32; 3], + ); + let reduction = ReduceTo::>::reduce_to(&source); + let target = reduction.target_problem(); + + let target_witness = BruteForce::new() + .find_witness(target) + .expect("target should have an optimum"); + let source_witness = reduction.extract_solution(&target_witness); + + crate::example_db::specs::rule_example_with_witness::<_, MinimumFeedbackArcSet>( + source, + SolutionPair { + source_config: source_witness, + target_config: target_witness, + }, + ) + }, + }] +} + +#[cfg(test)] +#[path = "../unit_tests/rules/minimumvertexcover_minimumfeedbackarcset.rs"] +mod tests; diff --git a/src/rules/mod.rs b/src/rules/mod.rs index 002775c1f..729115f7f 100644 --- a/src/rules/mod.rs +++ b/src/rules/mod.rs @@ -3,7 +3,9 @@ pub mod analysis; pub mod cost; pub mod registry; -pub use cost::{CustomCost, Minimize, MinimizeSteps, PathCostFn}; +pub use cost::{ + CustomCost, Minimize, MinimizeOutputSize, MinimizeSteps, MinimizeStepsThenOverhead, PathCostFn, +}; pub use registry::{EdgeCapabilities, ReductionEntry, ReductionOverhead}; pub(crate) mod circuit_spinglass; @@ -13,20 +15,29 @@ pub(crate) mod exactcoverby3sets_staffscheduling; pub(crate) mod factoring_circuit; mod graph; pub(crate) mod graph_helpers; +pub(crate) mod hamiltoniancircuit_biconnectivityaugmentation; pub(crate) mod hamiltoniancircuit_bottlenecktravelingsalesman; pub(crate) mod hamiltoniancircuit_hamiltonianpath; +pub(crate) mod hamiltoniancircuit_quadraticassignment; +pub(crate) mod hamiltoniancircuit_ruralpostman; +pub(crate) mod hamiltoniancircuit_stackercrane; +pub(crate) mod hamiltoniancircuit_strongconnectivityaugmentation; pub(crate) mod hamiltoniancircuit_travelingsalesman; +pub(crate) mod hamiltonianpath_consecutiveonessubmatrix; pub(crate) mod hamiltonianpath_isomorphicspanningtree; pub(crate) mod kclique_conjunctivebooleanquery; pub(crate) mod kclique_subgraphisomorphism; mod kcoloring_casts; mod knapsack_qubo; mod ksatisfiability_casts; +pub(crate) mod ksatisfiability_kclique; +pub(crate) mod ksatisfiability_minimumvertexcover; pub(crate) mod ksatisfiability_qubo; pub(crate) mod ksatisfiability_subsetsum; pub(crate) mod maximumclique_maximumindependentset; mod maximumindependentset_casts; mod maximumindependentset_gridgraph; +pub(crate) mod maximumindependentset_integralflowbundles; pub(crate) mod maximumindependentset_maximumclique; pub(crate) mod maximumindependentset_maximumsetpacking; mod maximumindependentset_triangular; @@ -35,10 +46,13 @@ mod maximumsetpacking_casts; pub(crate) mod maximumsetpacking_qubo; pub(crate) mod minimummultiwaycut_qubo; pub(crate) mod minimumvertexcover_maximumindependentset; +pub(crate) mod minimumvertexcover_minimumfeedbackarcset; pub(crate) mod minimumvertexcover_minimumfeedbackvertexset; pub(crate) mod minimumvertexcover_minimumsetcovering; pub(crate) mod partition_knapsack; pub(crate) mod partition_multiprocessorscheduling; +pub(crate) mod partition_sequencingwithinintervals; +pub(crate) mod partition_shortestweightconstrainedpath; pub(crate) mod sat_circuitsat; pub(crate) mod sat_coloring; pub(crate) mod sat_ksat; @@ -245,16 +259,25 @@ pub(crate) fn canonical_rule_example_specs() -> Vec Vec &Self::Target { + &self.target + } + + /// Extract a Partition assignment from a SequencingWithinIntervals solution. + /// + /// The target config encodes start-time offsets from release times. + /// For regular tasks (release = 0), the offset is the start time itself. + /// Tasks starting before `half` belong to subset 0; tasks starting at or + /// after `half + 1` belong to subset 1. + fn extract_solution(&self, target_solution: &[usize]) -> Vec { + (0..self.num_elements) + .map(|i| { + let start = target_solution[i] as u64; // release = 0, so offset = start + if start > self.half { + 1 + } else { + 0 + } + }) + .collect() + } +} + +#[reduction(overhead = { + num_tasks = "num_elements + 1", +})] +impl ReduceTo for Partition { + type Result = ReductionPartitionToSWI; + + fn reduce_to(&self) -> Self::Result { + let n = self.num_elements(); + let s = self.total_sum(); + let half = s / 2; + + // Regular tasks: one per element, release=0, deadline=S+1, length=a_i + let mut release_times = vec![0u64; n]; + let mut deadlines = vec![s + 1; n]; + let mut lengths: Vec = self.sizes().to_vec(); + + // Enforcer task: release=S/2, deadline=S/2+1, length=1 + // The enforcer is pinned at time S/2, splitting the timeline into two equal blocks. + release_times.push(half); + deadlines.push(half + 1); + lengths.push(1); + + ReductionPartitionToSWI { + target: SequencingWithinIntervals::new(release_times, deadlines, lengths), + num_elements: n, + half, + } + } +} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_rule_example_specs() -> Vec { + use crate::export::SolutionPair; + + vec![crate::example_db::specs::RuleExampleSpec { + id: "partition_to_sequencingwithinintervals", + build: || { + // sizes [1, 2, 3, 4], sum=10, half=5 + // partition: {2,3} in subset 0 (before enforcer), {1,4} in subset 1 (after enforcer) + // Schedule: tasks 1,2 (lengths 2,3) fill [0,5), enforcer at [5,6), tasks 0,3 (lengths 1,4) fill [6,11) + // Target config = start time offsets: task0=6, task1=0, task2=2, task3=7, enforcer=0 + crate::example_db::specs::rule_example_with_witness::<_, SequencingWithinIntervals>( + Partition::new(vec![1, 2, 3, 4]), + SolutionPair { + source_config: vec![1, 0, 0, 1], + target_config: vec![6, 0, 2, 7, 0], + }, + ) + }, + }] +} + +#[cfg(test)] +#[path = "../unit_tests/rules/partition_sequencingwithinintervals.rs"] +mod tests; diff --git a/src/rules/partition_shortestweightconstrainedpath.rs b/src/rules/partition_shortestweightconstrainedpath.rs new file mode 100644 index 000000000..02db41e27 --- /dev/null +++ b/src/rules/partition_shortestweightconstrainedpath.rs @@ -0,0 +1,141 @@ +//! Reduction from Partition to ShortestWeightConstrainedPath. +//! +//! Constructs a chain of n+1 vertices with two parallel edges per layer. +//! A balanced partition corresponds to a shortest weight-constrained s-t path. + +use crate::models::graph::ShortestWeightConstrainedPath; +use crate::models::misc::Partition; +use crate::reduction; +use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::topology::SimpleGraph; + +/// Result of reducing Partition to ShortestWeightConstrainedPath. +#[derive(Debug, Clone)] +pub struct ReductionPartitionToShortestWeightConstrainedPath { + target: ShortestWeightConstrainedPath, + n: usize, +} + +impl ReductionResult for ReductionPartitionToShortestWeightConstrainedPath { + type Source = Partition; + type Target = ShortestWeightConstrainedPath; + + fn target_problem(&self) -> &Self::Target { + &self.target + } + + fn extract_solution(&self, target_solution: &[usize]) -> Vec { + // Target edges are ordered: for layer i, edge 2*i is "include", + // edge 2*i+1 is "exclude". + // Include edge chosen → element in subset A_1 (config = 0). + // Exclude edge chosen → element in subset A_2 (config = 1). + (0..self.n) + .map(|i| { + let include_edge = 2 * i; + let exclude_edge = 2 * i + 1; + if target_solution[exclude_edge] == 1 { + 1 + } else { + debug_assert_eq!( + target_solution[include_edge], 1, + "layer {i}: neither include nor exclude edge selected" + ); + 0 + } + }) + .collect() + } +} + +fn partition_size_to_i32(value: u64) -> i32 { + i32::try_from(value).expect( + "Partition -> ShortestWeightConstrainedPath requires all sizes and weight_bound to fit in i32", + ) +} + +#[reduction(overhead = { + num_vertices = "num_elements + 1", + num_edges = "2 * num_elements", +})] +impl ReduceTo> for Partition { + type Result = ReductionPartitionToShortestWeightConstrainedPath; + + fn reduce_to(&self) -> Self::Result { + let n = self.num_elements(); + let num_vertices = n + 1; + + // Build edges: for each layer i (0..n), two parallel edges (v_i, v_{i+1}). + let mut edges = Vec::with_capacity(2 * n); + let mut edge_lengths = Vec::with_capacity(2 * n); + let mut edge_weights = Vec::with_capacity(2 * n); + + for i in 0..n { + let a_i = partition_size_to_i32(self.sizes()[i]); + let a_i_plus_1 = a_i.checked_add(1).expect("a_i + 1 overflows i32"); + + // "Include" edge: length = a_i + 1, weight = 1 + edges.push((i, i + 1)); + edge_lengths.push(a_i_plus_1); + edge_weights.push(1); + + // "Exclude" edge: length = 1, weight = a_i + 1 + edges.push((i, i + 1)); + edge_lengths.push(1); + edge_weights.push(a_i_plus_1); + } + + let total_sum = partition_size_to_i32(self.total_sum()); + let weight_bound = (total_sum / 2) + .checked_add(partition_size_to_i32(n as u64)) + .expect("weight_bound overflows i32"); + + let graph = SimpleGraph::new(num_vertices, edges); + + ReductionPartitionToShortestWeightConstrainedPath { + target: ShortestWeightConstrainedPath::new( + graph, + edge_lengths, + edge_weights, + 0, + n, + weight_bound, + ), + n, + } + } +} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_rule_example_specs() -> Vec { + use crate::export::SolutionPair; + + vec![crate::example_db::specs::RuleExampleSpec { + id: "partition_to_shortestweightconstrainedpath", + build: || { + // Partition {3, 1, 1, 2, 2, 1}: balanced split {3,2} vs {1,1,2,1}. + // Source config: [1,0,0,1,0,0] means elements 0,3 in A_2. + // Target: include edges for layers where config=0, exclude for config=1. + // Layer 0 (a=3): exclude (config=1) → target edge 1 + // Layer 1 (a=1): include (config=0) → target edge 2 + // Layer 2 (a=1): include (config=0) → target edge 4 + // Layer 3 (a=2): exclude (config=1) → target edge 7 + // Layer 4 (a=2): include (config=0) → target edge 8 + // Layer 5 (a=1): include (config=0) → target edge 10 + // Target config (12 edges): [0,1, 1,0, 1,0, 0,1, 1,0, 1,0] + crate::example_db::specs::rule_example_with_witness::< + _, + ShortestWeightConstrainedPath, + >( + Partition::new(vec![3, 1, 1, 2, 2, 1]), + SolutionPair { + source_config: vec![1, 0, 0, 1, 0, 0], + target_config: vec![0, 1, 1, 0, 1, 0, 0, 1, 1, 0, 1, 0], + }, + ) + }, + }] +} + +#[cfg(test)] +#[path = "../unit_tests/rules/partition_shortestweightconstrainedpath.rs"] +mod tests; diff --git a/src/rules/registry.rs b/src/rules/registry.rs index 00bd892a7..d8dc4bb43 100644 --- a/src/rules/registry.rs +++ b/src/rules/registry.rs @@ -157,6 +157,10 @@ pub struct ReductionEntry { /// Takes a `&dyn Any` (must be `&SourceType`), calls getter methods directly, /// and returns the computed target problem size. pub overhead_eval_fn: fn(&dyn Any) -> ProblemSize, + /// Extract source problem size from a type-erased instance. + /// Takes a `&dyn Any` (must be `&SourceType`), calls getter methods, + /// and returns the source problem's size fields as a `ProblemSize`. + pub source_size_fn: fn(&dyn Any) -> ProblemSize, } impl ReductionEntry { diff --git a/src/solvers/ilp/solver.rs b/src/solvers/ilp/solver.rs index 40812d7ce..51b2a0df2 100644 --- a/src/solvers/ilp/solver.rs +++ b/src/solvers/ilp/solver.rs @@ -141,6 +141,7 @@ impl ILPSolver { let mut model = unsolved .using(highs) .set_option("random_seed", 0i32) + .set_option("presolve", "off") .set_parallel(HighsParallelType::Off) .set_threads(1); if let Some(seconds) = self.time_limit { @@ -239,18 +240,23 @@ impl ILPSolver { any.is::>() || any.is::>() || any.is::() } + /// Two-level path selection: + /// 1. Dijkstra finds the cheapest path to each ILP variant using + /// `MinimizeStepsThenOverhead` (additive edge costs: step count + log overhead). + /// 2. Across ILP variants, we pick the path whose composed final output size + /// is smallest — this is the actual ILP problem size the solver will face. fn best_path_to_ilp( &self, graph: &crate::rules::ReductionGraph, name: &str, variant: &std::collections::BTreeMap, mode: ReductionMode, + instance: &dyn std::any::Any, ) -> Option { - use crate::types::ProblemSize; - let ilp_variants = graph.variants_for("ILP"); - let input_size = ProblemSize::new(vec![]); - let mut best_path = None; + let input_size = crate::rules::ReductionGraph::compute_source_size(name, instance); + let mut best_path: Option = None; + let mut best_cost = f64::INFINITY; for dv in &ilp_variants { if let Some(path) = graph.find_cheapest_path_mode( @@ -260,12 +266,16 @@ impl ILPSolver { dv, mode, &input_size, - &crate::rules::MinimizeSteps, + &crate::rules::MinimizeStepsThenOverhead, ) { - let is_better = best_path - .as_ref() - .is_none_or(|current: &crate::rules::ReductionPath| path.len() < current.len()); - if is_better { + // Use composed final output size for cross-variant comparison, + // since this determines the actual ILP problem size. + let final_size = graph + .evaluate_path_overhead(&path, &input_size) + .unwrap_or_default(); + let cost = final_size.total() as f64; + if cost < best_cost { + best_cost = cost; best_path = Some(path); } } @@ -290,10 +300,11 @@ impl ILPSolver { let graph = crate::rules::ReductionGraph::new(); - let Some(path) = self.best_path_to_ilp(&graph, name, variant, ReductionMode::Witness) + let Some(path) = + self.best_path_to_ilp(&graph, name, variant, ReductionMode::Witness, instance) else { if self - .best_path_to_ilp(&graph, name, variant, ReductionMode::Aggregate) + .best_path_to_ilp(&graph, name, variant, ReductionMode::Aggregate, instance) .is_some() { return Err(SolveViaReductionError::WitnessPathRequired { diff --git a/src/types.rs b/src/types.rs index b9236aec7..4d14f6ca2 100644 --- a/src/types.rs +++ b/src/types.rs @@ -504,7 +504,7 @@ impl fmt::Display for Extremum { } /// Problem size metadata (varies by problem type). -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct ProblemSize { /// Named size components. pub components: Vec<(String, usize)>, @@ -528,6 +528,11 @@ impl ProblemSize { .find(|(k, _)| k == name) .map(|(_, v)| *v) } + + /// Sum of all component values. + pub fn total(&self) -> usize { + self.components.iter().map(|(_, v)| *v).sum() + } } impl fmt::Display for ProblemSize { diff --git a/src/unit_tests/rules/cost.rs b/src/unit_tests/rules/cost.rs index bcf2f0151..c489b7fe3 100644 --- a/src/unit_tests/rules/cost.rs +++ b/src/unit_tests/rules/cost.rs @@ -48,3 +48,39 @@ fn test_minimize_missing_field() { assert_eq!(cost_fn.edge_cost(&overhead, &size), 0.0); } + +#[test] +fn test_minimize_output_size() { + let cost_fn = MinimizeOutputSize; + let size = ProblemSize::new(vec![("n", 10), ("m", 5)]); + let overhead = test_overhead(); + + // output n = 20, output m = 5 → total = 25 + assert_eq!(cost_fn.edge_cost(&overhead, &size), 25.0); +} + +#[test] +fn test_minimize_steps_then_overhead() { + let cost_fn = MinimizeStepsThenOverhead; + let size = ProblemSize::new(vec![("n", 10), ("m", 5)]); + let overhead = test_overhead(); + + let cost = cost_fn.edge_cost(&overhead, &size); + // Should be dominated by the step weight (1e9) with small overhead tiebreaker + assert!(cost > 1e8, "step weight should dominate"); + assert!(cost < 2e9, "should be roughly 1e9 + small tiebreaker"); + + // Two edges with different overhead should have different costs + let small_overhead = + ReductionOverhead::new(vec![("n", Expr::Const(1.0)), ("m", Expr::Const(1.0))]); + let cost_small = cost_fn.edge_cost(&small_overhead, &size); + // Both have the same step weight but different tiebreakers + assert!(cost > cost_small, "larger overhead should cost more"); +} + +#[test] +fn test_problem_size_total() { + let size = ProblemSize::new(vec![("a", 3), ("b", 7), ("c", 10)]); + assert_eq!(size.total(), 20); + assert_eq!(ProblemSize::new(vec![]).total(), 0); +} diff --git a/src/unit_tests/rules/graph.rs b/src/unit_tests/rules/graph.rs index ee1fb0933..a011c1a6b 100644 --- a/src/unit_tests/rules/graph.rs +++ b/src/unit_tests/rules/graph.rs @@ -1588,3 +1588,95 @@ fn test_variant_complexity() { None ); } + +#[test] +fn test_compute_source_size() { + let problem = MaximumIndependentSet::::new( + SimpleGraph::new(4, vec![(0, 1), (1, 2), (2, 3)]), + vec![1, 1, 1, 1], + ); + let size = ReductionGraph::compute_source_size("MaximumIndependentSet", &problem); + assert_eq!(size.get("num_vertices"), Some(4)); + assert_eq!(size.get("num_edges"), Some(3)); +} + +#[test] +fn test_compute_source_size_unknown_problem() { + let problem = 42u32; + let size = ReductionGraph::compute_source_size("NonExistentProblem", &problem); + assert!(size.components.is_empty()); +} + +#[test] +fn test_evaluate_path_overhead() { + use crate::rules::cost::MinimizeStepsThenOverhead; + + let graph = ReductionGraph::new(); + let src = ReductionGraph::variant_to_map(&MaximumIndependentSet::::variant()); + let dst = ReductionGraph::variant_to_map(&MinimumVertexCover::::variant()); + let input_size = ProblemSize::new(vec![("num_vertices", 10), ("num_edges", 20)]); + + let path = graph + .find_cheapest_path( + "MaximumIndependentSet", + &src, + "MinimumVertexCover", + &dst, + &input_size, + &MinimizeStepsThenOverhead, + ) + .expect("should find path"); + + let final_size = graph + .evaluate_path_overhead(&path, &input_size) + .expect("should evaluate overhead"); + + // MIS → MVC preserves num_vertices and num_edges + assert_eq!(final_size.get("num_vertices"), Some(10)); + assert_eq!(final_size.get("num_edges"), Some(20)); +} + +#[test] +fn test_evaluate_path_overhead_multistep() { + use crate::rules::cost::MinimizeStepsThenOverhead; + + // MIS → SetPacking → SetPacking → ILP (3 steps with size transformations) + let graph = ReductionGraph::new(); + let src = ReductionGraph::variant_to_map(&MaximumIndependentSet::::variant()); + let dst_variants = graph.variants_for("ILP"); + let dst = dst_variants + .iter() + .find(|v| v.get("variable") == Some(&"bool".to_string())) + .expect("ILP variant should exist"); + let input_size = ProblemSize::new(vec![("num_vertices", 10), ("num_edges", 20)]); + + let path = graph + .find_cheapest_path_mode( + "MaximumIndependentSet", + &src, + "ILP", + dst, + ReductionMode::Witness, + &input_size, + &MinimizeStepsThenOverhead, + ) + .expect("should find path"); + + assert!( + path.len() >= 2, + "path should have at least 2 steps, got {}", + path.len() + ); + + let final_size = graph + .evaluate_path_overhead(&path, &input_size) + .expect("should evaluate overhead"); + + // MIS(V=10,E=20) → SetPacking(sets=V=10, universe=E=20) → ... → ILP(vars=10, constraints=20) + // The final ILP dimensions should reflect the composed overhead, not the input. + assert_eq!(final_size.get("num_vars"), Some(10)); + assert_eq!(final_size.get("num_constraints"), Some(20)); + // Original MIS fields should NOT appear in the final output + assert_eq!(final_size.get("num_vertices"), None); + assert_eq!(final_size.get("num_edges"), None); +} diff --git a/src/unit_tests/rules/hamiltoniancircuit_biconnectivityaugmentation.rs b/src/unit_tests/rules/hamiltoniancircuit_biconnectivityaugmentation.rs new file mode 100644 index 000000000..b5ba18142 --- /dev/null +++ b/src/unit_tests/rules/hamiltoniancircuit_biconnectivityaugmentation.rs @@ -0,0 +1,121 @@ +use crate::models::graph::{BiconnectivityAugmentation, HamiltonianCircuit}; +use crate::rules::test_helpers::assert_satisfaction_round_trip_from_satisfaction_target; +use crate::rules::ReduceTo; +use crate::rules::ReductionResult; +use crate::solvers::BruteForce; +use crate::topology::SimpleGraph; +use crate::Problem; + +fn cycle4_hc() -> HamiltonianCircuit { + HamiltonianCircuit::new(SimpleGraph::cycle(4)) +} + +#[test] +fn test_hamiltoniancircuit_to_biconnectivityaugmentation_closed_loop() { + let source = cycle4_hc(); + let reduction = ReduceTo::>::reduce_to(&source); + + assert_satisfaction_round_trip_from_satisfaction_target( + &source, + &reduction, + "HamiltonianCircuit -> BiconnectivityAugmentation", + ); +} + +#[test] +fn test_hamiltoniancircuit_to_biconnectivityaugmentation_structure() { + let source = cycle4_hc(); + let reduction = ReduceTo::>::reduce_to(&source); + let target = reduction.target_problem(); + + // Same number of vertices + assert_eq!(target.num_vertices(), 4); + + // Initial graph is edgeless + assert_eq!(target.num_edges(), 0); + + // All pairs: C(4,2) = 6 potential edges + assert_eq!(target.num_potential_edges(), 6); + + // Budget = n = 4 + assert_eq!(*target.budget(), 4); + + // Check weights: edges in cycle have weight 1, non-edges have weight 2 + let weights = target.potential_weights(); + // (0,1) in cycle => w=1 + assert_eq!(weights[0], (0, 1, 1)); + // (0,2) not in cycle => w=2 + assert_eq!(weights[1], (0, 2, 2)); + // (0,3) in cycle => w=1 + assert_eq!(weights[2], (0, 3, 1)); + // (1,2) in cycle => w=1 + assert_eq!(weights[3], (1, 2, 1)); + // (1,3) not in cycle => w=2 + assert_eq!(weights[4], (1, 3, 2)); + // (2,3) in cycle => w=1 + assert_eq!(weights[5], (2, 3, 1)); +} + +#[test] +fn test_hamiltoniancircuit_to_biconnectivityaugmentation_extract_solution() { + let source = cycle4_hc(); + let reduction = ReduceTo::>::reduce_to(&source); + + // Select edges (0,1), (0,3), (1,2), (2,3) => config [1, 0, 1, 1, 0, 1] + let target_config = vec![1, 0, 1, 1, 0, 1]; + let extracted = reduction.extract_solution(&target_config); + + assert_eq!(extracted.len(), 4); + assert!( + source.evaluate(&extracted).0, + "extracted solution must be a valid HC" + ); +} + +#[test] +fn test_hamiltoniancircuit_to_biconnectivityaugmentation_no_circuit() { + // Path graph 0-1-2-3: no Hamiltonian circuit (endpoints have degree 1) + let source = HamiltonianCircuit::new(SimpleGraph::new(4, vec![(0, 1), (1, 2), (2, 3)])); + let reduction = ReduceTo::>::reduce_to(&source); + let target = reduction.target_problem(); + + // The target should have no feasible augmentation + let solver = BruteForce::new(); + let witness = solver.find_witness(target); + assert!( + witness.is_none(), + "target should be infeasible when source has no HC" + ); +} + +#[test] +fn test_hamiltoniancircuit_to_biconnectivityaugmentation_triangle() { + // Triangle graph: 3 vertices, 3 edges, has HC + let source = HamiltonianCircuit::new(SimpleGraph::cycle(3)); + let reduction = ReduceTo::>::reduce_to(&source); + + assert_satisfaction_round_trip_from_satisfaction_target( + &source, + &reduction, + "HamiltonianCircuit(triangle) -> BiconnectivityAugmentation", + ); +} + +#[test] +fn test_hamiltoniancircuit_to_biconnectivityaugmentation_complete4() { + // Complete graph K4: has many Hamiltonian circuits + let source = HamiltonianCircuit::new(SimpleGraph::complete(4)); + let reduction = ReduceTo::>::reduce_to(&source); + + // All potential edges have weight 1 (K4 has all edges) + let target = reduction.target_problem(); + for &(_, _, w) in target.potential_weights() { + assert_eq!(w, 1, "all edges in K4 should have weight 1"); + } + + assert_satisfaction_round_trip_from_satisfaction_target( + &source, + &reduction, + "HamiltonianCircuit(K4) -> BiconnectivityAugmentation", + ); +} diff --git a/src/unit_tests/rules/hamiltoniancircuit_quadraticassignment.rs b/src/unit_tests/rules/hamiltoniancircuit_quadraticassignment.rs new file mode 100644 index 000000000..52aef5eb1 --- /dev/null +++ b/src/unit_tests/rules/hamiltoniancircuit_quadraticassignment.rs @@ -0,0 +1,112 @@ +use crate::models::algebraic::QuadraticAssignment; +use crate::models::graph::HamiltonianCircuit; +use crate::rules::test_helpers::assert_satisfaction_round_trip_from_optimization_target; +use crate::rules::ReduceTo; +use crate::rules::ReductionResult; +use crate::solvers::BruteForce; +use crate::topology::{Graph, SimpleGraph}; +use crate::types::Min; +use crate::Problem; + +fn cycle4_hc() -> HamiltonianCircuit { + HamiltonianCircuit::new(SimpleGraph::cycle(4)) +} + +#[test] +fn test_hamiltoniancircuit_to_quadraticassignment_closed_loop() { + let source = cycle4_hc(); + let reduction = ReduceTo::::reduce_to(&source); + + assert_satisfaction_round_trip_from_optimization_target( + &source, + &reduction, + "HamiltonianCircuit -> QuadraticAssignment", + ); +} + +#[test] +fn test_hamiltoniancircuit_to_quadraticassignment_structure() { + let source = cycle4_hc(); + let reduction = ReduceTo::::reduce_to(&source); + let target = reduction.target_problem(); + + assert_eq!(target.num_facilities(), 4); + assert_eq!(target.num_locations(), 4); + + // Cost matrix: cycle adjacency on positions + let cost = target.cost_matrix(); + for (i, cost_row) in cost.iter().enumerate() { + for (j, &cost_val) in cost_row.iter().enumerate() { + let expected = if j == (i + 1) % 4 { 1 } else { 0 }; + assert_eq!(cost_val, expected, "cost[{i}][{j}] should be {expected}"); + } + } + + // Distance matrix: edge = 1, non-edge = omega = 5 + let dist = target.distance_matrix(); + for (k, dist_row) in dist.iter().enumerate() { + for (l, &dist_val) in dist_row.iter().enumerate() { + let expected = if k == l { + 0 + } else if source.graph().has_edge(k, l) { + 1 + } else { + 5 // omega = n + 1 = 5 + }; + assert_eq!(dist_val, expected, "dist[{k}][{l}] should be {expected}"); + } + } +} + +#[test] +fn test_hamiltoniancircuit_to_quadraticassignment_optimal_cost_equals_n() { + let source = cycle4_hc(); + let reduction = ReduceTo::::reduce_to(&source); + let target = reduction.target_problem(); + + // The identity permutation [0,1,2,3] is a valid HC on a 4-cycle, + // so the QAP optimum should be exactly n = 4. + let best = BruteForce::new() + .find_witness(target) + .expect("QAP should have an optimal solution"); + let value = target.evaluate(&best); + assert_eq!(value, Min(Some(4)), "optimal QAP cost should be n=4"); +} + +#[test] +fn test_hamiltoniancircuit_to_quadraticassignment_nonhamiltonian_cost_gap() { + // Star graph on 4 vertices has no Hamiltonian circuit + let source = HamiltonianCircuit::new(SimpleGraph::star(4)); + let reduction = ReduceTo::::reduce_to(&source); + let target = reduction.target_problem(); + let n = source.num_vertices(); + + let best = BruteForce::new() + .find_witness(target) + .expect("QAP always has a solution"); + let value = target.evaluate(&best); + assert!( + value.is_valid(), + "QAP solution should have a valid objective" + ); + assert!( + value.unwrap() > n as i64, + "expected QAP cost > {n} for non-Hamiltonian graph, got {:?}", + value + ); +} + +#[test] +fn test_hamiltoniancircuit_to_quadraticassignment_extract_solution() { + let source = cycle4_hc(); + let reduction = ReduceTo::::reduce_to(&source); + + // Permutation [0,1,2,3] visits 0->1->2->3->0 on cycle4 + let target_config = vec![0, 1, 2, 3]; + let extracted = reduction.extract_solution(&target_config); + assert_eq!(extracted, vec![0, 1, 2, 3]); + assert!( + source.evaluate(&extracted).0, + "extracted solution should be a valid HC" + ); +} diff --git a/src/unit_tests/rules/hamiltoniancircuit_ruralpostman.rs b/src/unit_tests/rules/hamiltoniancircuit_ruralpostman.rs new file mode 100644 index 000000000..d65a02617 --- /dev/null +++ b/src/unit_tests/rules/hamiltoniancircuit_ruralpostman.rs @@ -0,0 +1,140 @@ +use crate::models::graph::{HamiltonianCircuit, RuralPostman}; +use crate::rules::test_helpers::assert_satisfaction_round_trip_from_optimization_target; +use crate::rules::ReduceTo; +use crate::rules::ReductionResult; +use crate::solvers::BruteForce; +use crate::topology::SimpleGraph; +use crate::types::Min; +use crate::Problem; + +fn triangle_hc() -> HamiltonianCircuit { + HamiltonianCircuit::new(SimpleGraph::new(3, vec![(0, 1), (1, 2), (0, 2)])) +} + +fn cycle4_hc() -> HamiltonianCircuit { + HamiltonianCircuit::new(SimpleGraph::cycle(4)) +} + +#[test] +fn test_hamiltoniancircuit_to_ruralpostman_closed_loop() { + let source = triangle_hc(); + let reduction = ReduceTo::>::reduce_to(&source); + + assert_satisfaction_round_trip_from_optimization_target( + &source, + &reduction, + "HamiltonianCircuit -> RuralPostman (triangle)", + ); +} + +#[test] +fn test_hamiltoniancircuit_to_ruralpostman_closed_loop_cycle4() { + let source = cycle4_hc(); + let reduction = ReduceTo::>::reduce_to(&source); + + assert_satisfaction_round_trip_from_optimization_target( + &source, + &reduction, + "HamiltonianCircuit -> RuralPostman (cycle4)", + ); +} + +#[test] +fn test_hamiltoniancircuit_to_ruralpostman_structure() { + let source = triangle_hc(); + let reduction = ReduceTo::>::reduce_to(&source); + let target = reduction.target_problem(); + + // 3 vertices -> 6 vertices + assert_eq!(target.num_vertices(), 6); + // 3 required edges + 2*3 connectivity edges = 9 + assert_eq!(target.num_edges(), 9); + // 3 required edges (one per vertex) + assert_eq!(target.num_required_edges(), 3); + + // All edges have weight 1 + let weights = target.edge_lengths(); + for (i, &w) in weights.iter().enumerate() { + assert_eq!(w, 1, "edge {i} should have weight 1"); + } +} + +#[test] +fn test_hamiltoniancircuit_to_ruralpostman_structure_cycle4() { + let source = cycle4_hc(); + let reduction = ReduceTo::>::reduce_to(&source); + let target = reduction.target_problem(); + + // 4 vertices -> 8 vertices + assert_eq!(target.num_vertices(), 8); + // 4 required edges + 2*4 connectivity edges = 12 + assert_eq!(target.num_edges(), 12); + // 4 required edges + assert_eq!(target.num_required_edges(), 4); +} + +#[test] +fn test_hamiltoniancircuit_to_ruralpostman_optimal_cost() { + // Triangle has a Hamiltonian circuit, so optimal RPP cost should be 2n = 6 + let source = triangle_hc(); + let reduction = ReduceTo::>::reduce_to(&source); + let target = reduction.target_problem(); + let best = BruteForce::new() + .find_witness(target) + .expect("should find a solution"); + + let metric = target.evaluate(&best); + assert_eq!(metric, Min(Some(6)), "optimal cost should be 2n=6"); +} + +#[test] +fn test_hamiltoniancircuit_to_ruralpostman_nonhamiltonian_cost_gap() { + // Star graph with 4 vertices has no Hamiltonian circuit + let source = HamiltonianCircuit::new(SimpleGraph::star(4)); + let n = source.num_vertices(); + assert_eq!(n, 4); + let reduction = ReduceTo::>::reduce_to(&source); + let target = reduction.target_problem(); + + // Verify source has no Hamiltonian circuit + let source_witness = BruteForce::new().find_witness(&source); + assert!(source_witness.is_none(), "star graph should have no HC"); + + // The RPP optimal cost should exceed 2n = 8 + let best = BruteForce::new().find_witness(target); + if let Some(config) = best { + let metric = target.evaluate(&config); + assert!( + metric.is_valid(), + "best RPP solution should be a valid circuit" + ); + let two_n = 2 * n as i32; + assert!( + metric.unwrap() > two_n, + "non-Hamiltonian source should give RPP cost > 2n={two_n}, got {}", + metric.unwrap() + ); + } +} + +#[test] +fn test_hamiltoniancircuit_to_ruralpostman_extract_solution() { + let source = triangle_hc(); + let reduction = ReduceTo::>::reduce_to(&source); + + let target = reduction.target_problem(); + let best = BruteForce::new() + .find_witness(target) + .expect("should find a solution"); + + let extracted = reduction.extract_solution(&best); + assert_eq!( + extracted.len(), + 3, + "extracted solution should have 3 vertices" + ); + assert!( + source.evaluate(&extracted).0, + "extracted solution should be a valid Hamiltonian circuit" + ); +} diff --git a/src/unit_tests/rules/hamiltoniancircuit_stackercrane.rs b/src/unit_tests/rules/hamiltoniancircuit_stackercrane.rs new file mode 100644 index 000000000..b3032f96a --- /dev/null +++ b/src/unit_tests/rules/hamiltoniancircuit_stackercrane.rs @@ -0,0 +1,101 @@ +use crate::models::graph::HamiltonianCircuit; +use crate::models::misc::StackerCrane; +use crate::rules::test_helpers::assert_satisfaction_round_trip_from_optimization_target; +use crate::rules::ReduceTo; +use crate::rules::ReductionResult; +use crate::solvers::BruteForce; +use crate::topology::SimpleGraph; +use crate::types::Min; +use crate::Problem; + +fn cycle4_hc() -> HamiltonianCircuit { + HamiltonianCircuit::new(SimpleGraph::cycle(4)) +} + +#[test] +fn test_hamiltoniancircuit_to_stackercrane_closed_loop() { + let source = cycle4_hc(); + let reduction = ReduceTo::::reduce_to(&source); + + assert_satisfaction_round_trip_from_optimization_target( + &source, + &reduction, + "HamiltonianCircuit -> StackerCrane", + ); +} + +#[test] +fn test_hamiltoniancircuit_to_stackercrane_structure() { + let source = cycle4_hc(); + let reduction = ReduceTo::::reduce_to(&source); + let target = reduction.target_problem(); + + // 4 vertices -> 8 target vertices (2 per original vertex) + assert_eq!(target.num_vertices(), 8); + // 4 arcs (one per original vertex) + assert_eq!(target.num_arcs(), 4); + // 4 original edges -> 8 undirected connector edges + assert_eq!(target.num_edges(), 8); + + // All arcs have length 1 + for &len in target.arc_lengths() { + assert_eq!(len, 1); + } + // All edges have length 0 + for &len in target.edge_lengths() { + assert_eq!(len, 0); + } +} + +#[test] +fn test_hamiltoniancircuit_to_stackercrane_optimal_cost() { + // A 4-cycle has a Hamiltonian circuit; optimal StackerCrane cost = 4. + let source = cycle4_hc(); + let reduction = ReduceTo::::reduce_to(&source); + let target = reduction.target_problem(); + + let witness = BruteForce::new() + .find_witness(target) + .expect("target should have a solution"); + let cost = target.evaluate(&witness); + assert_eq!(cost, Min(Some(4))); +} + +#[test] +fn test_hamiltoniancircuit_to_stackercrane_non_hamiltonian() { + // Star graph on 4 vertices: no Hamiltonian circuit. + // The optimal StackerCrane cost should exceed n = 4. + let source = HamiltonianCircuit::new(SimpleGraph::star(4)); + let reduction = ReduceTo::::reduce_to(&source); + let target = reduction.target_problem(); + + let witness = BruteForce::new().find_witness(target); + match witness { + Some(w) => { + let cost = target.evaluate(&w); + assert!( + cost.0.unwrap() > 4, + "non-Hamiltonian graph should have cost > n" + ); + } + None => { + // Disconnected split graph has no feasible walk — also correct. + } + } +} + +#[test] +fn test_hamiltoniancircuit_to_stackercrane_extract_solution() { + let source = cycle4_hc(); + let reduction = ReduceTo::::reduce_to(&source); + + // The identity permutation [0, 1, 2, 3] traverses arcs in order, + // corresponding to vertex order 0, 1, 2, 3 in the original graph. + let target_config = vec![0, 1, 2, 3]; + let extracted = reduction.extract_solution(&target_config); + assert_eq!(extracted, vec![0, 1, 2, 3]); + assert!( + source.evaluate(&extracted).0, + "extracted solution should be a valid HC" + ); +} diff --git a/src/unit_tests/rules/hamiltoniancircuit_strongconnectivityaugmentation.rs b/src/unit_tests/rules/hamiltoniancircuit_strongconnectivityaugmentation.rs new file mode 100644 index 000000000..77d41ac83 --- /dev/null +++ b/src/unit_tests/rules/hamiltoniancircuit_strongconnectivityaugmentation.rs @@ -0,0 +1,95 @@ +use crate::models::graph::{HamiltonianCircuit, StrongConnectivityAugmentation}; +use crate::rules::test_helpers::assert_satisfaction_round_trip_from_satisfaction_target; +use crate::rules::ReduceTo; +use crate::rules::ReductionResult; +use crate::solvers::BruteForce; +use crate::topology::{Graph, SimpleGraph}; +use crate::Problem; + +fn cycle4_hc() -> HamiltonianCircuit { + HamiltonianCircuit::new(SimpleGraph::cycle(4)) +} + +#[test] +fn test_hamiltoniancircuit_to_strongconnectivityaugmentation_closed_loop() { + let source = cycle4_hc(); + let reduction = ReduceTo::>::reduce_to(&source); + + assert_satisfaction_round_trip_from_satisfaction_target( + &source, + &reduction, + "HamiltonianCircuit -> StrongConnectivityAugmentation", + ); +} + +#[test] +fn test_hamiltoniancircuit_to_strongconnectivityaugmentation_structure() { + let source = cycle4_hc(); + let reduction = ReduceTo::>::reduce_to(&source); + let target = reduction.target_problem(); + + // Arc-less digraph on 4 vertices + assert_eq!(target.num_vertices(), 4); + assert_eq!(target.num_arcs(), 0); + + // n*(n-1) = 12 candidate arcs + assert_eq!(target.num_potential_arcs(), 12); + + // Budget = n = 4 + assert_eq!(*target.bound(), 4); + + // Weight-1 arcs correspond to edges in the source graph + let mut weight1_count = 0; + for &(u, v, w) in target.candidate_arcs() { + if source.graph().has_edge(u, v) { + assert_eq!(w, 1, "arc ({u}, {v}) should have weight 1"); + weight1_count += 1; + } else { + assert_eq!(w, 2, "arc ({u}, {v}) should have weight 2"); + } + } + // Cycle on 4 vertices has 4 edges => 8 directed weight-1 arcs + assert_eq!(weight1_count, 8); +} + +#[test] +fn test_hamiltoniancircuit_to_strongconnectivityaugmentation_nonhamiltonian() { + // Star graph on 4 vertices (center=0, leaves=1,2,3) has no Hamiltonian circuit. + let source = HamiltonianCircuit::new(SimpleGraph::star(4)); + let reduction = ReduceTo::>::reduce_to(&source); + let target = reduction.target_problem(); + + // With budget n=4, the only way to get strong connectivity at cost 4 + // is to use 4 weight-1 arcs. But star graph has 3 edges => 6 weight-1 arcs, + // and no Hamiltonian circuit exists, so no feasible solution should exist. + let witness = BruteForce::new().find_witness(target); + assert!( + witness.is_none(), + "non-Hamiltonian source should yield infeasible SCA" + ); +} + +#[test] +fn test_hamiltoniancircuit_to_strongconnectivityaugmentation_extract_solution() { + let source = cycle4_hc(); + let reduction = ReduceTo::>::reduce_to(&source); + let target = reduction.target_problem(); + + // Manually build the target config for directed cycle 0->1->2->3->0. + let n = 4; + let mut target_config = vec![0usize; n * (n - 1)]; + let cycle_arcs = [(0, 1), (1, 2), (2, 3), (3, 0)]; + for (u, v) in cycle_arcs { + let idx = u * (n - 1) + if v > u { v - 1 } else { v }; + target_config[idx] = 1; + } + + assert!(target.is_valid_solution(&target_config)); + + let extracted = reduction.extract_solution(&target_config); + assert_eq!(extracted.len(), 4); + assert!( + source.evaluate(&extracted).is_valid(), + "extracted solution must be a valid Hamiltonian circuit" + ); +} diff --git a/src/unit_tests/rules/hamiltonianpath_consecutiveonessubmatrix.rs b/src/unit_tests/rules/hamiltonianpath_consecutiveonessubmatrix.rs new file mode 100644 index 000000000..4ee5d323e --- /dev/null +++ b/src/unit_tests/rules/hamiltonianpath_consecutiveonessubmatrix.rs @@ -0,0 +1,154 @@ +use crate::models::algebraic::ConsecutiveOnesSubmatrix; +use crate::models::graph::HamiltonianPath; +use crate::rules::test_helpers::assert_satisfaction_round_trip_from_satisfaction_target; +use crate::rules::ReduceTo; +use crate::rules::ReductionResult; +use crate::solvers::BruteForce; +use crate::topology::SimpleGraph; +use crate::Problem; + +/// Helper: build a path graph 0-1-2-3 (has HP). +fn path4() -> HamiltonianPath { + HamiltonianPath::new(SimpleGraph::new(4, vec![(0, 1), (1, 2), (2, 3)])) +} + +#[test] +fn test_hamiltonianpath_to_consecutiveonessubmatrix_closed_loop() { + let source = path4(); + let reduction = ReduceTo::::reduce_to(&source); + + assert_satisfaction_round_trip_from_satisfaction_target( + &source, + &reduction, + "HamiltonianPath -> ConsecutiveOnesSubmatrix", + ); +} + +#[test] +fn test_hamiltonianpath_to_consecutiveonessubmatrix_structure() { + let source = path4(); + let reduction = ReduceTo::::reduce_to(&source); + let target = reduction.target_problem(); + + // 4 vertices -> 4 rows + assert_eq!(target.num_rows(), 4); + // 3 edges -> 3 columns + assert_eq!(target.num_cols(), 3); + // K = n - 1 = 3 + assert_eq!(target.bound(), 3); + + // Check incidence matrix structure: + // Edge 0: (0,1), Edge 1: (1,2), Edge 2: (2,3) + let matrix = target.matrix(); + // Vertex 0 is endpoint of edge 0 only + assert_eq!(matrix[0], vec![true, false, false]); + // Vertex 1 is endpoint of edges 0 and 1 + assert_eq!(matrix[1], vec![true, true, false]); + // Vertex 2 is endpoint of edges 1 and 2 + assert_eq!(matrix[2], vec![false, true, true]); + // Vertex 3 is endpoint of edge 2 only + assert_eq!(matrix[3], vec![false, false, true]); +} + +#[test] +fn test_hamiltonianpath_to_consecutiveonessubmatrix_extract_solution() { + let source = path4(); + let reduction = ReduceTo::::reduce_to(&source); + + // Select all 3 edges (columns) — they form the Hamiltonian path. + let target_config = vec![1, 1, 1]; + let extracted = reduction.extract_solution(&target_config); + + assert_eq!(extracted.len(), 4); + assert!( + source.evaluate(&extracted).0, + "extracted solution must be a valid Hamiltonian path" + ); +} + +#[test] +fn test_hamiltonianpath_to_consecutiveonessubmatrix_no_path_few_edges() { + // Disconnected graph: 0-1, 2-3 (2 edges < n-1 = 3, no Hamiltonian path). + // The reduction detects m < n-1 and produces a Tucker unsatisfiable instance. + let source = HamiltonianPath::new(SimpleGraph::new(4, vec![(0, 1), (2, 3)])); + let reduction = ReduceTo::::reduce_to(&source); + let target = reduction.target_problem(); + + let solver = BruteForce::new(); + let witness = solver.find_witness(target); + assert!( + witness.is_none(), + "disconnected graph with too few edges should be unsatisfiable" + ); +} + +#[test] +fn test_hamiltonianpath_to_consecutiveonessubmatrix_no_path_disconnected() { + // Two disjoint triangles: 6 vertices, 6 edges, no HP (disconnected). + let source = HamiltonianPath::new(SimpleGraph::new( + 6, + vec![(0, 1), (0, 2), (1, 2), (3, 4), (3, 5), (4, 5)], + )); + let reduction = ReduceTo::::reduce_to(&source); + let target = reduction.target_problem(); + + assert_eq!(target.num_rows(), 6); + assert_eq!(target.num_cols(), 6); + assert_eq!(target.bound(), 5); + + let solver = BruteForce::new(); + let witness = solver.find_witness(target); + assert!( + witness.is_none(), + "two disjoint triangles should have no Hamiltonian path" + ); +} + +#[test] +fn test_hamiltonianpath_to_consecutiveonessubmatrix_triangle() { + // Triangle: 0-1, 1-2, 0-2 (has HP, e.g. 0-1-2) + let source = HamiltonianPath::new(SimpleGraph::new(3, vec![(0, 1), (0, 2), (1, 2)])); + let reduction = ReduceTo::::reduce_to(&source); + + assert_satisfaction_round_trip_from_satisfaction_target( + &source, + &reduction, + "HamiltonianPath(triangle) -> ConsecutiveOnesSubmatrix", + ); +} + +#[test] +fn test_hamiltonianpath_to_consecutiveonessubmatrix_single_vertex() { + // Single vertex, no edges — trivially has HP (path of length 0). + let source = HamiltonianPath::new(SimpleGraph::new(1, vec![])); + let reduction = ReduceTo::::reduce_to(&source); + let target = reduction.target_problem(); + + assert_eq!(target.num_rows(), 1); + assert_eq!(target.num_cols(), 0); + assert_eq!(target.bound(), 0); + + // K=0 is vacuously satisfiable; empty config selects 0 columns. + let solver = BruteForce::new(); + let witness = solver.find_witness(target); + assert!(witness.is_some(), "single vertex should be satisfiable"); +} + +#[test] +fn test_hamiltonianpath_to_consecutiveonessubmatrix_cycle5() { + // 5-cycle: 0-1-2-3-4-0 (has HP, e.g. 0-1-2-3-4) + let source = HamiltonianPath::new(SimpleGraph::new( + 5, + vec![(0, 1), (1, 2), (2, 3), (3, 4), (0, 4)], + )); + let reduction = ReduceTo::::reduce_to(&source); + + assert_eq!(reduction.target_problem().num_cols(), 5); + assert_eq!(reduction.target_problem().bound(), 4); + + assert_satisfaction_round_trip_from_satisfaction_target( + &source, + &reduction, + "HamiltonianPath(C5) -> ConsecutiveOnesSubmatrix", + ); +} diff --git a/src/unit_tests/rules/ksatisfiability_kclique.rs b/src/unit_tests/rules/ksatisfiability_kclique.rs new file mode 100644 index 000000000..c9886f1c1 --- /dev/null +++ b/src/unit_tests/rules/ksatisfiability_kclique.rs @@ -0,0 +1,179 @@ +use super::*; +use crate::models::formula::CNFClause; +use crate::models::graph::KClique; +use crate::solvers::BruteForce; +use crate::topology::SimpleGraph; +use crate::traits::Problem; +use crate::variant::K3; + +#[test] +fn test_ksatisfiability_to_kclique_closed_loop() { + // (x1 ∨ x2 ∨ x3) ∧ (¬x1 ∨ ¬x2 ∨ x3), n=3, m=2 + let ksat = KSatisfiability::::new( + 3, + vec![ + CNFClause::new(vec![1, 2, 3]), // x1 ∨ x2 ∨ x3 + CNFClause::new(vec![-1, -2, 3]), // ¬x1 ∨ ¬x2 ∨ x3 + ], + ); + let reduction = ReduceTo::>::reduce_to(&ksat); + let target = reduction.target_problem(); + + // Verify structure: 3*2 = 6 vertices, k = 2 + assert_eq!(target.num_vertices(), 6); + assert_eq!(target.k(), 2); + + let solver = BruteForce::new(); + let solutions = solver.find_all_witnesses(target); + assert!(!solutions.is_empty()); + + // Every KClique solution must map back to a satisfying 3-SAT assignment + for sol in &solutions { + let extracted = reduction.extract_solution(sol); + assert_eq!(extracted.len(), 3); + assert!(ksat.evaluate(&extracted)); + } +} + +#[test] +fn test_ksatisfiability_to_kclique_unsatisfiable() { + // (x1 ∨ x1 ∨ x1) ∧ (¬x1 ∨ ¬x1 ∨ ¬x1) + // x1=T satisfies C0 but not C1; x1=F satisfies C1 but not C0. + let ksat = KSatisfiability::::new( + 1, + vec![ + CNFClause::new(vec![1, 1, 1]), + CNFClause::new(vec![-1, -1, -1]), + ], + ); + let reduction = ReduceTo::>::reduce_to(&ksat); + let target = reduction.target_problem(); + + // 6 vertices, k=2 + assert_eq!(target.num_vertices(), 6); + assert_eq!(target.k(), 2); + + // All cross-clause pairs contradict (x1 vs ¬x1), so no edges → no 2-clique. + assert_eq!(target.num_edges(), 0); + + let solver = BruteForce::new(); + let solution = solver.find_witness(target); + assert!(solution.is_none()); +} + +#[test] +fn test_ksatisfiability_to_kclique_single_clause() { + // Single clause: (x1 ∨ x2 ∨ x3) — always satisfiable (7/8 assignments) + // With m=1, k=1, any single vertex is a 1-clique. + let ksat = KSatisfiability::::new(3, vec![CNFClause::new(vec![1, 2, 3])]); + let reduction = ReduceTo::>::reduce_to(&ksat); + let target = reduction.target_problem(); + + // 3 vertices, k=1, no edges needed for 1-clique + assert_eq!(target.num_vertices(), 3); + assert_eq!(target.k(), 1); + + let solver = BruteForce::new(); + let solutions = solver.find_all_witnesses(target); + + // Each solution maps to a satisfying assignment + let mut sat_assignments = std::collections::HashSet::new(); + for sol in &solutions { + let extracted = reduction.extract_solution(sol); + assert!(ksat.evaluate(&extracted)); + sat_assignments.insert(extracted); + } + // 3 clique witnesses but they may map to different or same assignments + assert!(!sat_assignments.is_empty()); +} + +#[test] +fn test_ksatisfiability_to_kclique_structure() { + // Verify edge construction for a concrete example. + // (x1 ∨ x2 ∨ x3) ∧ (¬x1 ∨ ¬x2 ∨ x3) + // Clause 0 literals: [1, 2, 3], Clause 1 literals: [-1, -2, 3] + // Cross-clause pairs: + // (0,0)-(1,0): 1 vs -1 → contradict → no edge + // (0,0)-(1,1): 1 vs -2 → ok → edge (0,4) + // (0,0)-(1,2): 1 vs 3 → ok → edge (0,5) + // (0,1)-(1,0): 2 vs -1 → ok → edge (1,3) + // (0,1)-(1,1): 2 vs -2 → contradict → no edge + // (0,1)-(1,2): 2 vs 3 → ok → edge (1,5) + // (0,2)-(1,0): 3 vs -1 → ok → edge (2,3) + // (0,2)-(1,1): 3 vs -2 → ok → edge (2,4) + // (0,2)-(1,2): 3 vs 3 → ok → edge (2,5) + // Total: 7 edges + let ksat = KSatisfiability::::new( + 3, + vec![ + CNFClause::new(vec![1, 2, 3]), + CNFClause::new(vec![-1, -2, 3]), + ], + ); + let reduction = ReduceTo::>::reduce_to(&ksat); + let target = reduction.target_problem(); + + assert_eq!(target.num_vertices(), 6); + assert_eq!(target.num_edges(), 7); + assert_eq!(target.k(), 2); +} + +#[test] +fn test_ksatisfiability_to_kclique_three_clauses() { + // (x1 ∨ x2 ∨ x3) ∧ (¬x1 ∨ x2 ∨ ¬x3) ∧ (x1 ∨ ¬x2 ∨ x3) + let ksat = KSatisfiability::::new( + 3, + vec![ + CNFClause::new(vec![1, 2, 3]), + CNFClause::new(vec![-1, 2, -3]), + CNFClause::new(vec![1, -2, 3]), + ], + ); + let reduction = ReduceTo::>::reduce_to(&ksat); + let target = reduction.target_problem(); + + // 9 vertices, k=3 + assert_eq!(target.num_vertices(), 9); + assert_eq!(target.k(), 3); + + let solver = BruteForce::new(); + let solutions = solver.find_all_witnesses(target); + assert!(!solutions.is_empty()); + + // Verify all solutions map back correctly + for sol in &solutions { + let extracted = reduction.extract_solution(sol); + assert_eq!(extracted.len(), 3); + assert!(ksat.evaluate(&extracted)); + } +} + +#[test] +fn test_ksatisfiability_to_kclique_extract_solution_example() { + // Verify a specific known solution. + // (x1 ∨ x2 ∨ x3) ∧ (¬x1 ∨ ¬x2 ∨ x3) + // Assignment x1=F, x2=F, x3=T: + // Clause 0: x3 (position 2) true → vertex 2 + // Clause 1: ¬x1 (position 0) true → vertex 3 + // These vertices should be connected (3 vs -1: not contradictory). + let ksat = KSatisfiability::::new( + 3, + vec![ + CNFClause::new(vec![1, 2, 3]), + CNFClause::new(vec![-1, -2, 3]), + ], + ); + let reduction = ReduceTo::>::reduce_to(&ksat); + let target = reduction.target_problem(); + + // Vertices 2 and 3 selected + let specific_config = vec![0, 0, 1, 1, 0, 0]; + assert!(target.evaluate(&specific_config)); + + let extracted = reduction.extract_solution(&specific_config); + // Vertex 2 = clause 0, pos 2 → literal 3 (x3) → x3=T → assignment[2]=1 + // Vertex 3 = clause 1, pos 0 → literal -1 (¬x1) → x1=F → assignment[0]=0 + // Unset variables default to 0. + assert_eq!(extracted, vec![0, 0, 1]); + assert!(ksat.evaluate(&extracted)); +} diff --git a/src/unit_tests/rules/ksatisfiability_minimumvertexcover.rs b/src/unit_tests/rules/ksatisfiability_minimumvertexcover.rs new file mode 100644 index 000000000..b1687007b --- /dev/null +++ b/src/unit_tests/rules/ksatisfiability_minimumvertexcover.rs @@ -0,0 +1,144 @@ +use super::*; +use crate::models::formula::CNFClause; +use crate::models::graph::MinimumVertexCover; +use crate::rules::test_helpers::assert_satisfaction_round_trip_from_optimization_target; +use crate::solvers::BruteForce; +use crate::topology::SimpleGraph; +use crate::traits::Problem; +use crate::variant::K3; + +#[test] +fn test_ksatisfiability_to_minimumvertexcover_closed_loop() { + // (x1 v x2 v x3) ^ (~x1 v ~x2 v x3), n=3, m=2 + let ksat = KSatisfiability::::new( + 3, + vec![ + CNFClause::new(vec![1, 2, 3]), // x1 v x2 v x3 + CNFClause::new(vec![-1, -2, 3]), // ~x1 v ~x2 v x3 + ], + ); + let reduction = ReduceTo::>::reduce_to(&ksat); + let target = reduction.target_problem(); + + // Verify structure: 2*3 + 3*2 = 12 vertices + assert_eq!(target.num_vertices(), 12); + // Edges: 3 truth-setting + 6*2 = 15 + assert_eq!(target.num_edges(), 15); + + // Use the helper to verify full round-trip correctness + assert_satisfaction_round_trip_from_optimization_target( + &ksat, + &reduction, + "3SAT -> MVC closed loop", + ); +} + +#[test] +fn test_ksatisfiability_to_minimumvertexcover_unsatisfiable() { + // Unsatisfiable: (x1 v x1 v x1) ^ (~x1 v ~x1 v ~x1) ^ (x1 v x1 v x1) + let ksat = KSatisfiability::::new( + 1, + vec![ + CNFClause::new(vec![1, 1, 1]), + CNFClause::new(vec![-1, -1, -1]), + CNFClause::new(vec![1, 1, 1]), + ], + ); + let reduction = ReduceTo::>::reduce_to(&ksat); + let target = reduction.target_problem(); + + // n=1, m=3 -> 2 + 9 = 11 vertices, minimum VC should be > n + 2m = 7 + // if unsatisfiable. Actually MVC always has a solution (empty set is not valid + // for graphs with edges, but any superset works). The key property is: + // SAT is satisfiable iff MVC has size <= n + 2m. + let solver = BruteForce::new(); + let witness = solver.find_witness(target); + assert!(witness.is_some()); + let vc_config = witness.unwrap(); + let vc_size: usize = vc_config.iter().sum(); + // Unsatisfiable -> minimum VC size > n + 2m = 1 + 6 = 7 + assert!(vc_size > 7); +} + +#[test] +fn test_ksatisfiability_to_minimumvertexcover_single_clause() { + // Single clause: (x1 v x2 v x3) — 7 out of 8 assignments satisfy it + let ksat = KSatisfiability::::new(3, vec![CNFClause::new(vec![1, 2, 3])]); + let reduction = ReduceTo::>::reduce_to(&ksat); + let target = reduction.target_problem(); + + // 2*3 + 3*1 = 9 vertices, 3 + 6 = 9 edges + assert_eq!(target.num_vertices(), 9); + assert_eq!(target.num_edges(), 9); + + assert_satisfaction_round_trip_from_optimization_target( + &ksat, + &reduction, + "3SAT single clause -> MVC", + ); +} + +#[test] +fn test_ksatisfiability_to_minimumvertexcover_extract_solution() { + // Verify specific extraction: x1=F, x2=F, x3=T + let ksat = KSatisfiability::::new( + 3, + vec![ + CNFClause::new(vec![1, 2, 3]), + CNFClause::new(vec![-1, -2, 3]), + ], + ); + let reduction = ReduceTo::>::reduce_to(&ksat); + + // Literal vertices: u1(0), ~u1(1), u2(2), ~u2(3), u3(4), ~u3(5) + // Clause 0 triangle: v6, v7, v8 + // Clause 1 triangle: v9, v10, v11 + // + // For x1=F, x2=F, x3=T: + // Truth-setting: pick ~u1(1), ~u2(3), u3(4) [the true literal] + // Clause 0 (1,2,3): communication edges (6,0), (7,2), (8,4). + // u1(0) not in cover -> must pick v6. u2(2) not in cover -> must pick v7. + // u3(4) in cover -> edge (8,4) covered. Triangle covered by v6 and v7. + // Clause 1 (-1,-2,3): communication edges (9,1), (10,3), (11,4). + // All three endpoints (~u1, ~u2, u3) in cover. Pick any 2 from triangle: v9, v10. + let vc_config = vec![0, 1, 0, 1, 1, 0, 1, 1, 0, 1, 1, 0]; + // Verify this is a valid vertex cover + assert!(reduction.target_problem().is_valid_solution(&vc_config)); + + let extracted = reduction.extract_solution(&vc_config); + assert_eq!(extracted, vec![0, 0, 1]); // x1=F, x2=F, x3=T + assert!(ksat.evaluate(&extracted)); +} + +#[test] +fn test_ksatisfiability_to_minimumvertexcover_all_negated() { + // (~x1 v ~x2 v ~x3) — 7 satisfying assignments + let ksat = KSatisfiability::::new(3, vec![CNFClause::new(vec![-1, -2, -3])]); + let reduction = ReduceTo::>::reduce_to(&ksat); + + assert_satisfaction_round_trip_from_optimization_target( + &ksat, + &reduction, + "3SAT all negated -> MVC", + ); +} + +#[test] +fn test_ksatisfiability_to_minimumvertexcover_structure() { + // Verify edge structure for a simple case + let ksat = KSatisfiability::::new(2, vec![CNFClause::new(vec![1, -1, 2])]); + let reduction = ReduceTo::>::reduce_to(&ksat); + let target = reduction.target_problem(); + + // n=2, m=1 -> 4 + 3 = 7 vertices + assert_eq!(target.num_vertices(), 7); + // 2 truth-setting + 6*1 = 8 edges + assert_eq!(target.num_edges(), 8); + + // Minimum cover size for satisfiable formula = n + 2m = 2 + 2 = 4 + let solver = BruteForce::new(); + let witness = solver.find_witness(target); + assert!(witness.is_some()); + let vc_size: usize = witness.unwrap().iter().sum(); + assert_eq!(vc_size, 4); +} diff --git a/src/unit_tests/rules/maximumindependentset_integralflowbundles.rs b/src/unit_tests/rules/maximumindependentset_integralflowbundles.rs new file mode 100644 index 000000000..7ee27a1ac --- /dev/null +++ b/src/unit_tests/rules/maximumindependentset_integralflowbundles.rs @@ -0,0 +1,150 @@ +use super::*; +use crate::solvers::BruteForce; +use crate::traits::Problem; + +#[test] +fn test_maximumindependentset_to_integralflowbundles_closed_loop() { + // Path graph: 0-1-2-3-4 + let source = MaximumIndependentSet::new( + SimpleGraph::new(5, vec![(0, 1), (1, 2), (2, 3), (3, 4)]), + vec![1i32; 5], + ); + let reduction = ReduceTo::::reduce_to(&source); + let target = reduction.target_problem(); + + // n + 2 = 7 vertices, 2n = 10 arcs, m + n = 4 + 5 = 9 bundles + assert_eq!(target.num_vertices(), 7); + assert_eq!(target.num_arcs(), 10); + assert_eq!(target.num_bundles(), 9); + assert_eq!(target.requirement(), 1); + + // Every feasible flow witness maps back to a valid independent set + let solver = BruteForce::new(); + let witnesses = solver.find_all_witnesses(target); + assert!(!witnesses.is_empty()); + for w in &witnesses { + let source_config = reduction.extract_solution(w); + let value = source.evaluate(&source_config); + assert!(value.is_valid(), "Extracted config should be a valid IS"); + } +} + +#[test] +fn test_maximumindependentset_to_integralflowbundles_triangle() { + // Triangle: 0-1-2-0, unit weights. Any single vertex is an IS. + let source = MaximumIndependentSet::new( + SimpleGraph::new(3, vec![(0, 1), (1, 2), (0, 2)]), + vec![1i32; 3], + ); + let reduction = ReduceTo::::reduce_to(&source); + let target = reduction.target_problem(); + + assert_eq!(target.num_vertices(), 5); + assert_eq!(target.num_arcs(), 6); + assert_eq!(target.num_bundles(), 6); // 3 edges + 3 vertices + assert_eq!(target.requirement(), 1); + + let solver = BruteForce::new(); + let witnesses = solver.find_all_witnesses(target); + assert!(!witnesses.is_empty()); + for w in &witnesses { + let source_config = reduction.extract_solution(w); + let value = source.evaluate(&source_config); + assert!(value.is_valid()); + } +} + +#[test] +fn test_maximumindependentset_to_integralflowbundles_cycle5() { + // C5 (5-cycle): 5 vertices, 5 edges, unit weights. + let source = MaximumIndependentSet::new( + SimpleGraph::new(5, vec![(0, 1), (1, 2), (2, 3), (3, 4), (4, 0)]), + vec![1i32; 5], + ); + let reduction = ReduceTo::::reduce_to(&source); + let target = reduction.target_problem(); + + assert_eq!(target.num_vertices(), 7); + assert_eq!(target.num_arcs(), 10); + assert_eq!(target.num_bundles(), 10); // 5 edges + 5 vertices + assert_eq!(target.requirement(), 1); + + let solver = BruteForce::new(); + let witnesses = solver.find_all_witnesses(target); + assert!(!witnesses.is_empty()); + for w in &witnesses { + let source_config = reduction.extract_solution(w); + let value = source.evaluate(&source_config); + assert!(value.is_valid()); + } +} + +#[test] +fn test_maximumindependentset_to_integralflowbundles_empty_graph() { + // Empty graph (no edges): all vertices form an IS. + let source = MaximumIndependentSet::new(SimpleGraph::new(3, vec![]), vec![1i32; 3]); + let reduction = ReduceTo::::reduce_to(&source); + let target = reduction.target_problem(); + + assert_eq!(target.num_vertices(), 5); + assert_eq!(target.num_arcs(), 6); + assert_eq!(target.num_bundles(), 3); // 0 edges + 3 vertices + assert_eq!(target.requirement(), 1); + + let solver = BruteForce::new(); + let witnesses = solver.find_all_witnesses(target); + assert!(!witnesses.is_empty()); + for w in &witnesses { + let source_config = reduction.extract_solution(w); + let value = source.evaluate(&source_config); + assert!(value.is_valid()); + } +} + +#[test] +fn test_maximumindependentset_to_integralflowbundles_single_vertex() { + // Single vertex, no edges. Optimal MIS = 1. + let source = MaximumIndependentSet::new(SimpleGraph::new(1, vec![]), vec![1i32]); + let reduction = ReduceTo::::reduce_to(&source); + let target = reduction.target_problem(); + + assert_eq!(target.num_vertices(), 3); + assert_eq!(target.num_arcs(), 2); + assert_eq!(target.num_bundles(), 1); // 0 edges + 1 vertex + assert_eq!(target.requirement(), 1); + + let solver = BruteForce::new(); + let witnesses = solver.find_all_witnesses(target); + assert!(!witnesses.is_empty()); + for w in &witnesses { + let source_config = reduction.extract_solution(w); + let value = source.evaluate(&source_config); + assert!(value.is_valid()); + assert_eq!(value.unwrap(), 1); + } +} + +#[test] +fn test_maximumindependentset_to_integralflowbundles_structure() { + // Verify the graph structure of the reduction for K2 + let source = MaximumIndependentSet::new(SimpleGraph::new(2, vec![(0, 1)]), vec![1i32; 2]); + let reduction = ReduceTo::::reduce_to(&source); + let target = reduction.target_problem(); + + assert_eq!(target.num_vertices(), 4); + assert_eq!(target.num_arcs(), 4); + assert_eq!(target.num_bundles(), 3); // 1 edge + 2 vertex + assert_eq!(target.source(), 0); + assert_eq!(target.sink(), 3); + assert_eq!(target.requirement(), 1); + + let bundles = target.bundles(); + assert_eq!(bundles[0], vec![1, 3]); // edge bundle + assert_eq!(bundles[1], vec![0, 1]); // vertex 0 bundle + assert_eq!(bundles[2], vec![2, 3]); // vertex 1 bundle + + let caps = target.bundle_capacities(); + assert_eq!(caps[0], 1); // edge bundle cap + assert_eq!(caps[1], 2); // vertex bundle cap + assert_eq!(caps[2], 2); // vertex bundle cap +} diff --git a/src/unit_tests/rules/minimumvertexcover_minimumfeedbackarcset.rs b/src/unit_tests/rules/minimumvertexcover_minimumfeedbackarcset.rs new file mode 100644 index 000000000..5b6673a24 --- /dev/null +++ b/src/unit_tests/rules/minimumvertexcover_minimumfeedbackarcset.rs @@ -0,0 +1,156 @@ +#[cfg(feature = "example-db")] +use super::canonical_rule_example_specs; +use super::ReductionVCToFAS; +use crate::models::graph::{MinimumFeedbackArcSet, MinimumVertexCover}; +use crate::rules::test_helpers::assert_optimization_round_trip_from_optimization_target; +use crate::rules::traits::ReductionResult; +use crate::rules::ReduceTo; +#[cfg(feature = "example-db")] +use crate::solvers::BruteForce; +use crate::topology::{Graph, SimpleGraph}; +#[cfg(feature = "example-db")] +use crate::traits::Problem; + +fn triangle_source() -> MinimumVertexCover { + // Triangle: 0-1-2-0, unit weights; MVC = 2 + MinimumVertexCover::new( + SimpleGraph::new(3, vec![(0, 1), (1, 2), (2, 0)]), + vec![1i32; 3], + ) +} + +fn weighted_path_source() -> MinimumVertexCover { + // Path: 0-1-2-3-4, varied weights + MinimumVertexCover::new( + SimpleGraph::new(5, vec![(0, 1), (1, 2), (2, 3), (3, 4)]), + vec![4, 1, 3, 2, 5], + ) +} + +#[test] +fn test_minimumvertexcover_to_minimumfeedbackarcset_closed_loop() { + let source = triangle_source(); + let reduction: ReductionVCToFAS = ReduceTo::>::reduce_to(&source); + + assert_optimization_round_trip_from_optimization_target( + &source, + &reduction, + "MVC -> FAS closed loop (triangle)", + ); +} + +#[test] +fn test_minimumvertexcover_to_minimumfeedbackarcset_weighted_closed_loop() { + let source = weighted_path_source(); + let reduction: ReductionVCToFAS = ReduceTo::>::reduce_to(&source); + + assert_optimization_round_trip_from_optimization_target( + &source, + &reduction, + "MVC -> FAS closed loop (weighted path)", + ); +} + +#[test] +fn test_reduction_structure() { + let source = triangle_source(); + let reduction: ReductionVCToFAS = ReduceTo::>::reduce_to(&source); + let target = reduction.target_problem(); + + // 3 vertices → 6 vertices in target (v^in, v^out for each) + assert_eq!( + target.graph().num_vertices(), + 2 * source.graph().num_vertices() + ); + // 3 internal arcs + 2*3 crossing arcs = 9 + assert_eq!( + target.graph().num_arcs(), + source.graph().num_vertices() + 2 * source.graph().num_edges() + ); +} + +#[test] +fn test_internal_arcs_layout() { + let source = triangle_source(); + let reduction: ReductionVCToFAS = ReduceTo::>::reduce_to(&source); + let target = reduction.target_problem(); + let arcs = target.graph().arcs(); + let n = source.graph().num_vertices(); + + // First n arcs are internal: (v, n+v) + for (v, &arc) in arcs.iter().enumerate().take(n) { + assert_eq!(arc, (v, n + v), "internal arc {v} should be (v^in, v^out)"); + } +} + +#[test] +fn test_weight_assignment() { + let source = weighted_path_source(); + let reduction: ReductionVCToFAS = ReduceTo::>::reduce_to(&source); + let target = reduction.target_problem(); + let n = source.graph().num_vertices(); + let big_m: i32 = 1 + source.weights().iter().sum::(); + + // Internal arc weights match source vertex weights + for v in 0..n { + assert_eq!(target.weights()[v], source.weights()[v]); + } + // Crossing arc weights are all M + for i in n..target.graph().num_arcs() { + assert_eq!(target.weights()[i], big_m); + } +} + +#[test] +fn test_solution_extraction() { + let source = triangle_source(); + let reduction: ReductionVCToFAS = ReduceTo::>::reduce_to(&source); + + // Target has 9 arcs; first 3 are internal. Extract should take first 3. + let target_config = vec![1, 1, 0, 0, 0, 0, 0, 0, 0]; + let source_config = reduction.extract_solution(&target_config); + assert_eq!(source_config, vec![1, 1, 0]); +} + +#[cfg(feature = "example-db")] +#[test] +fn test_canonical_rule_example_spec_builds() { + let example = (canonical_rule_example_specs() + .into_iter() + .find(|spec| spec.id == "minimumvertexcover_to_minimumfeedbackarcset") + .expect("example spec should be registered") + .build)(); + + assert_eq!(example.source.problem, "MinimumVertexCover"); + assert_eq!(example.target.problem, "MinimumFeedbackArcSet"); + assert_eq!(example.solutions.len(), 1); + + let source: MinimumVertexCover = + serde_json::from_value(example.source.instance.clone()) + .expect("source example deserializes"); + let target: MinimumFeedbackArcSet = + serde_json::from_value(example.target.instance.clone()) + .expect("target example deserializes"); + let solution = &example.solutions[0]; + + let source_metric = source.evaluate(&solution.source_config); + let target_metric = target.evaluate(&solution.target_config); + assert!( + source_metric.is_valid(), + "source witness should be feasible" + ); + assert!( + target_metric.is_valid(), + "target witness should be feasible" + ); + + let best_source = BruteForce::new() + .find_witness(&source) + .expect("source example should have an optimum"); + let best_target = BruteForce::new() + .find_witness(&target) + .expect("target example should have an optimum"); + + assert_eq!(source_metric, source.evaluate(&best_source)); + assert_eq!(target_metric, target.evaluate(&best_target)); +} diff --git a/src/unit_tests/rules/partition_sequencingwithinintervals.rs b/src/unit_tests/rules/partition_sequencingwithinintervals.rs new file mode 100644 index 000000000..700c3831b --- /dev/null +++ b/src/unit_tests/rules/partition_sequencingwithinintervals.rs @@ -0,0 +1,140 @@ +use super::*; +use crate::models::misc::Partition; +use crate::rules::test_helpers::assert_satisfaction_round_trip_from_satisfaction_target; +use crate::solvers::BruteForce; +use crate::traits::Problem; + +fn reduce_partition(sizes: &[u64]) -> (Partition, ReductionPartitionToSWI) { + let source = Partition::new(sizes.to_vec()); + let reduction = ReduceTo::::reduce_to(&source); + (source, reduction) +} + +fn assert_satisfiability_matches( + source: &Partition, + target: &SequencingWithinIntervals, + expected: bool, +) { + let solver = BruteForce::new(); + assert_eq!(solver.find_witness(source).is_some(), expected); + assert_eq!(solver.find_witness(target).is_some(), expected); +} + +#[test] +fn test_partition_to_sequencingwithinintervals_closed_loop() { + // sizes [1, 2, 3, 4], sum=10, partition: {2,3} and {1,4} + let (source, reduction) = reduce_partition(&[1, 2, 3, 4]); + + assert_satisfaction_round_trip_from_satisfaction_target( + &source, + &reduction, + "Partition -> SequencingWithinIntervals closed loop", + ); +} + +#[test] +fn test_partition_to_sequencingwithinintervals_structure() { + let (source, reduction) = reduce_partition(&[1, 2, 3, 4]); + let target = reduction.target_problem(); + + // n+1 tasks (4 regular + 1 enforcer) + assert_eq!(target.num_tasks(), source.num_elements() + 1); + + // Regular tasks: release=0, deadline=11, lengths=[1,2,3,4] + let n = source.num_elements(); + for i in 0..n { + assert_eq!(target.release_times()[i], 0); + assert_eq!(target.deadlines()[i], 11); // S+1 = 10+1 + } + assert_eq!(&target.lengths()[..n], source.sizes()); + + // Enforcer: release=5, deadline=6, length=1 + assert_eq!(target.release_times()[n], 5); + assert_eq!(target.deadlines()[n], 6); + assert_eq!(target.lengths()[n], 1); +} + +#[test] +fn test_partition_to_sequencingwithinintervals_odd_sum() { + // sum = 2+4+5 = 11 (odd), no valid partition + let (source, reduction) = reduce_partition(&[2, 4, 5]); + let target = reduction.target_problem(); + + // Enforcer: release=5, deadline=6, length=1 + assert_eq!(target.release_times()[3], 5); + assert_eq!(target.deadlines()[3], 6); + assert_eq!(target.lengths()[3], 1); + + // Source is infeasible (odd sum, no balanced partition). + // Note: Target may be feasible (unequal windows allow scheduling) + // but any extracted witness would not satisfy Partition (forward-only reduction). + let solver = BruteForce::new(); + assert!(solver.find_witness(&source).is_none()); +} + +#[test] +fn test_partition_to_sequencingwithinintervals_equal_elements() { + // [3, 3, 3, 3], sum=12, half=6 + let (source, reduction) = reduce_partition(&[3, 3, 3, 3]); + let target = reduction.target_problem(); + + assert_eq!(target.num_tasks(), 5); + assert_satisfiability_matches(&source, target, true); + + assert_satisfaction_round_trip_from_satisfaction_target( + &source, + &reduction, + "Partition -> SWI equal elements", + ); +} + +#[test] +fn test_partition_to_sequencingwithinintervals_solution_extraction() { + let (source, reduction) = reduce_partition(&[1, 2, 3, 4]); + let target = reduction.target_problem(); + + let solver = BruteForce::new(); + let target_solutions = solver.find_all_witnesses(target); + + for sol in &target_solutions { + let extracted = reduction.extract_solution(sol); + // Extracted config should have length = num_elements (no enforcer) + assert_eq!(extracted.len(), source.num_elements()); + // If the target solution is valid, extracted should satisfy source + let target_valid = target.evaluate(sol); + let source_valid = source.evaluate(&extracted); + if target_valid.0 { + assert!( + source_valid.0, + "Valid SWI solution should yield valid Partition" + ); + } + } +} + +#[test] +fn test_partition_to_sequencingwithinintervals_two_elements() { + // [5, 5], sum=10, half=5 + let (source, reduction) = reduce_partition(&[5, 5]); + let target = reduction.target_problem(); + + assert_eq!(target.num_tasks(), 3); + assert_satisfiability_matches(&source, target, true); + + assert_satisfaction_round_trip_from_satisfaction_target( + &source, + &reduction, + "Partition -> SWI two elements", + ); +} + +#[test] +fn test_partition_to_sequencingwithinintervals_single_element() { + // [4], sum=4, half=2 + // Not partitionable (only one element) + let (source, reduction) = reduce_partition(&[4]); + let target = reduction.target_problem(); + + assert_eq!(target.num_tasks(), 2); + assert_satisfiability_matches(&source, target, false); +} diff --git a/src/unit_tests/rules/partition_shortestweightconstrainedpath.rs b/src/unit_tests/rules/partition_shortestweightconstrainedpath.rs new file mode 100644 index 000000000..1310c5776 --- /dev/null +++ b/src/unit_tests/rules/partition_shortestweightconstrainedpath.rs @@ -0,0 +1,92 @@ +use super::*; +use crate::models::graph::ShortestWeightConstrainedPath; +use crate::models::misc::Partition; +use crate::rules::test_helpers::assert_satisfaction_round_trip_from_optimization_target; +use crate::solvers::BruteForce; +use crate::topology::SimpleGraph; +use crate::traits::Problem; + +#[test] +fn test_partition_to_shortestweightconstrainedpath_closed_loop() { + let source = Partition::new(vec![3, 1, 1, 2, 2, 1]); + let reduction = ReduceTo::>::reduce_to(&source); + + assert_satisfaction_round_trip_from_optimization_target( + &source, + &reduction, + "Partition -> ShortestWeightConstrainedPath closed loop", + ); +} + +#[test] +fn test_partition_to_shortestweightconstrainedpath_structure() { + let source = Partition::new(vec![3, 1, 1, 2, 2, 1]); + let reduction = ReduceTo::>::reduce_to(&source); + let target = reduction.target_problem(); + + // n=6 elements → 7 vertices, 12 edges + assert_eq!(target.num_vertices(), 7); + assert_eq!(target.num_edges(), 12); + assert_eq!(target.source_vertex(), 0); + assert_eq!(target.target_vertex(), 6); + + // total_sum = 10, weight_bound = floor(10/2) + 6 = 11 + assert_eq!(*target.weight_bound(), 11); + + // Check edge lengths and weights for first layer (a_0 = 3): + // Include edge: length=4, weight=1; Exclude edge: length=1, weight=4 + assert_eq!(target.edge_lengths()[0], 4); // include + assert_eq!(target.edge_weights()[0], 1); + assert_eq!(target.edge_lengths()[1], 1); // exclude + assert_eq!(target.edge_weights()[1], 4); +} + +#[test] +fn test_partition_to_shortestweightconstrainedpath_unsatisfiable() { + // Odd total sum → no balanced partition exists + let source = Partition::new(vec![2, 4, 5]); + let reduction = ReduceTo::>::reduce_to(&source); + let target = reduction.target_problem(); + + // total_sum = 11, weight_bound = floor(11/2) + 3 = 8 + assert_eq!(*target.weight_bound(), 8); + + // The SWCP optimal path exists, but extracting it should not satisfy Partition. + let best = BruteForce::new() + .find_witness(target) + .expect("SWCP target should have an optimal solution"); + let extracted = reduction.extract_solution(&best); + assert!(!source.evaluate(&extracted)); +} + +#[test] +fn test_partition_to_shortestweightconstrainedpath_small() { + // Two elements: [3, 3] → balanced partition exists + let source = Partition::new(vec![3, 3]); + let reduction = ReduceTo::>::reduce_to(&source); + let target = reduction.target_problem(); + + assert_eq!(target.num_vertices(), 3); + assert_eq!(target.num_edges(), 4); + // total_sum = 6, weight_bound = 3 + 2 = 5 + assert_eq!(*target.weight_bound(), 5); + + assert_satisfaction_round_trip_from_optimization_target( + &source, + &reduction, + "Partition [3,3] -> SWCP", + ); +} + +#[test] +fn test_partition_to_shortestweightconstrainedpath_single_element() { + // Single element → no balanced partition (odd total) + let source = Partition::new(vec![4]); + let reduction = ReduceTo::>::reduce_to(&source); + let target = reduction.target_problem(); + + assert_eq!(target.num_vertices(), 2); + assert_eq!(target.num_edges(), 2); + // total_sum = 4, weight_bound = 2 + 1 = 3 + assert_eq!(*target.weight_bound(), 3); +} diff --git a/src/unit_tests/rules/registry.rs b/src/unit_tests/rules/registry.rs index 21ba8dde5..3fdef0c9d 100644 --- a/src/unit_tests/rules/registry.rs +++ b/src/unit_tests/rules/registry.rs @@ -18,6 +18,10 @@ fn dummy_overhead_eval_fn(_: &dyn std::any::Any) -> ProblemSize { ProblemSize::new(vec![]) } +fn dummy_source_size_fn(_: &dyn std::any::Any) -> ProblemSize { + ProblemSize::new(vec![]) +} + #[test] fn test_reduction_overhead_evaluate() { let overhead = ReductionOverhead::new(vec![ @@ -51,6 +55,7 @@ fn test_reduction_entry_overhead() { reduce_aggregate_fn: None, capabilities: EdgeCapabilities::witness_only(), overhead_eval_fn: dummy_overhead_eval_fn, + source_size_fn: dummy_source_size_fn, }; let overhead = entry.overhead(); @@ -72,6 +77,7 @@ fn test_reduction_entry_debug() { reduce_aggregate_fn: None, capabilities: EdgeCapabilities::witness_only(), overhead_eval_fn: dummy_overhead_eval_fn, + source_size_fn: dummy_source_size_fn, }; let debug_str = format!("{:?}", entry); @@ -92,6 +98,7 @@ fn test_is_base_reduction_unweighted() { reduce_aggregate_fn: None, capabilities: EdgeCapabilities::witness_only(), overhead_eval_fn: dummy_overhead_eval_fn, + source_size_fn: dummy_source_size_fn, }; assert!(entry.is_base_reduction()); } @@ -109,6 +116,7 @@ fn test_is_base_reduction_source_weighted() { reduce_aggregate_fn: None, capabilities: EdgeCapabilities::witness_only(), overhead_eval_fn: dummy_overhead_eval_fn, + source_size_fn: dummy_source_size_fn, }; assert!(!entry.is_base_reduction()); } @@ -126,6 +134,7 @@ fn test_is_base_reduction_target_weighted() { reduce_aggregate_fn: None, capabilities: EdgeCapabilities::witness_only(), overhead_eval_fn: dummy_overhead_eval_fn, + source_size_fn: dummy_source_size_fn, }; assert!(!entry.is_base_reduction()); } @@ -143,6 +152,7 @@ fn test_is_base_reduction_both_weighted() { reduce_aggregate_fn: None, capabilities: EdgeCapabilities::witness_only(), overhead_eval_fn: dummy_overhead_eval_fn, + source_size_fn: dummy_source_size_fn, }; assert!(!entry.is_base_reduction()); } @@ -161,6 +171,7 @@ fn test_is_base_reduction_no_weight_key() { reduce_aggregate_fn: None, capabilities: EdgeCapabilities::witness_only(), overhead_eval_fn: dummy_overhead_eval_fn, + source_size_fn: dummy_source_size_fn, }; assert!(entry.is_base_reduction()); } @@ -178,6 +189,7 @@ fn test_reduction_entry_can_store_aggregate_executor() { reduce_aggregate_fn: Some(dummy_reduce_aggregate_fn), capabilities: EdgeCapabilities::aggregate_only(), overhead_eval_fn: dummy_overhead_eval_fn, + source_size_fn: dummy_source_size_fn, }; assert!(entry.reduce_fn.is_none());