diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index 00bb1bf3..fd47115e 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$ 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 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}$, @@ -7225,6 +7225,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 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 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 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._ 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")[ 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. ][ @@ -7527,6 +7537,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$). ][ @@ -7605,6 +7666,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() @@ -8106,6 +8207,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. ][ @@ -8560,6 +8671,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. ][ @@ -8676,6 +8817,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. ][ @@ -8739,6 +8914,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' 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). +] + ] +} + #reduction-rule("MaximalIS", "ILP")[ An independent set that is also maximal: no vertex outside the set can be added without violating independence. ][ @@ -8946,6 +9169,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$. ][ @@ -9485,6 +9738,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. ][ @@ -10032,6 +10321,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. ][ @@ -10068,6 +10401,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/docs/paper/references.bib b/docs/paper/references.bib index f7d6f456..629c4a05 100644 --- a/docs/paper/references.bib +++ b/docs/paper/references.bib @@ -1521,6 +1521,18 @@ @techreport{plaisted1976 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} +} + @techreport{storer1977, author = {James A. Storer}, title = {NP-Completeness Results Concerning Data Compression}, diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index f766e542..764568d6 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -28,11 +28,11 @@ use problemreductions::models::misc::{ LongestCommonSubsequence, MinimumExternalMacroDataCompression, MinimumTardinessSequencing, MultiprocessorScheduling, PaintShop, PartiallyOrderedKnapsack, ProductionPlanning, QueryArg, RectilinearPictureCompression, RegisterSufficiency, ResourceConstrainedScheduling, - SchedulingToMinimizeWeightedCompletionTime, - SchedulingWithIndividualDeadlines, SequencingToMinimizeMaximumCumulativeCost, - SequencingToMinimizeWeightedCompletionTime, SequencingToMinimizeWeightedTardiness, - SequencingWithReleaseTimesAndDeadlines, SequencingWithinIntervals, ShortestCommonSupersequence, - StringToStringCorrection, SubsetSum, SumOfSquaresPartition, ThreePartition, TimetableDesign, + SchedulingToMinimizeWeightedCompletionTime, SchedulingWithIndividualDeadlines, + SequencingToMinimizeMaximumCumulativeCost, SequencingToMinimizeWeightedCompletionTime, + SequencingToMinimizeWeightedTardiness, SequencingWithReleaseTimesAndDeadlines, + SequencingWithinIntervals, ShortestCommonSupersequence, StringToStringCorrection, SubsetSum, + SumOfSquaresPartition, ThreePartition, TimetableDesign, }; use problemreductions::models::BiconnectivityAugmentation; use problemreductions::prelude::*; @@ -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/graph/minimum_vertex_cover.rs b/src/models/graph/minimum_vertex_cover.rs index b8586b57..db484b40 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/models/misc/ensemble_computation.rs b/src/models/misc/ensemble_computation.rs index 0de0d77f..479e7eb4 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" }, @@ -33,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>, @@ -146,49 +164,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 crate::types::Or(false); + 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(left) = self.decode_operand(left_operand, &computed) else { + return Min(None); + }; + let Some(right) = self.decode_operand(right_operand, &computed) else { + return Min(None); }; - if required_subsets.is_empty() { - return crate::types::Or(true); + + 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 +243,7 @@ pub(crate) fn canonical_model_example_specs() -> Vec 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/models/misc/paintshop.rs b/src/models/misc/paintshop.rs index ade889b1..b144ced5 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/models/mod.rs b/src/models/mod.rs index a04ae9dd..150ed8fa 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -45,12 +45,11 @@ pub use misc::{ MultiprocessorScheduling, PaintShop, Partition, PrecedenceConstrainedScheduling, ProductionPlanning, QueryArg, RectilinearPictureCompression, RegisterSufficiency, ResourceConstrainedScheduling, SchedulingToMinimizeWeightedCompletionTime, - SchedulingWithIndividualDeadlines, - SequencingToMinimizeMaximumCumulativeCost, SequencingToMinimizeWeightedCompletionTime, - SequencingToMinimizeWeightedTardiness, SequencingWithReleaseTimesAndDeadlines, - SequencingWithinIntervals, ShortestCommonSupersequence, StackerCrane, StaffScheduling, - StringToStringCorrection, SubsetSum, SumOfSquaresPartition, Term, ThreePartition, - TimetableDesign, + SchedulingWithIndividualDeadlines, SequencingToMinimizeMaximumCumulativeCost, + SequencingToMinimizeWeightedCompletionTime, SequencingToMinimizeWeightedTardiness, + SequencingWithReleaseTimesAndDeadlines, SequencingWithinIntervals, ShortestCommonSupersequence, + StackerCrane, StaffScheduling, StringToStringCorrection, SubsetSum, SumOfSquaresPartition, + Term, ThreePartition, TimetableDesign, }; pub use set::{ ComparativeContainment, ConsecutiveSets, ExactCoverBy3Sets, IntegerKnapsack, MaximumSetPacking, diff --git a/src/rules/hamiltoniancircuit_longestcircuit.rs b/src/rules/hamiltoniancircuit_longestcircuit.rs new file mode 100644 index 00000000..c6d9a0bb --- /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/kclique_balancedcompletebipartitesubgraph.rs b/src/rules/kclique_balancedcompletebipartitesubgraph.rs new file mode 100644 index 00000000..0c84772a --- /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/kcoloring_twodimensionalconsecutivesets.rs b/src/rules/kcoloring_twodimensionalconsecutivesets.rs new file mode 100644 index 00000000..18fd9575 --- /dev/null +++ b/src/rules/kcoloring_twodimensionalconsecutivesets.rs @@ -0,0 +1,145 @@ +//! 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, +} + +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. + // The target may use any labels, so we compress the distinct + // group indices used by vertex symbols to 0..2. + + let vertex_groups = &target_solution[..self.num_vertices]; + + // Collect distinct group indices used by vertices and map to 0..k-1 + let mut used: Vec = vertex_groups.to_vec(); + used.sort(); + used.dedup(); + + let group_to_color: std::collections::HashMap = used + .into_iter() + .enumerate() + .map(|(color, group)| (group, color % 3)) + .collect(); + + 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, + } + } +} + +#[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/longestcommonsubsequence_maximumindependentset.rs b/src/rules/longestcommonsubsequence_maximumindependentset.rs new file mode 100644 index 00000000..9cecb576 --- /dev/null +++ b/src/rules/longestcommonsubsequence_maximumindependentset.rs @@ -0,0 +1,227 @@ +//! 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, 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, 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, 0, 1, 1], + }, + ) + }, + }] +} + +#[cfg(test)] +#[path = "../unit_tests/rules/longestcommonsubsequence_maximumindependentset.rs"] +mod tests; diff --git a/src/rules/minimumvertexcover_ensemblecomputation.rs b/src/rules/minimumvertexcover_ensemblecomputation.rs new file mode 100644 index 00000000..3a3f4113 --- /dev/null +++ b/src/rules/minimumvertexcover_ensemblecomputation.rs @@ -0,0 +1,142 @@ +//! Reduction from MinimumVertexCover (unit-weight) 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 = |V| + |E| (search space bound; the optimal value encodes K*) +//! +//! The minimum sequence length is K* + |E|, where K* is the minimum vertex +//! cover size. This follows from the Garey & Johnson proof (PO9): each cover +//! vertex contributes one {a₀} ∪ {v} operation, and each edge contributes +//! one {u} ∪ z_k operation. +//! +//! 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}; +use crate::types::One; + +/// 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. + /// + /// 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]; + + 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 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); + + 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}} + // Optimal sequence length = K* + |E| = 1 + 1 = 2 + let source = MinimumVertexCover::new(SimpleGraph::new(2, vec![(0, 1)]), vec![One; 2]); + + // Optimal sequence for cover {0} (2 steps): + // 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 (unused) operands: (2, 1) + let target_config = vec![ + 2, 0, // step 0: {a₀} ∪ {0} + 1, 3, // step 1: {1} ∪ z₀ + 2, 1, // step 2: padding + ]; + // Extraction picks up vertices 0 (step 0) and 1 (steps 1 and 2). + // Cover {0,1} is valid (though not minimum — the optimal witness + // is found by BruteForce, giving cover {0} or {1}). + 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/minimumvertexcover_minimumhittingset.rs b/src/rules/minimumvertexcover_minimumhittingset.rs new file mode 100644 index 00000000..0b426e71 --- /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 abb9e1fb..abaf3f40 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; @@ -25,15 +26,18 @@ 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; +pub(crate) mod kcoloring_twodimensionalconsecutivesets; mod knapsack_qubo; mod ksatisfiability_casts; 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; @@ -45,15 +49,21 @@ 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; +pub(crate) mod minimumvertexcover_minimumhittingset; pub(crate) mod minimumvertexcover_minimumsetcovering; +pub(crate) mod paintshop_qubo; pub(crate) mod partition_cosineproductintegration; 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 rootedtreearrangement_rootedtreestorageassignment; pub(crate) mod sat_circuitsat; pub(crate) mod sat_coloring; pub(crate) mod sat_ksat; @@ -63,6 +73,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; @@ -267,6 +278,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec Vec Vec 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.saturating_sub(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/rules/partition_subsetsum.rs b/src/rules/partition_subsetsum.rs new file mode 100644 index 00000000..a092d46a --- /dev/null +++ b/src/rules/partition_subsetsum.rs @@ -0,0 +1,89 @@ +//! Reduction from Partition to SubsetSum. +//! +//! Partition is the special case of SubsetSum where the target B equals half the +//! total sum. This reduction copies the element sizes and sets B = S/2. If S is +//! odd, a trivially infeasible SubsetSum instance is returned (sizes = [], target = 1). + +use crate::models::misc::{Partition, SubsetSum}; +use crate::reduction; +use crate::rules::traits::{ReduceTo, ReductionResult}; +use num_bigint::BigUint; + +/// Result of reducing Partition to SubsetSum. +#[derive(Debug, Clone)] +pub struct ReductionPartitionToSubsetSum { + target: SubsetSum, + /// Number of elements in the original Partition instance. + /// When the total sum is odd, the target has 0 elements but the source has n. + source_n: usize, +} + +impl ReductionResult for ReductionPartitionToSubsetSum { + type Source = Partition; + type Target = SubsetSum; + + fn target_problem(&self) -> &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/rules/partitionintopathsoflength2_boundedcomponentspanningforest.rs b/src/rules/partitionintopathsoflength2_boundedcomponentspanningforest.rs new file mode 100644 index 00000000..df158820 --- /dev/null +++ b/src/rules/partitionintopathsoflength2_boundedcomponentspanningforest.rs @@ -0,0 +1,99 @@ +//! Reduction from PartitionIntoPathsOfLength2 to BoundedComponentSpanningForest. +//! +//! Given a PartitionIntoPathsOfLength2 instance with graph G = (V, E), |V| = 3q, +//! construct a BoundedComponentSpanningForest instance on the same graph with +//! unit vertex weights, K = q = |V|/3 components, and B = 3. +//! +//! A valid P3-partition (each triple induces at least 2 edges, hence is connected) +//! directly corresponds to a bounded-component partition with at most q components +//! of weight at most 3. +//! +//! Reference: Garey & Johnson, ND10, p.208; Hadlock (1974). + +use crate::models::graph::{BoundedComponentSpanningForest, PartitionIntoPathsOfLength2}; +use crate::reduction; +use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::topology::{Graph, SimpleGraph}; + +/// Result of reducing PartitionIntoPathsOfLength2 to BoundedComponentSpanningForest. +#[derive(Debug, Clone)] +pub struct ReductionPPL2ToBCSF { + target: BoundedComponentSpanningForest, +} + +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; + + // Handle empty graph: max_components must be >= 1 + let max_components = if q == 0 { 1 } else { q }; + + let target = BoundedComponentSpanningForest::new( + SimpleGraph::new(n, self.graph().edges()), + vec![1i32; n], // unit weights + max_components, // K = max(|V|/3, 1) + 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/rules/rootedtreearrangement_rootedtreestorageassignment.rs b/src/rules/rootedtreearrangement_rootedtreestorageassignment.rs new file mode 100644 index 00000000..91f4d4a1 --- /dev/null +++ b/src/rules/rootedtreearrangement_rootedtreestorageassignment.rs @@ -0,0 +1,129 @@ +//! Reduction from RootedTreeArrangement to RootedTreeStorageAssignment. +//! +//! Given a RootedTreeArrangement instance with graph G = (V, E) and bound K, +//! construct a RootedTreeStorageAssignment instance: +//! - Universe X = V (the vertex set) +//! - For each edge {u, v} in E, create a 2-element subset {u, v} +//! - Bound K' = K - |E| +//! +//! The extension cost for a single edge subset {u,v} equals d_T(u,v) - 1 +//! in the rooted tree, so total extension cost = total arrangement cost - |E|. + +use crate::models::graph::RootedTreeArrangement; +use crate::models::set::RootedTreeStorageAssignment; +use crate::reduction; +use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::topology::{Graph, SimpleGraph}; + +/// Result of reducing RootedTreeArrangement to RootedTreeStorageAssignment. +#[derive(Debug, Clone)] +pub struct ReductionRootedTreeArrangementToRootedTreeStorageAssignment { + target: RootedTreeStorageAssignment, + /// Number of vertices in the source graph (needed for solution extraction). + num_vertices: usize, +} + +impl ReductionResult for ReductionRootedTreeArrangementToRootedTreeStorageAssignment { + type Source = RootedTreeArrangement; + 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|. If this underflows (K < |E|), the source instance + // is infeasible (each edge contributes at least 1 to the arrangement + // cost). In that case, return a fixed gadget instance that is + // guaranteed infeasible for the target problem as well. + let bound = match self.bound().checked_sub(num_edges) { + Some(b) => b, + None => { + // Gadget: universe {0,1,2} with all 2-element subsets and bound 0. + // For any rooted tree on three vertices, at least one pair has + // distance 2, so at least one subset has extension cost >= 1. + // Thus the minimum total extension cost is >= 1, making this + // instance infeasible for bound 0. + let gadget_n = 3; + let gadget_subsets = vec![vec![0, 1], vec![1, 2], vec![0, 2]]; + let target = RootedTreeStorageAssignment::new(gadget_n, gadget_subsets, 0); + + return ReductionRootedTreeArrangementToRootedTreeStorageAssignment { + target, + num_vertices: gadget_n, + }; + } + }; + + 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/rules/subsetsum_capacityassignment.rs b/src/rules/subsetsum_capacityassignment.rs new file mode 100644 index 00000000..85d5f1d5 --- /dev/null +++ b/src/rules/subsetsum_capacityassignment.rs @@ -0,0 +1,108 @@ +//! Reduction from SubsetSum to CapacityAssignment. +//! +//! Each element becomes a communication link with two capacity levels. +//! Choosing the high capacity (index 1) corresponds to including the element +//! in the subset. The delay budget constraint enforces that enough elements +//! are included to make the total cost equal to the target sum B. + +use crate::models::misc::{CapacityAssignment, SubsetSum}; +use crate::reduction; +use crate::rules::traits::{ReduceTo, ReductionResult}; + +/// Result of reducing SubsetSum to CapacityAssignment. +#[derive(Debug, Clone)] +pub struct ReductionSubsetSumToCapacityAssignment { + target: CapacityAssignment, +} + +impl ReductionResult for ReductionSubsetSumToCapacityAssignment { + type Source = SubsetSum; + type Target = CapacityAssignment; + + fn target_problem(&self) -> &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"); + // Use saturating subtraction to avoid underflow when target_val > total_sum. + // In that case, treat the delay budget as 0 so the reduction remains sound. + let delay_budget = total_sum.saturating_sub(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/models/misc/ensemble_computation.rs b/src/unit_tests/models/misc/ensemble_computation.rs index eed2489e..317e3112 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/analysis.rs b/src/unit_tests/rules/analysis.rs index 020b89b6..cd450618 100644 --- a/src/unit_tests/rules/analysis.rs +++ b/src/unit_tests/rules/analysis.rs @@ -243,6 +243,11 @@ fn test_find_dominated_rules_returns_known_set() { let allowed: std::collections::HashSet<(&str, &str)> = [ // 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/hamiltoniancircuit_longestcircuit.rs b/src/unit_tests/rules/hamiltoniancircuit_longestcircuit.rs new file mode 100644 index 00000000..821d9c4b --- /dev/null +++ b/src/unit_tests/rules/hamiltoniancircuit_longestcircuit.rs @@ -0,0 +1,78 @@ +use crate::models::graph::{HamiltonianCircuit, LongestCircuit}; +use crate::rules::test_helpers::assert_satisfaction_round_trip_from_optimization_target; +use crate::rules::ReduceTo; +use crate::rules::ReductionResult; +use crate::solvers::BruteForce; +use crate::topology::{Graph, SimpleGraph}; +use crate::types::Max; +use crate::Problem; + +fn cycle4_hc() -> 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)); +} diff --git a/src/unit_tests/rules/kclique_balancedcompletebipartitesubgraph.rs b/src/unit_tests/rules/kclique_balancedcompletebipartitesubgraph.rs new file mode 100644 index 00000000..074fd400 --- /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" + ); +} diff --git a/src/unit_tests/rules/kcoloring_twodimensionalconsecutivesets.rs b/src/unit_tests/rules/kcoloring_twodimensionalconsecutivesets.rs new file mode 100644 index 00000000..39012b08 --- /dev/null +++ b/src/unit_tests/rules/kcoloring_twodimensionalconsecutivesets.rs @@ -0,0 +1,112 @@ +use super::*; +use crate::models::graph::KColoring; +use crate::models::set::TwoDimensionalConsecutiveSets; +use crate::rules::test_helpers::assert_satisfaction_round_trip_from_satisfaction_target; +use crate::rules::traits::ReduceTo; +use crate::solvers::BruteForce; +use crate::topology::SimpleGraph; +use crate::traits::Problem; +use crate::variant::K3; + +#[test] +fn test_kcoloring_to_twodimensionalconsecutivesets_closed_loop() { + // Triangle graph: 3-colorable + let source = KColoring::::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 + ); + } +} diff --git a/src/unit_tests/rules/longestcommonsubsequence_maximumindependentset.rs b/src/unit_tests/rules/longestcommonsubsequence_maximumindependentset.rs new file mode 100644 index 00000000..16d3ddb9 --- /dev/null +++ b/src/unit_tests/rules/longestcommonsubsequence_maximumindependentset.rs @@ -0,0 +1,138 @@ +use super::*; +use crate::rules::test_helpers::assert_optimization_round_trip_from_optimization_target; +use crate::solvers::BruteForce; +use crate::topology::Graph; +use crate::traits::Problem; + +#[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 reduction = ReduceTo::>::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)", + ); +} diff --git a/src/unit_tests/rules/minimumvertexcover_ensemblecomputation.rs b/src/unit_tests/rules/minimumvertexcover_ensemblecomputation.rs new file mode 100644 index 00000000..afb8d3af --- /dev/null +++ b/src/unit_tests/rules/minimumvertexcover_ensemblecomputation.rs @@ -0,0 +1,141 @@ +use crate::models::graph::MinimumVertexCover; +use crate::models::misc::EnsembleComputation; +use crate::rules::traits::ReduceTo; +use crate::rules::ReductionResult; +use crate::solvers::BruteForce; +use crate::topology::{Graph, SimpleGraph}; +use crate::traits::Problem; +use crate::types::{Min, One}; + +/// Verify that a configuration is a valid vertex cover. +fn is_valid_cover(graph: &SimpleGraph, config: &[usize]) -> 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, optimal EC length = K* + |E| = 2 + let graph = SimpleGraph::new(2, vec![(0, 1)]); + let source = MinimumVertexCover::new(graph.clone(), vec![One; 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 — optimal value should be 2 (K*=1 + |E|=1) + use crate::solvers::Solver; + let solver = BruteForce::new(); + 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); + 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![One; 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); + 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![One; 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![One; 2]); + let reduction = ReduceTo::::reduce_to(&source); + + // 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]; + + let target = reduction.target_problem(); + assert_eq!(target.evaluate(&config), Min(Some(2))); + + 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() { + let graph = SimpleGraph::new(2, vec![(0, 1)]); + let source = MinimumVertexCover::new(graph.clone(), vec![One; 2]); + let reduction = ReduceTo::::reduce_to(&source); + + // 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), Min(Some(2))); + + let cover = reduction.extract_solution(&config); + assert_eq!(cover, vec![1, 1]); + assert!(is_valid_cover(&graph, &cover)); +} + +#[test] +fn test_empty_graph() { + let graph = SimpleGraph::new(3, vec![]); + 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); + + // No subsets → optimal value is 0 + use crate::solvers::Solver; + let solver = BruteForce::new(); + let optimal = solver.solve(target); + assert_eq!(optimal, Min(Some(0))); +} diff --git a/src/unit_tests/rules/minimumvertexcover_minimumhittingset.rs b/src/unit_tests/rules/minimumvertexcover_minimumhittingset.rs new file mode 100644 index 00000000..9e7a7b6a --- /dev/null +++ b/src/unit_tests/rules/minimumvertexcover_minimumhittingset.rs @@ -0,0 +1,128 @@ +use super::*; +use crate::rules::test_helpers::assert_optimization_round_trip_from_optimization_target; +use crate::solvers::BruteForce; + +#[test] +fn test_minimumvertexcover_to_minimumhittingset_closed_loop() { + // 6-vertex graph from the issue example + // Edges: {0,1}, {0,2}, {1,3}, {2,3}, {2,4}, {3,5}, {4,5}, {1,4} + // Minimum vertex cover size = 3 (e.g., {0, 3, 4}) + let vc_problem = 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], + ); + let reduction = ReduceTo::::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]); +} diff --git a/src/unit_tests/rules/paintshop_qubo.rs b/src/unit_tests/rules/paintshop_qubo.rs new file mode 100644 index 00000000..385d60da --- /dev/null +++ b/src/unit_tests/rules/paintshop_qubo.rs @@ -0,0 +1,118 @@ +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", + ); +} + +#[test] +fn test_paintshop_to_qubo_empty_sequence() { + // Empty PaintShop with 0 cars should not panic + let source = PaintShop::new(Vec::<&str>::new()); + let reduction = ReduceTo::>::reduce_to(&source); + let qubo = reduction.target_problem(); + assert_eq!(qubo.num_vars(), 0); +} + +#[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()); +} diff --git a/src/unit_tests/rules/partition_subsetsum.rs b/src/unit_tests/rules/partition_subsetsum.rs new file mode 100644 index 00000000..9395e857 --- /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", + ); +} diff --git a/src/unit_tests/rules/partitionintopathsoflength2_boundedcomponentspanningforest.rs b/src/unit_tests/rules/partitionintopathsoflength2_boundedcomponentspanningforest.rs new file mode 100644 index 00000000..66482bc1 --- /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); +} diff --git a/src/unit_tests/rules/rootedtreearrangement_rootedtreestorageassignment.rs b/src/unit_tests/rules/rootedtreearrangement_rootedtreestorageassignment.rs new file mode 100644 index 00000000..ec413aeb --- /dev/null +++ b/src/unit_tests/rules/rootedtreearrangement_rootedtreestorageassignment.rs @@ -0,0 +1,126 @@ +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"); +} + +#[test] +fn test_rootedtreearrangement_to_rootedtreestorageassignment_infeasible_underflow() { + // K < |E|: bound is too small for a 3-edge path, so source is infeasible. + // The reduction should return an infeasible gadget rather than panic. + let source = RootedTreeArrangement::new(SimpleGraph::new(4, vec![(0, 1), (1, 2), (2, 3)]), 2); + let reduction = ReduceTo::::reduce_to(&source); + let target = reduction.target_problem(); + + // Gadget should be infeasible + let solver = BruteForce::new(); + assert!( + solver.find_witness(&source).is_none(), + "source with K=2 < |E|=3 should be infeasible" + ); + assert!( + solver.find_witness(target).is_none(), + "gadget target should also be infeasible" + ); +} diff --git a/src/unit_tests/rules/subsetsum_capacityassignment.rs b/src/unit_tests/rules/subsetsum_capacityassignment.rs new file mode 100644 index 00000000..ddd1078c --- /dev/null +++ b/src/unit_tests/rules/subsetsum_capacityassignment.rs @@ -0,0 +1,114 @@ +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_target_exceeds_sum() { + // target > sum(sizes): unsatisfiable, should not panic from underflow + let source = SubsetSum::new(vec![1u32, 2, 3], 100u32); + let reduction = ReduceTo::::reduce_to(&source); + let target = reduction.target_problem(); + + // delay_budget = saturating_sub(6, 100) = 0 + assert_eq!(target.delay_budget(), 0); + + // No subset sums to 100, so source is unsatisfiable + let solver = BruteForce::new(); + let witness = solver.find_witness(target); + if let Some(config) = witness { + let extracted = reduction.extract_solution(&config); + assert!( + !source.evaluate(&extracted), + "source should be unsatisfiable" + ); + } +} + +#[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" + ); + } +}