Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 17 additions & 10 deletions docs/paper/reductions.typ
Original file line number Diff line number Diff line change
Expand Up @@ -14036,32 +14036,39 @@ The following reductions to Integer Linear Programming are straightforward formu

#let lcs_mis = load-example("LongestCommonSubsequence", "MaximumIndependentSet")
#let lcs_mis_sol = lcs_mis.solutions.at(0)
#let lcs_mis_length = lcs_mis_sol.source_config.filter(x => x != none).len()
#let lcs_mis_slots = lcs_mis_sol.source_config.len()
#reduction-rule("LongestCommonSubsequence", "MaximumIndependentSet",
example: true,
example-caption: [LCS of two strings over a 3-symbol alphabet],
example-caption: [Two conflicting match tuples for strings with opposite symbol order],
extra: [
#pred-commands(
"pred create --example " + rule-spec(lcs_mis) + " -o lcs.json",
"pred reduce lcs.json --via route.json -o bundle.json",
"pred solve bundle.json",
"pred evaluate lcs.json --config " + cli-config(lcs_mis_sol.source_config),
)
Source LCS: config $(#fmt-values(lcs_mis_sol.source_config))$ \
Target MIS: $S = {#lcs_mis_sol.target_config.enumerate().filter(((i, x)) => x).map(((i, x)) => str(i)).join(", ")}$ (size #lcs_mis_sol.target_config.filter(x => x).len()) \
MIS size $=$ LCS length $= #lcs_mis_sol.target_config.filter(x => x).len()$ #sym.checkmark
The strings #fmt-values(lcs_mis.source.instance.strings.at(0)) and #fmt-values(lcs_mis.source.instance.strings.at(1)) leave one embedding string after normalization, and the source witness has length #lcs_mis_slots. The graph has #lcs_mis.target.instance.graph.num_vertices vertices and #lcs_mis.target.instance.graph.edges.len() edges. Source witness $(#fmt-values(lcs_mis_sol.source_config))$ decodes from target vertices ${#lcs_mis_sol.target_config.enumerate().filter(((i, x)) => x).map(((i, x)) => str(i)).join(", ")}$, with MIS size $= #lcs_mis_sol.target_config.filter(x => x).len() = #lcs_mis_length$.
],
)[
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.
Use a match-tuple graph when it has neither more vertices nor more edges than a reward-anchored graph; otherwise use the anchored graph. Let $k$ be the number of input strings, $T$ their total length, $L$ the shortest string length (zero for no strings), $S=sum_r |s_r|(|s_r|+1)\/2$, and $A=L(L+1)\/2$. The target satisfies
$ V <= L(k+T-L), quad E <= A(S-A)+L(k-1). $
][
_Construction._ Given $k$ strings $s_1, dots, s_k$ over alphabet $Sigma$ (size $|Sigma|$):
_Construction._ Choose the first shortest string as the anchor and delete its symbols absent from any input string. Write $a$ for this filtered anchor and $h=|a|$. Filter every other string to the symbols of $a$, omit strings containing $a$ as a subsequence, and remove duplicates, leaving $m$ embedding strings.

_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)$.
_Anchored encoding._ Create one reward vertex $z_p$ for each anchor position $p$. For each embedding string $s_r$ and position $p$, create a clique $Y_(r,p)$ containing one vertex per position $j$ with $s_r[j]=a[p]$, plus a skip vertex. Join $z_p$ to that skip. Between slots $p<q$ in the same string, join matching vertices $j,j'$ exactly when $j>=j'$. There are no other edges.

_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)$.
_Tuple encoding and selection._ A vertex is a tuple of equal-symbol positions, one in $a$ and one in each embedding string. Two vertices are adjacent unless all coordinates are strictly increasing in the same direction. Its vertex count is $V_t=sum_p product_r |{j:s_r[j]=a[p]}|$; form these tuples only if $V_t<=V_a$. Choose the tuple graph precisely when also $E_t<=E_a$, where subscripts denote the tuple and anchored graphs. Otherwise choose the anchored graph.

_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$.
_Correctness._ Every common subsequence uses only symbols present in all input strings, so filtering the anchor preserves the feasible words. Filtering other strings, removing duplicates, and omitting strings containing the filtered anchor also preserve these words. Tuple independent sets, sorted by anchor position, are exactly common-subsequence embeddings, so their maximum size is $"LCS"$. For the anchored encoding:

_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.
($arrow.r.double$) A common subsequence of length $ell$ selects its reward vertices and increasing matching positions in each embedding string, using skips at the other slots. This gives an independent set of size $h m+ell$.

($arrow.l.double$) An independent set with $q$ rewards and $d$ empty embedding groups has size $h m+q-d$. A selected reward missing a match in some string has an empty group, because its skip is adjacent to the reward. Discard these rewards; each discarded slot accounts for a distinct empty group. The remaining $ell>=q-d$ symbols form a common subsequence, so the independent-set size is at most $h m+ell<=h m+"LCS"$. The forward construction attains equality, and every optimum decodes an optimal common subsequence. In particular, empty embedding groups are permitted. The two objectives differ, so the reduction registers witness extraction without an aggregate value map.

_Solution extraction._ Validate independence once. Order tuple vertices by anchor position and anchored rewards by anchor slot. Read the symbol of each selected vertex that has a selected match in every embedding string; tuples have no embedding strings, so every selected tuple is read. Pad the result to the original length $L$. This also maps nonoptimal independent sets to feasible common subsequences. With no embedding strings the tuple graph has $h$ isolated vertices and is always selected.

_Overhead._ For $k>=1$, the anchored graph has $h(m+1)+F$ vertices, where $F$ counts retained equal-symbol position pairs. Since $h<=L$, $m<=k-1$, and $F<=L(T-L)$, the vertex bound follows. For one embedding string of length $n$, within-slot cliques contribute at most $h n(n+1)\/2$ edges. Each of the $h(h-1)\/2$ slot pairs contributes at most $n(n+1)\/2$ crossing edges, counting all pairs $j>=j'$. Together these are at most $A n(n+1)\/2$ edges, plus at most $L$ reward-to-skip edges. Summing over a subset of the original non-anchor strings gives $E<=A(S-A)+L(k-1)$. Empty input has $V=E=L=0$ and satisfies both formulas. Dominance transfers both bounds to the tuple branch. All bounds use only registered source parameters.
]

#let cs_ilp_str = load-example(
Expand Down
121 changes: 106 additions & 15 deletions problemreductions-cli/tests/cli_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5615,8 +5615,8 @@ fn test_path_overall_preserves_unavailable_fields_alongside_exact_fields() {
let output = pred()
.args([
"path",
"MinimumVertexCover/SimpleGraph/One",
"LongestCommonSubsequence",
"SubsetSum",
"DecisionOpenShopScheduling",
"--limit",
"1",
"--json",
Expand All @@ -5628,22 +5628,113 @@ fn test_path_overall_preserves_unavailable_fields_alongside_exact_fields() {
let fields = envelope["paths"][0]["overall_parameters"]["fields"]
.as_array()
.unwrap();
let relations = fields
let machines = fields
.iter()
.map(|field| {
(
field["field"].as_str().unwrap(),
field["relation"].as_str().unwrap(),
)
})
.collect::<std::collections::BTreeMap<_, _>>();
assert_eq!(relations["alphabet_size"], "exact");
assert_eq!(relations["cross_frequency_product"], "unavailable");
let unavailable = fields
.find(|field| field["field"] == "num_machines")
.unwrap();
assert_eq!(machines["relation"], "exact");
let horizon = fields
.iter()
.find(|field| field["field"] == "schedule_horizon")
.unwrap();
assert_eq!(horizon["relation"], "unavailable");
assert!(horizon["reason"]
.as_str()
.is_some_and(|reason| !reason.is_empty()));
}

#[test]
fn test_vertex_cover_via_lcs_to_mis_solves_and_recovers_optimum() {
let dir =
std::env::temp_dir().join(format!("pred_vertex_cover_lcs_mis_{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let source = dir.join("source.json");
let route = dir.join("route.json");
let bundle = dir.join("bundle.json");
let created = pred()
.args([
"create",
"MinimumVertexCover/SimpleGraph/One",
"--graph",
"0-1,1-2",
"-o",
source.to_str().unwrap(),
])
.output()
.unwrap();
assert!(
created.status.success(),
"{}",
String::from_utf8_lossy(&created.stderr)
);
write_named_route(
"MinimumVertexCover/SimpleGraph/One",
"MaximumIndependentSet/SimpleGraph/One",
&[
"MinimumVertexCover",
"LongestCommonSubsequence",
"MaximumIndependentSet",
],
&route,
);
let selected: serde_json::Value =
serde_json::from_slice(&std::fs::read(&route).unwrap()).unwrap();
assert!(selected["overall_parameters"]["fields"]
.as_array()
.unwrap()
.iter()
.find(|field| field["relation"] == "unavailable")
.all(|field| field["relation"] != "unavailable"));
let reduced = pred()
.args([
"reduce",
source.to_str().unwrap(),
"--via",
route.to_str().unwrap(),
"-o",
bundle.to_str().unwrap(),
])
.output()
.unwrap();
assert!(
reduced.status.success(),
"{}",
String::from_utf8_lossy(&reduced.stderr)
);
let solved = pred()
.args([
"solve",
bundle.to_str().unwrap(),
"--solver",
"ilp",
"--json",
])
.output()
.unwrap();
assert!(
solved.status.success(),
"{}",
String::from_utf8_lossy(&solved.stderr)
);
let result: serde_json::Value = serde_json::from_slice(&solved.stdout).unwrap();
assert_eq!(result["evaluation"], "Min(1)");
let evaluated = pred()
.args([
"evaluate",
source.to_str().unwrap(),
"--config",
&result["solution"].to_string(),
"--json",
])
.output()
.unwrap();
assert!(!unavailable["reason"].as_str().unwrap().is_empty());
assert!(
evaluated.status.success(),
"{}",
String::from_utf8_lossy(&evaluated.stderr)
);
let value: serde_json::Value = serde_json::from_slice(&evaluated.stdout).unwrap();
assert_eq!(value["result"], "Min(1)");
std::fs::remove_dir_all(dir).unwrap();
}

#[test]
Expand Down
22 changes: 1 addition & 21 deletions src/models/misc/longest_common_subsequence.rs
Original file line number Diff line number Diff line change
Expand Up @@ -171,30 +171,11 @@ 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::<usize>()
})
.sum()
}
}

/// Check whether `candidate` is a subsequence of `target` using greedy
/// left-to-right matching.
fn is_subsequence(candidate: &[usize], target: &[usize]) -> bool {
pub(crate) fn is_subsequence(candidate: &[usize], target: &[usize]) -> bool {
let mut it = target.iter();
for &symbol in candidate {
loop {
Expand All @@ -215,7 +196,6 @@ impl Problem for LongestCommonSubsequence {

crate::problem_parameters![
("alphabet_size", alphabet_size),
("cross_frequency_product", cross_frequency_product),
("max_length", max_length),
("num_strings", num_strings),
("num_transitions", num_transitions),
Expand Down
1 change: 1 addition & 0 deletions src/models/misc/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,7 @@ pub use integer_expression_membership::{IntExpr, IntegerExpressionMembership};
pub use job_shop_scheduling::JobShopScheduling;
pub use knapsack::Knapsack;
pub use kth_largest_m_tuple::KthLargestMTuple;
pub(crate) use longest_common_subsequence::is_subsequence;
pub use longest_common_subsequence::LongestCommonSubsequence;
pub use maximum_likelihood_ranking::MaximumLikelihoodRanking;
pub use minimum_axiom_set::MinimumAxiomSet;
Expand Down
Loading
Loading