From 54d6c2974c3b821b3aa8f94a5886f1c62da9e20f Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Fri, 18 Sep 2026 00:11:24 +0800 Subject: [PATCH 1/4] Fix recovery from interchangeable 3-partition pairing elements --- docs/paper/reductions.typ | 4 +- ...threedimensionalmatching_threepartition.rs | 174 ++++++------------ ...threedimensionalmatching_threepartition.rs | 57 ++++++ 3 files changed, 112 insertions(+), 123 deletions(-) diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index ee531965e..ad22273a8 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -19075,9 +19075,9 @@ The following table shows concrete target-variable counts for example instances, ($arrow.r.double$) Given a perfect matching $M'$, form one ABCD group for every source triple. If $m_l in M'$, combine $u_l$ with the unique first-occurrence $B$, $C$, and $D$ items of coordinates $(a_l, b_l, c_l)$; otherwise combine $u_l$ with the corresponding later-occurrence dummy items. Because $r = 32 q$ prevents carries between the $r$, $r^2$, $r^3$, and $r^4$ digits, every such group sums to $T_1$, so the tagged instance has a 4-partition. For each tagged 4-set choose any two members $a_i, a_j$ and let the other two be $a_k, a_l$. Then ${w_i, w_j, u_(i j)}$ and ${w_k, w_l, u'_(i j)}$ both sum to $B$. Every pairing gadget not used this way joins one filler in a triple ${u_(i j), u'_(i j), 20 T_2}$. Hence the produced 3-Partition instance is feasible. - ($arrow.l.double$) In any feasible target solution every number lies strictly between $B / 4$ and $B / 2$, so the partition really is into triples. Modulo 4, regular numbers are congruent to 1, pairing numbers to 2, and fillers to 0. Therefore every triple is either of type $(1, 1, 2)$ or $(0, 2, 2)$. The $(0, 2, 2)$ triples identify the unused pairing gadgets, leaving a family of $(1, 1, 2)$ triples that reconstructs a 4-partition of the tagged numbers. Since $1 + 2 + 4 + 8 equiv 15 mod 16$, every recovered tagged 4-set contains exactly one former $A$-, $B$-, $C$-, and $D$-item. The carry-free base-$r$ encoding then forces each ABCD group to be either a real group (all first occurrences) or a dummy group (all later occurrences). The real groups pick exactly $q$ source triples, one for each coordinate of $W$, $X$, and $Y$, so they form a perfect 3-dimensional matching. + ($arrow.l.double$) In any feasible target solution every number lies strictly between $B / 4$ and $B / 2$, so the partition really is into triples. Modulo 4, regular numbers are congruent to 1, pairing numbers to 2, and fillers to 0. Therefore every triple is either of type $(1, 1, 2)$ or $(0, 2, 2)$. First normalize the $(0, 2, 2)$ triples as in @garey1979: if a filler shares a triple with pairing elements $p, q$, exchange $q$ with the original mate of $p$. Both have the same size, since every original pair sums to $44 T_2 + 4 = B - 20 T_2$, so both affected triples remain valid. Each exchange fixes a filler triple without disturbing a previously fixed one. After normalization, every remaining original pair occurs in two $(1, 1, 2)$ triples. Their four actual regular elements sum to $2 B - (44 T_2 + 4) = 84 T_2 + 4$, so the corresponding tagged numbers sum to $T_2$. These disjoint four-sets reconstruct a 4-partition. Since $1 + 2 + 4 + 8 equiv 15 mod 16$, every recovered tagged 4-set contains exactly one former $A$-, $B$-, $C$-, and $D$-item. The carry-free base-$r$ encoding then forces each ABCD group to be either a real group (all first occurrences) or a dummy group (all later occurrences). The real groups pick exactly $q$ source triples, one for each coordinate of $W$, $X$, and $Y$, so they form a perfect 3-dimensional matching. - _Solution extraction._ Reverse the 4-Partition $arrow.r$ 3-Partition gadget by pairing each triple containing some $u_(i j)$ with the unique triple containing the matching $u'_(i j)$. This recovers the tagged 4-set. Undo the mod-16 tags to obtain one ABCD group, discard every dummy group whose $B$, $C$, and $D$ items are not first occurrences, and read the selected source triple from the surviving $A$-item. + _Solution extraction._ Normalize filler triples by the equal-size exchanges above, maintaining each element's current group and position. Then pair the remaining triples containing original mates $u_(i j), u'_(i j)$ and collect their four actual regular elements; their indices need not equal the indices used to construct the pairing gadget. The normalization and pairing take linear time in the target element count. Undo the mod-16 tags to obtain one ABCD group, discard every dummy group whose $B$, $C$, and $D$ items are not first occurrences, and read the selected source triple from the surviving $A$-item. ] #let tdm_ilp = load-example("ThreeDimensionalMatching", "ILP") diff --git a/src/rules/threedimensionalmatching_threepartition.rs b/src/rules/threedimensionalmatching_threepartition.rs index 03a6ce030..8bce221e9 100644 --- a/src/rules/threedimensionalmatching_threepartition.rs +++ b/src/rules/threedimensionalmatching_threepartition.rs @@ -9,7 +9,6 @@ use crate::models::misc::ThreePartition; use crate::models::set::ThreeDimensionalMatching; use crate::reduction; use crate::rules::traits::{ReduceTo, ReductionResult}; -use std::collections::HashMap; #[derive(Debug, Clone, Copy)] enum Step2Item { @@ -33,18 +32,6 @@ enum Step2Item { }, } -#[derive(Debug, Clone, Copy)] -enum PairingKind { - U, - UPrime, -} - -#[derive(Debug, Default, Clone, Copy)] -struct PairUsage { - saw_u: bool, - uprime_regulars: Option<[usize; 2]>, -} - /// Result of reducing ThreeDimensionalMatching to ThreePartition. #[derive(Debug, Clone)] pub struct ReductionThreeDimensionalMatchingToThreePartition { @@ -67,27 +54,6 @@ impl ReductionThreeDimensionalMatchingToThreePartition { self.pairing_start() + 2 * self.pair_keys.len() } - fn classify_target_element(&self, element_index: usize) -> TargetElement { - if element_index < self.num_regulars() { - return TargetElement::Regular { - step2_index: element_index, - }; - } - - if element_index < self.filler_start() { - let pairing_offset = element_index - self.pairing_start(); - let pair_index = pairing_offset / 2; - let kind = if pairing_offset.is_multiple_of(2) { - PairingKind::U - } else { - PairingKind::UPrime - }; - return TargetElement::Pairing { pair_index, kind }; - } - - TargetElement::Filler - } - fn decode_real_group(&self, step2_group: [usize; 4]) -> Option { let mut a_item = None; let mut b_item = None; @@ -143,6 +109,8 @@ impl ReductionThreeDimensionalMatchingToThreePartition { #[cfg(test)] fn build_target_witness(&self, source_solution: &[usize]) -> Vec { + use std::collections::HashMap; + let mut a_indices = vec![0usize; self.num_source_triples]; let mut first_b_by_w = HashMap::new(); let mut first_c_by_x = HashMap::new(); @@ -298,98 +266,62 @@ impl ReductionResult for ReductionThreeDimensionalMatchingToThreePartition { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - - Ok({ - let mut groups = vec![Vec::new(); self.target.num_groups()]; - for (element_index, &group_index) in target_solution.iter().enumerate() { - groups[group_index].push(element_index); - } - - let mut pair_usage: HashMap<(usize, usize), PairUsage> = HashMap::new(); - - for members in groups.into_iter().filter(|members| !members.is_empty()) { - let mut regulars = Vec::new(); - let mut pairing = None; - let mut has_filler = false; - - for element_index in members { - match self.classify_target_element(element_index) { - TargetElement::Regular { step2_index } => regulars.push(step2_index), - TargetElement::Pairing { pair_index, kind } => { - pairing = Some((pair_index, kind)) - } - TargetElement::Filler => has_filler = true, - } - } - - if has_filler || regulars.len() != 2 { - continue; - } + let value = + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + if !value.0 { + return Err(crate::rules::ExtractionError::invalid( + "target assignment is not a feasible 3-partition", + )); + } - let Some((pair_index, kind)) = pairing else { - continue; - }; + let mut groups = vec![Vec::with_capacity(3); self.target.num_groups()]; + let mut positions = Vec::with_capacity(target_solution.len()); + for (element, &group) in target_solution.iter().enumerate() { + positions.push((group, groups[group].len())); + groups[group].push(element); + } - let pair_key = self.pair_keys[pair_index]; - let regular_pair = sorted_pair(regulars[0], regulars[1]); - let usage = pair_usage.entry(pair_key).or_default(); + // Garey--Johnson's reverse construction first normalizes filler triples. + // Each has two pairing elements whose sum equals that of an original + // U/UPrime pair. Exchange the second element with the first's mate: + // they have equal sizes, so both affected triples remain valid. A mate + // cannot belong to an already normalized filler triple unless it is + // already here, so each iteration permanently normalizes one triple. + // Initial index order puts regulars first and fillers last; exchanges + // only move pairing elements, preserving those positions. + let pairing_start = self.pairing_start(); + for filler in self.filler_start()..target_solution.len() { + let (group, _) = positions[filler]; + let first = groups[group][0]; + let second = groups[group][1]; + let mate = pairing_start + ((first - pairing_start) ^ 1); + let (mate_group, mate_slot) = positions[mate]; + groups[group][1] = mate; + groups[mate_group][mate_slot] = second; + positions[mate] = (group, 1); + positions[second] = (mate_group, mate_slot); + } - match kind { - PairingKind::U => { - if regular_pair == [pair_key.0, pair_key.1] { - usage.saw_u = true; - } - } - PairingKind::UPrime => { - usage.uprime_regulars = Some(regular_pair); - } - } + let mut source_solution = vec![false; self.num_source_triples]; + for first in (pairing_start..self.filler_start()).step_by(2) { + let (left, _) = positions[first]; + if groups[left][0] >= pairing_start { + continue; // This complete pair is used by a filler triple. } - - let mut source_solution = vec![false; self.num_source_triples]; - - for ((left, right), usage) in pair_usage { - let Some(other_two) = usage.uprime_regulars else { - continue; - }; - if !usage.saw_u { - continue; - } - - let mut group = [left, right, other_two[0], other_two[1]]; - group.sort_unstable(); - if group.windows(2).any(|window| window[0] == window[1]) { - continue; - } - - if let Some(source_triple) = self.decode_real_group(group) { - source_solution[source_triple] = true; - } + let (right, _) = positions[first + 1]; + // Use the actual regular elements, not the pair's construction + // indices: equal-valued pairing elements are interchangeable. + let regulars = [ + groups[left][0], + groups[left][1], + groups[right][0], + groups[right][1], + ]; + if let Some(source_triple) = self.decode_real_group(regulars) { + source_solution[source_triple] = true; } - - source_solution - }) - } -} - -#[derive(Debug, Clone, Copy)] -enum TargetElement { - Regular { - step2_index: usize, - }, - Pairing { - pair_index: usize, - kind: PairingKind, - }, - Filler, -} - -fn sorted_pair(a: usize, b: usize) -> [usize; 2] { - if a <= b { - [a, b] - } else { - [b, a] + } + Ok(source_solution) } } diff --git a/src/unit_tests/rules/threedimensionalmatching_threepartition.rs b/src/unit_tests/rules/threedimensionalmatching_threepartition.rs index f9cb2913b..23df83cfe 100644 --- a/src/unit_tests/rules/threedimensionalmatching_threepartition.rs +++ b/src/unit_tests/rules/threedimensionalmatching_threepartition.rs @@ -123,3 +123,60 @@ fn test_threedimensionalmatching_to_threepartition_uncovered_coordinate_maps_to_ "target instance should be infeasible" ); } + +#[test] +fn test_threedimensionalmatching_to_threepartition_extracts_noncanonical_partition() { + let (source, reduction) = reduce(1, &[(0, 0, 0)]); + // A mathematically valid witness found independently by HiGHS. Both regular + // triples initially contain UPrime elements; filler triples mix pair IDs. + // Keep the witness fixed so this regression does not depend on the backend. + let witness = vec![ + 4, 0, 0, 4, 2, 1, 3, 3, 6, 0, 6, 4, 5, 5, 2, 1, 3, 1, 6, 2, 5, + ]; + assert!(reduction.target_problem().evaluate(&witness).unwrap().0); + let extracted = reduction.extract_solution(&witness).unwrap(); + assert_eq!(extracted, vec![true]); + assert!(source.evaluate(&extracted).unwrap().0); + // Group labels have no mathematical significance. + let relabeled = witness.iter().map(|group| 6 - group).collect(); + assert_eq!(reduction.extract_solution(&relabeled).unwrap(), extracted); +} + +#[test] +fn test_threedimensionalmatching_to_threepartition_equal_size_permutations() { + let (source, reduction) = reduce(2, &[(0, 0, 0), (0, 1, 1), (1, 0, 0), (1, 1, 1)]); + let target = reduction.target_problem(); + for matching in [[1, 0, 0, 1], [0, 1, 1, 0]] { + let mut witness = reduction.build_target_witness(&matching); + let mut exchanges = 0; + // Cumulative equal-size exchanges preserve a valid partition while + // exercising regular-item identities, mixed fillers, and dummy groups. + for left in 0..target.num_elements() { + for right in left + 1..target.num_elements() { + if target.sizes()[left] == target.sizes()[right] && witness[left] != witness[right] + { + witness.swap(left, right); + assert!(target.evaluate(&witness).unwrap().0); + let extracted = reduction.extract_solution(&witness).unwrap(); + assert!(source.evaluate(&extracted).unwrap().0); + exchanges += 1; + } + } + } + assert!(exchanges > 0); + } +} + +#[test] +fn test_threedimensionalmatching_to_threepartition_rejects_invalid_partitions() { + let (_, reduction) = reduce(1, &[(0, 0, 0)]); + let valid = reduction.build_target_witness(&[1]); + assert!(reduction.extract_solution(&vec![]).is_err()); + let mut invalid = valid.clone(); + invalid[0] = reduction.target_problem().num_groups(); + assert!(reduction.extract_solution(&invalid).is_err()); + assert!(reduction.extract_solution(&vec![0; valid.len()]).is_err()); + let mut wrong_sum = valid; + wrong_sum.swap(0, 2); + assert!(reduction.extract_solution(&wrong_sum).is_err()); +} From 5707218190a714ac460e5301d5e78f41e7320dc9 Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Fri, 18 Sep 2026 00:34:14 +0800 Subject: [PATCH 2/4] Fix set splitting reduction for repeated elements --- src/models/set/set_splitting.rs | 5 ++- src/rules/setsplitting_betweenness.rs | 13 ++++++-- .../rules/setsplitting_betweenness.rs | 31 +++++++++++++++++++ 3 files changed, 46 insertions(+), 3 deletions(-) diff --git a/src/models/set/set_splitting.rs b/src/models/set/set_splitting.rs index 1c7f46a76..eaf326f36 100644 --- a/src/models/set/set_splitting.rs +++ b/src/models/set/set_splitting.rs @@ -68,6 +68,9 @@ fn normalize_subsets(universe_size: usize, subsets: &[Vec]) -> (usize, Ve for subset in subsets { let mut remainder = subset.clone(); + // Repeated occurrences represent the same set element. + remainder.sort_unstable(); + remainder.dedup(); while remainder.len() > 3 { let positive_aux = next_element; let negative_aux = next_element + 1; @@ -154,7 +157,7 @@ impl SetSplitting { (universe_size, size2, size3) } - /// Universe size after decomposing all subsets to size 2 or 3. + /// Universe size after deduplicating subsets and decomposing sizes above 3. pub fn normalized_universe_size(&self) -> usize { self.normalized_stats().0 } diff --git a/src/rules/setsplitting_betweenness.rs b/src/rules/setsplitting_betweenness.rs index a62f799d4..7e6ec7963 100644 --- a/src/rules/setsplitting_betweenness.rs +++ b/src/rules/setsplitting_betweenness.rs @@ -1,11 +1,12 @@ //! Reduction from Set Splitting to Betweenness. //! -//! Decompose each subset to size 2 or 3 using complementarity pairs, then +//! Deduplicate each subset and decompose sizes above 3 using complementarity pairs, then //! place a single pole element `p` in the Betweenness instance. A size-2 //! subset `{u, v}` becomes `(u, p, v)`, forcing opposite sides of the pole. //! A size-3 subset `{u, v, w}` becomes `(u, d, v)` and `(d, p, w)` with one //! fresh auxiliary element `d`, which is satisfiable exactly when the three //! elements are not monochromatic with respect to the pole. +//! A singleton becomes two incompatible order constraints, preserving infeasibility. use crate::models::misc::Betweenness; use crate::models::set::SetSplitting; @@ -63,6 +64,14 @@ impl ReduceTo for SetSplitting { for subset in normalized_subsets { match subset.as_slice() { + [u] => { + // A singleton cannot contain both colors. These orders + // cannot both hold for three distinct elements. + let auxiliary = num_elements; + num_elements += 1; + triples.push((*u, pole, auxiliary)); + triples.push((pole, *u, auxiliary)); + } [u, v] => triples.push((*u, pole, *v)), [u, v, w] => { let auxiliary = num_elements; @@ -75,7 +84,7 @@ impl ReduceTo for SetSplitting { SetSplitting, Betweenness, >( - "normalized subset must contain two or three elements" + "normalized subset must contain one, two or three elements", )); } } diff --git a/src/unit_tests/rules/setsplitting_betweenness.rs b/src/unit_tests/rules/setsplitting_betweenness.rs index b43fa7fb1..75a56fb01 100644 --- a/src/unit_tests/rules/setsplitting_betweenness.rs +++ b/src/unit_tests/rules/setsplitting_betweenness.rs @@ -31,6 +31,37 @@ fn test_setsplitting_to_betweenness_closed_loop() { ); } +#[test] +fn test_repeated_elements_preserve_splittability() { + for (subset, feasible) in [ + (vec![0, 0, 1], true), + (vec![1, 0, 1, 0, 1], true), + (vec![0, 0], false), + (vec![1, 1, 1, 1], false), + ] { + let source = SetSplitting::new(2, vec![subset]); + assert_eq!( + BruteForce::new().solve(&source).unwrap().is_some(), + feasible + ); + let reduction = ReduceTo::::reduce_to(&source).unwrap(); + assert_eq!( + BruteForce::new() + .solve(reduction.target_problem()) + .unwrap() + .is_some(), + feasible + ); + if feasible { + assert_satisfaction_round_trip_from_satisfaction_target( + &source, + &reduction, + "repeated elements", + ); + } + } +} + #[test] fn test_setsplitting_to_betweenness_issue_yes_instance_structure() { let source = issue_yes_instance(); From b8a5765d21d19768c36bc49b0e5fc6726b37222e Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Fri, 18 Sep 2026 00:52:29 +0800 Subject: [PATCH 3/4] Preserve negative vertex weights in AND/OR graph reduction --- docs/paper/reductions.typ | 6 ++--- ...imumvertexcover_minimumweightandorgraph.rs | 23 +++++++++++++------ ...imumvertexcover_minimumweightandorgraph.rs | 18 +++++++++++++++ 3 files changed, 37 insertions(+), 10 deletions(-) diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index ee531965e..a841725a8 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -11900,7 +11900,7 @@ the displayed rule, extracted from the corresponding `pred path` entry. )[ This reduction encodes vertex cover as a minimum-weight solution subgraph problem on a three-layer AND/OR DAG. The root AND gate requires all edges to be covered; each edge becomes an OR gate selecting which endpoint covers it; and each vertex becomes a sink whose arc weight equals the vertex weight. The minimum-weight solution subgraph selects exactly the arcs corresponding to a minimum vertex cover. ][ - _Construction._ Given a Minimum Vertex Cover instance $(G = (V, E), bold(w))$ with $n = |V|$ vertices and $m = |E|$ edges, build an AND/OR graph $D$ with $1 + m + 2n$ vertices arranged in three layers: + _Construction._ Given a Minimum Vertex Cover instance $(G = (V, E), bold(w))$, let $N = {v in V : w_v < 0}$. Every optimum contains $N$: adding an omitted negative-weight vertex preserves coverage and strictly decreases cost. Remove all edges incident to $N$ and set the residual weights of vertices in $N$ to zero. Keep the original vertex indices. Below, $E$ denotes these residual edges, $m = |E|$, and $w$ denotes the nonnegative residual weights; the original optimum equals the residual optimum plus $sum_(v in N) w_v$ using the original weights. Build an AND/OR graph $D$ with $1 + m + 2n$ vertices, where $n = |V|$: - *Root (AND gate):* A single vertex $r$ (index 0) with gate type AND. - *Edge layer (OR gates):* For each edge $e_i = {u, v}$ ($i = 0, dots, m-1$), create vertex $e_i$ (index $1 + i$) with gate type OR and an arc $(r, e_i)$ of weight 1. @@ -11909,9 +11909,9 @@ the displayed rule, extracted from the corresponding `pred path` entry. Since $r$ is AND, any solution subgraph must include all arcs from $r$ to the edge-layer vertices (cost $m$). Each edge-OR vertex $e_i$ requires at least one of its two outgoing arcs to $c_u$ and $c_v$ (selecting which endpoint covers edge $i$). Each activated cover vertex $c_j$ requires its outgoing arc to $s_j$ (contributing $w_j$). The total weight is $m + |{"activated cover arcs"}| + sum_(j in C) w_j$. - _Correctness._ ($arrow.r.double$) If $C subset.eq V$ is a vertex cover with weight $W$, then for each edge $e_i = {u, v}$, at least one endpoint lies in $C$; select the arc from $e_i$ to that endpoint's cover vertex. Activate all cover-to-sink arcs for vertices in $C$. This satisfies the AND gate at the root (all edge arcs selected), every edge OR gate (at least one child selected), and all activated cover vertices (sink arc selected). The total weight is $m + |{"edge-to-cover arcs"}| + W$. ($arrow.l.double$) In any valid solution subgraph, the AND root forces all $m$ edge arcs. Each edge OR vertex selects at least one arc to a cover vertex, activating that cover vertex and its sink arc. The set of activated cover vertices forms a vertex cover (every edge has at least one endpoint activated). The sink arc weights sum to the cover weight, so any minimum-weight solution subgraph corresponds to a minimum vertex cover. + _Correctness._ ($arrow.r.double$) Given a residual cover $C$, choose exactly one endpoint in $C$ for each edge. Activate only cover vertices reached by these choices, and their sink arcs. This is a valid solution of cost at most $2m + w(C)$, since omitted cover vertices have nonnegative weight. ($arrow.l.double$) Any valid target solution activates a residual cover $C$ and costs at least $2m + w(C)$: the root requires all $m$ arcs and each edge gate requires at least one unit-weight outgoing arc. These bounds imply that the target optimum is exactly $2m$ plus the residual cover optimum, and every target optimum recovers an optimal residual cover. Adding $N$ restores an original optimum, including negative vertices that are isolated or redundant for coverage. - _Solution extraction._ Examine the cover-to-sink arcs (indices $3m, dots, 3m + n - 1$ in the arc list): $c_j = 1$ if arc $(c_j, s_j)$ is selected, $c_j = 0$ otherwise. + _Solution extraction._ Select every vertex in $N$, together with vertices whose cover-to-sink arcs (indices $3m, dots, 3m + n - 1$) are selected. Evaluate this cover using the original weights. ] #reduction-rule("MaximumMatching", "MaximumSetPacking")[ diff --git a/src/rules/minimumvertexcover_minimumweightandorgraph.rs b/src/rules/minimumvertexcover_minimumweightandorgraph.rs index 247ce8733..e78eb3ad2 100644 --- a/src/rules/minimumvertexcover_minimumweightandorgraph.rs +++ b/src/rules/minimumvertexcover_minimumweightandorgraph.rs @@ -12,7 +12,7 @@ use crate::topology::SimpleGraph; pub struct ReductionVCToAndOrGraph { target: MinimumWeightAndOrGraph, sink_arc_start: usize, - num_source_vertices: usize, + forced_cover: Vec, } impl ReductionResult for ReductionVCToAndOrGraph { @@ -30,15 +30,17 @@ impl ReductionResult for ReductionVCToAndOrGraph { crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; Ok({ - (0..self.num_source_vertices) - .map(|j| target_solution[self.sink_arc_start + j]) + self.forced_cover + .iter() + .enumerate() + .map(|(j, &forced)| forced || target_solution[self.sink_arc_start + j]) .collect() }) } } #[reduction( - transform = exact { + transform = upper_bound { num_vertices = "1 + num_edges + 2 * num_vertices", num_arcs = "3 * num_edges + num_vertices", } @@ -48,7 +50,14 @@ impl ReduceTo for MinimumVertexCover fn reduce_to(&self) -> Result { let n = self.graph().num_vertices(); - let edges = self.graph().edges(); + // Every optimum includes negative-weight vertices, even isolated ones. + let forced_cover: Vec<_> = self.weights().iter().map(|&w| w < 0).collect(); + let edges: Vec<_> = self + .graph() + .edges() + .into_iter() + .filter(|&(u, v)| !forced_cover[u] && !forced_cover[v]) + .collect(); let m = edges.len(); let num_target_vertices = 1 + m + (2 * n); @@ -80,7 +89,7 @@ impl ReduceTo for MinimumVertexCover let sink_arc_start = arcs.len(); for (j, &weight) in self.weights().iter().enumerate() { arcs.push((cover_vertex(j), sink_vertex(j))); - arc_weights.push(weight); + arc_weights.push(weight.max(0)); } let target = @@ -89,7 +98,7 @@ impl ReduceTo for MinimumVertexCover Ok(ReductionVCToAndOrGraph { target, sink_arc_start, - num_source_vertices: n, + forced_cover, }) } } diff --git a/src/unit_tests/rules/minimumvertexcover_minimumweightandorgraph.rs b/src/unit_tests/rules/minimumvertexcover_minimumweightandorgraph.rs index 6b93320a5..29035992f 100644 --- a/src/unit_tests/rules/minimumvertexcover_minimumweightandorgraph.rs +++ b/src/unit_tests/rules/minimumvertexcover_minimumweightandorgraph.rs @@ -29,6 +29,24 @@ fn test_minimumvertexcover_to_minimumweightandorgraph_closed_loop() { ); } +#[test] +fn negative_vertices_are_selected_even_when_isolated_or_redundant() { + for (n, edges, weights) in [ + (1, vec![], vec![-1]), + (2, vec![(0, 1)], vec![-1, -1]), + (3, vec![(0, 1), (1, 2)], vec![-2, 3, 1]), + (2, vec![(0, 0), (0, 1)], vec![-1, 2]), + ] { + let source = MinimumVertexCover::new(SimpleGraph::new(n, edges), weights); + let reduction = ReduceTo::::reduce_to(&source).unwrap(); + assert_optimization_round_trip_from_optimization_target( + &source, + &reduction, + "signed vertex cover", + ); + } +} + #[test] fn test_reduction_structure() { let source = issue_example_source(); From b16a1bc1c5ba8ce1063b937b5661207d2a7651ee Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Fri, 18 Sep 2026 01:16:08 +0800 Subject: [PATCH 4/4] Preserve empty matching instances and return target construction errors --- docs/paper/reductions.typ | 2 +- ...threedimensionalmatching_threepartition.rs | 29 ++++++------- ...threedimensionalmatching_threepartition.rs | 42 +++++++++++++++++++ 3 files changed, 58 insertions(+), 15 deletions(-) diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index ad22273a8..a73cd3407 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -19053,7 +19053,7 @@ The following table shows concrete target-variable counts for example instances, )[ This $O(t^2)$ reduction @garey1979 first checks whether every coordinate of $W$, $X$, and $Y$ appears in some triple; uncovered coordinates yield a fixed infeasible 3-Partition instance. Otherwise it composes the classical 3DM $arrow.r$ ABCD-Partition, ABCD-Partition $arrow.r$ 4-Partition, and 4-Partition $arrow.r$ 3-Partition constructions, producing $24 t^2 - 3 t$ integers arranged into $8 t^2 - t$ triples. ][ - _Construction._ Let the source instance have universe size $q$ and triples $m_l = (w_(a_l), x_(b_l), y_(c_l))$ for $l = 0, dots, t - 1$. If some coordinate of $W union X union Y$ is absent from all triples, the source instance is trivially NO, so the implementation returns a fixed infeasible 3-Partition instance with sizes $(6, 6, 6, 6, 7, 9)$ and bound $20$. + _Construction._ Let the source instance have universe size $q$ and triples $m_l = (w_(a_l), x_(b_l), y_(c_l))$ for $l = 0, dots, t - 1$. If $q=0$, the empty matching is a solution: return sizes $(1,1,1)$ with bound $3$, and recover the empty matching. Otherwise, if some coordinate of $W union X union Y$ is absent from all triples (including $t=0$), return the fixed infeasible instance $(6,6,6,6,7,9)$ with bound $20$. Including these constant cases, $24t^2-3t+6$ elements and $8t^2-t+2$ groups are upper bounds, not exact counts. Otherwise set $r = 32 q$ and $T_1 = 40 r^4$. For each triple create $ u_l = 10 r^4 - c_l r^3 - b_l r^2 - a_l r, $ diff --git a/src/rules/threedimensionalmatching_threepartition.rs b/src/rules/threedimensionalmatching_threepartition.rs index 8bce221e9..58b95a84b 100644 --- a/src/rules/threedimensionalmatching_threepartition.rs +++ b/src/rules/threedimensionalmatching_threepartition.rs @@ -274,6 +274,9 @@ impl ReductionResult for ReductionThreeDimensionalMatchingToThreePartition { )); } + if self.num_source_triples == 0 { + return Ok(Vec::new()); + } let mut groups = vec![Vec::with_capacity(3); self.target.num_groups()]; let mut positions = Vec::with_capacity(target_solution.len()); for (element, &group) in target_solution.iter().enumerate() { @@ -339,9 +342,9 @@ fn enumerate_pair_keys(num_regulars: usize) -> Option> { } #[reduction( - transform = exact { - num_elements = "24 * num_triples * num_triples - 3 * num_triples", - num_groups = "8 * num_triples * num_triples - num_triples", + transform = upper_bound { + num_elements = "24 * num_triples * num_triples - 3 * num_triples + 6", + num_groups = "8 * num_triples * num_triples - num_triples + 2", })] impl ReduceTo for ThreeDimensionalMatching { type Result = ReductionThreeDimensionalMatchingToThreePartition; @@ -351,16 +354,13 @@ impl ReduceTo for ThreeDimensionalMatching { let t = self.num_triples(); if q == 0 { - return Err(crate::rules::ReductionError::invalid_target::< - ThreeDimensionalMatching, - ThreePartition, - >("source universe must be nonempty")); - } - if t == 0 { - return Err(crate::rules::ReductionError::invalid_target::< - ThreeDimensionalMatching, - ThreePartition, - >("source must contain at least one triple")); + // The empty matching covers the empty universe. + return Ok(ReductionThreeDimensionalMatchingToThreePartition { + target: ThreePartition::new(vec![1, 1, 1], 3), + step2_items: Vec::new(), + pair_keys: Vec::new(), + num_source_triples: 0, + }); } let mut covered_w = vec![false; q]; @@ -571,7 +571,8 @@ impl ReduceTo for ThreeDimensionalMatching { .ok_or_else(|| arithmetic_overflow("computing the 3-Partition bound"))?; Ok(ReductionThreeDimensionalMatchingToThreePartition { - target: ThreePartition::new(sizes, bound), + target: ThreePartition::try_new(sizes, bound) + .map_err(>::target_construction)?, step2_items, pair_keys, num_source_triples: t, diff --git a/src/unit_tests/rules/threedimensionalmatching_threepartition.rs b/src/unit_tests/rules/threedimensionalmatching_threepartition.rs index 23df83cfe..e76e50b55 100644 --- a/src/unit_tests/rules/threedimensionalmatching_threepartition.rs +++ b/src/unit_tests/rules/threedimensionalmatching_threepartition.rs @@ -36,6 +36,48 @@ fn test_threedimensionalmatching_to_threepartition_q1_overhead_and_bounds() { .all(|&size| 4 * i128::from(size) > bound && 2 * i128::from(size) < bound)); } +#[test] +fn empty_triple_sets_preserve_empty_and_nonempty_universe_truth() { + let entry = inventory::iter:: + .into_iter() + .find(|e| { + e.source_name == ThreeDimensionalMatching::NAME && e.target_name == ThreePartition::NAME + }) + .unwrap(); + let contract = entry.parameter_contract().unwrap(); + let transform = contract.transform().unwrap(); + for q in [0, 1] { + let (source, reduction) = reduce(q, &[]); + let bound = transform.evaluate(&source.parameters()).unwrap(); + assert!( + reduction.target_problem().num_elements() as u64 <= bound.get("num_elements").unwrap() + ); + assert!(reduction.target_problem().num_groups() as u64 <= bound.get("num_groups").unwrap()); + if q == 0 { + crate::rules::test_helpers::assert_satisfaction_round_trip_from_satisfaction_target( + &source, + &reduction, + "empty matching domain", + ); + } else { + assert!(BruteForce::new().solve(&source).unwrap().is_none()); + assert!(BruteForce::new() + .solve(reduction.target_problem()) + .unwrap() + .is_none()); + } + } +} + +#[test] +fn target_sum_overflow_is_a_construction_error() { + let source = ThreeDimensionalMatching::new(20, (0..20).map(|i| (i, i, i)).collect()); + assert!(matches!( + ReduceTo::::reduce_to(&source), + Err(crate::rules::ReductionError::Construction { .. }) + )); +} + #[test] fn test_threedimensionalmatching_to_threepartition_q2_overhead_matches_vector() { let (_source, reduction) = reduce(2, &[(0, 0, 0), (1, 1, 1)]);