problemreductions/rules/closestsubstring_ilp.rs
1//! Reduction from ClosestSubstring to ILP (Integer Linear Programming).
2//!
3//! Given an alphabet of size `q`, `n` input strings `s_1, ..., s_n` (not
4//! necessarily of equal length), and a window length `ell`, the goal is to
5//! pick a center `c in Sigma^ell` and one length-`ell` window from each input
6//! string that together minimize the worst-case Hamming distance between the
7//! center and any chosen window. The ILP encoding combines the
8//! center-selection variables of ClosestString with one-hot window-choice
9//! indicators, plus a radius variable that is active only on each selected
10//! window.
11//!
12//! - Integer `x_{r, a}` for `r in {0, ..., ell - 1}` and
13//! `a in {0, ..., q - 1}`: `x_{r, a} = 1` iff the center has symbol `a` at
14//! position `r`. The non-negativity of ILP variables together with the
15//! assignment constraint forces every `x_{r, a} in {0, 1}`.
16//! - Integer `y_{i, p}` for input string `s_i` and window start
17//! `p in {0, ..., W_i - 1}` where `W_i = |s_i| - ell + 1`: `y_{i, p} = 1` iff
18//! window `p` is selected from string `s_i`.
19//! - Nonnegative integer radius variable `R`.
20//! - Assignment constraint: `sum_a x_{r, a} = 1` for every position `r`.
21//! - Window-choice constraint: `sum_p y_{i, p} = 1` for every input string.
22//! - Conditional radius constraint per `(i, p)`:
23//! `R + sum_{r} x_{r, s_i[p + r]} - ell * y_{i, p} >= 0`.
24//! When `y_{i, p} = 1`, this becomes `R >= d_H(c, s_i[p..p + ell))`; when
25//! `y_{i, p} = 0`, the constraint is automatically satisfied.
26//! - Objective: minimize `R`.
27//!
28//! Reference: Ming Li, Bin Ma, and Lusheng Wang, "On the closest string and
29//! substring problems," Journal of the ACM 49(2):157-171, 2002.
30//! <https://doi.org/10.1145/506147.506150>
31
32use crate::models::algebraic::{Bounded, IntegerVariable, LinearConstraint, ObjectiveSense, ILP};
33use crate::models::misc::ClosestSubstring;
34use crate::reduction;
35use crate::rules::traits::{ReduceTo, ReductionResult};
36
37/// Result of reducing ClosestSubstring to ILP.
38///
39/// Variable layout (`ILP<i64, i64, Bounded>`, all non-negative):
40/// - `x_{r, a}` at index `r * alphabet_size + a` for `r in [0, ell)` and
41/// `a in [0, q)`, forced into `{0, 1}` by the assignment constraints.
42/// - `y_{i, p}` at index `q * ell + window_offsets[i] + p` for input string
43/// `s_i` and window start `p in [0, W_i)`, forced into `{0, 1}` by the
44/// window-choice constraints.
45/// - `R` (radius) at index `q * ell + total_num_windows`, a non-negative
46/// integer in `[0, ell]`.
47#[derive(Debug, Clone)]
48pub struct ReductionClosestSubstringToILP {
49 target: ILP<i64, i64, Bounded>,
50 alphabet_size: usize,
51 substring_length: usize,
52 /// Prefix sums of per-string window counts: `window_offsets[i]` is the
53 /// number of `y_{j, p}` variables for `j < i`. Has length `num_strings`.
54 window_offsets: Vec<usize>,
55 /// `window_counts[i] = W_i = |s_i| - ell + 1`.
56 window_counts: Vec<usize>,
57}
58
59impl ReductionResult for ReductionClosestSubstringToILP {
60 type Source = ClosestSubstring;
61 type Target = ILP<i64, i64, Bounded>;
62
63 fn target_problem(&self) -> &ILP<i64, i64, Bounded> {
64 &self.target
65 }
66
67 /// Decode the integer ILP assignment into the source config layout.
68 ///
69 /// `ClosestSubstring::evaluate` expects `config` of length `ell + n`: the
70 /// first `ell` entries are the center symbols, the remaining `n` entries
71 /// are per-string window starts. For each center position `r`, we pick the
72 /// unique alphabet symbol `a` with `x_{r, a} = 1`; for each input string
73 /// `s_i`, we pick the unique window start `p` with `y_{i, p} = 1`.
74 fn extract_solution(
75 &self,
76 target_solution: &<Self::Target as crate::traits::Problem>::Solution,
77 ) -> crate::rules::ExtractionResult<<Self::Source as crate::traits::Problem>::Solution> {
78 crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?;
79
80 let q = self.alphabet_size;
81 let ell = self.substring_length;
82 let y_base = q * ell;
83 let mut out = Vec::with_capacity(ell + self.window_counts.len());
84
85 for position in 0..ell {
86 let block = &target_solution[position * q..(position + 1) * q];
87 out.push(decode_one_hot(block, "center position", position)?);
88 }
89 for (string, &window_count) in self.window_counts.iter().enumerate() {
90 let start = y_base + self.window_offsets[string];
91 out.push(decode_one_hot(
92 &target_solution[start..start + window_count],
93 "string window",
94 string,
95 )?);
96 }
97
98 Ok(out)
99 }
100}
101
102fn decode_one_hot(
103 block: &[i64],
104 block_name: &str,
105 block_index: usize,
106) -> crate::rules::ExtractionResult<usize> {
107 let mut selected = block.iter().enumerate().filter(|(_, value)| **value == 1);
108 let index = selected.next().map(|(index, _)| index).ok_or_else(|| {
109 crate::rules::ExtractionError::invalid(format!(
110 "{block_name} {block_index} has no selected value"
111 ))
112 })?;
113 if selected.next().is_some() || block.iter().any(|&value| value > 1) {
114 return Err(crate::rules::ExtractionError::invalid(format!(
115 "{block_name} {block_index} is not one-hot"
116 )));
117 }
118 Ok(index)
119}
120
121#[reduction(transform = {
122 exact {
123 num_vars = "alphabet_size * substring_length + total_num_windows + 1",
124 num_constraints = "substring_length + num_strings + total_num_windows + 1",
125 },
126 upper_bound {
127 max_constraint_magnitude_bits = "substring_length + 1",
128 num_nonzeros = "(alphabet_size * substring_length + total_num_windows + 1) * (substring_length + num_strings + total_num_windows + 1)",
129 },
130})]
131impl ReduceTo<ILP<i64, i64, Bounded>> for ClosestSubstring {
132 type Result = ReductionClosestSubstringToILP;
133
134 fn reduce_to(&self) -> Result<Self::Result, crate::rules::ReductionError> {
135 let q = self.alphabet_size();
136 let ell = self.substring_length();
137 let strings = self.strings();
138 let n = strings.len();
139
140 let window_counts: Vec<usize> = strings.iter().map(|s| s.len() - ell + 1).collect();
141 let mut window_offsets: Vec<usize> = Vec::with_capacity(n);
142 {
143 let mut acc = 0usize;
144 for &w in &window_counts {
145 window_offsets.push(acc);
146 acc += w;
147 }
148 }
149 let total_windows: usize = window_counts.iter().sum();
150
151 let x_idx = |r: usize, a: usize| -> usize { r * q + a };
152 let y_base = q * ell;
153 let y_idx = |i: usize, p: usize| -> usize { y_base + window_offsets[i] + p };
154 let r_idx = y_base + total_windows;
155 let num_vars = r_idx + 1;
156 let ell_i64 = Self::exact_i64(ell, "encoding the substring length")?;
157
158 let mut constraints: Vec<LinearConstraint> =
159 Vec::with_capacity(ell + n + total_windows + 1);
160
161 // Assignment constraints: exactly one symbol per center position.
162 // Together with the non-negativity built into `ILP<i64, i64, Bounded>`, this also
163 // forces every x_{r, a} to lie in {0, 1}.
164 for r in 0..ell {
165 let terms: Vec<(usize, i64)> = (0..q).map(|a| (x_idx(r, a), 1)).collect();
166 constraints.push(LinearConstraint::eq(terms, 1));
167 }
168
169 // Tight upper bound on R: the worst-case Hamming distance over a
170 // length-ell window is at most ell. Added as a single-term `<=`
171 // constraint so the solver's bound-tightening pass (which scans for
172 // exactly this pattern) picks it up. Without this, R defaults to the
173 // full i64 domain, which severely degrades HiGHS performance even on
174 // tiny instances.
175 constraints.push(LinearConstraint::le(vec![(r_idx, 1)], ell_i64));
176
177 // Window-choice constraints: exactly one window per input string.
178 // Combined with non-negativity, this forces every y_{i, p} in {0, 1}.
179 for (i, &w_i) in window_counts.iter().enumerate() {
180 let terms: Vec<(usize, i64)> = (0..w_i).map(|p| (y_idx(i, p), 1)).collect();
181 constraints.push(LinearConstraint::eq(terms, 1));
182 }
183
184 // Conditional radius constraints: for every (input string, window
185 // start) pair, R + sum_r x_{r, s_i[p + r]} - ell * y_{i, p} >= 0.
186 // - If y_{i, p} = 1: R >= ell - sum_r x_{r, s_i[p + r]} = d_H(c, window).
187 // - If y_{i, p} = 0: the LHS is R + (nonneg match count) >= 0,
188 // automatically satisfied because R >= 0.
189 for (i, s) in strings.iter().enumerate() {
190 for p in 0..window_counts[i] {
191 let mut terms: Vec<(usize, i64)> = Vec::with_capacity(ell + 2);
192 terms.push((r_idx, 1));
193 for r in 0..ell {
194 terms.push((x_idx(r, s[p + r]), 1));
195 }
196 terms.push((y_idx(i, p), -ell_i64));
197 constraints.push(LinearConstraint::ge(terms, 0));
198 }
199 }
200
201 // Objective: minimize R.
202 let objective = vec![(r_idx, 1)];
203
204 let mut variables = vec![IntegerVariable::binary(); num_vars];
205 variables[r_idx] =
206 IntegerVariable::new(Some(0), Some(ell_i64)).map_err(Self::target_construction)?;
207
208 let target =
209 ILP::with_variables(variables, constraints, objective, ObjectiveSense::Minimize)
210 .map_err(Self::target_construction)?;
211
212 Ok(ReductionClosestSubstringToILP {
213 target,
214 alphabet_size: q,
215 substring_length: ell,
216 window_offsets,
217 window_counts,
218 })
219 }
220}
221
222#[cfg(feature = "example-db")]
223pub(crate) fn canonical_rule_example_specs() -> Vec<crate::example_db::specs::RuleExampleSpec> {
224 vec![crate::example_db::specs::RuleExampleSpec {
225 id: "closestsubstring_to_ilp",
226 build: || {
227 // Canonical issue #1033 instance: binary alphabet, length-3
228 // windows on three length-5 strings. Optimum radius is 1; one
229 // optimal center is 010 with windows (0, 1, 0) selecting 000,
230 // 010, 110 from s_1, s_2, s_3 respectively.
231 let source = ClosestSubstring::new(
232 2,
233 vec![
234 vec![0, 0, 0, 1, 1],
235 vec![1, 0, 1, 0, 0],
236 vec![1, 1, 0, 0, 1],
237 ],
238 3,
239 )
240 .unwrap();
241 crate::example_db::specs::rule_example_via_bounded_ilp::<_>(source)
242 },
243 }]
244}
245
246#[cfg(test)]
247#[path = "../unit_tests/rules/closestsubstring_ilp.rs"]
248mod tests;