diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index 44256b45b..ede29d9cc 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -14036,9 +14036,11 @@ 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", @@ -14046,22 +14048,27 @@ The following reductions to Integer Linear Programming are straightforward formu "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=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( diff --git a/problemreductions-cli/tests/cli_tests.rs b/problemreductions-cli/tests/cli_tests.rs index 8013f5077..c2a2c8e55 100644 --- a/problemreductions-cli/tests/cli_tests.rs +++ b/problemreductions-cli/tests/cli_tests.rs @@ -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", @@ -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::>(); - 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] diff --git a/src/models/misc/longest_common_subsequence.rs b/src/models/misc/longest_common_subsequence.rs index 1f28fda78..24b0bfdac 100644 --- a/src/models/misc/longest_common_subsequence.rs +++ b/src/models/misc/longest_common_subsequence.rs @@ -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::() - }) - .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 { @@ -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), diff --git a/src/models/misc/mod.rs b/src/models/misc/mod.rs index e18f75d24..83dd2e2b3 100644 --- a/src/models/misc/mod.rs +++ b/src/models/misc/mod.rs @@ -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; diff --git a/src/rules/longestcommonsubsequence_maximumindependentset.rs b/src/rules/longestcommonsubsequence_maximumindependentset.rs index be5454a14..0705b3bcd 100644 --- a/src/rules/longestcommonsubsequence_maximumindependentset.rs +++ b/src/rules/longestcommonsubsequence_maximumindependentset.rs @@ -1,37 +1,33 @@ -//! Reduction from LongestCommonSubsequence to MaximumIndependentSet. +//! Polynomial reduction from LCS to unweighted independent set. //! -//! 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 match tuples when they dominate the normalized reward-anchored graph. +//! The target optimum is LCS for tuples, or h * m + LCS for h slots and m strings. use crate::models::graph::MaximumIndependentSet; -use crate::models::misc::LongestCommonSubsequence; +use crate::models::misc::{is_subsequence, LongestCommonSubsequence}; use crate::reduction; use crate::rules::traits::{ReduceTo, ReductionResult}; -use crate::topology::SimpleGraph; +use crate::topology::{Graph, SimpleGraph}; use crate::types::One; +use std::collections::{BTreeSet, HashSet}; +use std::ops::Range; + +/// Both encodings decode the same way: vertex `i < symbols.len()` selects +/// `symbols[i]` when every embedding group `i` is nonempty. Tuple vertices, +/// ordered by anchor slot, have no embedding groups. +#[derive(Debug, Clone)] +struct Encoding { + symbols: Vec, + // Matching vertices per string and slot; the skip vertex is range.end. + embeddings: Vec>>, +} -/// 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. +/// Target graph and witness mapping for the selected encoding. #[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, + encoding: Encoding, } impl ReductionResult for ReductionLCSToIS { @@ -42,184 +38,203 @@ impl ReductionResult for ReductionLCSToIS { &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: &::Solution, + solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - - Ok({ - // Collect selected match nodes with their characters - let mut selected: Vec<(usize, usize)> = target_solution - .iter() - .enumerate() - .filter(|(_, &v)| v) - .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(Some(ch)); - } - // Pad with alphabet_size (the padding symbol) - while config.len() < self.max_length { - config.push(None); - } - config - }) + crate::rules::traits::validate_target_witness( + self.target_problem(), + solution, + |value| value.0.is_some(), + "selected vertices do not form an independent set", + )?; + let Encoding { + symbols, + embeddings, + } = &self.encoding; + let mut decoded = symbols + .iter() + .enumerate() + .filter(|&(i, _)| { + solution[i] + && embeddings + .iter() + .all(|groups| solution[groups[i].clone()].iter().any(|&bit| bit)) + }) + .map(|(_, &symbol)| Some(symbol)) + .collect::>(); + decoded.resize(self.max_length, None); + Ok(decoded) } } -#[reduction( - transform = upper_bound { - num_vertices = "cross_frequency_product", - num_edges = "cross_frequency_product^2", - } -)] -impl ReduceTo> for LongestCommonSubsequence { - type Result = ReductionLCSToIS; - - fn reduce_to(&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 +fn tuple_graph( + anchor: &[usize], + matches: &[Vec>], + vertex_cap: usize, + edge_cap: usize, +) -> Option<(SimpleGraph, Encoding)> { + let count = (0..anchor.len()).try_fold(0usize, |total, p| { + let product = matches.iter().try_fold(1usize, |product, slots| { + product + .checked_mul(slots[p].len()) + .filter(|&n| n <= vertex_cap) + })?; + total.checked_add(product).filter(|&n| n <= vertex_cap) + })?; + let mut tuples = Vec::with_capacity(count); + for p in 0..anchor.len() { + let mut product = vec![vec![p]]; + for slots in matches { + product = product .iter() - .map(|s| { - s.iter() - .enumerate() - .filter(|(_, &sym)| sym == c) - .map(|(i, _)| i) - .collect() + .flat_map(|prefix| { + slots[p].iter().map(|&j| { + let mut tuple = prefix.clone(); + tuple.push(j); + tuple + }) }) .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)); + tuples.extend(product); + } + let mut edges = Vec::new(); + for (i, left) in tuples.iter().enumerate() { + for (j, right) in tuples.iter().enumerate().skip(i + 1) { + let before = left[0] < right[0]; + if left + .iter() + .zip(right) + .any(|(a, b)| a == b || (a < b) != before) + { + if edges.len() == edge_cap { + return None; } + edges.push((i, j)); } } - - let target = MaximumIndependentSet::new( - SimpleGraph::new(num_vertices, edges), - vec![One; num_vertices], - ); - - Ok(ReductionLCSToIS { - target, - match_nodes, - match_chars, - max_length: self.max_length(), - }) } + let encoding = Encoding { + symbols: tuples.iter().map(|tuple| anchor[tuple[0]]).collect(), + embeddings: vec![], + }; + Some((SimpleGraph::new(count, edges), encoding)) } -/// 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; +fn anchored_graph(anchor: Vec, matches: &[Vec>]) -> (SimpleGraph, Encoding) { + let mut edges = Vec::new(); + let mut cursor = anchor.len(); + let mut embeddings = Vec::new(); + for slots in matches { + let mut groups = Vec::>::new(); + for (p, positions) in slots.iter().enumerate() { + let group = cursor..cursor + positions.len(); + let skip = group.end; + cursor = skip + 1; + for a in group.start..=skip { + edges.extend((a + 1..=skip).map(|b| (a, b))); + } + edges.push((p, skip)); + for (earlier, previous) in slots.iter().zip(&groups) { + for (i, &position) in earlier.iter().enumerate() { + let conflicts = positions.partition_point(|&j| j <= position); + edges.extend((0..conflicts).map(|j| (previous.start + i, group.start + j))); + } + } + groups.push(group); } + embeddings.push(groups); } - !all_less && !all_greater + ( + SimpleGraph::new(cursor, edges), + Encoding { + symbols: anchor, + embeddings, + }, + ) } -/// Compute the Cartesian product of a list of position vectors. -fn cartesian_product(lists: &[Vec]) -> Vec> { - if lists.is_empty() { - return vec![vec![]]; - } +#[reduction(transform = upper_bound { + num_vertices = "max_length * (num_strings + total_length - max_length)", + num_edges = "max_length * (max_length + 1) / 2 * (sum_triangular_lengths - max_length * (max_length + 1) / 2) + max_length * (num_strings - 1)", +})] +impl ReduceTo> for LongestCommonSubsequence { + type Result = ReductionLCSToIS; - 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; + fn reduce_to(&self) -> Result { + let anchor = self + .strings() + .iter() + .min_by_key(|s| s.len()) + .into_iter() + .flatten() + .copied() + .filter(|symbol| self.strings().iter().all(|s| s.contains(symbol))) + .collect::>(); + let symbols = anchor.iter().copied().collect::>(); + let strings = self + .strings() + .iter() + .map(|s| { + s.iter() + .copied() + .filter(|c| symbols.contains(c)) + .collect::>() + }) + .filter(|string| !is_subsequence(&anchor, string)) + .collect::>(); + let matches = strings + .iter() + .map(|s| { + anchor + .iter() + .map(|symbol| { + (0..s.len()) + .filter(|&j| s[j] == *symbol) + .collect::>() + }) + .collect::>() + }) + .collect::>(); + let anchored = anchored_graph(anchor.clone(), &matches); + >>::exact_i64( + anchored.0.num_vertices(), + "representing the independent-set objective", + )?; + let (graph, encoding) = tuple_graph( + &anchor, + &matches, + anchored.0.num_vertices(), + anchored.0.num_edges(), + ) + .unwrap_or(anchored); + let weights = vec![One; graph.num_vertices()]; + let target = MaximumIndependentSet::new(graph, weights); + Ok(ReductionLCSToIS { + target, + max_length: self.max_length(), + encoding, + }) } - 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, v4, v5} gives LCS "BAC" (length 3). - // Match nodes (ordered by character): - // c=A(0): v0=(0,1), v1=(0,3), v2=(2,1), v3=(2,3) - // c=B(1): v4=(1,0) - // c=C(2): v5=(3,2) - // MIS {v2, v4, v5} => positions B@(1,0), A@(2,1), C@(3,2) - // source_config = [1, 0, 2, null] (B, A, C, padding) + let source = LongestCommonSubsequence::new(2, vec![vec![0, 1], vec![1, 0]]); crate::example_db::specs::rule_example_with_witness::< _, MaximumIndependentSet, >( - lcs_abac_baca(), + source, SolutionPair { - source_config: serde_json::json!(vec![Some(1), Some(0), Some(2), None]), - target_config: serde_json::json!(vec![false, false, true, false, true, true]), + source_config: serde_json::json!(vec![Some(0), None]), + target_config: serde_json::json!(vec![true, false]), }, ) }, diff --git a/src/rules/minimumvertexcover_longestcommonsubsequence.rs b/src/rules/minimumvertexcover_longestcommonsubsequence.rs index cfe1799c0..fa54c9539 100644 --- a/src/rules/minimumvertexcover_longestcommonsubsequence.rs +++ b/src/rules/minimumvertexcover_longestcommonsubsequence.rs @@ -51,9 +51,6 @@ impl ReductionResult for ReductionVCToLCS { num_transitions = "num_vertices", }, }, - unavailable = { - cross_frequency_product = "each symbol occurs once in the base string and at most twice in each of num_edges edge strings; the source-only bound num_vertices * 2^num_edges needs a variable exponent, unsupported by the exact evaluator; endpoint incidences determine the exact product", - } )] impl ReduceTo for MinimumVertexCover { type Result = ReductionVCToLCS; diff --git a/src/unit_tests/rules/longestcommonsubsequence_maximumindependentset.rs b/src/unit_tests/rules/longestcommonsubsequence_maximumindependentset.rs index 7daf6a1e0..0ded1991d 100644 --- a/src/unit_tests/rules/longestcommonsubsequence_maximumindependentset.rs +++ b/src/unit_tests/rules/longestcommonsubsequence_maximumindependentset.rs @@ -1,150 +1,265 @@ use super::*; -use crate::rules::test_helpers::assert_optimization_round_trip_from_optimization_target; -use crate::solvers::BruteForce; -use crate::topology::Graph; +use crate::rules::test_helpers::{ + assert_optimization_round_trip_from_optimization_target, assert_parameter_predictions, +}; +use crate::solvers::{BruteForce, ILPSolver}; use crate::traits::Problem; +use crate::types::Max; + +fn reduce(source: &LongestCommonSubsequence) -> ReductionLCSToIS { + let reduction = ReduceTo::>::reduce_to(source).unwrap(); + assert_parameter_predictions(source, &reduction); + reduction +} + +fn assert_round_trip(source: &LongestCommonSubsequence, length: i64) { + let source_solution = BruteForce::new().solve(source).unwrap().unwrap(); + assert_eq!( + source.evaluate(&source_solution).unwrap(), + Max(Some(length)) + ); + let reduction = reduce(source); + let target = reduction.target_problem(); + // Brute force over every optimal target witness stays fast up to 13 vertices. + if target.num_vertices() <= 13 { + assert_optimization_round_trip_from_optimization_target(source, &reduction, "LCS to MIS"); + } + let solution = ILPSolver::new().solve(target).unwrap(); + let decoded = reduction.extract_solution(&solution).unwrap(); + assert_eq!(source.evaluate(&decoded).unwrap(), Max(Some(length))); +} + +fn repetition_pair() -> LongestCommonSubsequence { + LongestCommonSubsequence::new(2, vec![vec![0, 1], vec![1, 0, 0], vec![1, 0, 0, 0]]) +} + +fn opposite_pair() -> LongestCommonSubsequence { + LongestCommonSubsequence::new(2, vec![vec![0, 1], vec![1, 0]]) +} #[test] fn test_longestcommonsubsequence_to_maximumindependentset_closed_loop() { - // Issue example: k=2, s1="ABAC", s2="BACA", alphabet={A=0, B=1, C=2} - let lcs = LongestCommonSubsequence::new( - 3, - vec![ - vec![0, 1, 0, 2], // ABAC - vec![1, 0, 2, 0], // BACA - ], + let source = opposite_pair(); + assert_round_trip(&source, 1); + let reduction = reduce(&source); + assert_eq!(reduction.target_problem().num_vertices(), 2); + assert_eq!(reduction.target_problem().num_edges(), 1); + let witness = vec![true, false]; + assert_eq!( + reduction.target_problem().evaluate(&witness).unwrap(), + Max(Some(1)) ); - let reduction = ReduceTo::>::reduce_to(&lcs) - .expect("reduction should succeed"); - assert_optimization_round_trip_from_optimization_target( - &lcs, - &reduction, - "LCS->MIS (ABAC/BACA)", + assert_eq!( + reduction.extract_solution(&witness).unwrap(), + vec![Some(0), None] ); } #[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 - ], +fn test_lcs_to_mis_filters_before_deduplicating_tuple_inputs() { + let source = LongestCommonSubsequence::new( + 4, + vec![vec![0, 1], vec![1, 2, 0], vec![1, 3, 0], vec![1, 0]], ); - let reduction = ReduceTo::>::reduce_to(&lcs) - .expect("reduction should succeed"); - let target = reduction.target_problem(); - - assert_eq!(target.graph().num_vertices(), 6); - assert_eq!(target.graph().num_edges(), 9); + let reduction = reduce(&source); + assert_eq!(reduction.target_problem().num_vertices(), 2); + assert_eq!(reduction.target_problem().num_edges(), 1); + assert_round_trip(&source, 1); } #[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); +fn test_lcs_to_mis_uses_tuples_for_the_repeated_symbol_example() { + let source = LongestCommonSubsequence::new(3, vec![vec![0, 1, 0, 2], vec![1, 0, 2, 0]]); + let reduction = reduce(&source); + assert_eq!(reduction.target_problem().num_vertices(), 6); + assert_eq!(reduction.target_problem().num_edges(), 9); + assert_round_trip(&source, 3); } #[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) - .expect("reduction should succeed"); - let target = reduction.target_problem(); +fn test_lcs_to_mis_normalization_preserves_original_padding() { + let source = LongestCommonSubsequence::new(2, vec![vec![0, 1], vec![0; 3], vec![0; 2]]); + let reduction = reduce(&source); + assert_eq!(reduction.target_problem().num_vertices(), 1); + assert_eq!(reduction.target_problem().num_edges(), 0); + assert_eq!( + reduction.extract_solution(&vec![true]).unwrap(), + vec![Some(0), None] + ); + assert_round_trip(&source, 1); +} - let solver = BruteForce::new(); - let witness = solver - .solve(target) +#[test] +fn test_lcs_to_mis_polynomial_size_for_many_repeated_strings() { + // Containing strings add no constraints, even when there would be 2^64 tuples. + let source = LongestCommonSubsequence::new(1, vec![vec![0, 0]; 64]); + let reduction = reduce(&source); + assert_eq!(reduction.target_problem().num_vertices(), 2); + assert_eq!(reduction.target_problem().num_edges(), 0); + assert_round_trip(&source, 2); + let entry = crate::rules::registry::reduction_entries() + .into_iter() + .find(|entry| { + entry.source_name == LongestCommonSubsequence::NAME + && entry.target_name == "MaximumIndependentSet" + }) + .unwrap(); + assert!(entry.reduce_aggregate_fn.is_none()); + let contract = entry.parameter_contract().unwrap(); + let predicted = contract + .transform() .unwrap() - .expect("should have a solution"); - let mis_size: usize = witness.iter().filter(|&&selected| selected).count(); - assert_eq!(mis_size, 3); + .evaluate(&source.parameters()) + .unwrap(); + // A=3, S-A=63*3: embedding cliques/crossings <= 567, reward links <= 126. + assert_eq!(predicted.get("num_vertices"), Some(380)); + assert_eq!(predicted.get("num_edges"), Some(693)); } #[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) - .expect("reduction should succeed"); - assert_optimization_round_trip_from_optimization_target( - &lcs, - &reduction, - "LCS->MIS (3 strings)", - ); +fn test_lcs_to_mis_ordering_repetition_and_empty_common_subsequences() { + for (alphabet, strings, length) in [ + (2, vec![vec![0, 1, 0], vec![1, 0, 1], vec![0, 1, 1]], 2), + (2, vec![vec![0, 1], vec![1, 0], vec![0, 1], vec![1, 0]], 1), + ( + 3, + vec![vec![2, 0, 1, 2], vec![0, 1], vec![0, 1], vec![1, 0]], + 1, + ), + (2, vec![vec![0, 0, 1], vec![0; 5], vec![0; 6]], 2), + (2, vec![vec![0, 1, 0], vec![0, 0, 1]], 2), + (2, vec![vec![0, 1, 0]], 3), + (2, vec![vec![0], vec![1]], 0), + (2, vec![vec![0, 1], vec![]], 0), + (0, vec![vec![], vec![]], 0), + (0, vec![], 0), + ] { + assert_round_trip(&LongestCommonSubsequence::new(alphabet, strings), length); + } } #[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) - .expect("reduction should succeed"); - assert_optimization_round_trip_from_optimization_target( - &lcs, - &reduction, - "LCS->MIS (single char)", - ); +fn test_lcs_to_mis_rejects_malformed_witnesses() { + for source in [opposite_pair(), repetition_pair()] { + let reduction = reduce(&source); + let n = reduction.target_problem().num_vertices(); + for witness in [vec![false; n - 1], vec![false; n + 1], vec![true; n]] { + assert!(reduction.extract_solution(&witness).is_err()); + } + } } #[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) - .expect("reduction should succeed"); - let target = reduction.target_problem(); +fn test_lcs_to_mis_keeps_anchored_when_tuples_have_more_edges() { + let source = repetition_pair(); + let reduction = reduce(&source); + // Seven tuples have 21 edges; the anchored graph has 13 vertices but only 20 edges. + assert_eq!(reduction.target_problem().num_vertices(), 13); + assert_eq!(reduction.target_problem().num_edges(), 20); + assert_round_trip(&source, 1); +} + +#[test] +fn test_lcs_to_mis_caps_many_string_tuple_products() { + let mut strings = vec![vec![0, 1]]; + strings.extend((1..=64).map(|n| { + let mut string = vec![0; n + 1]; + string[0] = 1; + string + })); + let source = LongestCommonSubsequence::new(2, strings); + let reduction = reduce(&source); + // Tuple count 64! + 1 exceeds usize; the anchored graph stays polynomial. + assert_eq!(reduction.target_problem().num_vertices(), 2274); + assert_eq!(reduction.target_problem().num_edges(), 48032); + let mut witness = vec![false; 2274]; + // Embed symbol 1 everywhere, and use the skip in each symbol-0 group. + witness[1] = true; + let mut cursor = 2; + for n in 1..=64 { + witness[cursor + n] = true; + witness[cursor + n + 1] = true; + cursor += n + 3; + } + assert_eq!( + reduction.target_problem().evaluate(&witness).unwrap(), + Max(Some(129)) + ); + assert_eq!( + reduction.extract_solution(&witness).unwrap(), + vec![Some(1), None] + ); +} - // 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_tuple_cap_includes_the_boundary() { + let matches = vec![vec![vec![1], vec![0]]]; + assert!(tuple_graph(&[0, 1], &matches, 2, 1).is_some()); + assert!(tuple_graph(&[0, 1], &matches, 1, 1).is_none()); + assert!(tuple_graph(&[0, 1], &matches, 2, 0).is_none()); } #[test] -fn test_lcs_to_mis_extract_solution() { - let lcs = LongestCommonSubsequence::new( - 3, +fn test_lcs_to_mis_rejects_reused_positions_in_nonadjacent_slots() { + let source = LongestCommonSubsequence::new( + 2, vec![ - vec![0, 1, 0, 2], // ABAC - vec![1, 0, 2, 0], // BACA + vec![0, 1, 0], + vec![0, 0, 1], + vec![0, 0, 0, 1], + vec![1, 0, 0], ], ); - let reduction = ReduceTo::>::reduce_to(&lcs) - .expect("reduction should succeed"); - - // 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 - .solve(reduction.target_problem()) - .unwrap() - .expect("should have a solution"); - let source_sol = reduction.extract_solution(&witness).unwrap(); + let reduction = reduce(&source); + let mut witness = vec![false; reduction.target_problem().num_vertices()]; + // First and third slots both use position zero of [0,0,0,1]. + witness[3] = true; + witness[9] = true; + assert_eq!( + reduction.target_problem().evaluate(&witness).unwrap(), + Max(None) + ); + assert!(reduction.extract_solution(&witness).is_err()); +} - // The extracted solution should be valid for the source - let value = lcs.evaluate(&source_sol).unwrap(); - 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_decodes_missing_embedding_groups() { + let reduction = reduce(&repetition_pair()); + let mut witness = vec![false; 13]; + witness[0] = true; + assert_eq!( + reduction.extract_solution(&witness).unwrap(), + vec![None, None] + ); + // Both rewards active; only slot 0 has matches in both strings. Two groups are empty. + witness[1] = true; + witness[2] = true; + witness[7] = true; + assert_eq!( + reduction.target_problem().evaluate(&witness).unwrap(), + Max(Some(4)) + ); + assert_eq!( + reduction.extract_solution(&witness).unwrap(), + vec![Some(0), None] + ); } #[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) - .expect("reduction should succeed"); - assert_optimization_round_trip_from_optimization_target( - &lcs, - &reduction, - "LCS->MIS (4 strings)", +fn test_lcs_to_mis_preserves_sparse_original_symbols() { + let source = LongestCommonSubsequence::new(usize::MAX, vec![vec![usize::MAX - 1]]); + let reduction = reduce(&source); + assert_eq!(reduction.target_problem().num_vertices(), 1); + let solution = BruteForce::new() + .solve(reduction.target_problem()) + .unwrap() + .unwrap(); + assert_eq!( + reduction.target_problem().evaluate(&solution).unwrap(), + Max(Some(1)) + ); + assert_eq!( + reduction.extract_solution(&solution).unwrap(), + vec![Some(usize::MAX - 1)] ); }