From 5b9baa60bec1073a5ab94325171cb317baf6f9d2 Mon Sep 17 00:00:00 2001 From: zazabap Date: Sat, 28 Mar 2026 09:35:35 +0000 Subject: [PATCH 01/17] feat: add PaintShop -> QUBO reduction (#649) Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/paper/reductions.typ | 44 ++++++++++ src/models/misc/paintshop.rs | 10 +++ src/rules/mod.rs | 2 + src/rules/paintshop_qubo.rs | 107 ++++++++++++++++++++++++ src/unit_tests/rules/paintshop_qubo.rs | 109 +++++++++++++++++++++++++ 5 files changed, 272 insertions(+) create mode 100644 src/rules/paintshop_qubo.rs create mode 100644 src/unit_tests/rules/paintshop_qubo.rs diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index de79d7edb..050639e79 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -9480,6 +9480,50 @@ The following reductions to Integer Linear Programming are straightforward formu If $nu_t = 1$, output the source code $2 n$. If $d_(t,j) = 1$, output $j$. If $s_(t,j) = 1$, output $ell_(t - 1) + j$. This is exactly the encoding used by `evaluate()`: deletions use raw positions, swaps are offset by the current length, and no-op is the distinguished value $2 n$. ] +#let ps_qubo = load-example("PaintShop", "QUBO") +#let ps_qubo_sol = ps_qubo.solutions.at(0) +#reduction-rule("PaintShop", "QUBO", + example: true, + example-caption: [4 cars, sequence length 8], + extra: [ + #pred-commands( + "pred create --example " + problem-spec(ps_qubo.source) + " -o paintshop.json", + "pred reduce paintshop.json --to " + target-spec(ps_qubo) + " -o bundle.json", + "pred solve bundle.json", + "pred evaluate paintshop.json --config " + ps_qubo_sol.source_config.map(str).join(","), + ) + #{ + let n = ps_qubo.source.instance.num_cars + let seq = ps_qubo.source.instance.sequence_indices + let labels = ps_qubo.source.instance.car_labels + let is-first = ps_qubo.source.instance.is_first + let seq-labels = seq.map(i => labels.at(i)) + let coloring = seq.enumerate().map(((pos, car)) => { + let first-color = ps_qubo_sol.source_config.at(car) + if is-first.at(pos) { first-color } else { 1 - first-color } + }) + [*Source:* $n = #n$ cars, sequence $(#seq-labels.join(", "))$. \ + *Parity:* #seq.enumerate().map(((pos, _)) => if is-first.at(pos) { "1st" } else { "2nd" }).join(", ") \ + *Step 1 -- One QUBO variable per car.* Binary variable $x_i in {0,1}$ for each car $i$: $x_i = 0$ means "first occurrence gets color 0, second gets color 1"; $x_i = 1$ reverses. \ + *Step 2 -- Build the $Q$ matrix from adjacent pairs.* For each adjacent pair $(j, j+1)$ in the sequence with distinct cars $a, b$: if both positions have the _same_ parity (both first or both second occurrence), a color switch occurs when $x_a != x_b$, contributing $+1$ to $Q_(a a)$, $+1$ to $Q_(b b)$, and $-2$ to $Q_(a b)$. If they have _different_ parity, a switch occurs when $x_a = x_b$, contributing $-1$ to $Q_(a a)$, $-1$ to $Q_(b b)$, and $+2$ to $Q_(a b)$. \ + *Step 3 -- Verify.* The QUBO solution $bold(x) = (#ps_qubo_sol.target_config.map(str).join(", "))$ yields coloring $(#coloring.map(str).join(", "))$ with #{coloring.windows(2).filter(w => w.at(0) != w.at(1)).len()} color switches #sym.checkmark ] + } + ], +)[ + Each car's two occurrences must receive opposite colors, so a single binary variable per car determines the full coloring. Adjacent pairs in the sequence contribute quadratic terms to a QUBO matrix based on their parity (first vs.\ second occurrence), and the QUBO minimum plus a constant offset equals the minimum number of color switches @Streif2021. +][ + _Construction._ Given $n$ cars, each appearing exactly twice in a sequence of length $2n$, introduce binary variables $x_1, ..., x_n$ (one per car). Initialize an $n times n$ upper-triangular matrix $Q$ of zeros. For each adjacent pair of positions $(j, j+1)$ with distinct cars $a, b$: + + - *Same parity* (both first or both second occurrence): a color switch occurs iff $x_a != x_b$. The switch indicator is $x_a + x_b - 2 x_a x_b$, so add $+1$ to $Q_(a a)$, $+1$ to $Q_(b b)$, and $-2$ to $Q_(a b)$. + - *Different parity* (one first, one second): a color switch occurs iff $x_a = x_b$. The switch indicator is $1 - x_a - x_b + 2 x_a x_b$, so add $-1$ to $Q_(a a)$, $-1$ to $Q_(b b)$, and $+2$ to $Q_(a b)$, with a constant offset of $+1$. + + Adjacent pairs where both positions are the same car always produce a switch (constant term), and are skipped. The QUBO objective is $min bold(x)^top Q bold(x)$; the minimum number of color switches equals the QUBO minimum plus the total constant offset (number of different-parity pairs plus number of same-car pairs). + + _Correctness._ ($arrow.r.double$) Any PaintShop coloring corresponds to a binary assignment $bold(x)$ with the same number of switches (up to the constant offset). ($arrow.l.double$) Any QUBO minimizer $bold(x)$ defines a valid coloring (each car's two occurrences get opposite colors), and the offset-adjusted objective equals the switch count. Since the correspondence is bijective and value-preserving, optimality is preserved. + + _Solution extraction._ The QUBO solution $(x_1, ..., x_n)$ maps directly back: car $i$'s first occurrence gets color $x_i$, second gets $1 - x_i$. +] + #reduction-rule("PaintShop", "ILP")[ One binary variable per car determines its first color, the second occurrence receives the opposite color automatically, and switch indicators count color changes along the sequence. ][ diff --git a/src/models/misc/paintshop.rs b/src/models/misc/paintshop.rs index ade889b1a..b144ced5b 100644 --- a/src/models/misc/paintshop.rs +++ b/src/models/misc/paintshop.rs @@ -134,6 +134,16 @@ impl PaintShop { &self.car_labels } + /// Get the sequence as car indices. + pub fn sequence_indices(&self) -> &[usize] { + &self.sequence_indices + } + + /// Get whether each position is the first occurrence of its car. + pub fn is_first(&self) -> &[bool] { + &self.is_first + } + /// Get the coloring of the sequence from a configuration. /// /// Config assigns a color (0 or 1) to each car for its first occurrence. diff --git a/src/rules/mod.rs b/src/rules/mod.rs index 729115f7f..f94b3fa0a 100644 --- a/src/rules/mod.rs +++ b/src/rules/mod.rs @@ -49,6 +49,7 @@ pub(crate) mod minimumvertexcover_maximumindependentset; pub(crate) mod minimumvertexcover_minimumfeedbackarcset; pub(crate) mod minimumvertexcover_minimumfeedbackvertexset; pub(crate) mod minimumvertexcover_minimumsetcovering; +pub(crate) mod paintshop_qubo; pub(crate) mod partition_knapsack; pub(crate) mod partition_multiprocessorscheduling; pub(crate) mod partition_sequencingwithinintervals; @@ -283,6 +284,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec, +} + +impl ReductionResult for ReductionPaintShopToQUBO { + type Source = PaintShop; + type Target = QUBO; + + fn target_problem(&self) -> &Self::Target { + &self.target + } + + /// The QUBO solution maps directly back: car i's first occurrence gets + /// color x_i, second gets 1 - x_i. + fn extract_solution(&self, target_solution: &[usize]) -> Vec { + target_solution.to_vec() + } +} + +#[reduction(overhead = { num_vars = "num_cars" })] +impl ReduceTo> for PaintShop { + type Result = ReductionPaintShopToQUBO; + + fn reduce_to(&self) -> Self::Result { + let n = self.num_cars(); + let seq = self.sequence_indices(); + let is_first = self.is_first(); + let seq_len = seq.len(); + + let mut matrix = vec![vec![0.0f64; n]; n]; + + // For each adjacent pair in the sequence + for pos in 0..(seq_len - 1) { + let a = seq[pos]; + let b = seq[pos + 1]; + + // Skip if same car (always a color change, constant term) + if a == b { + continue; + } + + let parity_a = is_first[pos]; + let parity_b = is_first[pos + 1]; + + // Ensure we write to upper triangular: smaller index first + let (lo, hi) = if a < b { (a, b) } else { (b, a) }; + + if parity_a == parity_b { + // Same parity: color change when x_a != x_b + // Contribution: +1 to Q[a][a], +1 to Q[b][b], -2 to Q[lo][hi] + matrix[a][a] += 1.0; + matrix[b][b] += 1.0; + matrix[lo][hi] -= 2.0; + } else { + // Different parity: color change when x_a == x_b + // Contribution: -1 to Q[a][a], -1 to Q[b][b], +2 to Q[lo][hi] + matrix[a][a] -= 1.0; + matrix[b][b] -= 1.0; + matrix[lo][hi] += 2.0; + } + } + + ReductionPaintShopToQUBO { + target: QUBO::from_matrix(matrix), + } + } +} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_rule_example_specs() -> Vec { + use crate::export::SolutionPair; + + vec![crate::example_db::specs::RuleExampleSpec { + id: "paintshop_to_qubo", + build: || { + // Issue example: Sequence [A, B, C, A, D, B, D, C], 4 cars + let source = PaintShop::new(vec!["A", "B", "C", "A", "D", "B", "D", "C"]); + crate::example_db::specs::rule_example_with_witness::<_, QUBO>( + source, + SolutionPair { + source_config: vec![1, 0, 0, 0], + target_config: vec![1, 0, 0, 0], + }, + ) + }, + }] +} + +#[cfg(test)] +#[path = "../unit_tests/rules/paintshop_qubo.rs"] +mod tests; diff --git a/src/unit_tests/rules/paintshop_qubo.rs b/src/unit_tests/rules/paintshop_qubo.rs new file mode 100644 index 000000000..0ca3018a6 --- /dev/null +++ b/src/unit_tests/rules/paintshop_qubo.rs @@ -0,0 +1,109 @@ +use super::*; +use crate::rules::test_helpers::assert_optimization_round_trip_from_optimization_target; +use crate::solvers::BruteForce; + +#[test] +fn test_paintshop_to_qubo_closed_loop() { + // Issue example: Sequence [A, B, C, A, D, B, D, C], 4 cars + let source = PaintShop::new(vec!["A", "B", "C", "A", "D", "B", "D", "C"]); + let reduction = ReduceTo::>::reduce_to(&source); + let qubo = reduction.target_problem(); + + // 4 cars -> 4 QUBO variables + assert_eq!(qubo.num_vars(), 4); + + assert_optimization_round_trip_from_optimization_target( + &source, + &reduction, + "PaintShop->QUBO closed loop", + ); +} + +#[test] +fn test_paintshop_to_qubo_simple() { + // Simple case: a, b, a, b + let source = PaintShop::new(vec!["a", "b", "a", "b"]); + let reduction = ReduceTo::>::reduce_to(&source); + let qubo = reduction.target_problem(); + + assert_eq!(qubo.num_vars(), 2); + + assert_optimization_round_trip_from_optimization_target( + &source, + &reduction, + "PaintShop->QUBO simple", + ); +} + +#[test] +fn test_paintshop_to_qubo_optimal_value() { + // Issue example verifies optimal QUBO = -1, total switches = -1 + 3 = 2 + let source = PaintShop::new(vec!["A", "B", "C", "A", "D", "B", "D", "C"]); + let reduction = ReduceTo::>::reduce_to(&source); + let qubo = reduction.target_problem(); + + let solver = BruteForce::new(); + let best_target = solver.find_all_witnesses(qubo); + + // Extract solutions and verify they are optimal for the source + for sol in &best_target { + let source_sol = reduction.extract_solution(sol); + let switches = source.count_switches(&source_sol); + // Optimal is 2 switches + assert_eq!(switches, 2, "Expected 2 switches for optimal solution"); + } +} + +#[test] +fn test_paintshop_to_qubo_matrix_structure() { + // Issue example: verify the Q matrix matches expected values + let source = PaintShop::new(vec!["A", "B", "C", "A", "D", "B", "D", "C"]); + let reduction = ReduceTo::>::reduce_to(&source); + let qubo = reduction.target_problem(); + + let m = qubo.matrix(); + // From the issue: + // Q = [ -1, -2, 2, 2 ] + // [ 0, 2, -2, 0 ] + // [ 0, 0, 1, -2 ] + // [ 0, 0, 0, 0 ] + assert_eq!(m[0][0], -1.0); + assert_eq!(m[0][1], -2.0); + assert_eq!(m[0][2], 2.0); + assert_eq!(m[0][3], 2.0); + assert_eq!(m[1][1], 2.0); + assert_eq!(m[1][2], -2.0); + assert_eq!(m[1][3], 0.0); + assert_eq!(m[2][2], 1.0); + assert_eq!(m[2][3], -2.0); + assert_eq!(m[3][3], 0.0); +} + +#[test] +fn test_paintshop_to_qubo_two_cars() { + // Two cars, adjacent: a, b, b, a + let source = PaintShop::new(vec!["a", "b", "b", "a"]); + let reduction = ReduceTo::>::reduce_to(&source); + + assert_optimization_round_trip_from_optimization_target( + &source, + &reduction, + "PaintShop->QUBO two cars", + ); +} + +#[cfg(feature = "example-db")] +#[test] +fn test_paintshop_to_qubo_canonical_example_spec() { + let spec = canonical_rule_example_specs() + .into_iter() + .find(|spec| spec.id == "paintshop_to_qubo") + .expect("missing canonical PaintShop -> QUBO example spec"); + let example = (spec.build)(); + + assert_eq!(example.source.problem, "PaintShop"); + assert_eq!(example.target.problem, "QUBO"); + assert_eq!(example.source.instance["num_cars"], 4); + assert_eq!(example.target.instance["num_vars"], 4); + assert!(!example.solutions.is_empty()); +} From 4c6db4df118c74bf94ec6016fda96c5255464894 Mon Sep 17 00:00:00 2001 From: zazabap Date: Sat, 28 Mar 2026 09:42:37 +0000 Subject: [PATCH 02/17] feat: add MinimumVertexCover -> MinimumHittingSet reduction (#200) Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/paper/reductions.typ | 34 +++++ src/models/graph/minimum_vertex_cover.rs | 5 +- .../minimumvertexcover_minimumhittingset.rs | 93 +++++++++++++ src/rules/mod.rs | 2 + .../minimumvertexcover_minimumhittingset.rs | 128 ++++++++++++++++++ 5 files changed, 260 insertions(+), 2 deletions(-) create mode 100644 src/rules/minimumvertexcover_minimumhittingset.rs create mode 100644 src/unit_tests/rules/minimumvertexcover_minimumhittingset.rs diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index 050639e79..5d4600531 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -8124,6 +8124,40 @@ The following reductions to Integer Linear Programming are straightforward formu _Remark._ Zero-weight edges are excluded because they allow degenerate optimal ILP solutions containing redundant cycles at no cost; following the convention of practical solvers (e.g., SCIP-Jack @kochmartin1998steiner), such edges should be contracted before applying the reduction. ] +#let mvc_hs = load-example("MinimumVertexCover", "MinimumHittingSet") +#let mvc_hs_sol = mvc_hs.solutions.at(0) +#let mvc_hs_cover = mvc_hs_sol.source_config.enumerate().filter(((i, x)) => x == 1).map(((i, x)) => i) +#let mvc_hs_hit = mvc_hs_sol.target_config.enumerate().filter(((i, x)) => x == 1).map(((i, x)) => i) +#reduction-rule("MinimumVertexCover", "MinimumHittingSet", + example: true, + example-caption: [Unit-weight VC to Hitting Set ($n = #graph-num-vertices(mvc_hs.source.instance)$, $|E| = #graph-num-edges(mvc_hs.source.instance)$)], + extra: [ + #pred-commands( + "pred create --example 'MVC {weight: One}' -o mvc.json", + "pred reduce mvc.json --to " + target-spec(mvc_hs) + " -o bundle.json", + "pred solve bundle.json", + "pred evaluate mvc.json --config " + mvc_hs_sol.source_config.map(str).join(","), + ) + Source VC: $C = {#mvc_hs_cover.map(str).join(", ")}$ (size #mvc_hs_cover.len()) #h(1em) + Target HS: $H = {#mvc_hs_hit.map(str).join(", ")}$ (size #mvc_hs_hit.len()) \ + The hitting set $H$ is identical to the vertex cover $C$ because the universe elements are the vertices and the subsets are the edges. + ], +)[ + Vertex Cover is the special case of Hitting Set where every set has exactly two elements @garey1979. Given a unit-weight VC instance $G = (V, E)$, let the universe $U = V$ and define one 2-element subset ${u, v}$ per edge $(u, v) in E$. The budget is unchanged. +][ + _Construction._ Given unit-weight VC instance $(G, k)$ with $G = (V, E)$, construct Hitting Set instance $(U, cal(S), k)$: + - Universe: $U = V$ with $|U| = |V|$ elements. + - Collection: $cal(S) = {{u, v} : (u, v) in E}$ with $|cal(S)| = |E|$ subsets, each of size 2. + - Budget: $k' = k$ (unchanged). + + _Correctness._ ($arrow.r.double$) If $C subset.eq V$ is a vertex cover, then for every edge $(u, v) in E$, at least one of $u, v$ lies in $C$, so $C$ intersects the subset ${u, v} in cal(S)$. Hence $C$ is a hitting set. + ($arrow.l.double$) If $H subset.eq U$ hits every subset ${u, v} in cal(S)$, then for every edge $(u, v) in E$, $H$ contains $u$ or $v$, so $H$ is a vertex cover. + + Since both problems minimise cardinality (unit weights), an optimal vertex cover of size $k$ corresponds to an optimal hitting set of the same size. + + _Solution extraction._ The hitting set $H$ is directly the vertex cover: $c_v = h_v$ for each $v in V$. +] + #reduction-rule("MinimumHittingSet", "ILP")[ Each set must contain at least one selected element -- a standard set-covering constraint on the element indicators. ][ diff --git a/src/models/graph/minimum_vertex_cover.rs b/src/models/graph/minimum_vertex_cover.rs index b8586b57a..db484b409 100644 --- a/src/models/graph/minimum_vertex_cover.rs +++ b/src/models/graph/minimum_vertex_cover.rs @@ -6,7 +6,7 @@ use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; -use crate::types::{Min, WeightElement}; +use crate::types::{Min, One, WeightElement}; use num_traits::Zero; use serde::{Deserialize, Serialize}; @@ -17,7 +17,7 @@ inventory::submit! { aliases: &["MVC"], dimensions: &[ VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), - VariantDimension::new("weight", "i32", &["i32"]), + VariantDimension::new("weight", "i32", &["i32", "One"]), ], module_path: module_path!(), description: "Find minimum weight vertex cover in a graph", @@ -152,6 +152,7 @@ fn is_vertex_cover_config(graph: &G, config: &[usize]) -> bool { crate::declare_variants! { default MinimumVertexCover => "1.1996^num_vertices", + MinimumVertexCover => "1.1996^num_vertices", } #[cfg(feature = "example-db")] diff --git a/src/rules/minimumvertexcover_minimumhittingset.rs b/src/rules/minimumvertexcover_minimumhittingset.rs new file mode 100644 index 000000000..0b426e715 --- /dev/null +++ b/src/rules/minimumvertexcover_minimumhittingset.rs @@ -0,0 +1,93 @@ +//! Reduction from MinimumVertexCover (unit-weight) to MinimumHittingSet. +//! +//! Each edge becomes a 2-element subset and vertices become universe elements. +//! A vertex cover of G is exactly a hitting set for the edge-subset collection. + +use crate::models::graph::MinimumVertexCover; +use crate::models::set::MinimumHittingSet; +use crate::reduction; +use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::topology::{Graph, SimpleGraph}; +use crate::types::One; + +/// Result of reducing MinimumVertexCover to MinimumHittingSet. +#[derive(Debug, Clone)] +pub struct ReductionVCToHS { + target: MinimumHittingSet, +} + +impl ReductionResult for ReductionVCToHS { + type Source = MinimumVertexCover; + type Target = MinimumHittingSet; + + fn target_problem(&self) -> &Self::Target { + &self.target + } + + /// Solution extraction: variables correspond 1:1. + /// Element i in the hitting set corresponds to vertex i in the vertex cover. + fn extract_solution(&self, target_solution: &[usize]) -> Vec { + target_solution.to_vec() + } +} + +#[reduction( + overhead = { + universe_size = "num_vertices", + num_sets = "num_edges", + } +)] +impl ReduceTo for MinimumVertexCover { + type Result = ReductionVCToHS; + + fn reduce_to(&self) -> Self::Result { + let edges = self.graph().edges(); + let num_vertices = self.graph().num_vertices(); + + // For each edge (u, v), create a 2-element subset {u, v}. + let sets: Vec> = edges.iter().map(|&(u, v)| vec![u, v]).collect(); + + let target = MinimumHittingSet::new(num_vertices, sets); + + ReductionVCToHS { target } + } +} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_rule_example_specs() -> Vec { + use crate::export::SolutionPair; + + vec![crate::example_db::specs::RuleExampleSpec { + id: "minimumvertexcover_to_minimumhittingset", + build: || { + // 6-vertex graph from the issue example + let source = MinimumVertexCover::new( + SimpleGraph::new( + 6, + vec![ + (0, 1), + (0, 2), + (1, 3), + (2, 3), + (2, 4), + (3, 5), + (4, 5), + (1, 4), + ], + ), + vec![One; 6], + ); + crate::example_db::specs::rule_example_with_witness::<_, MinimumHittingSet>( + source, + SolutionPair { + source_config: vec![1, 0, 0, 1, 1, 0], + target_config: vec![1, 0, 0, 1, 1, 0], + }, + ) + }, + }] +} + +#[cfg(test)] +#[path = "../unit_tests/rules/minimumvertexcover_minimumhittingset.rs"] +mod tests; diff --git a/src/rules/mod.rs b/src/rules/mod.rs index f94b3fa0a..e0d8b6330 100644 --- a/src/rules/mod.rs +++ b/src/rules/mod.rs @@ -48,6 +48,7 @@ pub(crate) mod minimummultiwaycut_qubo; pub(crate) mod minimumvertexcover_maximumindependentset; pub(crate) mod minimumvertexcover_minimumfeedbackarcset; pub(crate) mod minimumvertexcover_minimumfeedbackvertexset; +pub(crate) mod minimumvertexcover_minimumhittingset; pub(crate) mod minimumvertexcover_minimumsetcovering; pub(crate) mod paintshop_qubo; pub(crate) mod partition_knapsack; @@ -292,6 +293,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec::reduce_to(&vc_problem); + + assert_optimization_round_trip_from_optimization_target( + &vc_problem, + &reduction, + "VC(One)->HittingSet closed loop", + ); +} + +#[test] +fn test_vc_to_hs_structure() { + // Path graph 0-1-2 with edges (0,1) and (1,2) + let vc_problem = + MinimumVertexCover::new(SimpleGraph::new(3, vec![(0, 1), (1, 2)]), vec![One; 3]); + let reduction = ReduceTo::::reduce_to(&vc_problem); + let hs_problem = reduction.target_problem(); + + // Universe size = num_vertices = 3 + assert_eq!(hs_problem.universe_size(), 3); + // Number of sets = num_edges = 2 + assert_eq!(hs_problem.num_sets(), 2); + + // Each edge becomes a 2-element subset + assert_eq!(hs_problem.get_set(0), Some(&vec![0, 1])); + assert_eq!(hs_problem.get_set(1), Some(&vec![1, 2])); +} + +#[test] +fn test_vc_to_hs_triangle() { + // Triangle graph: 3 vertices, 3 edges + let vc_problem = MinimumVertexCover::new( + SimpleGraph::new(3, vec![(0, 1), (1, 2), (0, 2)]), + vec![One; 3], + ); + let reduction = ReduceTo::::reduce_to(&vc_problem); + let hs_problem = reduction.target_problem(); + + assert_eq!(hs_problem.universe_size(), 3); + assert_eq!(hs_problem.num_sets(), 3); + + // All sets have exactly 2 elements + for i in 0..3 { + assert_eq!(hs_problem.get_set(i).unwrap().len(), 2); + } + + // Solve both and verify they match + let solver = BruteForce::new(); + let vc_solutions = solver.find_all_witnesses(&vc_problem); + let hs_solutions = solver.find_all_witnesses(hs_problem); + + // Minimum vertex cover of triangle = 2, same for hitting set + assert_eq!(vc_solutions[0].iter().filter(|&&x| x == 1).count(), 2); + assert_eq!(hs_solutions[0].iter().filter(|&&x| x == 1).count(), 2); +} + +#[test] +fn test_vc_to_hs_empty_graph() { + // Graph with no edges: no sets to hit + let vc_problem = MinimumVertexCover::new(SimpleGraph::new(3, vec![]), vec![One; 3]); + let reduction = ReduceTo::::reduce_to(&vc_problem); + let hs_problem = reduction.target_problem(); + + assert_eq!(hs_problem.universe_size(), 3); + assert_eq!(hs_problem.num_sets(), 0); +} + +#[test] +fn test_vc_to_hs_star_graph() { + // Star graph: center vertex 0 connected to 1, 2, 3 + let vc_problem = MinimumVertexCover::new( + SimpleGraph::new(4, vec![(0, 1), (0, 2), (0, 3)]), + vec![One; 4], + ); + let reduction = ReduceTo::::reduce_to(&vc_problem); + let hs_problem = reduction.target_problem(); + + assert_eq!(hs_problem.universe_size(), 4); + assert_eq!(hs_problem.num_sets(), 3); + + // Each set is a 2-element subset containing vertex 0 + for i in 0..3 { + let set = hs_problem.get_set(i).unwrap(); + assert_eq!(set.len(), 2); + assert!(set.contains(&0)); + } + + // Minimum cover = just vertex 0 + let solver = BruteForce::new(); + let solutions = solver.find_all_witnesses(&vc_problem); + assert_eq!(solutions[0], vec![1, 0, 0, 0]); +} + +#[test] +fn test_vc_to_hs_solution_extraction() { + // Verify that extract_solution is identity (1:1 correspondence) + let vc_problem = + MinimumVertexCover::new(SimpleGraph::new(3, vec![(0, 1), (1, 2)]), vec![One; 3]); + let reduction = ReduceTo::::reduce_to(&vc_problem); + + let target_solution = vec![0, 1, 0]; + let extracted = reduction.extract_solution(&target_solution); + assert_eq!(extracted, vec![0, 1, 0]); +} From ff3e708a0ea10c96ce4ec52192953cbebb4b9c52 Mon Sep 17 00:00:00 2001 From: zazabap Date: Sat, 28 Mar 2026 09:42:20 +0000 Subject: [PATCH 03/17] feat: add PartitionIntoPathsOfLength2 -> BoundedComponentSpanningForest reduction (#241) Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/paper/reductions.typ | 30 ++++++ src/rules/mod.rs | 4 + ...flength2_boundedcomponentspanningforest.rs | 96 +++++++++++++++++++ ...flength2_boundedcomponentspanningforest.rs | 96 +++++++++++++++++++ 4 files changed, 226 insertions(+) create mode 100644 src/rules/partitionintopathsoflength2_boundedcomponentspanningforest.rs create mode 100644 src/unit_tests/rules/partitionintopathsoflength2_boundedcomponentspanningforest.rs diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index 5d4600531..95a7271da 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -8428,6 +8428,36 @@ The following reductions to Integer Linear Programming are straightforward formu _Solution extraction._ Vertex $v$ goes to group $arg max_g x_(v,g)$. ] +#let ppl2_bcsf = load-example("PartitionIntoPathsOfLength2", "BoundedComponentSpanningForest") +#let ppl2_bcsf_sol = ppl2_bcsf.solutions.at(0) +#reduction-rule("PartitionIntoPathsOfLength2", "BoundedComponentSpanningForest", + example: true, + example-caption: [6-vertex graph ($n = 6$, $q = 2$): two $P_3$ paths], + extra: [ + #pred-commands( + "pred create --example PartitionIntoPathsOfLength2 -o ppl2.json", + "pred reduce ppl2.json --to " + target-spec(ppl2_bcsf) + " -o bundle.json", + "pred solve bundle.json", + "pred evaluate ppl2.json --config " + ppl2_bcsf_sol.source_config.map(str).join(","), + ) + Source PPL2: groups $= {#ppl2_bcsf_sol.source_config.map(str).join(", ")}$ on a graph with $n = #graph-num-vertices(ppl2_bcsf.source.instance)$ vertices and $|E| = #graph-num-edges(ppl2_bcsf.source.instance)$ edges \ + Target BCSF: components $= {#ppl2_bcsf_sol.target_config.map(str).join(", ")}$, $K = #ppl2_bcsf.target.instance.max_components$, $B = #ppl2_bcsf.target.instance.max_weight$ \ + Identity mapping: source and target configs coincide #sym.checkmark + ], +)[ + This $O(n + m)$ parameter-setting reduction (Hadlock, 1974; Garey and Johnson @garey1979[ND10, p.~208]) constructs a Bounded Component Spanning Forest instance on the same graph with unit vertex weights, $K = |V| slash 3$ components, and weight bound $B = 3$. +][ + _Construction._ Given a Partition into Paths of Length 2 instance on graph $G = (V, E)$ with $|V| = 3q$: + - Graph: use $G$ unchanged. + - Vertex weights: $w(v) = 1$ for all $v in V$. + - Component bound: $K = q = |V| slash 3$. + - Weight bound: $B = 3$. + + _Correctness._ ($arrow.r.double$) Suppose $V$ has a valid $P_3$-partition $V_1, dots, V_q$ where each $V_t$ induces at least 2 edges. Since a graph on 3 vertices with at least 2 edges is connected, each $V_t$ is a connected component of weight $1 + 1 + 1 = 3 <= B$. There are $q = K$ components. ($arrow.l.double$) Suppose $V$ has a partition into at most $K = q$ connected components each of weight at most $B = 3$. Since all weights are 1, each component has at most 3 vertices. With $3q$ vertices and at most $q$ components, the pigeonhole principle forces exactly $q$ components of exactly 3 vertices each. A connected graph on 3 vertices has at least 2 edges (a path $P_3$ or a triangle $K_3$), satisfying the $P_3$-partition requirement. + + _Solution extraction._ Identity: the component assignment in BCSF is directly a group assignment in PPL2. +] + #reduction-rule("SumOfSquaresPartition", "ILP")[ Partition elements into groups minimizing $sum_g (sum_(i in g) s_i)^2$. ][ diff --git a/src/rules/mod.rs b/src/rules/mod.rs index e0d8b6330..bad96dbe9 100644 --- a/src/rules/mod.rs +++ b/src/rules/mod.rs @@ -55,6 +55,7 @@ pub(crate) mod partition_knapsack; pub(crate) mod partition_multiprocessorscheduling; pub(crate) mod partition_sequencingwithinintervals; pub(crate) mod partition_shortestweightconstrainedpath; +pub(crate) mod partitionintopathsoflength2_boundedcomponentspanningforest; pub(crate) mod sat_circuitsat; pub(crate) mod sat_coloring; pub(crate) mod sat_ksat; @@ -287,6 +288,9 @@ pub(crate) fn canonical_rule_example_specs() -> Vec, +} + +impl ReductionResult for ReductionPPL2ToBCSF { + type Source = PartitionIntoPathsOfLength2; + type Target = BoundedComponentSpanningForest; + + fn target_problem(&self) -> &Self::Target { + &self.target + } + + /// Extract source solution from target solution. + /// + /// Both problems use the same vertex-to-group assignment encoding, + /// so the solution mapping is identity. + fn extract_solution(&self, target_solution: &[usize]) -> Vec { + target_solution.to_vec() + } +} + +#[reduction( + overhead = { + num_vertices = "num_vertices", + num_edges = "num_edges", + max_components = "num_vertices / 3", + } +)] +impl ReduceTo> + for PartitionIntoPathsOfLength2 +{ + type Result = ReductionPPL2ToBCSF; + + fn reduce_to(&self) -> Self::Result { + let n = self.num_vertices(); + let q = n / 3; + + let target = BoundedComponentSpanningForest::new( + SimpleGraph::new(n, self.graph().edges()), + vec![1i32; n], // unit weights + q, // K = |V|/3 + 3, // B = 3 + ); + + ReductionPPL2ToBCSF { target } + } +} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_rule_example_specs() -> Vec { + use crate::export::SolutionPair; + + vec![crate::example_db::specs::RuleExampleSpec { + id: "partitionintopathsoflength2_to_boundedcomponentspanningforest", + build: || { + // 6-vertex graph with two P3 paths: 0-1-2 and 3-4-5 + let source = PartitionIntoPathsOfLength2::new(SimpleGraph::new( + 6, + vec![(0, 1), (1, 2), (3, 4), (4, 5)], + )); + crate::example_db::specs::rule_example_with_witness::< + _, + BoundedComponentSpanningForest, + >( + source, + SolutionPair { + source_config: vec![0, 0, 0, 1, 1, 1], + target_config: vec![0, 0, 0, 1, 1, 1], + }, + ) + }, + }] +} + +#[cfg(test)] +#[path = "../unit_tests/rules/partitionintopathsoflength2_boundedcomponentspanningforest.rs"] +mod tests; diff --git a/src/unit_tests/rules/partitionintopathsoflength2_boundedcomponentspanningforest.rs b/src/unit_tests/rules/partitionintopathsoflength2_boundedcomponentspanningforest.rs new file mode 100644 index 000000000..66482bc1e --- /dev/null +++ b/src/unit_tests/rules/partitionintopathsoflength2_boundedcomponentspanningforest.rs @@ -0,0 +1,96 @@ +use super::*; +use crate::models::graph::{BoundedComponentSpanningForest, PartitionIntoPathsOfLength2}; +use crate::rules::test_helpers::assert_satisfaction_round_trip_from_satisfaction_target; +use crate::rules::ReduceTo; +use crate::solvers::BruteForce; +use crate::topology::SimpleGraph; +use crate::traits::Problem; + +#[test] +fn test_partitionintopathsoflength2_to_boundedcomponentspanningforest_closed_loop() { + // 6-vertex graph with two P3 paths: 0-1-2 and 3-4-5 + let source = + PartitionIntoPathsOfLength2::new(SimpleGraph::new(6, vec![(0, 1), (1, 2), (3, 4), (4, 5)])); + let result = ReduceTo::>::reduce_to(&source); + let target = result.target_problem(); + + // Check target structure + assert_eq!(target.num_vertices(), 6); + assert_eq!(target.num_edges(), 4); + assert_eq!(target.max_components(), 2); // K = 6/3 = 2 + assert_eq!(*target.max_weight(), 3); // B = 3 + + // All weights should be 1 + assert!(target.weights().iter().all(|&w| w == 1)); + + assert_satisfaction_round_trip_from_satisfaction_target( + &source, + &result, + "PPL2->BCSF closed loop", + ); +} + +#[test] +fn test_partitionintopathsoflength2_to_boundedcomponentspanningforest_no_solution() { + // 6 vertices, only edges within first 3 vertices, none in the second 3. + // Second triple {3,4,5} has no edges, so it can't form a connected component. + let source = PartitionIntoPathsOfLength2::new(SimpleGraph::new( + 6, + vec![(0, 1), (1, 2), (0, 2)], // triangle on {0,1,2}, no edges on {3,4,5} + )); + let result = ReduceTo::>::reduce_to(&source); + let solver = BruteForce::new(); + let solutions = solver.find_all_witnesses(result.target_problem()); + assert!( + solutions.is_empty(), + "No P3-partition exists, so BCSF should have no solution" + ); +} + +#[test] +fn test_partitionintopathsoflength2_to_boundedcomponentspanningforest_triangle_partition() { + // 9-vertex graph from the issue example + let source = PartitionIntoPathsOfLength2::new(SimpleGraph::new( + 9, + vec![ + (0, 1), + (1, 2), + (0, 2), + (3, 4), + (4, 5), + (6, 7), + (7, 8), + (1, 3), + (2, 6), + (5, 8), + (0, 5), + ], + )); + let result = ReduceTo::>::reduce_to(&source); + let target = result.target_problem(); + + assert_eq!(target.num_vertices(), 9); + assert_eq!(target.max_components(), 3); // K = 9/3 = 3 + assert_eq!(*target.max_weight(), 3); // B = 3 + + assert_satisfaction_round_trip_from_satisfaction_target( + &source, + &result, + "PPL2->BCSF 9-vertex closed loop", + ); +} + +#[test] +fn test_partitionintopathsoflength2_to_boundedcomponentspanningforest_extract_solution() { + // Verify extract_solution is identity + let source = + PartitionIntoPathsOfLength2::new(SimpleGraph::new(6, vec![(0, 1), (1, 2), (3, 4), (4, 5)])); + let result = ReduceTo::>::reduce_to(&source); + + let target_config = vec![0, 0, 0, 1, 1, 1]; + let extracted = result.extract_solution(&target_config); + assert_eq!(extracted, vec![0, 0, 0, 1, 1, 1]); + + // Verify the extracted solution is valid in the source + assert!(source.evaluate(&extracted).0); +} From 51a1d2933e0899f06f2071044d933cbb1647dce5 Mon Sep 17 00:00:00 2001 From: zazabap Date: Sat, 28 Mar 2026 09:34:07 +0000 Subject: [PATCH 04/17] feat: add HamiltonianCircuit -> LongestCircuit reduction (#358) Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/paper/reductions.typ | 36 +++++++++ .../hamiltoniancircuit_longestcircuit.rs | 69 ++++++++++++++++ src/rules/mod.rs | 2 + .../hamiltoniancircuit_longestcircuit.rs | 78 +++++++++++++++++++ 4 files changed, 185 insertions(+) create mode 100644 src/rules/hamiltoniancircuit_longestcircuit.rs create mode 100644 src/unit_tests/rules/hamiltoniancircuit_longestcircuit.rs diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index 95a7271da..7e9fb2bea 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -8997,6 +8997,42 @@ The following reductions to Integer Linear Programming are straightforward formu _Solution extraction._ Mark an edge selected in the source config iff it appears between two consecutive positions in the decoded cycle. ] +#let hc_lc = load-example("HamiltonianCircuit", "LongestCircuit") +#let hc_lc_sol = hc_lc.solutions.at(0) +#let hc_lc_n = graph-num-vertices(hc_lc.source.instance) +#let hc_lc_source_edges = hc_lc.source.instance.graph.edges +#let hc_lc_target_edges = hc_lc.target.instance.graph.edges +#let hc_lc_target_weights = hc_lc.target.instance.edge_lengths +#let hc_lc_selected_edges = hc_lc_target_edges.enumerate().filter(((i, _)) => hc_lc_sol.target_config.at(i) == 1).map(((i, e)) => (e.at(0), e.at(1))) +#reduction-rule("HamiltonianCircuit", "LongestCircuit", + example: true, + example-caption: [Cycle graph on $#hc_lc_n$ vertices with unit edge lengths], + extra: [ + #pred-commands( + "pred create --example " + problem-spec(hc_lc.source) + " -o hc.json", + "pred reduce hc.json --to " + target-spec(hc_lc) + " -o bundle.json", + "pred solve bundle.json", + "pred evaluate hc.json --config " + hc_lc_sol.source_config.map(str).join(","), + ) + + *Step 1 -- Start from the source graph.* The canonical source fixture is the cycle on vertices ${0, 1, dots, #(hc_lc_n - 1)}$ with $#hc_lc_source_edges.len()$ edges. The stored Hamiltonian-circuit witness is the permutation $[#hc_lc_sol.source_config.map(str).join(", ")]$.\ + + *Step 2 -- Assign unit edge lengths.* The target keeps the same $#hc_lc_n$ vertices and $#hc_lc_target_edges.len()$ edges. Every edge receives length $1$, so the edge-length vector is $[#hc_lc_target_weights.map(str).join(", ")]$.\ + + *Step 3 -- Verify the canonical witness.* The stored target configuration $[#hc_lc_sol.target_config.map(str).join(", ")]$ selects the edges #hc_lc_selected_edges.map(e => $(#e.at(0), #e.at(1))$).join(", "). The total circuit length is $#hc_lc_selected_edges.len() times 1 = #hc_lc_n = n$, confirming a Hamiltonian circuit. Traversing the selected edges recovers the vertex permutation $[#hc_lc_sol.source_config.map(str).join(", ")]$.\ + + *Multiplicity:* The fixture stores one canonical witness. For the $#hc_lc_n$-cycle there are $#hc_lc_n times 2 = #(hc_lc_n * 2)$ directed Hamiltonian circuits (choice of start vertex and direction), but they all select the same undirected edge set. + ], +)[ + @garey1979 This $O(m)$ reduction copies the graph unchanged and assigns unit weight to every edge ($n$ target vertices, $m$ target edges). A Hamiltonian circuit exists iff the optimal circuit length equals $n$. +][ + _Construction._ Given a Hamiltonian Circuit instance $G = (V, E)$ with $n = |V|$ and $m = |E|$, construct a Longest Circuit instance on the same graph $G' = G$ with edge lengths $l(e) = 1$ for every $e in E$. + + _Correctness._ ($arrow.r.double$) If $G$ has a Hamiltonian circuit $v_0, v_1, dots, v_(n-1), v_0$, then this circuit uses $n$ edges each of length 1, giving total length $n$. Since a simple circuit on $n$ vertices can use at most $n$ edges, this is optimal. ($arrow.l.double$) If the longest circuit in $G'$ has length $n$, it uses $n$ unit-weight edges and therefore visits $n$ distinct vertices, i.e., every vertex exactly once. This circuit is therefore a Hamiltonian circuit in $G$. + + _Solution extraction._ Read the selected target edges, traverse the unique degree-2 cycle they form, and return the resulting vertex permutation as the source Hamiltonian-circuit witness. +] + #reduction-rule("LongestCircuit", "ILP")[ A direct cycle-selection ILP uses binary edge variables, degree constraints, and a connectivity witness to force exactly one simple circuit of length at least the bound. ][ diff --git a/src/rules/hamiltoniancircuit_longestcircuit.rs b/src/rules/hamiltoniancircuit_longestcircuit.rs new file mode 100644 index 000000000..c6d9a0bba --- /dev/null +++ b/src/rules/hamiltoniancircuit_longestcircuit.rs @@ -0,0 +1,69 @@ +//! Reduction from HamiltonianCircuit to LongestCircuit. +//! +//! Given an HC instance G = (V, E), construct an LC instance on the same graph +//! with unit edge weights. A Hamiltonian circuit exists iff the optimal circuit +//! length equals |V|. + +use crate::models::graph::{HamiltonianCircuit, LongestCircuit}; +use crate::reduction; +use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::topology::{Graph, SimpleGraph}; + +/// Result of reducing HamiltonianCircuit to LongestCircuit. +#[derive(Debug, Clone)] +pub struct ReductionHamiltonianCircuitToLongestCircuit { + target: LongestCircuit, +} + +impl ReductionResult for ReductionHamiltonianCircuitToLongestCircuit { + type Source = HamiltonianCircuit; + type Target = LongestCircuit; + + fn target_problem(&self) -> &Self::Target { + &self.target + } + + fn extract_solution(&self, target_solution: &[usize]) -> Vec { + crate::rules::graph_helpers::edges_to_cycle_order(self.target.graph(), target_solution) + } +} + +#[reduction( + overhead = { + num_vertices = "num_vertices", + num_edges = "num_edges", + } +)] +impl ReduceTo> for HamiltonianCircuit { + type Result = ReductionHamiltonianCircuitToLongestCircuit; + + fn reduce_to(&self) -> Self::Result { + let n = self.num_vertices(); + let edges = self.graph().edges(); + let target = LongestCircuit::new(SimpleGraph::new(n, edges), vec![1i32; self.num_edges()]); + ReductionHamiltonianCircuitToLongestCircuit { 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_longestcircuit", + build: || { + let source = HamiltonianCircuit::new(SimpleGraph::cycle(4)); + crate::example_db::specs::rule_example_with_witness::<_, LongestCircuit>( + source, + SolutionPair { + source_config: vec![0, 1, 2, 3], + target_config: vec![1, 1, 1, 1], + }, + ) + }, + }] +} + +#[cfg(test)] +#[path = "../unit_tests/rules/hamiltoniancircuit_longestcircuit.rs"] +mod tests; diff --git a/src/rules/mod.rs b/src/rules/mod.rs index bad96dbe9..17edbc543 100644 --- a/src/rules/mod.rs +++ b/src/rules/mod.rs @@ -18,6 +18,7 @@ pub(crate) mod graph_helpers; pub(crate) mod hamiltoniancircuit_biconnectivityaugmentation; pub(crate) mod hamiltoniancircuit_bottlenecktravelingsalesman; pub(crate) mod hamiltoniancircuit_hamiltonianpath; +pub(crate) mod hamiltoniancircuit_longestcircuit; pub(crate) mod hamiltoniancircuit_quadraticassignment; pub(crate) mod hamiltoniancircuit_ruralpostman; pub(crate) mod hamiltoniancircuit_stackercrane; @@ -265,6 +266,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec HamiltonianCircuit { + HamiltonianCircuit::new(SimpleGraph::cycle(4)) +} + +#[test] +fn test_hamiltoniancircuit_to_longestcircuit_closed_loop() { + let source = cycle4_hc(); + let reduction = ReduceTo::>::reduce_to(&source); + + assert_satisfaction_round_trip_from_optimization_target( + &source, + &reduction, + "HamiltonianCircuit -> LongestCircuit", + ); +} + +#[test] +fn test_hamiltoniancircuit_to_longestcircuit_structure() { + let source = cycle4_hc(); + let reduction = ReduceTo::>::reduce_to(&source); + let target = reduction.target_problem(); + + // Same graph structure + assert_eq!(target.graph().num_vertices(), 4); + assert_eq!(target.graph().num_edges(), 4); + + // All unit weights + assert!(target.edge_lengths().iter().all(|&w| w == 1)); +} + +#[test] +fn test_hamiltoniancircuit_to_longestcircuit_nonhamiltonian() { + // Star graph on 4 vertices: no Hamiltonian circuit + let source = HamiltonianCircuit::new(SimpleGraph::star(4)); + let reduction = ReduceTo::>::reduce_to(&source); + let target = reduction.target_problem(); + + let solver = BruteForce::new(); + let witness = solver.find_witness(target); + + match witness { + Some(sol) => { + let value = target.evaluate(&sol); + // Optimal circuit length must be strictly less than n=4 + assert!( + value.unwrap() < 4, + "star graph should not have a circuit of length 4" + ); + } + None => { + // No circuit at all in a star graph — also acceptable + } + } +} + +#[test] +fn test_hamiltoniancircuit_to_longestcircuit_extract_solution() { + let source = cycle4_hc(); + let reduction = ReduceTo::>::reduce_to(&source); + let target = reduction.target_problem(); + + // All edges selected forms a Hamiltonian circuit on the cycle graph + let target_solution = vec![1, 1, 1, 1]; + let extracted = reduction.extract_solution(&target_solution); + + assert_eq!(target.evaluate(&target_solution), Max(Some(4))); + assert_eq!(extracted.len(), 4); + assert!(source.evaluate(&extracted)); +} From 179136e3ca6f10d6ec0c4534eccc6620fb49a327 Mon Sep 17 00:00:00 2001 From: zazabap Date: Sat, 28 Mar 2026 09:33:36 +0000 Subject: [PATCH 05/17] feat: add Partition -> SubsetSum reduction (#387) Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/paper/reductions.typ | 40 +++++++++ src/rules/mod.rs | 2 + src/rules/partition_subsetsum.rs | 89 +++++++++++++++++++++ src/unit_tests/rules/partition_subsetsum.rs | 69 ++++++++++++++++ 4 files changed, 200 insertions(+) create mode 100644 src/rules/partition_subsetsum.rs create mode 100644 src/unit_tests/rules/partition_subsetsum.rs diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index 7e9fb2bea..e6abe1a56 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -7053,6 +7053,46 @@ where $P$ is a penalty weight large enough that any constraint violation costs m _Solution extraction._ Return the same binary selection vector on the original elements: item $i$ is selected in the knapsack witness if and only if element $i$ belongs to the extracted partition subset. ] +#let part_ss = load-example("Partition", "SubsetSum") +#let part_ss_sol = part_ss.solutions.at(0) +#let part_ss_sizes = part_ss.source.instance.sizes +#let part_ss_n = part_ss_sizes.len() +#let part_ss_total = part_ss_sizes.fold(0, (a, b) => a + b) +#let part_ss_target = part_ss_total / 2 +#let part_ss_selected = part_ss_sol.source_config.enumerate().filter(((i, x)) => x == 1).map(((i, x)) => i) +#let part_ss_selected_sizes = part_ss_selected.map(i => part_ss_sizes.at(i)) +#let part_ss_selected_sum = part_ss_selected_sizes.fold(0, (a, b) => a + b) +#reduction-rule("Partition", "SubsetSum", + example: true, + example-caption: [#part_ss_n elements, total sum $S = #part_ss_total$], + extra: [ + #pred-commands( + "pred create --example " + problem-spec(part_ss.source) + " -o partition.json", + "pred reduce partition.json --to " + target-spec(part_ss) + " -o bundle.json", + "pred solve bundle.json", + "pred evaluate partition.json --config " + part_ss_sol.source_config.map(str).join(","), + ) + + *Step 1 -- Source instance.* The canonical Partition instance has sizes $(#part_ss_sizes.map(str).join(", "))$ with total sum $S = #part_ss_total$, so a balanced witness must hit exactly $S / 2 = #part_ss_target$. + + *Step 2 -- Build the Subset Sum instance.* The reduction copies the sizes directly: $(#part_ss_sizes.map(str).join(", "))$, and sets the target $B = S / 2 = #part_ss_target$. The number of binary variables is unchanged ($n = #part_ss_n$). + + *Step 3 -- Verify the canonical witness.* The serialized witness uses the same binary vector on both sides, $bold(x) = (#part_ss_sol.source_config.map(str).join(", "))$. It selects elements at indices $\{#part_ss_selected.map(str).join(", ")\}$ with sizes $(#part_ss_selected_sizes.map(str).join(", "))$, so the chosen subset sums to $#part_ss_selected_sum = #part_ss_target = B$ #sym.checkmark. + + *Witness semantics.* The example DB stores one canonical balanced subset. Multiple subsets may sum to $B$, but one witness suffices to demonstrate the reduction. + ], +)[ + This $O(n)$ reduction @garey1979[SP13] @karp1972 embeds a Partition instance into Subset Sum by copying the element sizes and setting the target to half the total sum. For $n$ source elements it produces $n$ Subset Sum items. +][ + _Construction._ Given positive sizes $s_0, dots, s_(n-1)$ with total sum $S = sum_(i=0)^(n-1) s_i$, construct a Subset Sum instance with the same sizes and target + $ B = S / 2. $ + If $S$ is odd, return a trivially infeasible Subset Sum instance (empty sizes, target $= 1$). + + _Correctness._ ($arrow.r.double$) If the Partition instance is satisfiable, some subset $A'$ has sum $S / 2 = B$, so the Subset Sum instance is satisfiable. ($arrow.l.double$) If the Subset Sum instance is satisfiable, some subset sums to $B = S / 2$, so its complement sums to $S - S / 2 = S / 2$, giving a balanced partition. When $S$ is odd, $S / 2$ is not an integer and no subset of positive integers can sum to it; the trivially infeasible target instance correctly reflects this. + + _Solution extraction._ Return the same binary selection vector: element $i$ is in the partition subset if and only if it is selected in the Subset Sum witness. +] + #let ks_qubo = load-example("Knapsack", "QUBO") #let ks_qubo_sol = ks_qubo.solutions.at(0) #let ks_qubo_num_items = ks_qubo.source.instance.weights.len() diff --git a/src/rules/mod.rs b/src/rules/mod.rs index 17edbc543..27b324394 100644 --- a/src/rules/mod.rs +++ b/src/rules/mod.rs @@ -56,6 +56,7 @@ pub(crate) mod partition_knapsack; pub(crate) mod partition_multiprocessorscheduling; pub(crate) mod partition_sequencingwithinintervals; pub(crate) mod partition_shortestweightconstrainedpath; +pub(crate) mod partition_subsetsum; pub(crate) mod partitionintopathsoflength2_boundedcomponentspanningforest; pub(crate) mod sat_circuitsat; pub(crate) mod sat_coloring; @@ -296,6 +297,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec &Self::Target { + &self.target + } + + fn extract_solution(&self, target_solution: &[usize]) -> Vec { + if target_solution.len() == self.source_n { + // Normal case: same elements, same binary vector. + target_solution.to_vec() + } else { + // Odd-sum case: target is trivially infeasible (0 elements). + // Return all-zero config for the source (which also won't satisfy it). + vec![0; self.source_n] + } + } +} + +#[reduction(overhead = { + num_elements = "num_elements", +})] +impl ReduceTo for Partition { + type Result = ReductionPartitionToSubsetSum; + + fn reduce_to(&self) -> Self::Result { + let total = self.total_sum(); + let source_n = self.num_elements(); + + if !total.is_multiple_of(2) { + // Odd total sum: no balanced partition exists. + // Return a trivially infeasible SubsetSum: no elements, target = 1. + ReductionPartitionToSubsetSum { + target: SubsetSum::new_unchecked(vec![], BigUint::from(1u32)), + source_n, + } + } else { + let sizes: Vec = self.sizes().iter().map(|&s| BigUint::from(s)).collect(); + let target_val = BigUint::from(total / 2); + ReductionPartitionToSubsetSum { + target: SubsetSum::new_unchecked(sizes, target_val), + source_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_subsetsum", + build: || { + crate::example_db::specs::rule_example_with_witness::<_, SubsetSum>( + Partition::new(vec![3, 1, 1, 2, 2, 1]), + SolutionPair { + source_config: vec![1, 0, 0, 1, 0, 0], + target_config: vec![1, 0, 0, 1, 0, 0], + }, + ) + }, + }] +} + +#[cfg(test)] +#[path = "../unit_tests/rules/partition_subsetsum.rs"] +mod tests; diff --git a/src/unit_tests/rules/partition_subsetsum.rs b/src/unit_tests/rules/partition_subsetsum.rs new file mode 100644 index 000000000..9395e8571 --- /dev/null +++ b/src/unit_tests/rules/partition_subsetsum.rs @@ -0,0 +1,69 @@ +use super::*; +use crate::rules::test_helpers::assert_satisfaction_round_trip_from_satisfaction_target; +use crate::solvers::BruteForce; +use crate::traits::Problem; + +#[test] +fn test_partition_to_subsetsum_closed_loop() { + let source = Partition::new(vec![3, 1, 1, 2, 2, 1]); + let reduction = ReduceTo::::reduce_to(&source); + + assert_satisfaction_round_trip_from_satisfaction_target( + &source, + &reduction, + "Partition -> SubsetSum closed loop", + ); +} + +#[test] +fn test_partition_to_subsetsum_structure() { + let source = Partition::new(vec![3, 1, 1, 2, 2, 1]); + let reduction = ReduceTo::::reduce_to(&source); + let target = reduction.target_problem(); + + // Same number of elements + assert_eq!(target.num_elements(), source.num_elements()); + // Target is S/2 = 10/2 = 5 + assert_eq!(*target.target(), num_bigint::BigUint::from(5u32)); + // Sizes are preserved + let expected_sizes: Vec = vec![3u32, 1, 1, 2, 2, 1] + .into_iter() + .map(num_bigint::BigUint::from) + .collect(); + assert_eq!(target.sizes(), &expected_sizes); +} + +#[test] +fn test_partition_to_subsetsum_odd_total() { + // Odd total sum: 2 + 4 + 5 = 11, no balanced partition possible + let source = Partition::new(vec![2, 4, 5]); + let reduction = ReduceTo::::reduce_to(&source); + let target = reduction.target_problem(); + + // Trivially infeasible: empty sizes, target = 1 + assert_eq!(target.num_elements(), 0); + assert_eq!(*target.target(), num_bigint::BigUint::from(1u32)); + + // No witness should exist for the target + let witness = BruteForce::new().find_witness(target); + assert!(witness.is_none()); + + // extract_solution should return all-zeros for the source + let extracted = reduction.extract_solution(&[]); + assert_eq!(extracted, vec![0, 0, 0]); + // The extracted solution should not satisfy the source + assert!(!source.evaluate(&extracted)); +} + +#[test] +fn test_partition_to_subsetsum_equal_elements() { + // All equal: [2, 2, 2, 2], total = 8, target = 4 + let source = Partition::new(vec![2, 2, 2, 2]); + let reduction = ReduceTo::::reduce_to(&source); + + assert_satisfaction_round_trip_from_satisfaction_target( + &source, + &reduction, + "Partition -> SubsetSum equal elements", + ); +} From 97c073efe5906d8573310bd6d5f177753ccfdb05 Mon Sep 17 00:00:00 2001 From: zazabap Date: Sat, 28 Mar 2026 09:34:28 +0000 Subject: [PATCH 06/17] feat: add RootedTreeArrangement -> RootedTreeStorageAssignment reduction (#424) Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/paper/reductions.typ | 28 +++++ src/rules/mod.rs | 2 + ...arrangement_rootedtreestorageassignment.rs | 109 ++++++++++++++++++ ...arrangement_rootedtreestorageassignment.rs | 106 +++++++++++++++++ 4 files changed, 245 insertions(+) create mode 100644 src/rules/rootedtreearrangement_rootedtreestorageassignment.rs create mode 100644 src/unit_tests/rules/rootedtreearrangement_rootedtreestorageassignment.rs diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index e6abe1a56..3b20678e7 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -9700,6 +9700,34 @@ The following reductions to Integer Linear Programming are straightforward formu _Solution extraction._ For each tree vertex $u$, output the unique graph vertex $v$ with $x_(u,v) = 1$. ] +#let rta_rtsa = load-example("RootedTreeArrangement", "RootedTreeStorageAssignment") +#let rta_rtsa_sol = rta_rtsa.solutions.at(0) +#reduction-rule("RootedTreeArrangement", "RootedTreeStorageAssignment", + example: true, + example-caption: [Path graph $P_4$ ($n = 4$, $|E| = 3$, $K = 5$)], + extra: [ + #pred-commands( + "pred create --example " + problem-spec(rta_rtsa.source) + " -o rta.json", + "pred reduce rta.json --to " + target-spec(rta_rtsa) + " -o bundle.json", + "pred solve bundle.json", + "pred evaluate rta.json --config " + rta_rtsa_sol.source_config.map(str).join(","), + ) + Source: path graph $P_4$ with vertices ${0, 1, 2, 3}$, edges $\{0,1\}, \{1,2\}, \{2,3\}$, and bound $K = 5$. \ + Target: universe $X = {0, 1, 2, 3}$, subsets $\{0,1\}, \{1,2\}, \{2,3\}$, bound $K' = 5 - 3 = 2$. \ + The chain tree $0 arrow 1 arrow 2 arrow 3$ (parent array $(0, 0, 1, 2)$) with identity mapping gives total stretch $1 + 1 + 1 = 3 <= 5$ in the source. In the target, every edge subset is already a parent-child pair, so extension cost is $0 + 0 + 0 = 0 <= 2$ #sym.checkmark + ], +)[ + This $O(|E|)$ reduction @gavril1977 transforms a graph-embedding arrangement problem into a set-system path-cover problem. Each edge $\{u, v\}$ of the source graph becomes a 2-element required subset $\{u, v\}$ whose elements must lie on a directed path in a rooted tree. The bound adjusts by $K' = K - |E|$, since each edge contributes at least 1 to the arrangement cost but 0 to the extension cost when its endpoints are adjacent in the tree. +][ + _Construction._ Given a Rooted Tree Arrangement instance with graph $G = (V, E)$ and bound $K$, construct a Rooted Tree Storage Assignment instance as follows. Set the universe $X = V$ with $|X| = |V|$ elements. For each edge $\{u, v\} in E$, create a 2-element subset $X_e = \{u, v\}$, yielding a collection $cal(C) = \{X_e : e in E\}$ of $|E|$ subsets. Set the bound $K' = K - |E|$. + + _Correctness._ ($arrow.r.double$) Suppose there exists a rooted tree $T$ on $|V|$ nodes and a bijection $f: V arrow U$ with $sum_(\{u,v\} in E) d_T(f(u), f(v)) <= K$, where every edge pair lies on a common root-to-leaf path. Using $T$ as the storage tree and the identity embedding (since $X = V$), for each edge $e = \{u, v\}$ the extended subset $X'_e$ consists of all nodes on the path from $f(u)$ to $f(v)$, costing $d_T(f(u), f(v)) - 1$ additional elements. The total extension cost is $sum_(e in E) (d_T(f(u), f(v)) - 1) = (sum d_T) - |E| <= K - |E| = K'$. + + ($arrow.l.double$) Suppose there exists a rooted tree $T = (X, A)$ and extended subsets forming directed paths with total extension cost $<= K'$. The same tree $T$ with the identity mapping $f(v) = v$ gives total arrangement stretch $= "extension cost" + |E| <= K' + |E| = K$. + + _Solution extraction._ The target solution is a parent array defining a rooted tree on $X = V$. The source solution is this same parent array concatenated with the identity mapping $f(v) = v$ for all $v in V$. +] + #reduction-rule("RootedTreeStorageAssignment", "ILP")[ Choose one parent for each non-root element, enforce acyclicity with depth variables, and linearize the path-extension cost of every subset by selecting its top and bottom vertices in the rooted tree. ][ diff --git a/src/rules/mod.rs b/src/rules/mod.rs index 27b324394..bc628dd7e 100644 --- a/src/rules/mod.rs +++ b/src/rules/mod.rs @@ -58,6 +58,7 @@ pub(crate) mod partition_sequencingwithinintervals; pub(crate) mod partition_shortestweightconstrainedpath; pub(crate) mod partition_subsetsum; pub(crate) mod partitionintopathsoflength2_boundedcomponentspanningforest; +pub(crate) mod rootedtreearrangement_rootedtreestorageassignment; pub(crate) mod sat_circuitsat; pub(crate) mod sat_coloring; pub(crate) mod sat_ksat; @@ -298,6 +299,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec; + type Target = RootedTreeStorageAssignment; + + fn target_problem(&self) -> &Self::Target { + &self.target + } + + /// Extract a source solution from a target solution. + /// + /// The target config is a parent array defining a rooted tree on X = V. + /// The source config is [parent_array | identity_mapping] since X = V + /// means the mapping f is the identity. + fn extract_solution(&self, target_solution: &[usize]) -> Vec { + let n = self.num_vertices; + // target_solution is the parent array of the rooted tree on X = V + // Source config = [parent_array, identity_mapping] + let mut source_config = target_solution.to_vec(); + // Append identity mapping: f(v) = v for all v + source_config.extend(0..n); + source_config + } +} + +#[reduction( + overhead = { + universe_size = "num_vertices", + num_subsets = "num_edges", + } +)] +impl ReduceTo for RootedTreeArrangement { + type Result = ReductionRootedTreeArrangementToRootedTreeStorageAssignment; + + fn reduce_to(&self) -> Self::Result { + let n = self.num_vertices(); + let edges = self.graph().edges(); + let num_edges = edges.len(); + + // Each edge becomes a 2-element subset + let subsets: Vec> = edges.iter().map(|&(u, v)| vec![u, v]).collect(); + + // Bound K' = K - |E|; saturate at 0 to avoid underflow + let bound = self.bound().saturating_sub(num_edges); + + let target = RootedTreeStorageAssignment::new(n, subsets, bound); + + ReductionRootedTreeArrangementToRootedTreeStorageAssignment { + target, + 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: "rootedtreearrangement_to_rootedtreestorageassignment", + build: || { + // Path graph P4: 0-1-2-3, bound K=5 + // Optimal tree: chain 0->1->2->3 (root=0), identity mapping + // Total distance = 1+1+1 = 3 <= 5 + // Target: universe_size=4, subsets={{0,1},{1,2},{2,3}}, bound=5-3=2 + // Target tree: parent=[0,0,1,2], identity mapping + // Extension cost = 0+0+0 = 0 <= 2 + let source = + RootedTreeArrangement::new(SimpleGraph::new(4, vec![(0, 1), (1, 2), (2, 3)]), 5); + let source_config = vec![0, 0, 1, 2, 0, 1, 2, 3]; + let target_config = vec![0, 0, 1, 2]; + crate::example_db::specs::rule_example_with_witness::<_, RootedTreeStorageAssignment>( + source, + SolutionPair { + source_config, + target_config, + }, + ) + }, + }] +} + +#[cfg(test)] +#[path = "../unit_tests/rules/rootedtreearrangement_rootedtreestorageassignment.rs"] +mod tests; diff --git a/src/unit_tests/rules/rootedtreearrangement_rootedtreestorageassignment.rs b/src/unit_tests/rules/rootedtreearrangement_rootedtreestorageassignment.rs new file mode 100644 index 000000000..9198b996e --- /dev/null +++ b/src/unit_tests/rules/rootedtreearrangement_rootedtreestorageassignment.rs @@ -0,0 +1,106 @@ +use super::*; +use crate::rules::test_helpers::assert_satisfaction_round_trip_from_satisfaction_target; +use crate::solvers::BruteForce; + +#[test] +fn test_rootedtreearrangement_to_rootedtreestorageassignment_closed_loop() { + // Path graph P4: 0-1-2-3, bound K=5 + // Optimal chain tree gives total distance 3 <= 5 + let source = RootedTreeArrangement::new(SimpleGraph::new(4, vec![(0, 1), (1, 2), (2, 3)]), 5); + let reduction = ReduceTo::::reduce_to(&source); + assert_satisfaction_round_trip_from_satisfaction_target(&source, &reduction, "P4 path graph"); +} + +#[test] +fn test_rootedtreearrangement_to_rootedtreestorageassignment_target_structure() { + // Triangle graph: 3 vertices, 3 edges, bound K=6 + let source = RootedTreeArrangement::new(SimpleGraph::new(3, vec![(0, 1), (0, 2), (1, 2)]), 6); + let reduction = ReduceTo::::reduce_to(&source); + let target = reduction.target_problem(); + + // Universe size = num_vertices = 3 + assert_eq!(target.universe_size(), 3); + // Num subsets = num_edges = 3 + assert_eq!(target.num_subsets(), 3); + // Bound = K - |E| = 6 - 3 = 3 + assert_eq!(target.bound(), 3); + // Each subset is a 2-element set from an edge + for subset in target.subsets() { + assert_eq!(subset.len(), 2); + } +} + +#[test] +fn test_rootedtreearrangement_to_rootedtreestorageassignment_star_graph() { + // Star graph K_{1,3}: center=0, leaves=1,2,3 + // Bound K=3 (optimal: root at 0, each leaf distance 1, total=3) + let source = RootedTreeArrangement::new(SimpleGraph::new(4, vec![(0, 1), (0, 2), (0, 3)]), 3); + let reduction = ReduceTo::::reduce_to(&source); + let target = reduction.target_problem(); + + // K' = 3 - 3 = 0 (no extensions needed for a star rooted at center) + assert_eq!(target.bound(), 0); + assert_satisfaction_round_trip_from_satisfaction_target(&source, &reduction, "star K_{1,3}"); +} + +#[test] +fn test_rootedtreearrangement_to_rootedtreestorageassignment_unsatisfiable() { + // K4 with tight bound: any tree on 4 vertices has at most 3 edges on + // root-to-leaf paths. K4 has 6 edges, and its minimum total stretch + // on a chain tree is 1+1+1+2+2+3=10. With K=7 it should be infeasible. + let source = RootedTreeArrangement::new( + SimpleGraph::new(4, vec![(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)]), + 7, + ); + let reduction = ReduceTo::::reduce_to(&source); + let target = reduction.target_problem(); + + // K' = 7 - 6 = 1 + assert_eq!(target.bound(), 1); + + // Both source and target should be unsatisfiable + let solver = BruteForce::new(); + assert!( + solver.find_witness(&source).is_none(), + "K4 with K=7 should be unsatisfiable" + ); + assert!( + solver.find_witness(target).is_none(), + "target should also be unsatisfiable" + ); +} + +#[test] +fn test_rootedtreearrangement_to_rootedtreestorageassignment_solution_extraction() { + // Simple edge: 2 vertices, 1 edge {0,1}, bound K=1 + let source = RootedTreeArrangement::new(SimpleGraph::new(2, vec![(0, 1)]), 1); + let reduction = ReduceTo::::reduce_to(&source); + let target = reduction.target_problem(); + + // Target: universe_size=2, subsets={{0,1}}, bound=0 + assert_eq!(target.universe_size(), 2); + assert_eq!(target.bound(), 0); + + // Target solution: parent array [0, 0] means tree rooted at 0 with 1->0 + let target_config = vec![0, 0]; + let source_config = reduction.extract_solution(&target_config); + + // Source config should be [parent_array | identity_mapping] = [0, 0, 0, 1] + assert_eq!(source_config, vec![0, 0, 0, 1]); + // Verify it's valid for the source + assert!(source.is_valid_solution(&source_config)); +} + +#[test] +fn test_rootedtreearrangement_to_rootedtreestorageassignment_empty_graph() { + // Graph with no edges + let source = RootedTreeArrangement::new(SimpleGraph::new(3, vec![]), 0); + let reduction = ReduceTo::::reduce_to(&source); + let target = reduction.target_problem(); + + assert_eq!(target.universe_size(), 3); + assert_eq!(target.num_subsets(), 0); + assert_eq!(target.bound(), 0); + + assert_satisfaction_round_trip_from_satisfaction_target(&source, &reduction, "empty graph"); +} From a3e82096f7575ce6c76eb0206815ff55a9917226 Mon Sep 17 00:00:00 2001 From: zazabap Date: Sat, 28 Mar 2026 09:29:53 +0000 Subject: [PATCH 07/17] feat: add SubsetSum -> CapacityAssignment reduction (#426) Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/paper/reductions.typ | 51 +++++++++ src/rules/mod.rs | 2 + src/rules/subsetsum_capacityassignment.rs | 106 ++++++++++++++++++ .../rules/subsetsum_capacityassignment.rs | 92 +++++++++++++++ 4 files changed, 251 insertions(+) create mode 100644 src/rules/subsetsum_capacityassignment.rs create mode 100644 src/unit_tests/rules/subsetsum_capacityassignment.rs diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index 3b20678e7..7a09faae5 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -6993,6 +6993,57 @@ where $P$ is a penalty weight large enough that any constraint violation costs m ] } +#{ + let ss-ca = load-example("SubsetSum", "CapacityAssignment") + let ss-ca-sol = ss-ca.solutions.at(0) + let ss-ca-sizes = ss-ca.source.instance.sizes.map(int) + let ss-ca-target = int(ss-ca.source.instance.target) + let ss-ca-n = ss-ca-sizes.len() + let ss-ca-S = ss-ca-sizes.fold(0, (a, b) => a + b) + let ss-ca-J = ss-ca-S - ss-ca-target + let ss-ca-selected = ss-ca-sol.source_config.enumerate().filter(((i, x)) => x == 1).map(((i, x)) => i) + let ss-ca-selected-sizes = ss-ca-selected.map(i => ss-ca-sizes.at(i)) + let ss-ca-selected-sum = ss-ca-selected-sizes.fold(0, (a, b) => a + b) + let ss-ca-not-selected = ss-ca-sol.source_config.enumerate().filter(((i, x)) => x == 0).map(((i, x)) => i) + let ss-ca-delay-sum = ss-ca-not-selected.map(i => ss-ca-sizes.at(i)).fold(0, (a, b) => a + b) + [ + #reduction-rule("SubsetSum", "CapacityAssignment", + example: true, + example-caption: [#ss-ca-n elements, target sum $B = #ss-ca-target$], + extra: [ + #pred-commands( + "pred create --example SubsetSum -o subsetsum.json", + "pred reduce subsetsum.json --to " + target-spec(ss-ca) + " -o bundle.json", + "pred solve bundle.json", + "pred evaluate subsetsum.json --config " + ss-ca-sol.source_config.map(str).join(","), + ) + *Step 1 -- Source instance.* The canonical Subset Sum instance has sizes $(#ss-ca-sizes.map(str).join(", "))$ and target $B = #ss-ca-target$. The total sum is $S = #ss-ca-S$. + + *Step 2 -- Build the Capacity Assignment instance.* The reduction creates #ss-ca-n communication links with two capacities ${1, 2}$. For each link $c_i$ with element value $a_i$: cost row $(0, a_i)$ and delay row $(a_i, 0)$. The delay budget is $J = S - B = #ss-ca-S - #ss-ca-target = #ss-ca-J$. + + *Step 3 -- Verify the canonical witness.* The fixture stores source config $(#ss-ca-sol.source_config.map(str).join(", "))$, selecting elements at indices $#ss-ca-selected.map(str).join(", ")$ with values $#ss-ca-selected-sizes.map(str).join(" + ") = #ss-ca-selected-sum = B$. In the target, these links get high capacity (index 1) with total cost $#ss-ca-selected-sum$ and the remaining links get low capacity (index 0) with total delay $#ss-ca-delay-sum <= #ss-ca-J = J$. + + *Witness semantics.* The example DB stores one canonical witness. Other subsets summing to $B$ would also yield valid witnesses. + ], + )[ + This $O(n)$ reduction from Subset Sum to Capacity Assignment follows the original NP-completeness proof of Van Sickle and Chandy @vansicklechandy1977 (GJ SR7 @garey1979). Each element becomes a communication link with two capacity levels; the cost/delay duality encodes complementary subset selection. + ][ + _Construction._ Given sizes $a_1, dots, a_n in ZZ^+$ and target $B$, let $S = sum_(i=1)^n a_i$. Create $n$ communication links with capacity set $M = {1, 2}$. For each link $c_i$: + - Cost: $g(c_i, 1) = 0$, $g(c_i, 2) = a_i$ (non-decreasing since $0 <= a_i$). + - Delay: $d(c_i, 1) = a_i$, $d(c_i, 2) = 0$ (non-increasing since $a_i >= 0$). + Set the delay budget $J = S - B$. + + _Correctness._ For any assignment $sigma$, the total cost is $sum_(i: sigma(c_i)=2) a_i$ and the total delay is $sum_(i: sigma(c_i)=1) a_i$. Since every element contributes to exactly one of these sums, cost $+$ delay $= S$. + + ($arrow.r.double$) If $A' subset.eq A$ sums to $B$, assign $sigma(c_i) = 2$ for $a_i in A'$ and $sigma(c_i) = 1$ otherwise. Total cost $= B$, total delay $= S - B = J$. + + ($arrow.l.double$) The delay constraint forces delay $<= S - B$, so cost $>= S - (S - B) = B$. If the optimal cost equals $B$, the high-capacity links form a subset summing to exactly $B$. If no such subset exists, the minimum cost is strictly greater than $B$. + + _Solution extraction._ Return the target configuration unchanged: capacity index 1 (high) for link $c_i$ means element $a_i$ is selected. + ] + ] +} + #reduction-rule("ILP", "QUBO")[ A binary ILP optimizes a linear objective over binary variables subject to linear constraints. The penalty method converts each equality constraint $bold(a)_k^top bold(x) = b_k$ into the quadratic penalty $(bold(a)_k^top bold(x) - b_k)^2$, which is zero if and only if the constraint is satisfied. Inequality constraints are first converted to equalities using binary slack variables with powers-of-two coefficients. The resulting unconstrained quadratic over binary variables is a QUBO whose matrix $Q$ combines the negated objective (as diagonal terms) with the expanded constraint penalties (as a Gram matrix $A^top A$). ][ diff --git a/src/rules/mod.rs b/src/rules/mod.rs index bc628dd7e..55ccbf10f 100644 --- a/src/rules/mod.rs +++ b/src/rules/mod.rs @@ -68,6 +68,7 @@ pub(crate) mod satisfiability_naesatisfiability; mod spinglass_casts; pub(crate) mod spinglass_maxcut; pub(crate) mod spinglass_qubo; +pub(crate) mod subsetsum_capacityassignment; pub(crate) mod subsetsum_closestvectorproblem; #[cfg(test)] pub(crate) mod test_helpers; @@ -313,6 +314,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec &Self::Target { + &self.target + } + + /// Solution extraction: capacity index 1 (high) means the element is selected. + fn extract_solution(&self, target_solution: &[usize]) -> Vec { + target_solution.to_vec() + } +} + +#[reduction(overhead = { + num_links = "num_elements", + num_capacities = "2", +})] +impl ReduceTo for SubsetSum { + type Result = ReductionSubsetSumToCapacityAssignment; + + fn reduce_to(&self) -> Self::Result { + let n = self.num_elements(); + + // Capacities: {1, 2} + let capacities = vec![1, 2]; + + // For each element a_i: + // cost(c_i, 1) = 0 (low capacity = not selected) + // cost(c_i, 2) = a_i (high capacity = selected, costs a_i) + // delay(c_i, 1) = a_i (low capacity incurs delay a_i) + // delay(c_i, 2) = 0 (high capacity has zero delay) + let mut cost = Vec::with_capacity(n); + let mut delay = Vec::with_capacity(n); + + for size in self.sizes() { + let a_i: u64 = size + .try_into() + .expect("SubsetSum element must fit in u64 for CapacityAssignment reduction"); + cost.push(vec![0, a_i]); + delay.push(vec![a_i, 0]); + } + + // Delay budget J = S - B, where S = sum of all elements + let total_sum: u64 = self + .sizes() + .iter() + .map(|s| -> u64 { + s.try_into() + .expect("SubsetSum element must fit in u64 for CapacityAssignment reduction") + }) + .sum(); + let target_val: u64 = self + .target() + .try_into() + .expect("SubsetSum target must fit in u64 for CapacityAssignment reduction"); + let delay_budget = total_sum - target_val; + + ReductionSubsetSumToCapacityAssignment { + target: CapacityAssignment::new(capacities, cost, delay, delay_budget), + } + } +} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_rule_example_specs() -> Vec { + use crate::export::SolutionPair; + + vec![crate::example_db::specs::RuleExampleSpec { + id: "subsetsum_to_capacityassignment", + build: || { + // SubsetSum: sizes = [3, 7, 1, 8, 2, 4], target = 11 + // Solution: select elements 0 and 3 (values 3 and 8), sum = 11. + // In CapacityAssignment: config [1, 0, 0, 1, 0, 0] means + // links 0,3 get high capacity (index 1), others get low (index 0). + crate::example_db::specs::rule_example_with_witness::<_, CapacityAssignment>( + SubsetSum::new(vec![3u32, 7, 1, 8, 2, 4], 11u32), + SolutionPair { + source_config: vec![1, 0, 0, 1, 0, 0], + target_config: vec![1, 0, 0, 1, 0, 0], + }, + ) + }, + }] +} + +#[cfg(test)] +#[path = "../unit_tests/rules/subsetsum_capacityassignment.rs"] +mod tests; diff --git a/src/unit_tests/rules/subsetsum_capacityassignment.rs b/src/unit_tests/rules/subsetsum_capacityassignment.rs new file mode 100644 index 000000000..fb484ee34 --- /dev/null +++ b/src/unit_tests/rules/subsetsum_capacityassignment.rs @@ -0,0 +1,92 @@ +use super::*; +use crate::models::misc::{CapacityAssignment, SubsetSum}; +use crate::rules::test_helpers::assert_satisfaction_round_trip_from_optimization_target; +use crate::solvers::BruteForce; +use crate::traits::Problem; + +#[test] +fn test_subsetsum_to_capacityassignment_closed_loop() { + // YES instance: {3, 7, 1, 8, 2, 4}, target 11 → subset {3, 8} sums to 11 + let source = SubsetSum::new(vec![3u32, 7, 1, 8, 2, 4], 11u32); + let reduction = ReduceTo::::reduce_to(&source); + + assert_satisfaction_round_trip_from_optimization_target( + &source, + &reduction, + "SubsetSum -> CapacityAssignment closed loop", + ); +} + +#[test] +fn test_subsetsum_to_capacityassignment_structure() { + let source = SubsetSum::new(vec![3u32, 7, 1, 8, 4, 12], 15u32); + let reduction = ReduceTo::::reduce_to(&source); + let target = reduction.target_problem(); + + // 6 elements → 6 links, 2 capacities + assert_eq!(target.num_links(), 6); + assert_eq!(target.num_capacities(), 2); + assert_eq!(target.capacities(), &[1, 2]); + + // Check cost/delay for first link (a_0 = 3): + // cost(c_0, low) = 0, cost(c_0, high) = 3 + assert_eq!(target.cost()[0], vec![0, 3]); + // delay(c_0, low) = 3, delay(c_0, high) = 0 + assert_eq!(target.delay()[0], vec![3, 0]); + + // Delay budget = S - B = 35 - 15 = 20 + assert_eq!(target.delay_budget(), 20); +} + +#[test] +fn test_subsetsum_to_capacityassignment_no_instance() { + // NO instance: {1, 5, 11, 6}, target 4 → no subset sums to 4 + let source = SubsetSum::new(vec![1u32, 5, 11, 6], 4u32); + let reduction = ReduceTo::::reduce_to(&source); + let target = reduction.target_problem(); + + // S = 23, B = 4, delay_budget = 19 + assert_eq!(target.delay_budget(), 19); + + // The optimal CapacityAssignment cost should be > 4 (since no subset sums to 4) + let best = BruteForce::new() + .find_witness(target) + .expect("CapacityAssignment should have a feasible solution"); + let extracted = reduction.extract_solution(&best); + // The extracted config should NOT satisfy SubsetSum + assert!(!source.evaluate(&extracted)); +} + +#[test] +fn test_subsetsum_to_capacityassignment_small() { + // Two elements: {3, 3}, target 3 → subset {3} sums to 3 + let source = SubsetSum::new(vec![3u32, 3], 3u32); + let reduction = ReduceTo::::reduce_to(&source); + + assert_satisfaction_round_trip_from_optimization_target( + &source, + &reduction, + "SubsetSum [3,3] target 3 -> CapacityAssignment", + ); +} + +#[test] +fn test_subsetsum_to_capacityassignment_monotonicity() { + // Verify cost non-decreasing and delay non-increasing for all links + let source = SubsetSum::new(vec![5u32, 10, 15], 20u32); + let reduction = ReduceTo::::reduce_to(&source); + let target = reduction.target_problem(); + + for (link, cost_row) in target.cost().iter().enumerate() { + assert!( + cost_row.windows(2).all(|w| w[0] <= w[1]), + "cost row {link} must be non-decreasing" + ); + } + for (link, delay_row) in target.delay().iter().enumerate() { + assert!( + delay_row.windows(2).all(|w| w[0] >= w[1]), + "delay row {link} must be non-increasing" + ); + } +} From 5377f674170ea2fb8f7c0a8600c3c5e53c47f89a Mon Sep 17 00:00:00 2001 From: zazabap Date: Sat, 28 Mar 2026 09:32:40 +0000 Subject: [PATCH 08/17] feat: add LongestCommonSubsequence -> MaximumIndependentSet reduction (#109) Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/paper/reductions.typ | 30 +++ src/models/misc/longest_common_subsequence.rs | 19 ++ ...commonsubsequence_maximumindependentset.rs | 228 ++++++++++++++++++ src/rules/mod.rs | 2 + ...commonsubsequence_maximumindependentset.rs | 138 +++++++++++ 5 files changed, 417 insertions(+) create mode 100644 src/rules/longestcommonsubsequence_maximumindependentset.rs create mode 100644 src/unit_tests/rules/longestcommonsubsequence_maximumindependentset.rs diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index 7a09faae5..f44b11265 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -8099,6 +8099,36 @@ The following reductions to Integer Linear Programming are straightforward formu _Solution extraction._ From QUBO solution $x^*$, for each position $p$ find the unique vertex $v$ with $x^*_(v n + p) = 1$. Map consecutive position pairs to edge indices. ] +#let lcs_mis = load-example("LongestCommonSubsequence", "MaximumIndependentSet") +#let lcs_mis_sol = lcs_mis.solutions.at(0) +#reduction-rule("LongestCommonSubsequence", "MaximumIndependentSet", + example: true, + example-caption: [LCS of two strings over a 3-symbol alphabet], + extra: [ + #pred-commands( + "pred create --example LCS -o lcs.json", + "pred reduce lcs.json --to " + target-spec(lcs_mis) + " -o bundle.json", + "pred solve bundle.json", + "pred evaluate lcs.json --config " + lcs_mis_sol.source_config.map(str).join(","), + ) + Source LCS: config $(#lcs_mis_sol.source_config.map(str).join(", "))$ \ + Target MIS: $S = {#lcs_mis_sol.target_config.enumerate().filter(((i, x)) => x == 1).map(((i, x)) => str(i)).join(", ")}$ (size #lcs_mis_sol.target_config.filter(x => x == 1).len()) \ + MIS size $=$ LCS length $= #lcs_mis_sol.target_config.filter(x => x == 1).len()$ #sym.checkmark + ], +)[ + A match-node construction transforms a $k$-string LCS instance into a Maximum Independent Set problem on a conflict graph. Each vertex represents a $k$-tuple of positions (one per string) that all share the same character, and edges connect pairs that cannot coexist in any valid common subsequence. The MIS of this graph equals the LCS length. +][ + _Construction._ Given $k$ strings $s_1, dots, s_k$ over alphabet $Sigma$ (size $|Sigma|$): + + _Vertices:_ For each character $c in Sigma$, create a vertex for every $k$-tuple $(p_1, dots, p_k)$ where $s_i [p_i] = c$ for all $i$. The total vertex count equals $sum_(c in Sigma) product_(i=1)^k "count"(c, s_i)$. + + _Edges:_ Two vertices $u = (a_1, dots, a_k)$ and $v = (b_1, dots, b_k)$ are connected if they _conflict_ --- they cannot both appear in a valid common subsequence. A conflict occurs when the position differences are not consistently ordered: $not (forall i: a_i < b_i)$ and $not (forall i: a_i > b_i)$. + + _Correctness._ ($arrow.r.double$) A common subsequence of length $ell$ selects $ell$ match nodes whose positions are strictly increasing in every string, so no two are adjacent --- forming an independent set of size $ell$. ($arrow.l.double$) An independent set of size $ell$ consists of $ell$ mutually non-conflicting match nodes, meaning their positions are consistently ordered across all strings. Sorting by any string's position yields a valid common subsequence of length $ell$. + + _Solution extraction._ Sort the selected vertices by position in $s_1$. Read off the characters to obtain the common subsequence, then pad to `max_length` with the padding symbol. +] + #reduction-rule("LongestCommonSubsequence", "ILP")[ An optimization ILP formulation maximizes the length of a common subsequence. Binary variables choose a symbol (or padding) at each witness position. Match variables link active positions to source string indices, and the objective maximizes the number of non-padding positions. ][ diff --git a/src/models/misc/longest_common_subsequence.rs b/src/models/misc/longest_common_subsequence.rs index 6f945aede..7425ad942 100644 --- a/src/models/misc/longest_common_subsequence.rs +++ b/src/models/misc/longest_common_subsequence.rs @@ -118,6 +118,25 @@ impl LongestCommonSubsequence { pub fn num_transitions(&self) -> usize { self.max_length.saturating_sub(1) } + + /// Returns the cross-frequency product: the sum over each alphabet symbol + /// of the product of that symbol's frequency across all input strings. + /// + /// Formally: Σ_{c ∈ 0..alphabet_size} Π_{i=1..k} count(c, strings\[i\]) + /// where count(c, s) is the number of occurrences of symbol c in string s. + /// + /// This equals the exact number of match-node vertices in the LCS → MaxIS + /// reduction graph. + pub fn cross_frequency_product(&self) -> usize { + (0..self.alphabet_size) + .map(|c| { + self.strings + .iter() + .map(|s| s.iter().filter(|&&sym| sym == c).count()) + .product::() + }) + .sum() + } } /// Check whether `candidate` is a subsequence of `target` using greedy diff --git a/src/rules/longestcommonsubsequence_maximumindependentset.rs b/src/rules/longestcommonsubsequence_maximumindependentset.rs new file mode 100644 index 000000000..816bafc93 --- /dev/null +++ b/src/rules/longestcommonsubsequence_maximumindependentset.rs @@ -0,0 +1,228 @@ +//! Reduction from LongestCommonSubsequence to MaximumIndependentSet. +//! +//! Constructs a conflict graph where vertices are match-node k-tuples +//! (positions in each string that share the same character) and edges +//! connect conflicting tuples that cannot both appear in a valid common +//! subsequence. A maximum independent set in this graph corresponds to +//! a longest common subsequence. +//! +//! Reference: Santini, Blum, Djukanovic et al. (2021), +//! "Solving Longest Common Subsequence Problems via a Transformation +//! to the Maximum Clique Problem," Computers & Operations Research. + +use crate::models::graph::MaximumIndependentSet; +use crate::models::misc::LongestCommonSubsequence; +use crate::reduction; +use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::topology::SimpleGraph; +use crate::types::One; + +/// Result of reducing LongestCommonSubsequence to MaximumIndependentSet. +/// +/// Each vertex in the target graph corresponds to a match-node k-tuple +/// `(p_1, ..., p_k)` where all strings have the same character at their +/// respective positions. +#[derive(Debug, Clone)] +pub struct ReductionLCSToIS { + /// The target MaximumIndependentSet problem. + target: MaximumIndependentSet, + /// Match-node k-tuples: `match_nodes[v]` gives the position tuple for vertex v. + match_nodes: Vec>, + /// Character for each match node. + match_chars: Vec, + /// Maximum possible subsequence length in the source problem. + max_length: usize, + /// Alphabet size of the source problem (used as the padding symbol). + alphabet_size: usize, +} + +impl ReductionResult for ReductionLCSToIS { + type Source = LongestCommonSubsequence; + type Target = MaximumIndependentSet; + + fn target_problem(&self) -> &Self::Target { + &self.target + } + + /// Extract an LCS solution from a MaximumIndependentSet solution. + /// + /// Selected vertices correspond to match nodes. Sort by position in + /// the first string to get the subsequence order, then pad to `max_length`. + fn extract_solution(&self, target_solution: &[usize]) -> Vec { + // Collect selected match nodes with their characters + let mut selected: Vec<(usize, usize)> = target_solution + .iter() + .enumerate() + .filter(|(_, &v)| v == 1) + .map(|(i, _)| (self.match_nodes[i][0], self.match_chars[i])) + .collect(); + // Sort by position in the first string + selected.sort_by_key(|&(pos, _)| pos); + + // Build config: characters followed by padding + let mut config = Vec::with_capacity(self.max_length); + for &(_, ch) in &selected { + config.push(ch); + } + // Pad with alphabet_size (the padding symbol) + while config.len() < self.max_length { + config.push(self.alphabet_size); + } + config + } +} + +#[reduction( + overhead = { + num_vertices = "cross_frequency_product", + num_edges = "cross_frequency_product^2", + } +)] +impl ReduceTo> for LongestCommonSubsequence { + type Result = ReductionLCSToIS; + + fn reduce_to(&self) -> Self::Result { + let strings = self.strings(); + let k = self.num_strings(); + + // Step 1: Build match nodes. + // For each character c, find all k-tuples of positions where every + // string has character c at its respective position. + let mut match_nodes: Vec> = Vec::new(); + let mut match_chars: Vec = Vec::new(); + + for c in 0..self.alphabet_size() { + // For each string, collect positions where character c appears + let positions_per_string: Vec> = strings + .iter() + .map(|s| { + s.iter() + .enumerate() + .filter(|(_, &sym)| sym == c) + .map(|(i, _)| i) + .collect() + }) + .collect(); + + // Generate all k-tuples (Cartesian product of position lists) + let tuples = cartesian_product(&positions_per_string); + for tuple in tuples { + match_nodes.push(tuple); + match_chars.push(c); + } + } + + let num_vertices = match_nodes.len(); + + // Step 2: Build conflict edges. + // Two nodes u = (a_1, ..., a_k) and v = (b_1, ..., b_k) conflict when + // they cannot both appear in a valid common subsequence: NOT(all a_i < b_i) + // AND NOT(all a_i > b_i). + let mut edges: Vec<(usize, usize)> = Vec::new(); + + for i in 0..num_vertices { + for j in (i + 1)..num_vertices { + if nodes_conflict(&match_nodes[i], &match_nodes[j], k) { + edges.push((i, j)); + } + } + } + + let target = MaximumIndependentSet::new( + SimpleGraph::new(num_vertices, edges), + vec![One; num_vertices], + ); + + ReductionLCSToIS { + target, + match_nodes, + match_chars, + max_length: self.max_length(), + alphabet_size: self.alphabet_size(), + } + } +} + +/// Check whether two match nodes conflict (cannot both be in a common subsequence). +/// +/// Two nodes `u = (a_1, ..., a_k)` and `v = (b_1, ..., b_k)` conflict when +/// NOT (all a_i < b_i) AND NOT (all a_i > b_i). +fn nodes_conflict(u: &[usize], v: &[usize], k: usize) -> bool { + let mut all_less = true; + let mut all_greater = true; + for i in 0..k { + if u[i] >= v[i] { + all_less = false; + } + if u[i] <= v[i] { + all_greater = false; + } + } + !all_less && !all_greater +} + +/// Compute the Cartesian product of a list of position vectors. +fn cartesian_product(lists: &[Vec]) -> Vec> { + if lists.is_empty() { + return vec![vec![]]; + } + + let mut result = vec![vec![]]; + for list in lists { + let mut new_result = Vec::new(); + for prefix in &result { + for &item in list { + let mut new_tuple = prefix.clone(); + new_tuple.push(item); + new_result.push(new_tuple); + } + } + result = new_result; + } + result +} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_rule_example_specs() -> Vec { + use crate::export::SolutionPair; + + /// Build the example from the issue: k=2, s1="ABAC", s2="BACA", alphabet={A,B,C}. + fn lcs_abac_baca() -> LongestCommonSubsequence { + // A=0, B=1, C=2 + LongestCommonSubsequence::new( + 3, + vec![ + vec![0, 1, 0, 2], // ABAC + vec![1, 0, 2, 0], // BACA + ], + ) + } + + vec![crate::example_db::specs::RuleExampleSpec { + id: "longestcommonsubsequence_to_maximumindependentset", + build: || { + // Issue example: MIS solution {v2, v3, v5} gives LCS "BAC" (length 3). + // Match nodes (ordered by character): + // c=A(0): v0=(0,1), v1=(0,3), v3=(2,1), v4=(2,3) + // c=B(1): v2=(1,0) + // c=C(2): v5=(3,2) + // MIS {v2, v3, v5} => positions B@(1,0), A@(2,1), C@(3,2) + // target_config = [0, 0, 1, 1, 0, 1] + // source_config = [1, 0, 2, 3] (B, A, C, padding) + crate::example_db::specs::rule_example_with_witness::< + _, + MaximumIndependentSet, + >( + lcs_abac_baca(), + SolutionPair { + source_config: vec![1, 0, 2, 3], + target_config: vec![0, 0, 1, 1, 0, 1], + }, + ) + }, + }] +} + +#[cfg(test)] +#[path = "../unit_tests/rules/longestcommonsubsequence_maximumindependentset.rs"] +mod tests; diff --git a/src/rules/mod.rs b/src/rules/mod.rs index 55ccbf10f..505850570 100644 --- a/src/rules/mod.rs +++ b/src/rules/mod.rs @@ -35,6 +35,7 @@ pub(crate) mod ksatisfiability_kclique; pub(crate) mod ksatisfiability_minimumvertexcover; pub(crate) mod ksatisfiability_qubo; pub(crate) mod ksatisfiability_subsetsum; +pub(crate) mod longestcommonsubsequence_maximumindependentset; pub(crate) mod maximumclique_maximumindependentset; mod maximumindependentset_casts; mod maximumindependentset_gridgraph; @@ -280,6 +281,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec>::reduce_to(&lcs); + assert_optimization_round_trip_from_optimization_target( + &lcs, + &reduction, + "LCS->MIS (ABAC/BACA)", + ); +} + +#[test] +fn test_lcs_to_mis_graph_structure() { + // Issue example: should produce 6 vertices, 9 edges + let lcs = LongestCommonSubsequence::new( + 3, + vec![ + vec![0, 1, 0, 2], // ABAC + vec![1, 0, 2, 0], // BACA + ], + ); + let reduction = ReduceTo::>::reduce_to(&lcs); + let target = reduction.target_problem(); + + assert_eq!(target.graph().num_vertices(), 6); + assert_eq!(target.graph().num_edges(), 9); +} + +#[test] +fn test_lcs_to_mis_cross_frequency_product() { + // s1="ABAC" has A:2, B:1, C:1 + // s2="BACA" has A:2, B:1, C:1 + // cross_freq = 2*2 + 1*1 + 1*1 = 6 + let lcs = LongestCommonSubsequence::new(3, vec![vec![0, 1, 0, 2], vec![1, 0, 2, 0]]); + assert_eq!(lcs.cross_frequency_product(), 6); +} + +#[test] +fn test_lcs_to_mis_optimal_value() { + // LCS of "ABAC" and "BACA" is "BAC" (length 3) + let lcs = LongestCommonSubsequence::new(3, vec![vec![0, 1, 0, 2], vec![1, 0, 2, 0]]); + let reduction = ReduceTo::>::reduce_to(&lcs); + let target = reduction.target_problem(); + + let solver = BruteForce::new(); + let witness = solver.find_witness(target).expect("should have a solution"); + let mis_size: usize = witness.iter().sum(); + assert_eq!(mis_size, 3); +} + +#[test] +fn test_lcs_to_mis_three_strings() { + // k=3 strings over binary alphabet + let lcs = LongestCommonSubsequence::new(2, vec![vec![0, 1, 0], vec![1, 0, 1], vec![0, 1, 1]]); + let reduction = ReduceTo::>::reduce_to(&lcs); + assert_optimization_round_trip_from_optimization_target( + &lcs, + &reduction, + "LCS->MIS (3 strings)", + ); +} + +#[test] +fn test_lcs_to_mis_single_char_alphabet() { + // All same character: LCS = min length + let lcs = LongestCommonSubsequence::new(1, vec![vec![0, 0, 0], vec![0, 0]]); + let reduction = ReduceTo::>::reduce_to(&lcs); + assert_optimization_round_trip_from_optimization_target( + &lcs, + &reduction, + "LCS->MIS (single char)", + ); +} + +#[test] +fn test_lcs_to_mis_no_common_chars() { + // No common characters: LCS = 0 + let lcs = LongestCommonSubsequence::new(2, vec![vec![0, 0, 0], vec![1, 1, 1]]); + let reduction = ReduceTo::>::reduce_to(&lcs); + let target = reduction.target_problem(); + + // No match nodes since no character appears in both strings at any position + // cross_freq = 0*3 + 3*0 = 0 + assert_eq!(target.graph().num_vertices(), 0); + assert_eq!(lcs.cross_frequency_product(), 0); +} + +#[test] +fn test_lcs_to_mis_extract_solution() { + let lcs = LongestCommonSubsequence::new( + 3, + vec![ + vec![0, 1, 0, 2], // ABAC + vec![1, 0, 2, 0], // BACA + ], + ); + let reduction = ReduceTo::>::reduce_to(&lcs); + + // Vertices: A nodes at indices 0-3, B node at index 4, C node at index 5 + // Actually the ordering depends on implementation: char 0 (A) first, then 1 (B), then 2 (C) + // Let's verify by solving + let solver = BruteForce::new(); + let witness = solver + .find_witness(reduction.target_problem()) + .expect("should have a solution"); + let source_sol = reduction.extract_solution(&witness); + + // The extracted solution should be valid for the source + let value = lcs.evaluate(&source_sol); + assert!(value.0.is_some(), "extracted solution should be valid"); + assert_eq!(value.0.unwrap(), 3, "LCS length should be 3"); +} + +#[test] +fn test_lcs_to_mis_four_strings() { + // k=4 strings + let lcs = + LongestCommonSubsequence::new(2, vec![vec![0, 1], vec![1, 0], vec![0, 1], vec![1, 0]]); + let reduction = ReduceTo::>::reduce_to(&lcs); + assert_optimization_round_trip_from_optimization_target( + &lcs, + &reduction, + "LCS->MIS (4 strings)", + ); +} From 2aa02f4332830720b9d6a1655e012c9456884cda Mon Sep 17 00:00:00 2001 From: zazabap Date: Sat, 28 Mar 2026 09:36:25 +0000 Subject: [PATCH 09/17] feat: add MinimumVertexCover -> EnsembleComputation reduction (#204) Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/paper/reductions.typ | 10 ++ .../minimumvertexcover_ensemblecomputation.rs | 132 +++++++++++++++ src/rules/mod.rs | 2 + .../minimumvertexcover_ensemblecomputation.rs | 156 ++++++++++++++++++ 4 files changed, 300 insertions(+) create mode 100644 src/rules/minimumvertexcover_ensemblecomputation.rs create mode 100644 src/unit_tests/rules/minimumvertexcover_ensemblecomputation.rs diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index f44b11265..f9cbd6928 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -6691,6 +6691,16 @@ Each reduction is presented as a *Rule* (with linked problem names and overhead _Solution extraction._ For covering ${S_v : v in C}$, return VC $= C$ (same variable assignment). ] +#reduction-rule("MinimumVertexCover", "EnsembleComputation")[ + This $O(|V| + |E|)$ reduction @garey1979 encodes the vertex-cover constraint as an ensemble-computation problem over disjoint unions. A fresh element $a_0$ is introduced, and each edge becomes a 3-element target subset. The budget $J = |V| + |E|$ is an upper bound ensuring the instance is always satisfiable. Vertices used as singletons in the satisfying sequence form a valid vertex cover. +][ + _Construction._ Given a VC instance $(G = (V, E), bold(w))$, let $a_0$ be a fresh element not in $V$. Set the universe $A = V union {a_0}$ with $|A| = |V| + 1$. For each edge ${u, v} in E$, add the subset ${a_0, u, v}$ to the collection $C$. Set budget $J = |V| + |E|$. + + _Correctness._ ($arrow.r.double$) If $C'$ is a vertex cover of size $K$, label its elements $v_1, dots, v_K$ and the edges $e_1, dots, e_m$. Since $C'$ covers every edge, each $e_j = {u_j, v_(r[j])}$ where $v_(r[j]) in C'$. The sequence of $K + m$ operations $z_i = {a_0} union {v_i}$ for $i = 1, dots, K$ followed by $z_(K+j) = {u_j} union z_(r[j])$ for $j = 1, dots, m$ produces every target subset within $J$ steps. ($arrow.l.double$) Given a satisfying sequence of $j <= J$ operations, the set $V' = {u in V : z_i = {a_0} union {u} "appears in the sequence"}$ is a vertex cover: an exchange argument (Garey & Johnson, PO9) shows that any minimum-length sequence can be normalized to use only ${a_0} union {u}$ and ${v} union z_k$ forms, and each edge's target subset requires at least one endpoint to be among the ${a_0}$-augmented vertices. + + _Solution extraction._ Collect all vertices that appear as singleton operands (indices $< |V|$) in the satisfying sequence. These form a valid vertex cover, since every target subset ${a_0, u, v}$ requires both $u$ and $v$ to enter the computation chain as singletons. +] + #reduction-rule("MaximumMatching", "MaximumSetPacking")[ A matching selects edges that share no endpoints; set packing selects sets that share no elements. By representing each edge as the 2-element set of its endpoints and using vertices as the universe, two edges conflict (share an endpoint) if and only if their sets overlap. This embeds matching as a special case of set packing where every set has size exactly 2. ][ diff --git a/src/rules/minimumvertexcover_ensemblecomputation.rs b/src/rules/minimumvertexcover_ensemblecomputation.rs new file mode 100644 index 000000000..5bfd479b6 --- /dev/null +++ b/src/rules/minimumvertexcover_ensemblecomputation.rs @@ -0,0 +1,132 @@ +//! Reduction from MinimumVertexCover to EnsembleComputation. +//! +//! Given a graph G = (V, E), construct an EnsembleComputation instance where: +//! - Universe A = V ∪ {a₀} (fresh element a₀ at index |V|) +//! - Collection C = {{a₀, u, v} : {u,v} ∈ E} +//! - Budget J = |V| + |E| (upper bound) +//! +//! Reference: Garey & Johnson, *Computers and Intractability*, Appendix Problem PO9. + +use crate::models::graph::MinimumVertexCover; +use crate::models::misc::EnsembleComputation; +use crate::reduction; +use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::topology::{Graph, SimpleGraph}; + +/// Result of reducing MinimumVertexCover to EnsembleComputation. +#[derive(Debug, Clone)] +pub struct ReductionVCToEC { + target: EnsembleComputation, + /// Number of vertices in the source graph (= index of fresh element a₀). + num_vertices: usize, +} + +impl ReductionResult for ReductionVCToEC { + type Source = MinimumVertexCover; + type Target = EnsembleComputation; + + fn target_problem(&self) -> &Self::Target { + &self.target + } + + /// Extract a vertex cover from an EnsembleComputation witness. + /// + /// Every vertex that appears as a singleton operand (index < `num_vertices`) + /// in the sequence is included in the cover. This yields a valid cover because + /// every required subset {a₀, u, v} requires all three elements (a₀, u, v) to + /// enter the computation chain as singletons, so both endpoints of every edge + /// are included. The result is valid but not necessarily minimum. + fn extract_solution(&self, target_solution: &[usize]) -> Vec { + let budget = self.target.budget(); + let mut cover = vec![0usize; self.num_vertices]; + + for step in 0..budget { + let left = target_solution[2 * step]; + let right = target_solution[2 * step + 1]; + + if left < self.num_vertices { + cover[left] = 1; + } + if right < self.num_vertices { + cover[right] = 1; + } + } + + cover + } +} + +#[reduction( + overhead = { + universe_size = "num_vertices + 1", + num_subsets = "num_edges", + } +)] +impl ReduceTo for MinimumVertexCover { + type Result = ReductionVCToEC; + + fn reduce_to(&self) -> Self::Result { + let num_vertices = self.graph().num_vertices(); + let edges = self.graph().edges(); + let num_edges = edges.len(); + let a0 = num_vertices; // fresh element index + + // Universe A = V ∪ {a₀}, size = |V| + 1 + let universe_size = num_vertices + 1; + + // Collection C: for each edge {u, v}, add subset {a₀, u, v} + let subsets: Vec> = edges.iter().map(|&(u, v)| vec![a0, u, v]).collect(); + + // Budget J = |V| + |E| (upper bound: K* ≤ |V| always) + let budget = num_vertices + num_edges; + + let target = EnsembleComputation::new(universe_size, subsets, budget); + + ReductionVCToEC { + target, + num_vertices, + } + } +} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_rule_example_specs() -> Vec { + use crate::export::SolutionPair; + + vec![crate::example_db::specs::RuleExampleSpec { + id: "minimumvertexcover_to_ensemblecomputation", + build: || { + // Single edge graph: 2 vertices {0,1}, 1 edge (0,1) + // Minimum vertex cover K* = 1 (either {0} or {1}) + // Budget = 2 + 1 = 3, universe_size = 3, a₀ = 2 + // Subsets = {{0,1,2}} + let source = MinimumVertexCover::new(SimpleGraph::new(2, vec![(0, 1)]), vec![1i32; 2]); + + // Satisfying sequence for cover {0}: + // Step 0: {a₀=2} ∪ {0} → z₀ = {0,2} operands: (2, 0) + // Step 1: {1} ∪ z₀ → z₁ = {0,1,2} ✓ operands: (1, 3) where 3 = universe_size + 0 + // Step 2: padding {a₀=2} ∪ {1} operands: (2, 1) + // + // Extraction picks up all singleton vertex operands: vertex 0 (step 0), + // vertex 1 (steps 1 and 2). The extracted cover {0,1} is valid. + let target_config = vec![ + 2, 0, // step 0: {a₀} ∪ {0} + 1, 3, // step 1: {1} ∪ z₀ + 2, 1, // step 2: padding + ]; + let source_config = vec![1, 1]; + + crate::example_db::specs::rule_example_with_witness::<_, EnsembleComputation>( + source, + SolutionPair { + source_config, + target_config, + }, + ) + }, + }] +} + +#[cfg(test)] +#[path = "../unit_tests/rules/minimumvertexcover_ensemblecomputation.rs"] +mod tests; diff --git a/src/rules/mod.rs b/src/rules/mod.rs index 505850570..e4e05f0a8 100644 --- a/src/rules/mod.rs +++ b/src/rules/mod.rs @@ -47,6 +47,7 @@ pub(crate) mod maximummatching_maximumsetpacking; mod maximumsetpacking_casts; pub(crate) mod maximumsetpacking_qubo; pub(crate) mod minimummultiwaycut_qubo; +pub(crate) mod minimumvertexcover_ensemblecomputation; pub(crate) mod minimumvertexcover_maximumindependentset; pub(crate) mod minimumvertexcover_minimumfeedbackarcset; pub(crate) mod minimumvertexcover_minimumfeedbackvertexset; @@ -303,6 +304,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec bool { + for (u, v) in graph.edges() { + if config[u] == 0 && config[v] == 0 { + return false; + } + } + true +} + +#[test] +fn test_minimumvertexcover_to_ensemblecomputation_closed_loop() { + // Single edge: 2 vertices, 1 edge (0,1) + // K* = 1, budget = 3, universe_size = 3 + let graph = SimpleGraph::new(2, vec![(0, 1)]); + let source = MinimumVertexCover::new(graph.clone(), vec![1i32; 2]); + let reduction = ReduceTo::::reduce_to(&source); + let target = reduction.target_problem(); + + // Verify target structure + assert_eq!(target.universe_size(), 3); // |V| + 1 + assert_eq!(target.num_subsets(), 1); // |E| + assert_eq!(target.budget(), 3); // |V| + |E| + + // Solve target with brute force + let solver = BruteForce::new(); + let witnesses = solver.find_all_witnesses(target); + assert!(!witnesses.is_empty(), "EC instance should be satisfiable"); + + // Every extracted solution must be a valid vertex cover + for witness in &witnesses { + let source_config = reduction.extract_solution(witness); + assert_eq!(source_config.len(), 2); + assert!( + is_valid_cover(&graph, &source_config), + "Extracted config {:?} is not a valid vertex cover (from target witness {:?})", + source_config, + witness + ); + } +} + +#[test] +fn test_reduction_structure_triangle() { + // Triangle K₃: 3 vertices, 3 edges + let graph = SimpleGraph::new(3, vec![(0, 1), (1, 2), (0, 2)]); + let source = MinimumVertexCover::new(graph, vec![1i32; 3]); + let reduction = ReduceTo::::reduce_to(&source); + let target = reduction.target_problem(); + + // Verify sizes + assert_eq!(target.universe_size(), 4); // 3 + 1 + assert_eq!(target.num_subsets(), 3); // 3 edges + assert_eq!(target.budget(), 6); // 3 + 3 + + // Verify subsets: each edge {u,v} maps to {a₀=3, u, v} + let subsets = target.subsets(); + assert_eq!(subsets.len(), 3); + // Subsets are normalized (sorted), so {3,0,1} → [0,1,3] + assert!(subsets.contains(&vec![0, 1, 3])); + assert!(subsets.contains(&vec![1, 2, 3])); + assert!(subsets.contains(&vec![0, 2, 3])); +} + +#[test] +fn test_reduction_structure_path() { + // Path P₃: 3 vertices {0,1,2}, 2 edges + let graph = SimpleGraph::new(3, vec![(0, 1), (1, 2)]); + let source = MinimumVertexCover::new(graph, vec![1i32; 3]); + let reduction = ReduceTo::::reduce_to(&source); + let target = reduction.target_problem(); + + assert_eq!(target.universe_size(), 4); + assert_eq!(target.num_subsets(), 2); + assert_eq!(target.budget(), 5); // 3 + 2 +} + +#[test] +fn test_extract_solution_correctness() { + // Single edge: vertices {0,1}, edge (0,1), a₀ = 2 + let graph = SimpleGraph::new(2, vec![(0, 1)]); + let source = MinimumVertexCover::new(graph.clone(), vec![1i32; 2]); + let reduction = ReduceTo::::reduce_to(&source); + + // Manually construct a target config for cover {0,1}: + // Step 0: {a₀=2} ∪ {0} → z₀ = {0,2} operands: (2, 0) + // Step 1: {1} ∪ z₀ → z₁ = {0,1,2} operands: (1, 3) + // Step 2: padding {a₀=2} ∪ {1} operands: (2, 1) + let config = vec![2, 0, 1, 3, 2, 1]; + + // Verify config is a valid EC witness + let target = reduction.target_problem(); + assert_eq!(target.evaluate(&config), Or(true)); + + // Extract and verify — singleton extraction picks up vertices 0 (step 0) + // and 1 (steps 1 and 2), giving a valid cover + let cover = reduction.extract_solution(&config); + assert_eq!(cover, vec![1, 1]); + assert!(is_valid_cover(&graph, &cover)); +} + +#[test] +fn test_extract_from_non_normalized_witness() { + // Test extraction from a witness that uses {u} ∪ {v} before combining with a₀ + // Single edge: vertices {0,1}, edge (0,1), a₀ = 2, budget = 3 + let graph = SimpleGraph::new(2, vec![(0, 1)]); + let source = MinimumVertexCover::new(graph.clone(), vec![1i32; 2]); + let reduction = ReduceTo::::reduce_to(&source); + + // Non-normalized sequence: + // Step 0: {0} ∪ {1} → z₀ = {0,1} operands: (0, 1) + // Step 1: {a₀=2} ∪ z₀ → z₁ = {0,1,2} ✓ operands: (2, 3) + // Step 2: {a₀=2} ∪ {0} → padding operands: (2, 0) + let config = vec![0, 1, 2, 3, 2, 0]; + + let target = reduction.target_problem(); + assert_eq!(target.evaluate(&config), Or(true)); + + // Both vertices 0 and 1 appear as singletons + let cover = reduction.extract_solution(&config); + assert_eq!(cover, vec![1, 1]); + assert!(is_valid_cover(&graph, &cover)); +} + +#[test] +fn test_empty_graph() { + // Graph with vertices but no edges: any empty cover works + let graph = SimpleGraph::new(3, vec![]); + let source = MinimumVertexCover::new(graph.clone(), vec![1i32; 3]); + let reduction = ReduceTo::::reduce_to(&source); + let target = reduction.target_problem(); + + assert_eq!(target.universe_size(), 4); + assert_eq!(target.num_subsets(), 0); + assert_eq!(target.budget(), 3); // 3 + 0 + + // With no subsets, EC is trivially satisfiable + let solver = BruteForce::new(); + let witnesses = solver.find_all_witnesses(target); + assert!(!witnesses.is_empty()); + + // Any extraction from any witness should be a valid cover (empty is valid for no edges) + for witness in &witnesses { + let cover = reduction.extract_solution(witness); + assert!(is_valid_cover(&graph, &cover)); + } +} From 79239311729313937e2ff1de7d6b1317d3e4fba0 Mon Sep 17 00:00:00 2001 From: zazabap Date: Sat, 28 Mar 2026 09:37:16 +0000 Subject: [PATCH 10/17] feat: add KClique -> BalancedCompleteBipartiteSubgraph reduction (#231) Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/paper/reductions.typ | 48 ++++++ ...lique_balancedcompletebipartitesubgraph.rs | 138 ++++++++++++++++ src/rules/mod.rs | 2 + src/unit_tests/rules/analysis.rs | 5 + ...lique_balancedcompletebipartitesubgraph.rs | 149 ++++++++++++++++++ 5 files changed, 342 insertions(+) create mode 100644 src/rules/kclique_balancedcompletebipartitesubgraph.rs create mode 100644 src/unit_tests/rules/kclique_balancedcompletebipartitesubgraph.rs diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index f9cbd6928..ef5273c34 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -8352,6 +8352,54 @@ The following reductions to Integer Linear Programming are straightforward formu _Solution extraction._ $K = {v : x_v = 1}$. ] +#{ + let kc_bcbs = load-example("KClique", "BalancedCompleteBipartiteSubgraph", + source-variant: ("graph": "SimpleGraph")) + let kc_bcbs_sol = kc_bcbs.solutions.at(0) + let src = kc_bcbs.source.instance + let tgt = kc_bcbs.target.instance + let n = src.graph.num_vertices + let m = src.graph.edges.len() + let k = src.k + let ck2 = calc.div-euclid(k * (k - 1), 2) + let n_prime = n + ck2 + let target_k = n_prime - k + [ +#reduction-rule("KClique", "BalancedCompleteBipartiteSubgraph", + example: true, + example-caption: [#n-vertex graph with $k = #k$: non-incidence gadget construction], + extra: [ + #pred-commands( + "pred create --example " + problem-spec(kc_bcbs.source) + " -o kclique.json", + "pred reduce kclique.json --to " + target-spec(kc_bcbs) + " -o bundle.json", + "pred solve bundle.json", + "pred evaluate kclique.json --config " + kc_bcbs_sol.source_config.map(str).join(","), + ) + + *Step 1 -- Pad the vertex set.* $C(#k, 2) = #ck2$ padding vertices are added, giving $n' = #n + #ck2 = #n_prime$ left vertices (Part $A$). + + *Step 2 -- Build Part $B$.* Part $B$ has $#m$ edge elements (one per original edge) plus $#n - #k = #{n - k}$ padding elements, for $|B| = #tgt.graph.right_size$. + + *Step 3 -- Bipartite edges.* For each $v in A$ and edge element $e_j = {u, w}$, add $(v, e_j)$ iff $v in.not {u, w}$ (non-incidence). All padding elements connect to all left vertices. + + *Step 4 -- Set target parameter.* $K' = n' - k = #n_prime - #k = #target_k$. + + *Step 5 -- Verify a solution.* The #k-clique is $S = {#kc_bcbs_sol.source_config.enumerate().filter(((i, x)) => x == 1).map(((i, _)) => str(i)).join(", ")}$. The #target_k left vertices NOT in $S$ plus the #ck2 padding vertices form the left side $A'$. The right side $B'$ contains the #ck2 intra-clique edge elements plus #{n - k} padding elements ($|B'| = #target_k$). All $#target_k times #target_k$ cross-edges are present because no $v in A'$ is an endpoint of any selected edge element. + + *Multiplicity:* The fixture stores one canonical witness. + ], +)[ + This $O(n^2 + n m)$ reduction (Johnson, 1987; Garey and Johnson GT24) constructs a bipartite graph $H = (A union.dot B, F)$ with $|A| = n + binom(k, 2)$ and $|B| = m + n - k$ using a non-incidence encoding. The target biclique size is $K' = n + binom(k, 2) - k$. +][ + _Construction._ Given $k$-Clique instance $(G = (V, E), k)$ with $n = |V|$, $m = |E|$: Let $C = binom(k, 2) = k(k-1)/2$. Add $C$ isolated vertices to $V$, giving $V' = {v_0, ..., v_(n'-1)}$ with $n' = n + C$. Part $A = V'$. Part $B$ has $m$ _edge elements_ ${e_0, ..., e_(m-1)}$ (one per edge of $G$) and $n - k$ _padding elements_ ${w_0, ..., w_(n-k-1)}$. Add bipartite edge $(v, e_j)$ iff $v$ is NOT an endpoint of $e_j$ (non-incidence). Add $(v, w_i)$ for all $v in A$, $w_i in W$ (full padding). Set $K' = n' - k$. + + _Correctness._ ($arrow.r.double$) If $S subset.eq V$ is a $k$-clique, let $A' = V' without S$ ($|A'| = n' - k = K'$) and $B' = E(S) union W$ where $E(S)$ is the set of intra-clique edges. Since $|E(S)| = C$ and $|W| = n - k$, we have $|B'| = K'$. For any $v in A'$ and $e_j in E(S)$: both endpoints of $e_j$ lie in $S$ but $v in.not S$, so $v$ is not an endpoint --- the non-incidence edge exists. For padding elements, all edges exist by construction. ($arrow.l.double$) If $(A', B')$ is a balanced $K'$-biclique, let $S = {v in V : v in.not A'}$ with $|S| = k$. Any edge $e_j$ with an endpoint $u in A'$ cannot be in $B'$ (since $(u, e_j) in.not F$). So $B' sect E subset.eq E(S)$. Since $|B'| = K'$ and $|W| = n - k$, we need $|B' sect E| >= K' - |W| = C$. But $|E(S)| <= binom(k, 2) = C$, so $|E(S)| = C$, meaning $S$ is a $k$-clique. + + _Solution extraction._ For each original vertex $v in {0, ..., n-1}$: $"source"[v] = 1 - "target"[v]$ (vertices NOT selected on the left side form the clique). +] + ] +} + #reduction-rule("MaximalIS", "ILP")[ An independent set that is also maximal: no vertex outside the set can be added without violating independence. ][ diff --git a/src/rules/kclique_balancedcompletebipartitesubgraph.rs b/src/rules/kclique_balancedcompletebipartitesubgraph.rs new file mode 100644 index 000000000..0c84772a3 --- /dev/null +++ b/src/rules/kclique_balancedcompletebipartitesubgraph.rs @@ -0,0 +1,138 @@ +//! Reduction from KClique to BalancedCompleteBipartiteSubgraph. +//! +//! Classical reduction attributed to Garey and Johnson (GT24) and published in +//! Johnson (1987). Given a KClique instance (G, k), constructs a bipartite graph +//! where Part A = padded vertex set and Part B = edge elements + padding elements, +//! with non-incidence adjacency encoding. + +use crate::models::graph::{BalancedCompleteBipartiteSubgraph, KClique}; +use crate::reduction; +use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::topology::{BipartiteGraph, Graph, SimpleGraph}; + +/// Result of reducing KClique to BalancedCompleteBipartiteSubgraph. +/// +/// Stores the target problem and the number of original vertices for +/// solution extraction. +#[derive(Debug, Clone)] +pub struct ReductionKCliqueToBCBS { + target: BalancedCompleteBipartiteSubgraph, + /// Number of vertices in the original graph (before padding). + num_original_vertices: usize, +} + +impl ReductionResult for ReductionKCliqueToBCBS { + type Source = KClique; + type Target = BalancedCompleteBipartiteSubgraph; + + fn target_problem(&self) -> &BalancedCompleteBipartiteSubgraph { + &self.target + } + + /// Extract KClique solution from BalancedCompleteBipartiteSubgraph solution. + /// + /// The k-clique is S = {v in V : v not in A'}, i.e., the original vertices + /// NOT selected on the left side. For each original vertex v (0..n-1): + /// source_config[v] = 1 - target_config[v]. + fn extract_solution(&self, target_solution: &[usize]) -> Vec { + (0..self.num_original_vertices) + .map(|v| 1 - target_solution[v]) + .collect() + } +} + +#[reduction( + overhead = { + left_size = "num_vertices + k * (k - 1) / 2", + right_size = "num_edges + num_vertices - k", + k = "num_vertices + k * (k - 1) / 2 - k", + } +)] +impl ReduceTo for KClique { + type Result = ReductionKCliqueToBCBS; + + fn reduce_to(&self) -> Self::Result { + let n = self.num_vertices(); + let k = self.k(); + let edges: Vec<(usize, usize)> = self.graph().edges(); + let m = edges.len(); + + // C(k, 2) = k*(k-1)/2 — number of edges in a k-clique + let ck2 = k * (k - 1) / 2; + + // Part A (left partition): n' = n + C(k,2) vertices + let left_size = n + ck2; + + // Part B (right partition): m edge elements + (n - k) padding elements + let num_padding = n - k; + let right_size = m + num_padding; + + // Target biclique parameter: K' = n' - k + let target_k = left_size - k; + + // Build bipartite edges using non-incidence encoding + let mut bip_edges = Vec::new(); + + for v in 0..left_size { + // Edge elements: add edge (v, j) if v is NOT an endpoint of edges[j] + for (j, &(u, w)) in edges.iter().enumerate() { + if v != u && v != w { + // For padded vertices (v >= n), they are never endpoints + // of any original edge, so they always connect. + bip_edges.push((v, j)); + } + } + + // Padding elements: always connected + for p in 0..num_padding { + bip_edges.push((v, m + p)); + } + } + + let graph = BipartiteGraph::new(left_size, right_size, bip_edges); + let target = BalancedCompleteBipartiteSubgraph::new(graph, target_k); + + ReductionKCliqueToBCBS { + target, + num_original_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: "kclique_to_balancedcompletebipartitesubgraph", + build: || { + // 4-vertex graph with edges {0,1}, {0,2}, {1,2}, {2,3}, k=3 + // Known 3-clique: {0, 1, 2} + let source = KClique::new(SimpleGraph::new(4, vec![(0, 1), (0, 2), (1, 2), (2, 3)]), 3); + // Source config: vertices {0,1,2} selected = [1,1,1,0] + // Target: left_size=7, right_size=5, k'=4 + // Left side: NOT selecting clique vertices -> select {3,4,5,6} + // target_config for left: [0,0,0,1,1,1,1] + // Right side: select edge elements for clique edges + padding + // e0={0,1}, e1={0,2}, e2={1,2} are clique edges -> select them + // e3={2,3} is not a clique edge -> don't select + // w0 is padding -> select + // target_config for right: [1,1,1,0,1] + // Full target config: [0,0,0,1,1,1,1, 1,1,1,0,1] + crate::example_db::specs::rule_example_with_witness::< + _, + BalancedCompleteBipartiteSubgraph, + >( + source, + SolutionPair { + source_config: vec![1, 1, 1, 0], + target_config: vec![0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 0, 1], + }, + ) + }, + }] +} + +#[cfg(test)] +#[path = "../unit_tests/rules/kclique_balancedcompletebipartitesubgraph.rs"] +mod tests; diff --git a/src/rules/mod.rs b/src/rules/mod.rs index e4e05f0a8..f6c43ef86 100644 --- a/src/rules/mod.rs +++ b/src/rules/mod.rs @@ -26,6 +26,7 @@ pub(crate) mod hamiltoniancircuit_strongconnectivityaugmentation; pub(crate) mod hamiltoniancircuit_travelingsalesman; pub(crate) mod hamiltonianpath_consecutiveonessubmatrix; pub(crate) mod hamiltonianpath_isomorphicspanningtree; +pub(crate) mod kclique_balancedcompletebipartitesubgraph; pub(crate) mod kclique_conjunctivebooleanquery; pub(crate) mod kclique_subgraphisomorphism; mod kcoloring_casts; @@ -279,6 +280,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec = [ // Composite through CircuitSAT → ILP is better ("Factoring", "ILP {variable: \"i32\"}"), + // KClique → BCBS → ILP is better than direct KClique → ILP + ( + "KClique {graph: \"SimpleGraph\"}", + "ILP {variable: \"bool\"}", + ), // K3-SAT → QUBO via SAT → CircuitSAT → SpinGlass chain ("KSatisfiability {k: \"K3\"}", "QUBO {weight: \"f64\"}"), // Knapsack -> ILP -> QUBO is better than the direct penalty reduction diff --git a/src/unit_tests/rules/kclique_balancedcompletebipartitesubgraph.rs b/src/unit_tests/rules/kclique_balancedcompletebipartitesubgraph.rs new file mode 100644 index 000000000..074fd4007 --- /dev/null +++ b/src/unit_tests/rules/kclique_balancedcompletebipartitesubgraph.rs @@ -0,0 +1,149 @@ +use super::*; +use crate::rules::test_helpers::assert_satisfaction_round_trip_from_satisfaction_target; +use crate::solvers::BruteForce; +use crate::traits::Problem; +use crate::types::Or; + +#[test] +fn test_kclique_to_balancedcompletebipartitesubgraph_closed_loop() { + // 4-vertex graph with edges {0,1}, {0,2}, {1,2}, {2,3}, k=3 + // Known 3-clique: {0, 1, 2} + let source = KClique::new(SimpleGraph::new(4, vec![(0, 1), (0, 2), (1, 2), (2, 3)]), 3); + let reduction = ReduceTo::::reduce_to(&source); + let target = reduction.target_problem(); + + // Verify target sizes + // left_size = n + C(k,2) = 4 + 3 = 7 + assert_eq!(target.left_size(), 7); + // right_size = m + (n - k) = 4 + 1 = 5 + assert_eq!(target.right_size(), 5); + // target k = left_size - k = 7 - 3 = 4 + assert_eq!(target.k(), 4); + + assert_satisfaction_round_trip_from_satisfaction_target( + &source, + &reduction, + "KClique->BalancedCompleteBipartiteSubgraph closed loop", + ); +} + +#[test] +fn test_kclique_to_bcbs_complete_graph() { + // K4 graph, k=3 -> should find a 3-clique + let source = KClique::new( + SimpleGraph::new(4, vec![(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)]), + 3, + ); + let reduction = ReduceTo::::reduce_to(&source); + let target = reduction.target_problem(); + + // left_size = 4 + 3 = 7, right_size = 6 + 1 = 7, target_k = 7 - 3 = 4 + assert_eq!(target.left_size(), 7); + assert_eq!(target.right_size(), 7); + assert_eq!(target.k(), 4); + + let bf = BruteForce::new(); + let witness = bf.find_witness(target).expect("K4 should contain K3"); + let extracted = reduction.extract_solution(&witness); + assert_eq!(source.evaluate(&extracted), Or(true)); + // Exactly 3 vertices should be selected + assert_eq!(extracted.iter().sum::(), 3); +} + +#[test] +fn test_kclique_to_bcbs_no_clique() { + // Path graph: 0-1-2-3, k=3 -> no 3-clique exists + let source = KClique::new(SimpleGraph::new(4, vec![(0, 1), (1, 2), (2, 3)]), 3); + let reduction = ReduceTo::::reduce_to(&source); + let target = reduction.target_problem(); + + // left_size = 4 + 3 = 7, right_size = 3 + 1 = 4, target_k = 7 - 3 = 4 + assert_eq!(target.left_size(), 7); + assert_eq!(target.right_size(), 4); + assert_eq!(target.k(), 4); + + // No balanced biclique should exist + let bf = BruteForce::new(); + let witness = bf.find_witness(target); + assert!( + witness.is_none(), + "path graph should not contain a 3-clique" + ); + + // Also verify brute force on source agrees + let source_witness = bf.find_witness(&source); + assert!(source_witness.is_none()); +} + +#[test] +fn test_kclique_to_bcbs_k_equals_2() { + // k=2 means we need an edge + let source = KClique::new(SimpleGraph::new(4, vec![(0, 1), (2, 3)]), 2); + let reduction = ReduceTo::::reduce_to(&source); + let target = reduction.target_problem(); + + // left_size = 4 + 1 = 5, right_size = 2 + 2 = 4, target_k = 5 - 2 = 3 + assert_eq!(target.left_size(), 5); + assert_eq!(target.right_size(), 4); + assert_eq!(target.k(), 3); + + let bf = BruteForce::new(); + let witness = bf + .find_witness(target) + .expect("graph has edges, so 2-clique exists"); + let extracted = reduction.extract_solution(&witness); + assert_eq!(source.evaluate(&extracted), Or(true)); + assert_eq!(extracted.iter().sum::(), 2); +} + +#[test] +fn test_kclique_to_bcbs_k_equals_1() { + // k=1: any graph has a 1-clique (single vertex) + let source = KClique::new(SimpleGraph::new(3, vec![(0, 1)]), 1); + let reduction = ReduceTo::::reduce_to(&source); + let target = reduction.target_problem(); + + // left_size = 3 + 0 = 3, right_size = 1 + 2 = 3, target_k = 3 - 1 = 2 + assert_eq!(target.left_size(), 3); + assert_eq!(target.right_size(), 3); + assert_eq!(target.k(), 2); + + let bf = BruteForce::new(); + let witness = bf.find_witness(target).expect("should find a 1-clique"); + let extracted = reduction.extract_solution(&witness); + assert_eq!(source.evaluate(&extracted), Or(true)); + assert_eq!(extracted.iter().sum::(), 1); +} + +#[test] +fn test_kclique_to_bcbs_bipartite_counterexample() { + // K_{3,3} bipartite graph: vertices {0,1,2} on left, {3,4,5} on right + // All 9 cross-edges. Max clique = 2 (bipartite => no triangles). + // k=3 should fail. + let source = KClique::new( + SimpleGraph::new( + 6, + vec![ + (0, 3), + (0, 4), + (0, 5), + (1, 3), + (1, 4), + (1, 5), + (2, 3), + (2, 4), + (2, 5), + ], + ), + 3, + ); + let reduction = ReduceTo::::reduce_to(&source); + let target = reduction.target_problem(); + + let bf = BruteForce::new(); + let witness = bf.find_witness(target); + assert!( + witness.is_none(), + "K_{{3,3}} has no 3-clique, so target should be unsatisfiable" + ); +} From 83075ae5157d17ffbec4085fd56784139fc4e66e Mon Sep 17 00:00:00 2001 From: zazabap Date: Sat, 28 Mar 2026 09:38:49 +0000 Subject: [PATCH 11/17] feat: add KColoring(K3) -> TwoDimensionalConsecutiveSets reduction (#437) Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/paper/reductions.typ | 10 ++ ...kcoloring_twodimensionalconsecutivesets.rs | 150 ++++++++++++++++++ src/rules/mod.rs | 2 + ...kcoloring_twodimensionalconsecutivesets.rs | 112 +++++++++++++ 4 files changed, 274 insertions(+) create mode 100644 src/rules/kcoloring_twodimensionalconsecutivesets.rs create mode 100644 src/unit_tests/rules/kcoloring_twodimensionalconsecutivesets.rs diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index ef5273c34..c6c2ca8d7 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -7655,6 +7655,16 @@ where $P$ is a penalty weight large enough that any constraint violation costs m _Solution extraction._ For each vertex $v$, find $c$ with $x_(v,c) = 1$; assign color $c$ to $v$. ] +#reduction-rule("KColoring", "TwoDimensionalConsecutiveSets")[ + @lipski1977fct A graph 3-coloring can be encoded as a partition problem on an alphabet. Each edge becomes a size-3 subset containing the two endpoint symbols plus a unique dummy symbol, and a valid 3-coloring corresponds to partitioning the alphabet into 3 groups where each edge-subset spans exactly 3 consecutive groups with one element per group. The reduction uses $n + m$ alphabet symbols and $m$ subsets for a graph with $n$ vertices and $m$ edges. +][ + _Construction._ Given $G = (V, E)$ with $|V| = n$ and $|E| = m$, build alphabet $Sigma = V union {d_e : e in E}$ of size $n + m$. For each edge $e = {u, v} in E$, define subset $Sigma_e = {u, v, d_e}$. The collection is $cal(C) = {Sigma_e : e in E}$ with $|cal(C)| = m$ subsets, each of size 3. + + _Correctness._ ($arrow.r.double$) Given a valid 3-coloring $chi: V arrow {1, 2, 3}$, define partition groups $X_c = {v in V : chi(v) = c}$ for $c in {1, 2, 3}$. For each edge $e = {u, v}$, assign dummy $d_e$ to the unique third color $c^* in {1, 2, 3} backslash {chi(u), chi(v)}$ (which exists since $chi(u) != chi(v)$). Then $Sigma_e = {u, v, d_e}$ has its three elements in three distinct groups ${X_(chi(u)), X_(chi(v)), X_(c^*)} = {X_1, X_2, X_3}$, which are consecutive with one element per group. ($arrow.l.double$) If a valid partition into $k$ groups exists, each size-3 subset ${u, v, d_e}$ must occupy 3 distinct consecutive groups. In particular, $u$ and $v$ are in different groups. Mapping groups to colors gives a valid 3-coloring. + + _Solution extraction._ The first $n$ symbols in the target configuration correspond to the graph vertices. Their group assignments, compressed to $0, 1, 2$, yield the 3-coloring. +] + #reduction-rule("Factoring", "ILP")[ Integer multiplication $p times q = N$ is a system of bilinear equations over binary factor bits with carry propagation. Each bit-product $p_i q_j$ is a bilinear term that McCormick linearization replaces with an auxiliary variable and three inequalities. The carry-chain equations are already linear, so the full system becomes a binary ILP with $O(m n)$ variables and constraints. ][ diff --git a/src/rules/kcoloring_twodimensionalconsecutivesets.rs b/src/rules/kcoloring_twodimensionalconsecutivesets.rs new file mode 100644 index 000000000..b1494f06d --- /dev/null +++ b/src/rules/kcoloring_twodimensionalconsecutivesets.rs @@ -0,0 +1,150 @@ +//! Reduction from KColoring (K3) to TwoDimensionalConsecutiveSets. +//! +//! Given a graph G = (V, E) with |V| = n and |E| = m, construct: +//! +//! - Alphabet: V union {d_e : e in E}, size n + m +//! - For each edge e = {u, v}, one subset {u, v, d_e} of size 3 +//! +//! A valid 3-coloring corresponds to a partition into 3 groups where each +//! edge-subset spans 3 consecutive groups with one element per group. +//! +//! Reference: Garey & Johnson, Appendix A4.2, p.230 (Lipski 1977). + +use crate::models::graph::KColoring; +use crate::models::set::TwoDimensionalConsecutiveSets; +use crate::reduction; +use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::topology::{Graph, SimpleGraph}; +use crate::variant::K3; + +/// Result of reducing KColoring to TwoDimensionalConsecutiveSets. +#[derive(Debug, Clone)] +pub struct ReductionKColoringToTDCS { + target: TwoDimensionalConsecutiveSets, + /// Number of vertices in the source graph. + num_vertices: usize, + /// Edges of the source graph (stored for solution extraction). + edges: Vec<(usize, usize)>, +} + +impl ReductionResult for ReductionKColoringToTDCS { + type Source = KColoring; + type Target = TwoDimensionalConsecutiveSets; + + fn target_problem(&self) -> &Self::Target { + &self.target + } + + /// Extract a 3-coloring from a TwoDimensionalConsecutiveSets solution. + /// + /// The target solution assigns each alphabet symbol to a group index. + /// The first `num_vertices` symbols correspond to graph vertices, + /// so their group assignments directly give a valid 3-coloring + /// (after remapping to colors 0, 1, 2). + fn extract_solution(&self, target_solution: &[usize]) -> Vec { + // The target solution is config[symbol] = group_index. + // Vertex symbols are indices 0..num_vertices. + // We need to remap the group indices to colors 0, 1, 2. + // Since there are only 3 groups used, we can directly use the group indices + // if they are already 0, 1, 2. But the target may use any 3 labels from + // 0..alphabet_size, so we need to compress them to 0..2. + + let vertex_groups: Vec = target_solution[..self.num_vertices].to_vec(); + + // Collect all distinct group indices used by all symbols and compress to 0..k-1 + let mut used_groups: Vec = target_solution.to_vec(); + used_groups.sort(); + used_groups.dedup(); + + let mut group_to_color = vec![0usize; self.num_vertices + self.edges.len()]; + for (color, &group) in used_groups.iter().enumerate() { + if group < group_to_color.len() { + group_to_color[group] = color; + } + } + + vertex_groups.iter().map(|&g| group_to_color[g]).collect() + } +} + +#[reduction( + overhead = { + alphabet_size = "num_vertices + num_edges", + num_subsets = "num_edges", + } +)] +impl ReduceTo for KColoring { + type Result = ReductionKColoringToTDCS; + + fn reduce_to(&self) -> Self::Result { + let n = self.graph().num_vertices(); + let edges: Vec<(usize, usize)> = self.graph().edges(); + let m = edges.len(); + let alphabet_size = n + m; + + // For each edge e_i = {u, v}, create subset {u, v, n + i} + let subsets: Vec> = edges + .iter() + .enumerate() + .map(|(i, &(u, v))| vec![u, v, n + i]) + .collect(); + + let target = TwoDimensionalConsecutiveSets::new(alphabet_size, subsets); + + ReductionKColoringToTDCS { + target, + num_vertices: n, + edges, + } + } +} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_rule_example_specs() -> Vec { + use crate::export::SolutionPair; + use crate::traits::Problem; + + vec![crate::example_db::specs::RuleExampleSpec { + id: "kcoloring_to_twodimensionalconsecutivesets", + build: || { + // Small 3-colorable graph: triangle with pendant + // 0 -- 1 -- 2 -- 0, plus 2 -- 3 + // 3-coloring: 0->0, 1->1, 2->2, 3->0 + let source = + KColoring::::new(SimpleGraph::new(4, vec![(0, 1), (1, 2), (0, 2), (2, 3)])); + let reduction = as ReduceTo< + TwoDimensionalConsecutiveSets, + >>::reduce_to(&source); + let target = reduction.target_problem(); + + // Source coloring: 0->0, 1->1, 2->2, 3->0 + // Target config: vertex 0->group 0, vertex 1->group 1, vertex 2->group 2, vertex 3->group 0 + // Dummies: + // d_{0,1} (symbol 4): colors used {0,1}, dummy->group 2 + // d_{1,2} (symbol 5): colors used {1,2}, dummy->group 0 + // d_{0,2} (symbol 6): colors used {0,2}, dummy->group 1 + // d_{2,3} (symbol 7): colors used {2,0}, dummy->group 1 + let source_config = vec![0, 1, 2, 0]; + let target_config = vec![0, 1, 2, 0, 2, 0, 1, 1]; + + // Verify the target config is valid + assert!( + target.evaluate(&target_config).0, + "canonical example target config must be valid" + ); + + crate::example_db::specs::assemble_rule_example( + &source, + target, + vec![SolutionPair { + source_config, + target_config, + }], + ) + }, + }] +} + +#[cfg(test)] +#[path = "../unit_tests/rules/kcoloring_twodimensionalconsecutivesets.rs"] +mod tests; diff --git a/src/rules/mod.rs b/src/rules/mod.rs index f6c43ef86..6485acfb2 100644 --- a/src/rules/mod.rs +++ b/src/rules/mod.rs @@ -30,6 +30,7 @@ pub(crate) mod kclique_balancedcompletebipartitesubgraph; pub(crate) mod kclique_conjunctivebooleanquery; pub(crate) mod kclique_subgraphisomorphism; mod kcoloring_casts; +pub(crate) mod kcoloring_twodimensionalconsecutivesets; mod knapsack_qubo; mod ksatisfiability_casts; pub(crate) mod ksatisfiability_kclique; @@ -283,6 +284,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec::new(SimpleGraph::new(3, vec![(0, 1), (1, 2), (0, 2)])); + let reduction = ReduceTo::::reduce_to(&source); + + assert_satisfaction_round_trip_from_satisfaction_target( + &source, + &reduction, + "K3-coloring triangle -> TDCS", + ); +} + +#[test] +fn test_kcoloring_to_tdcs_target_structure() { + // Graph with 4 vertices and 3 edges: path 0-1-2-3 + let source = KColoring::::new(SimpleGraph::new(4, vec![(0, 1), (1, 2), (2, 3)])); + let reduction = ReduceTo::::reduce_to(&source); + let target = reduction.target_problem(); + + // Alphabet: 4 vertices + 3 edges = 7 + assert_eq!(target.alphabet_size(), 7); + // One subset per edge + assert_eq!(target.num_subsets(), 3); + // Each subset has size 3 + for subset in target.subsets() { + assert_eq!(subset.len(), 3); + } +} + +#[test] +fn test_kcoloring_to_tdcs_non_3colorable() { + // K3 with an extra vertex connected to all 3: K4 restricted to 3 vertices + 1 + // Use K_3 + edge to make a non-3-colorable subgraph: vertex 0 connected to 1, 2; + // vertex 1 connected to 2; all three connected to vertex 3 + // This is K4 but we only check source side (target brute-force too slow). + let source = KColoring::::new(SimpleGraph::new( + 4, + vec![(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)], + )); + + let solver = BruteForce::new(); + let source_solutions = solver.find_all_witnesses(&source); + assert!(source_solutions.is_empty(), "K4 is not 3-colorable"); + + // Verify the reduction produces the correct structure + let reduction = ReduceTo::::reduce_to(&source); + let target = reduction.target_problem(); + assert_eq!(target.alphabet_size(), 10); // 4 vertices + 6 edges + assert_eq!(target.num_subsets(), 6); +} + +#[test] +fn test_kcoloring_to_tdcs_bipartite() { + // Path 0-1-2: bipartite, 2-colorable (hence 3-colorable) + let source = KColoring::::new(SimpleGraph::new(3, vec![(0, 1), (1, 2)])); + let reduction = ReduceTo::::reduce_to(&source); + + assert_satisfaction_round_trip_from_satisfaction_target( + &source, + &reduction, + "K3-coloring path -> TDCS", + ); +} + +#[test] +fn test_kcoloring_to_tdcs_single_edge() { + // Single edge: trivially 3-colorable + let source = KColoring::::new(SimpleGraph::new(2, vec![(0, 1)])); + let reduction = ReduceTo::::reduce_to(&source); + let target = reduction.target_problem(); + + assert_eq!(target.alphabet_size(), 3); // 2 vertices + 1 edge + assert_eq!(target.num_subsets(), 1); + + assert_satisfaction_round_trip_from_satisfaction_target( + &source, + &reduction, + "K3-coloring single edge -> TDCS", + ); +} + +#[test] +fn test_kcoloring_to_tdcs_extract_solution_valid() { + // Triangle: verify extracted coloring is valid + let source = KColoring::::new(SimpleGraph::new(3, vec![(0, 1), (1, 2), (0, 2)])); + let reduction = ReduceTo::::reduce_to(&source); + + let solver = BruteForce::new(); + let target_solutions = solver.find_all_witnesses(reduction.target_problem()); + + for target_sol in &target_solutions { + let source_sol = reduction.extract_solution(target_sol); + assert_eq!(source_sol.len(), 3); + // Verify it is a valid coloring + assert!( + source.evaluate(&source_sol).0, + "Extracted coloring must be valid: {:?}", + source_sol + ); + } +} From 6d6a370c975fbb36e2c76490babb73bc8b0bee66 Mon Sep 17 00:00:00 2001 From: zazabap Date: Sat, 28 Mar 2026 09:49:49 +0000 Subject: [PATCH 12/17] fix: correct LCS->MIS example_db target_config --- src/rules/longestcommonsubsequence_maximumindependentset.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/rules/longestcommonsubsequence_maximumindependentset.rs b/src/rules/longestcommonsubsequence_maximumindependentset.rs index 816bafc93..9f590b005 100644 --- a/src/rules/longestcommonsubsequence_maximumindependentset.rs +++ b/src/rules/longestcommonsubsequence_maximumindependentset.rs @@ -207,7 +207,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec positions B@(1,0), A@(2,1), C@(3,2) - // target_config = [0, 0, 1, 1, 0, 1] + // target_config = [0, 0, 1, 0, 1, 1] (v2=(2,1), v4=(1,0), v5=(3,2)) // source_config = [1, 0, 2, 3] (B, A, C, padding) crate::example_db::specs::rule_example_with_witness::< _, @@ -216,7 +216,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec Date: Sun, 29 Mar 2026 14:49:24 +0000 Subject: [PATCH 13/17] fix: escape #k-clique Typst variable reference in paper Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/paper/reductions.typ | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index 587e97a00..2d11e39c4 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -8473,7 +8473,7 @@ The following reductions to Integer Linear Programming are straightforward formu *Step 4 -- Set target parameter.* $K' = n' - k = #n_prime - #k = #target_k$. - *Step 5 -- Verify a solution.* The #k-clique is $S = {#kc_bcbs_sol.source_config.enumerate().filter(((i, x)) => x == 1).map(((i, _)) => str(i)).join(", ")}$. The #target_k left vertices NOT in $S$ plus the #ck2 padding vertices form the left side $A'$. The right side $B'$ contains the #ck2 intra-clique edge elements plus #{n - k} padding elements ($|B'| = #target_k$). All $#target_k times #target_k$ cross-edges are present because no $v in A'$ is an endpoint of any selected edge element. + *Step 5 -- Verify a solution.* The #k\-clique is $S = {#kc_bcbs_sol.source_config.enumerate().filter(((i, x)) => x == 1).map(((i, _)) => str(i)).join(", ")}$. The #target_k left vertices NOT in $S$ plus the #ck2 padding vertices form the left side $A'$. The right side $B'$ contains the #ck2 intra-clique edge elements plus #{n - k} padding elements ($|B'| = #target_k$). All $#target_k times #target_k$ cross-edges are present because no $v in A'$ is an endpoint of any selected edge element. *Multiplicity:* The fixture stores one canonical witness. ], From 4d7501aad80a17ba663b166283cacd9bbcd18129 Mon Sep 17 00:00:00 2001 From: zazabap Date: Sun, 29 Mar 2026 14:56:19 +0000 Subject: [PATCH 14/17] =?UTF-8?q?fix:=20add=20missing=20Streif2021=20bibli?= =?UTF-8?q?ography=20entry=20for=20PaintShop=E2=86=92QUBO?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/paper/references.bib | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/paper/references.bib b/docs/paper/references.bib index 3b63d53a5..051e93f7f 100644 --- a/docs/paper/references.bib +++ b/docs/paper/references.bib @@ -1454,3 +1454,15 @@ @techreport{plaisted1976 number = {STAN-CS-76-583}, year = {1976} } + +@article{Streif2021, + title = {Beating classical heuristics for the binary paint shop problem + with the quantum approximate optimization algorithm}, + author = {Streif, Michael and Yarkoni, Sheir and Skolik, Andrea + and Neukart, Florian and Leib, Martin}, + journal = {Physical Review A}, + volume = {104}, + pages = {012403}, + year = {2021}, + doi = {10.1103/PhysRevA.104.012403} +} From bc76f7cab847157a5818668bcf8f23c8e1e480da Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Mon, 30 Mar 2026 10:57:59 +0800 Subject: [PATCH 15/17] fix: replace deprecated 'sect' with 'inter' in Typst paper Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/paper/reductions.typ | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index ccc0eb396..998218b0c 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -8955,7 +8955,7 @@ The following reductions to Integer Linear Programming are straightforward formu ][ _Construction._ Given $k$-Clique instance $(G = (V, E), k)$ with $n = |V|$, $m = |E|$: Let $C = binom(k, 2) = k(k-1)/2$. Add $C$ isolated vertices to $V$, giving $V' = {v_0, ..., v_(n'-1)}$ with $n' = n + C$. Part $A = V'$. Part $B$ has $m$ _edge elements_ ${e_0, ..., e_(m-1)}$ (one per edge of $G$) and $n - k$ _padding elements_ ${w_0, ..., w_(n-k-1)}$. Add bipartite edge $(v, e_j)$ iff $v$ is NOT an endpoint of $e_j$ (non-incidence). Add $(v, w_i)$ for all $v in A$, $w_i in W$ (full padding). Set $K' = n' - k$. - _Correctness._ ($arrow.r.double$) If $S subset.eq V$ is a $k$-clique, let $A' = V' without S$ ($|A'| = n' - k = K'$) and $B' = E(S) union W$ where $E(S)$ is the set of intra-clique edges. Since $|E(S)| = C$ and $|W| = n - k$, we have $|B'| = K'$. For any $v in A'$ and $e_j in E(S)$: both endpoints of $e_j$ lie in $S$ but $v in.not S$, so $v$ is not an endpoint --- the non-incidence edge exists. For padding elements, all edges exist by construction. ($arrow.l.double$) If $(A', B')$ is a balanced $K'$-biclique, let $S = {v in V : v in.not A'}$ with $|S| = k$. Any edge $e_j$ with an endpoint $u in A'$ cannot be in $B'$ (since $(u, e_j) in.not F$). So $B' sect E subset.eq E(S)$. Since $|B'| = K'$ and $|W| = n - k$, we need $|B' sect E| >= K' - |W| = C$. But $|E(S)| <= binom(k, 2) = C$, so $|E(S)| = C$, meaning $S$ is a $k$-clique. + _Correctness._ ($arrow.r.double$) If $S subset.eq V$ is a $k$-clique, let $A' = V' without S$ ($|A'| = n' - k = K'$) and $B' = E(S) union W$ where $E(S)$ is the set of intra-clique edges. Since $|E(S)| = C$ and $|W| = n - k$, we have $|B'| = K'$. For any $v in A'$ and $e_j in E(S)$: both endpoints of $e_j$ lie in $S$ but $v in.not S$, so $v$ is not an endpoint --- the non-incidence edge exists. For padding elements, all edges exist by construction. ($arrow.l.double$) If $(A', B')$ is a balanced $K'$-biclique, let $S = {v in V : v in.not A'}$ with $|S| = k$. Any edge $e_j$ with an endpoint $u in A'$ cannot be in $B'$ (since $(u, e_j) in.not F$). So $B' inter E subset.eq E(S)$. Since $|B'| = K'$ and $|W| = n - k$, we need $|B' inter E| >= K' - |W| = C$. But $|E(S)| <= binom(k, 2) = C$, so $|E(S)| = C$, meaning $S$ is a $k$-clique. _Solution extraction._ For each original vertex $v in {0, ..., n-1}$: $"source"[v] = 1 - "target"[v]$ (vertices NOT selected on the left side form the clique). ] From 749401324590a3b64621eb4b3a3815cc19bb14b5 Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Mon, 30 Mar 2026 11:59:45 +0800 Subject: [PATCH 16/17] =?UTF-8?q?fix:=20change=20EnsembleComputation=20to?= =?UTF-8?q?=20optimization=20(Min)=20and=20fix=20MVC=E2=86=92EC=20r?= =?UTF-8?q?eduction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - EnsembleComputation: Value changed from Or (feasibility) to Min (minimize sequence length). The budget parameter remains as a search-space bound, but evaluate() now returns the number of steps used rather than just true/false. - MVC→EC reduction: source changed from MinimumVertexCover to MinimumVertexCover. The weighted variant was unsound because EC has no weight field. With both sides as Min, the optimal value relationship J* = K* + |E| is tight — no trivial upper bound needed. - Updated paper entries, model tests, and rule tests accordingly. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/paper/reductions.typ | 12 ++-- src/models/misc/ensemble_computation.rs | 67 +++++++++---------- .../minimumvertexcover_ensemblecomputation.rs | 42 +++++++----- .../models/misc/ensemble_computation.rs | 30 +++++++-- .../minimumvertexcover_ensemblecomputation.rs | 57 ++++++---------- 5 files changed, 109 insertions(+), 99 deletions(-) diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index 998218b0c..4b7652bea 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -3573,9 +3573,9 @@ A classical NP-complete problem from Garey and Johnson @garey1979[Ch.~3, p.~76], ] #problem-def("EnsembleComputation")[ - Given a finite set $A$, a collection $C$ of subsets of $A$, and a positive integer $J$, determine whether there exists a sequence $S = (z_1 <- x_1 union y_1, z_2 <- x_2 union y_2, dots, z_j <- x_j union y_j)$ of $j <= J$ union operations such that each operand $x_i, y_i$ is either a singleton ${a}$ for some $a in A$ or a previously computed set $z_k$ with $k < i$, the two operands are disjoint for every step, and every target subset $c in C$ is equal to some computed set $z_i$. + Given a finite set $A$, a collection $C$ of subsets of $A$, and a positive integer $J$ (search-space bound), find the minimum number of union operations in a sequence $S = (z_1 <- x_1 union y_1, z_2 <- x_2 union y_2, dots, z_j <- x_j union y_j)$ with $j <= J$ such that each operand $x_i, y_i$ is either a singleton ${a}$ for some $a in A$ or a previously computed set $z_k$ with $k < i$, the two operands are disjoint for every step, and every target subset $c in C$ is equal to some computed set $z_i$. ][ - Ensemble Computation is problem PO9 in Garey and Johnson @garey1979. It can be viewed as monotone circuit synthesis over set union: each operation introduces one reusable intermediate set, and the objective is simply to realize all targets within the given budget. The implementation in this library uses $2J$ operand variables with domain size $|A| + J$ and accepts as soon as some valid prefix has produced every target set, so the original "$j <= J$" semantics are preserved under brute-force enumeration. The resulting search space yields a straightforward exact upper bound of $(|A| + J)^(2J)$. Järvisalo, Kaski, Koivisto, and Korhonen study SAT encodings for finding efficient ensemble computations in a monotone-circuit setting @jarvisalo2012. + Ensemble Computation is problem PO9 in Garey and Johnson @garey1979. It can be viewed as monotone circuit synthesis over set union: each operation introduces one reusable intermediate set, and the objective is to realize all targets in the fewest operations. The original GJ formulation is a decision problem with a budget parameter $J$; this library models the optimization variant that minimizes the sequence length, using $J$ as a search-space bound. The implementation uses $2J$ operand variables with domain size $|A| + J$ and reports the first step at which all targets are produced. The resulting search space yields a straightforward exact upper bound of $(|A| + J)^(2J)$. Järvisalo, Kaski, Koivisto, and Korhonen study SAT encodings for finding efficient ensemble computations in a monotone-circuit setting @jarvisalo2012. *Example.* Let $A = {0, 1, 2, 3}$, $C = {{0, 1, 2}, {0, 1, 3}}$, and $J = 4$. A satisfying witness uses three essential unions: $z_1 = {0} union {1} = {0, 1}$, @@ -7226,13 +7226,13 @@ Each reduction is presented as a *Rule* (with linked problem names and overhead ] #reduction-rule("MinimumVertexCover", "EnsembleComputation")[ - This $O(|V| + |E|)$ reduction @garey1979 encodes the vertex-cover constraint as an ensemble-computation problem over disjoint unions. A fresh element $a_0$ is introduced, and each edge becomes a 3-element target subset. The budget $J = |V| + |E|$ is an upper bound ensuring the instance is always satisfiable. Vertices used as singletons in the satisfying sequence form a valid vertex cover. + This $O(|V| + |E|)$ reduction @garey1979 encodes the unit-weight vertex-cover problem as an ensemble-computation minimization over disjoint unions. A fresh element $a_0$ is introduced, and each edge becomes a 3-element target subset. The minimum sequence length equals $K^* + |E|$, where $K^*$ is the minimum vertex cover size. ][ - _Construction._ Given a VC instance $(G = (V, E), bold(w))$, let $a_0$ be a fresh element not in $V$. Set the universe $A = V union {a_0}$ with $|A| = |V| + 1$. For each edge ${u, v} in E$, add the subset ${a_0, u, v}$ to the collection $C$. Set budget $J = |V| + |E|$. + _Construction._ Given a unit-weight VC instance $G = (V, E)$, let $a_0$ be a fresh element not in $V$. Set the universe $A = V union {a_0}$ with $|A| = |V| + 1$. For each edge ${u, v} in E$, add the subset ${a_0, u, v}$ to the collection $C$. Set the search-space bound $J = |V| + |E|$. - _Correctness._ ($arrow.r.double$) If $C'$ is a vertex cover of size $K$, label its elements $v_1, dots, v_K$ and the edges $e_1, dots, e_m$. Since $C'$ covers every edge, each $e_j = {u_j, v_(r[j])}$ where $v_(r[j]) in C'$. The sequence of $K + m$ operations $z_i = {a_0} union {v_i}$ for $i = 1, dots, K$ followed by $z_(K+j) = {u_j} union z_(r[j])$ for $j = 1, dots, m$ produces every target subset within $J$ steps. ($arrow.l.double$) Given a satisfying sequence of $j <= J$ operations, the set $V' = {u in V : z_i = {a_0} union {u} "appears in the sequence"}$ is a vertex cover: an exchange argument (Garey & Johnson, PO9) shows that any minimum-length sequence can be normalized to use only ${a_0} union {u}$ and ${v} union z_k$ forms, and each edge's target subset requires at least one endpoint to be among the ${a_0}$-augmented vertices. + _Correctness._ ($arrow.r.double$) If $C'$ is a vertex cover of size $K$, label its elements $v_1, dots, v_K$ and the edges $e_1, dots, e_m$. Since $C'$ covers every edge, each $e_j = {u_j, v_(r[j])}$ where $v_(r[j]) in C'$. The sequence of $K + m$ operations $z_i = {a_0} union {v_i}$ for $i = 1, dots, K$ followed by $z_(K+j) = {u_j} union z_(r[j])$ for $j = 1, dots, m$ produces every target subset in exactly $K + |E|$ steps. ($arrow.l.double$) An exchange argument (Garey & Johnson, PO9) shows that any minimum-length sequence can be normalized to use only ${a_0} union {u}$ and ${v} union z_k$ forms. Each edge contributes exactly one operation of the second form, so the number of first-form operations equals the sequence length minus $|E|$. Since the first-form vertices must cover all edges, the minimum sequence length is $K^* + |E|$. - _Solution extraction._ Collect all vertices that appear as singleton operands (indices $< |V|$) in the satisfying sequence. These form a valid vertex cover, since every target subset ${a_0, u, v}$ requires both $u$ and $v$ to enter the computation chain as singletons. + _Solution extraction._ From an optimal witness, collect all vertices appearing as singleton operands (indices $< |V|$). In a minimum-length normalized sequence, exactly the $K^*$ cover vertices appear as ${a_0}$-paired singletons. ] #reduction-rule("MaximumMatching", "MaximumSetPacking")[ diff --git a/src/models/misc/ensemble_computation.rs b/src/models/misc/ensemble_computation.rs index 0de0d77f0..c9ad358af 100644 --- a/src/models/misc/ensemble_computation.rs +++ b/src/models/misc/ensemble_computation.rs @@ -2,6 +2,7 @@ use crate::registry::{FieldInfo, ProblemSchemaEntry}; use crate::traits::Problem; +use crate::types::Min; use serde::{Deserialize, Serialize}; inventory::submit! { @@ -11,7 +12,7 @@ inventory::submit! { aliases: &[], dimensions: &[], module_path: module_path!(), - description: "Determine whether required subsets can be built by a bounded sequence of disjoint unions", + description: "Find the minimum-length sequence of disjoint unions that builds all required subsets", fields: &[ FieldInfo { name: "universe_size", type_name: "usize", description: "Number of elements in the universe A" }, FieldInfo { name: "subsets", type_name: "Vec>", description: "Required subsets that must appear among the computed z_i values" }, @@ -146,49 +147,47 @@ impl EnsembleComputation { impl Problem for EnsembleComputation { const NAME: &'static str = "EnsembleComputation"; - type Value = crate::types::Or; + type Value = Min; fn dims(&self) -> Vec { vec![self.universe_size + self.budget; 2 * self.budget] } - fn evaluate(&self, config: &[usize]) -> crate::types::Or { - crate::types::Or({ - if config.len() != 2 * self.budget { - return crate::types::Or(false); - } + fn evaluate(&self, config: &[usize]) -> Min { + if config.len() != 2 * self.budget { + return Min(None); + } + + let Some(required_subsets) = self.required_subsets() else { + return Min(None); + }; + if required_subsets.is_empty() { + return Min(Some(0)); + } + + let mut computed = Vec::with_capacity(self.budget); + for step in 0..self.budget { + let left_operand = config[2 * step]; + let right_operand = config[2 * step + 1]; - let Some(required_subsets) = self.required_subsets() else { - return crate::types::Or(false); + let Some(left) = self.decode_operand(left_operand, &computed) else { + return Min(None); }; - if required_subsets.is_empty() { - return crate::types::Or(true); + let Some(right) = self.decode_operand(right_operand, &computed) else { + return Min(None); + }; + + if !Self::are_disjoint(&left, &right) { + return Min(None); } - let mut computed = Vec::with_capacity(self.budget); - for step in 0..self.budget { - let left_operand = config[2 * step]; - let right_operand = config[2 * step + 1]; - - let Some(left) = self.decode_operand(left_operand, &computed) else { - return crate::types::Or(false); - }; - let Some(right) = self.decode_operand(right_operand, &computed) else { - return crate::types::Or(false); - }; - - if !Self::are_disjoint(&left, &right) { - return crate::types::Or(false); - } - - computed.push(Self::union_disjoint(&left, &right)); - if Self::all_required_subsets_present(&required_subsets, &computed) { - return crate::types::Or(true); - } + computed.push(Self::union_disjoint(&left, &right)); + if Self::all_required_subsets_present(&required_subsets, &computed) { + return Min(Some(step + 1)); } + } - false - }) + Min(None) } fn variant() -> Vec<(&'static str, &'static str)> { @@ -227,7 +226,7 @@ pub(crate) fn canonical_model_example_specs() -> Vec; + type Source = MinimumVertexCover; type Target = EnsembleComputation; fn target_problem(&self) -> &Self::Target { @@ -31,11 +37,13 @@ impl ReductionResult for ReductionVCToEC { /// Extract a vertex cover from an EnsembleComputation witness. /// - /// Every vertex that appears as a singleton operand (index < `num_vertices`) - /// in the sequence is included in the cover. This yields a valid cover because - /// every required subset {a₀, u, v} requires all three elements (a₀, u, v) to - /// enter the computation chain as singletons, so both endpoints of every edge - /// are included. The result is valid but not necessarily minimum. + /// The GJ proof shows that any minimum-length sequence can be normalized + /// so that only two forms of operations appear: + /// - z_i = {a₀} ∪ {v} — vertex v is in the cover + /// - z_j = {u} ∪ z_k — edge {u, v_r} is covered by v_r + /// + /// We collect all vertices that appear as singleton operands (index < |V|). + /// In a minimum-length witness, exactly the cover vertices appear this way. fn extract_solution(&self, target_solution: &[usize]) -> Vec { let budget = self.target.budget(); let mut cover = vec![0usize; self.num_vertices]; @@ -62,7 +70,7 @@ impl ReductionResult for ReductionVCToEC { num_subsets = "num_edges", } )] -impl ReduceTo for MinimumVertexCover { +impl ReduceTo for MinimumVertexCover { type Result = ReductionVCToEC; fn reduce_to(&self) -> Self::Result { @@ -77,7 +85,8 @@ impl ReduceTo for MinimumVertexCover { // Collection C: for each edge {u, v}, add subset {a₀, u, v} let subsets: Vec> = edges.iter().map(|&(u, v)| vec![a0, u, v]).collect(); - // Budget J = |V| + |E| (upper bound: K* ≤ |V| always) + // Budget bounds the search space; the optimal sequence length + // is K* + |E| where K* is the minimum vertex cover size. let budget = num_vertices + num_edges; let target = EnsembleComputation::new(universe_size, subsets, budget); @@ -100,20 +109,21 @@ pub(crate) fn canonical_rule_example_specs() -> Vec( diff --git a/src/unit_tests/models/misc/ensemble_computation.rs b/src/unit_tests/models/misc/ensemble_computation.rs index eed2489eb..317e31124 100644 --- a/src/unit_tests/models/misc/ensemble_computation.rs +++ b/src/unit_tests/models/misc/ensemble_computation.rs @@ -1,6 +1,7 @@ use super::*; use crate::solvers::BruteForce; use crate::traits::Problem; +use crate::types::Min; fn issue_problem() -> EnsembleComputation { EnsembleComputation::new(4, vec![vec![0, 1, 2], vec![0, 1, 3]], 4) @@ -26,35 +27,36 @@ fn test_ensemble_computation_creation() { fn test_ensemble_computation_issue_witness() { let problem = issue_problem(); - assert!(problem.evaluate(&[0, 1, 4, 2, 4, 3, 0, 1])); + // 3 steps used: z1={0,1}, z2={0,1,2}, z3={0,1,3} + assert_eq!(problem.evaluate(&[0, 1, 4, 2, 4, 3, 0, 1]), Min(Some(3))); } #[test] fn test_ensemble_computation_rejects_future_reference() { let problem = issue_problem(); - assert!(!problem.evaluate(&[4, 1, 0, 1, 0, 1, 0, 1])); + assert_eq!(problem.evaluate(&[4, 1, 0, 1, 0, 1, 0, 1]), Min(None)); } #[test] fn test_ensemble_computation_rejects_overlapping_operands() { let problem = issue_problem(); - assert!(!problem.evaluate(&[0, 0, 4, 2, 4, 3, 0, 1])); + assert_eq!(problem.evaluate(&[0, 0, 4, 2, 4, 3, 0, 1]), Min(None)); } #[test] fn test_ensemble_computation_rejects_missing_required_subset() { let problem = issue_problem(); - assert!(!problem.evaluate(&[0, 1, 0, 1, 0, 1, 0, 1])); + assert_eq!(problem.evaluate(&[0, 1, 0, 1, 0, 1, 0, 1]), Min(None)); } #[test] fn test_ensemble_computation_rejects_wrong_config_length() { let problem = issue_problem(); - assert!(!problem.evaluate(&[0, 1, 4, 2])); + assert_eq!(problem.evaluate(&[0, 1, 4, 2]), Min(None)); } #[test] @@ -78,7 +80,7 @@ fn test_ensemble_computation_serialization_round_trip() { assert_eq!(round_trip.universe_size(), 4); assert_eq!(round_trip.num_subsets(), 2); assert_eq!(round_trip.budget(), 4); - assert!(round_trip.evaluate(&[0, 1, 4, 2, 4, 3, 0, 1])); + assert_eq!(round_trip.evaluate(&[0, 1, 4, 2, 4, 3, 0, 1]), Min(Some(3))); } #[test] @@ -100,5 +102,19 @@ fn test_ensemble_computation_deserialization_rejects_zero_budget() { fn test_ensemble_computation_paper_example() { let problem = issue_problem(); - assert!(problem.evaluate(&[0, 1, 4, 2, 4, 3, 0, 1])); + // Witness uses 3 steps to build both subsets + assert_eq!(problem.evaluate(&[0, 1, 4, 2, 4, 3, 0, 1]), Min(Some(3))); +} + +#[test] +fn test_ensemble_computation_optimal_value() { + // {0,1} and {0,1,2}: need at least 2 operations + // Step 1: {0} ∪ {1} → {0,1} + // Step 2: z1 ∪ {2} → {0,1,2} + let problem = EnsembleComputation::new(3, vec![vec![0, 1], vec![0, 1, 2]], 2); + let solver = BruteForce::new(); + + use crate::solvers::Solver; + let optimal = solver.solve(&problem); + assert_eq!(optimal, Min(Some(2))); } diff --git a/src/unit_tests/rules/minimumvertexcover_ensemblecomputation.rs b/src/unit_tests/rules/minimumvertexcover_ensemblecomputation.rs index b1c11526d..afb8d3af6 100644 --- a/src/unit_tests/rules/minimumvertexcover_ensemblecomputation.rs +++ b/src/unit_tests/rules/minimumvertexcover_ensemblecomputation.rs @@ -5,7 +5,7 @@ use crate::rules::ReductionResult; use crate::solvers::BruteForce; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; -use crate::types::Or; +use crate::types::{Min, One}; /// Verify that a configuration is a valid vertex cover. fn is_valid_cover(graph: &SimpleGraph, config: &[usize]) -> bool { @@ -20,9 +20,9 @@ fn is_valid_cover(graph: &SimpleGraph, config: &[usize]) -> bool { #[test] fn test_minimumvertexcover_to_ensemblecomputation_closed_loop() { // Single edge: 2 vertices, 1 edge (0,1) - // K* = 1, budget = 3, universe_size = 3 + // K* = 1, optimal EC length = K* + |E| = 2 let graph = SimpleGraph::new(2, vec![(0, 1)]); - let source = MinimumVertexCover::new(graph.clone(), vec![1i32; 2]); + let source = MinimumVertexCover::new(graph.clone(), vec![One; 2]); let reduction = ReduceTo::::reduce_to(&source); let target = reduction.target_problem(); @@ -31,12 +31,14 @@ fn test_minimumvertexcover_to_ensemblecomputation_closed_loop() { assert_eq!(target.num_subsets(), 1); // |E| assert_eq!(target.budget(), 3); // |V| + |E| - // Solve target with brute force + // Solve target with brute force — optimal value should be 2 (K*=1 + |E|=1) + use crate::solvers::Solver; let solver = BruteForce::new(); - let witnesses = solver.find_all_witnesses(target); - assert!(!witnesses.is_empty(), "EC instance should be satisfiable"); + let optimal = solver.solve(target); + assert_eq!(optimal, Min(Some(2))); // Every extracted solution must be a valid vertex cover + let witnesses = solver.find_all_witnesses(target); for witness in &witnesses { let source_config = reduction.extract_solution(witness); assert_eq!(source_config.len(), 2); @@ -53,7 +55,7 @@ fn test_minimumvertexcover_to_ensemblecomputation_closed_loop() { fn test_reduction_structure_triangle() { // Triangle K₃: 3 vertices, 3 edges let graph = SimpleGraph::new(3, vec![(0, 1), (1, 2), (0, 2)]); - let source = MinimumVertexCover::new(graph, vec![1i32; 3]); + let source = MinimumVertexCover::new(graph, vec![One; 3]); let reduction = ReduceTo::::reduce_to(&source); let target = reduction.target_problem(); @@ -65,7 +67,6 @@ fn test_reduction_structure_triangle() { // Verify subsets: each edge {u,v} maps to {a₀=3, u, v} let subsets = target.subsets(); assert_eq!(subsets.len(), 3); - // Subsets are normalized (sorted), so {3,0,1} → [0,1,3] assert!(subsets.contains(&vec![0, 1, 3])); assert!(subsets.contains(&vec![1, 2, 3])); assert!(subsets.contains(&vec![0, 2, 3])); @@ -75,7 +76,7 @@ fn test_reduction_structure_triangle() { fn test_reduction_structure_path() { // Path P₃: 3 vertices {0,1,2}, 2 edges let graph = SimpleGraph::new(3, vec![(0, 1), (1, 2)]); - let source = MinimumVertexCover::new(graph, vec![1i32; 3]); + let source = MinimumVertexCover::new(graph, vec![One; 3]); let reduction = ReduceTo::::reduce_to(&source); let target = reduction.target_problem(); @@ -88,21 +89,17 @@ fn test_reduction_structure_path() { fn test_extract_solution_correctness() { // Single edge: vertices {0,1}, edge (0,1), a₀ = 2 let graph = SimpleGraph::new(2, vec![(0, 1)]); - let source = MinimumVertexCover::new(graph.clone(), vec![1i32; 2]); + let source = MinimumVertexCover::new(graph.clone(), vec![One; 2]); let reduction = ReduceTo::::reduce_to(&source); - // Manually construct a target config for cover {0,1}: // Step 0: {a₀=2} ∪ {0} → z₀ = {0,2} operands: (2, 0) // Step 1: {1} ∪ z₀ → z₁ = {0,1,2} operands: (1, 3) // Step 2: padding {a₀=2} ∪ {1} operands: (2, 1) let config = vec![2, 0, 1, 3, 2, 1]; - // Verify config is a valid EC witness let target = reduction.target_problem(); - assert_eq!(target.evaluate(&config), Or(true)); + assert_eq!(target.evaluate(&config), Min(Some(2))); - // Extract and verify — singleton extraction picks up vertices 0 (step 0) - // and 1 (steps 1 and 2), giving a valid cover let cover = reduction.extract_solution(&config); assert_eq!(cover, vec![1, 1]); assert!(is_valid_cover(&graph, &cover)); @@ -110,22 +107,16 @@ fn test_extract_solution_correctness() { #[test] fn test_extract_from_non_normalized_witness() { - // Test extraction from a witness that uses {u} ∪ {v} before combining with a₀ - // Single edge: vertices {0,1}, edge (0,1), a₀ = 2, budget = 3 let graph = SimpleGraph::new(2, vec![(0, 1)]); - let source = MinimumVertexCover::new(graph.clone(), vec![1i32; 2]); + let source = MinimumVertexCover::new(graph.clone(), vec![One; 2]); let reduction = ReduceTo::::reduce_to(&source); - // Non-normalized sequence: - // Step 0: {0} ∪ {1} → z₀ = {0,1} operands: (0, 1) - // Step 1: {a₀=2} ∪ z₀ → z₁ = {0,1,2} ✓ operands: (2, 3) - // Step 2: {a₀=2} ∪ {0} → padding operands: (2, 0) + // Non-normalized: {0} ∪ {1} first, then {a₀} ∪ z₀ let config = vec![0, 1, 2, 3, 2, 0]; let target = reduction.target_problem(); - assert_eq!(target.evaluate(&config), Or(true)); + assert_eq!(target.evaluate(&config), Min(Some(2))); - // Both vertices 0 and 1 appear as singletons let cover = reduction.extract_solution(&config); assert_eq!(cover, vec![1, 1]); assert!(is_valid_cover(&graph, &cover)); @@ -133,24 +124,18 @@ fn test_extract_from_non_normalized_witness() { #[test] fn test_empty_graph() { - // Graph with vertices but no edges: any empty cover works let graph = SimpleGraph::new(3, vec![]); - let source = MinimumVertexCover::new(graph.clone(), vec![1i32; 3]); + let source = MinimumVertexCover::new(graph.clone(), vec![One; 3]); let reduction = ReduceTo::::reduce_to(&source); let target = reduction.target_problem(); assert_eq!(target.universe_size(), 4); assert_eq!(target.num_subsets(), 0); - assert_eq!(target.budget(), 3); // 3 + 0 + assert_eq!(target.budget(), 3); - // With no subsets, EC is trivially satisfiable + // No subsets → optimal value is 0 + use crate::solvers::Solver; let solver = BruteForce::new(); - let witnesses = solver.find_all_witnesses(target); - assert!(!witnesses.is_empty()); - - // Any extraction from any witness should be a valid cover (empty is valid for no edges) - for witness in &witnesses { - let cover = reduction.extract_solution(witness); - assert!(is_valid_cover(&graph, &cover)); - } + let optimal = solver.solve(target); + assert_eq!(optimal, Min(Some(0))); } From b322ef9d582fea1f96806e4215a261bdb0f8f80d Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Mon, 30 Mar 2026 12:12:45 +0800 Subject: [PATCH 17/17] feat: add default budget for EnsembleComputation, make --budget optional - Add `with_default_budget()` and `default_budget()` methods. Default is sum of subset sizes (worst-case without reuse), clamped to at least 1. - CLI: --budget is now optional; omitting it uses the default. - Paper: remove J from problem definition (it's a search-space bound, not a mathematical parameter). Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/paper/reductions.typ | 2 +- problemreductions-cli/src/commands/create.rs | 23 +++++++++----------- src/models/misc/ensemble_computation.rs | 17 +++++++++++++++ 3 files changed, 28 insertions(+), 14 deletions(-) diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index 4b7652bea..fd47115e7 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -3573,7 +3573,7 @@ A classical NP-complete problem from Garey and Johnson @garey1979[Ch.~3, p.~76], ] #problem-def("EnsembleComputation")[ - Given a finite set $A$, a collection $C$ of subsets of $A$, and a positive integer $J$ (search-space bound), find the minimum number of union operations in a sequence $S = (z_1 <- x_1 union y_1, z_2 <- x_2 union y_2, dots, z_j <- x_j union y_j)$ with $j <= J$ such that each operand $x_i, y_i$ is either a singleton ${a}$ for some $a in A$ or a previously computed set $z_k$ with $k < i$, the two operands are disjoint for every step, and every target subset $c in C$ is equal to some computed set $z_i$. + Given a finite set $A$ and a collection $C$ of subsets of $A$, find the minimum number of union operations in a sequence $S = (z_1 <- x_1 union y_1, z_2 <- x_2 union y_2, dots, z_j <- x_j union y_j)$ such that each operand $x_i, y_i$ is either a singleton ${a}$ for some $a in A$ or a previously computed set $z_k$ with $k < i$, the two operands are disjoint for every step, and every target subset $c in C$ is equal to some computed set $z_i$. ][ Ensemble Computation is problem PO9 in Garey and Johnson @garey1979. It can be viewed as monotone circuit synthesis over set union: each operation introduces one reusable intermediate set, and the objective is to realize all targets in the fewest operations. The original GJ formulation is a decision problem with a budget parameter $J$; this library models the optimization variant that minimizes the sequence length, using $J$ as a search-space bound. The implementation uses $2J$ operand variables with domain size $|A| + J$ and reports the first step at which all targets are produced. The resulting search space yields a straightforward exact upper bound of $(|A| + J)^(2J)$. Järvisalo, Kaski, Koivisto, and Korhonen study SAT encodings for finding efficient ensemble computations in a monotone-circuit setting @jarvisalo2012. diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index 9e5f5f304..764568d62 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -661,7 +661,7 @@ fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str { "SpinGlass" => "--graph 0-1,1-2 --couplings 1,1", "KColoring" => "--graph 0-1,1-2,2-0 --k 3", "HamiltonianCircuit" => "--graph 0-1,1-2,2-3,3-0", - "EnsembleComputation" => "--universe 4 --sets \"0,1,2;0,1,3\" --budget 4", + "EnsembleComputation" => "--universe 4 --sets \"0,1,2;0,1,3\"", "RootedTreeStorageAssignment" => "--universe 5 --sets \"0,2;1,3;0,4;2,4\" --bound 1", "MinMaxMulticenter" => { "--graph 0-1,1-2,2-3 --weights 1,1,1,1 --edge-weights 1,1,1 --k 2" @@ -2622,26 +2622,23 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { // EnsembleComputation "EnsembleComputation" => { let usage = - "Usage: pred create EnsembleComputation --universe 4 --sets \"0,1,2;0,1,3\" --budget 4"; + "Usage: pred create EnsembleComputation --universe 4 --sets \"0,1,2;0,1,3\" [--budget 4]"; let universe_size = args.universe.ok_or_else(|| { anyhow::anyhow!("EnsembleComputation requires --universe\n\n{usage}") })?; let subsets = parse_sets(args)?; - let budget = args - .budget - .as_deref() - .ok_or_else(|| anyhow::anyhow!("EnsembleComputation requires --budget\n\n{usage}"))? - .parse::() - .map_err(|e| { + let instance = if let Some(budget_str) = args.budget.as_deref() { + let budget = budget_str.parse::().map_err(|e| { anyhow::anyhow!( "Invalid --budget value for EnsembleComputation: {e}\n\n{usage}" ) })?; - ( - ser(EnsembleComputation::try_new(universe_size, subsets, budget) - .map_err(anyhow::Error::msg)?)?, - resolved_variant.clone(), - ) + EnsembleComputation::try_new(universe_size, subsets, budget) + .map_err(anyhow::Error::msg)? + } else { + EnsembleComputation::with_default_budget(universe_size, subsets) + }; + (ser(instance)?, resolved_variant.clone()) } // ComparativeContainment diff --git a/src/models/misc/ensemble_computation.rs b/src/models/misc/ensemble_computation.rs index c9ad358af..479e7eb48 100644 --- a/src/models/misc/ensemble_computation.rs +++ b/src/models/misc/ensemble_computation.rs @@ -34,6 +34,23 @@ impl EnsembleComputation { Self::try_new(universe_size, subsets, budget).unwrap_or_else(|err| panic!("{err}")) } + /// Create with an automatically derived search-space bound. + /// + /// The default budget is the sum of all subset sizes (worst-case without + /// intermediate-set reuse). This is always sufficient for the optimal + /// solution to fit within the search space. + pub fn with_default_budget(universe_size: usize, subsets: Vec>) -> Self { + let budget = Self::default_budget(&subsets); + Self::new(universe_size, subsets, budget) + } + + /// Compute a default search-space bound from the subsets. + /// + /// Returns the sum of all subset sizes, clamped to at least 1. + pub fn default_budget(subsets: &[Vec]) -> usize { + subsets.iter().map(|s| s.len()).sum::().max(1) + } + pub fn try_new( universe_size: usize, subsets: Vec>,