Skip to content

Commit 2842578

Browse files
isPANNclaude
andcommitted
Add ClosestString -> ILP reduction (#1034)
Position-character ILP encoding for ClosestString: binary variables x_{j,a} encode the consensus string character at position j (with one-hot assignment constraints), and a single integer radius variable R is bounded by per-string Hamming-distance constraints. The objective minimizes R. Closes #1034. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 73ebcf1 commit 2842578

4 files changed

Lines changed: 292 additions & 0 deletions

File tree

docs/paper/reductions.typ

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13869,6 +13869,30 @@ The following reductions to Integer Linear Programming are straightforward formu
1386913869
_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.
1387013870
]
1387113871

13872+
#reduction-rule("ClosestString", "ILP")[
13873+
Binary variables select one alphabet symbol at each center position. An auxiliary radius variable upper-bounds the Hamming distance from the chosen center to every input string and is minimized.
13874+
][
13875+
_Construction._ Given alphabet $Sigma$ of size $q$, $n$ input strings $s_1, dots, s_n in Sigma^m$ of common length $m$:
13876+
13877+
_Variables:_ (1) $x_(j, a) in {0, 1}$ for $j in {0, dots, m - 1}$ and $a in {0, dots, q - 1}$: $x_(j, a) = 1$ iff the center has symbol $a$ at position $j$. (2) Nonnegative integer $R$: an upper bound on the worst-case Hamming distance.
13878+
13879+
_Constraints:_ (1) Assignment: $sum_(a = 0)^(q - 1) x_(j, a) = 1$ for every position $j$. Combined with the nonnegativity built into the ILP, this also forces every $x_(j, a) in {0, 1}$. (2) Radius: $R + sum_(j = 0)^(m - 1) x_(j, s_i [j]) >= m$ for every input string $s_i$, which is equivalent to $R >= m - sum_j x_(j, s_i [j]) = d_H (c, s_i)$.
13880+
13881+
_Objective:_ Minimize $R$.
13882+
13883+
The ILP is:
13884+
$
13885+
"minimize" quad & R \
13886+
"subject to" quad & sum_(a = 0)^(q - 1) x_(j, a) = 1 quad forall j in {0, dots, m - 1} \
13887+
& R + sum_(j = 0)^(m - 1) x_(j, s_i [j]) >= m quad forall i in {1, dots, n} \
13888+
& x_(j, a) in {0, 1}, quad R in ZZ_(>= 0).
13889+
$
13890+
13891+
_Correctness._ ($arrow.r.double$) Given an optimal center $c^* in Sigma^m$, set $x_(j, c^*[j]) = 1$ for every $j$ and let $R = max_i d_H (c^*, s_i)$. Each assignment constraint is satisfied, and each radius constraint reduces to $R >= d_H (c^*, s_i)$, which holds with equality at the worst case. ($arrow.l.double$) The assignment constraints force each $x_(j, *)$ block to be a one-hot vector, hence encode a unique center string $c$. The radius constraint then gives $R >= d_H (c, s_i)$ for every $i$, so $R >= max_i d_H (c, s_i)$. Minimizing $R$ therefore minimizes the worst-case Hamming distance.
13892+
13893+
_Solution extraction._ For each position $j$, read the unique symbol $a$ with $x_(j, a) = 1$; the resulting length-$m$ vector is the source center.
13894+
]
13895+
1387213896
#reduction-rule("LongestCommonSubsequence", "ILP")[
1387313897
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.
1387413898
][

src/rules/closeststring_ilp.rs

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
//! Reduction from ClosestString to ILP (Integer Linear Programming).
2+
//!
3+
//! Given an alphabet of size `q`, `n` input strings of common length `m`, and
4+
//! the goal of finding a center `c in Sigma^m` that minimizes the maximum
5+
//! Hamming distance to every input string, the natural encoding picks one
6+
//! alphabet symbol at every center position and constrains a radius variable
7+
//! to upper-bound every per-string Hamming distance:
8+
//!
9+
//! - Binary `x_{j, a} in {0, 1}` for `j in {0, ..., m - 1}` and `a in
10+
//! {0, ..., q - 1}`: `x_{j, a} = 1` iff the chosen center has symbol `a` at
11+
//! position `j`.
12+
//! - Nonnegative integer radius variable `R`.
13+
//! - Assignment constraint: `sum_a x_{j, a} = 1` for every position `j`.
14+
//! Because every ILP variable is a nonnegative integer, this also forces
15+
//! each `x_{j, a} in {0, 1}`.
16+
//! - Radius constraint per input string `s_i`:
17+
//! `R + sum_j x_{j, s_i[j]} >= m`, which is equivalent to `R >= d_H(c, s_i)`.
18+
//! - Objective: minimize `R`.
19+
//!
20+
//! Reference: Ming Li, Bin Ma, and Lusheng Wang, "On the closest string and
21+
//! substring problems," Journal of the ACM 49(2):157-171, 2002.
22+
//! <https://doi.org/10.1145/506147.506150>
23+
24+
use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP};
25+
use crate::models::misc::ClosestString;
26+
use crate::reduction;
27+
use crate::rules::traits::{ReduceTo, ReductionResult};
28+
29+
/// Result of reducing ClosestString to ILP.
30+
///
31+
/// Variable layout (`ILP<i32>`, all non-negative):
32+
/// - `x_{j, a}` at index `j * alphabet_size + a` for `j in [0, m)` and
33+
/// `a in [0, q)`, bounded to `{0, 1}`.
34+
/// - `R` (radius) at index `m * q`, an integer in `[0, m]`.
35+
#[derive(Debug, Clone)]
36+
pub struct ReductionClosestStringToILP {
37+
target: ILP<i32>,
38+
alphabet_size: usize,
39+
string_length: usize,
40+
}
41+
42+
impl ReductionResult for ReductionClosestStringToILP {
43+
type Source = ClosestString;
44+
type Target = ILP<i32>;
45+
46+
fn target_problem(&self) -> &ILP<i32> {
47+
&self.target
48+
}
49+
50+
/// Decode the integer ILP assignment into the source center config.
51+
///
52+
/// For every position `j`, choose the unique alphabet symbol `a` with
53+
/// `x_{j, a} = 1`. If the target assignment is missing or none of the
54+
/// per-position `x_{j, *}` variables are set to 1, we fall back to symbol
55+
/// `0` so the returned vector still has the expected length; partial /
56+
/// infeasible ILP solutions are the caller's responsibility.
57+
fn extract_solution(&self, target_solution: &[usize]) -> Vec<usize> {
58+
let q = self.alphabet_size;
59+
(0..self.string_length)
60+
.map(|j| {
61+
(0..q)
62+
.find(|&a| target_solution.get(j * q + a).copied().unwrap_or(0) == 1)
63+
.unwrap_or(0)
64+
})
65+
.collect()
66+
}
67+
}
68+
69+
#[reduction(
70+
overhead = {
71+
num_vars = "alphabet_size * string_length + 1",
72+
num_constraints = "string_length + num_strings",
73+
}
74+
)]
75+
impl ReduceTo<ILP<i32>> for ClosestString {
76+
type Result = ReductionClosestStringToILP;
77+
78+
fn reduce_to(&self) -> Self::Result {
79+
let q = self.alphabet_size();
80+
let m = self.string_length();
81+
let strings = self.strings();
82+
let n = strings.len();
83+
84+
let x_idx = |j: usize, a: usize| -> usize { j * q + a };
85+
let r_idx = q * m;
86+
let num_vars = q * m + 1;
87+
88+
let mut constraints: Vec<LinearConstraint> = Vec::with_capacity(m + n);
89+
90+
// Assignment constraints: exactly one symbol per center position.
91+
// Together with the non-negativity built into ILP<i32>, this also
92+
// forces every x_{j, a} to lie in {0, 1}.
93+
for j in 0..m {
94+
let terms: Vec<(usize, f64)> = (0..q).map(|a| (x_idx(j, a), 1.0)).collect();
95+
constraints.push(LinearConstraint::eq(terms, 1.0));
96+
}
97+
98+
// Radius constraints: R + sum_j x_{j, s_i[j]} >= m.
99+
// Equivalently, R >= m - sum_j x_{j, s_i[j]} = d_H(c, s_i).
100+
for s in strings.iter() {
101+
let mut terms: Vec<(usize, f64)> = Vec::with_capacity(m + 1);
102+
terms.push((r_idx, 1.0));
103+
for (j, &symbol) in s.iter().enumerate() {
104+
terms.push((x_idx(j, symbol), 1.0));
105+
}
106+
constraints.push(LinearConstraint::ge(terms, m as f64));
107+
}
108+
109+
// Objective: minimize R.
110+
let objective = vec![(r_idx, 1.0)];
111+
112+
let target = ILP::new(num_vars, constraints, objective, ObjectiveSense::Minimize);
113+
114+
ReductionClosestStringToILP {
115+
target,
116+
alphabet_size: q,
117+
string_length: m,
118+
}
119+
}
120+
}
121+
122+
#[cfg(feature = "example-db")]
123+
pub(crate) fn canonical_rule_example_specs() -> Vec<crate::example_db::specs::RuleExampleSpec> {
124+
vec![crate::example_db::specs::RuleExampleSpec {
125+
id: "closeststring_to_ilp",
126+
build: || {
127+
// Canonical issue #1032 instance: binary alphabet, the four length-3
128+
// strings 000, 011, 101, 110. Optimum radius is 2 (achieved by any
129+
// binary length-3 center, e.g. 000).
130+
let source = ClosestString::new(
131+
2,
132+
vec![vec![0, 0, 0], vec![0, 1, 1], vec![1, 0, 1], vec![1, 1, 0]],
133+
);
134+
crate::example_db::specs::rule_example_via_ilp::<_, i32>(source)
135+
},
136+
}]
137+
}
138+
139+
#[cfg(test)]
140+
#[path = "../unit_tests/rules/closeststring_ilp.rs"]
141+
mod tests;

src/rules/mod.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,8 @@ pub(crate) mod capacityassignment_ilp;
164164
#[cfg(feature = "ilp-solver")]
165165
pub(crate) mod circuit_ilp;
166166
#[cfg(feature = "ilp-solver")]
167+
pub(crate) mod closeststring_ilp;
168+
#[cfg(feature = "ilp-solver")]
167169
pub(crate) mod clustering_ilp;
168170
#[cfg(feature = "ilp-solver")]
169171
pub(crate) mod coloring_ilp;
@@ -548,6 +550,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec<crate::example_db::specs::Ru
548550
specs.extend(boundedcomponentspanningforest_ilp::canonical_rule_example_specs());
549551
specs.extend(capacityassignment_ilp::canonical_rule_example_specs());
550552
specs.extend(circuit_ilp::canonical_rule_example_specs());
553+
specs.extend(closeststring_ilp::canonical_rule_example_specs());
551554
specs.extend(clustering_ilp::canonical_rule_example_specs());
552555
specs.extend(coloring_ilp::canonical_rule_example_specs());
553556
specs.extend(consecutiveblockminimization_ilp::canonical_rule_example_specs());
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
use super::*;
2+
use crate::models::algebraic::{ObjectiveSense, ILP};
3+
use crate::models::misc::ClosestString;
4+
use crate::rules::test_helpers::assert_bf_vs_ilp;
5+
use crate::solvers::{BruteForce, ILPSolver, Solver};
6+
use crate::traits::Problem;
7+
use crate::types::Min;
8+
9+
/// Canonical issue #1032 instance: binary alphabet, four length-3 strings
10+
/// 000, 011, 101, 110. Every binary length-3 center attains radius exactly 2;
11+
/// no center attains radius 1.
12+
fn issue_instance() -> ClosestString {
13+
ClosestString::new(
14+
2,
15+
vec![vec![0, 0, 0], vec![0, 1, 1], vec![1, 0, 1], vec![1, 1, 0]],
16+
)
17+
}
18+
19+
#[test]
20+
fn test_closeststring_to_ilp_structure() {
21+
let source = issue_instance();
22+
let reduction = ReduceTo::<ILP<i32>>::reduce_to(&source);
23+
let ilp = reduction.target_problem();
24+
25+
// q = 2, m = 3 -> 2*3 + 1 = 7 variables.
26+
assert_eq!(ilp.num_vars, 7);
27+
// m = 3 assignment constraints + n = 4 radius constraints = 7.
28+
assert_eq!(ilp.constraints.len(), 7);
29+
assert_eq!(ilp.sense, ObjectiveSense::Minimize);
30+
31+
// The objective puts weight 1 on the radius variable only.
32+
assert_eq!(ilp.objective.len(), 1);
33+
let (r_idx, r_coeff) = ilp.objective[0];
34+
assert_eq!(r_idx, 2 * 3);
35+
assert!((r_coeff - 1.0).abs() < 1e-9);
36+
37+
// Each assignment constraint has q = 2 terms and rhs = 1.
38+
for c in ilp.constraints.iter().take(3) {
39+
assert_eq!(c.terms.len(), 2);
40+
assert!((c.rhs - 1.0).abs() < 1e-9);
41+
}
42+
43+
// Each radius constraint has m + 1 = 4 terms (one per position + R) and
44+
// rhs = m = 3.
45+
for c in ilp.constraints.iter().skip(3) {
46+
assert_eq!(c.terms.len(), 4);
47+
assert!((c.rhs - 3.0).abs() < 1e-9);
48+
}
49+
}
50+
51+
#[test]
52+
fn test_closeststring_to_ilp_closed_loop() {
53+
let source = issue_instance();
54+
let reduction = ReduceTo::<ILP<i32>>::reduce_to(&source);
55+
56+
let bf_value = BruteForce::new().solve(&source);
57+
let ilp_solution = ILPSolver::new()
58+
.solve(reduction.target_problem())
59+
.expect("ILP should be solvable");
60+
let extracted = reduction.extract_solution(&ilp_solution);
61+
let extracted_value = source.evaluate(&extracted);
62+
63+
// The extracted center must be syntactically valid and match the BF optimum.
64+
assert!(extracted_value.is_valid());
65+
assert_eq!(extracted_value, bf_value);
66+
// Sanity: the canonical instance has optimum radius 2.
67+
assert_eq!(extracted_value, Min(Some(2)));
68+
}
69+
70+
#[test]
71+
fn test_closeststring_to_ilp_bf_vs_ilp() {
72+
let source = issue_instance();
73+
let reduction = ReduceTo::<ILP<i32>>::reduce_to(&source);
74+
assert_bf_vs_ilp(&source, &reduction);
75+
}
76+
77+
#[test]
78+
fn test_closeststring_to_ilp_extract_known_center() {
79+
// Build the binary encoding of the center 000 by hand:
80+
// x_{0,0}=x_{1,0}=x_{2,0}=1, others 0, R = 2.
81+
let source = issue_instance();
82+
let reduction = ReduceTo::<ILP<i32>>::reduce_to(&source);
83+
84+
let mut target_solution = vec![0usize; reduction.target_problem().num_vars];
85+
target_solution[0] = 1; // x_{0,0}
86+
target_solution[2] = 1; // x_{1,0}
87+
target_solution[4] = 1; // x_{2,0}
88+
target_solution[6] = 2; // R = 2
89+
90+
let extracted = reduction.extract_solution(&target_solution);
91+
assert_eq!(extracted, vec![0, 0, 0]);
92+
assert_eq!(source.evaluate(&extracted), Min(Some(2)));
93+
}
94+
95+
#[test]
96+
fn test_closeststring_to_ilp_ternary_alphabet() {
97+
// q = 3, m = 2, three strings forcing a nonzero radius. The optimum
98+
// radius is 1 (any center matches at least one position of every string).
99+
let source = ClosestString::new(3, vec![vec![0, 1], vec![1, 2], vec![2, 0]]);
100+
let reduction = ReduceTo::<ILP<i32>>::reduce_to(&source);
101+
let ilp = reduction.target_problem();
102+
103+
// q * m + 1 = 3 * 2 + 1 = 7 variables; m + n = 2 + 3 = 5 constraints.
104+
assert_eq!(ilp.num_vars, 7);
105+
assert_eq!(ilp.constraints.len(), 5);
106+
107+
assert_bf_vs_ilp(&source, &reduction);
108+
}
109+
110+
#[test]
111+
fn test_closeststring_to_ilp_single_string_zero_radius() {
112+
// A single input string: the center equals the input and the optimum
113+
// radius is 0. This guards against off-by-one errors in the radius
114+
// constraints.
115+
let source = ClosestString::new(2, vec![vec![1, 0, 1, 1]]);
116+
let reduction = ReduceTo::<ILP<i32>>::reduce_to(&source);
117+
118+
let ilp_solution = ILPSolver::new()
119+
.solve(reduction.target_problem())
120+
.expect("ILP should be solvable");
121+
let extracted = reduction.extract_solution(&ilp_solution);
122+
assert_eq!(extracted, vec![1, 0, 1, 1]);
123+
assert_eq!(source.evaluate(&extracted), Min(Some(0)));
124+
}

0 commit comments

Comments
 (0)