diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index 88cd07556..5d70e79cc 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -19165,6 +19165,57 @@ The following table shows concrete variable overhead for example instances, take #let tdm_tp_sol = tdm_tp.solutions.at(0) #let tdm_tmi = load-example("ThreeDimensionalMatching", "ThreeMatroidIntersection") #let tdm_tmi_sol = tdm_tmi.solutions.at(0) +#let tdm_x3c = load-example("ThreeDimensionalMatching", "ExactCoverBy3Sets") +#let tdm_x3c_sol = tdm_x3c.solutions.at(0) +#reduction-rule("ThreeDimensionalMatching", "ExactCoverBy3Sets", + example: true, + example-caption: [$q = #tdm_x3c.source.instance.universe_size$, #tdm_x3c.source.instance.triples.len() triples $arrow.r$ #tdm_x3c.target.instance.subsets.len() tagged 3-sets], + extra: [ + #pred-commands( + "pred create --example " + problem-spec(tdm_x3c.source) + " -o three-dimensional-matching.json", + "pred reduce three-dimensional-matching.json --to " + target-spec(tdm_x3c) + " -o bundle.json", + "pred solve bundle.json", + "pred evaluate three-dimensional-matching.json --config " + tdm_x3c_sol.source_config.map(str).join(","), + ) + + #{ + let q = tdm_x3c.source.instance.universe_size + let triples = tdm_x3c.source.instance.triples + let subsets = tdm_x3c.target.instance.subsets + let witness-indices = tdm_x3c_sol.source_config.enumerate().filter(((i, x)) => x == 1).map(((i, x)) => i) + let witness-elements = witness-indices.map(i => subsets.at(i)).flatten().sorted() + let cross-indices = (triples.len() - 2, triples.len() - 1) + let cross-elements = cross-indices.map(i => subsets.at(i)).flatten().sorted() + let uncovered = range(3 * q).filter(element => not cross-elements.contains(element)) + [ + *Step 1 -- Read the indexed 3DM instance.* The three coordinate domains each have size $q = #q$. The fixture's indexed triple list is #triples.enumerate().map(((i, triple)) => "$t_" + str(i) + " = (" + triple.map(str).join(", ") + ")$").join([; ]). + + *Step 2 -- Tag the coordinate domains.* Put the first, second, and third coordinates in disjoint numeric blocks $[0, q)$, $[q, 2q)$, and $[2q, 3q)$. Thus triple $t_j = (a_j, b_j, c_j)$ becomes $S_j = {a_j, q + b_j, 2q + c_j}$. The exact five target subsets are #subsets.enumerate().map(((i, subset)) => "$S_" + str(i) + " = {" + subset.map(str).join(", ") + "}$").join([; ]). The target therefore has universe size $#tdm_x3c.target.instance.universe_size = 3 q$ and one subset per source triple. + + *Step 3 -- Verify the diagonal witness end-to-end.* The canonical source configuration is $(#tdm_x3c_sol.source_config.map(str).join(", "))$, selecting triple indices $#witness-indices.map(str).join(", ")$. Their target subsets have sorted union ${#witness-elements.map(str).join(", ")}$, which is every element of the tagged universe exactly once. The target configuration is the identical vector $(#tdm_x3c_sol.target_config.map(str).join(", "))$, so exact-cover feasibility maps back to the diagonal perfect matching #sym.checkmark. + + *Step 4 -- Understand the cross triples.* The final two triples map to $S_#cross-indices.at(0) = {#subsets.at(cross-indices.at(0)).map(str).join(", ")}$ and $S_#cross-indices.at(1) = {#subsets.at(cross-indices.at(1)).map(str).join(", ")}$. These two sets are mutually disjoint, but together they leave ${#uncovered.map(str).join(", ")}$ uncovered. None of the five available subsets is contained in that remaining three-element set, so the pair cannot be extended by any available third triple to an exact cover. + + *Multiplicity:* The fixture stores one canonical witness. + ] + } + ], +)[ + This $O(q + t)$ reduction @karp1972 @garey1979[SP1--SP2] embeds the three coordinate domains of a Three-Dimensional Matching instance into three disjoint tagged blocks. For $t$ indexed source triples it constructs an Exact Cover by 3-Sets instance with exactly $3q$ universe elements and $t$ subsets (hence $t$ subset variables). +][ + _Construction._ Let $q in NN$ and let the source contain the indexed list $T = (t_0, dots, t_(t - 1))$, where $t_j = (a_j, b_j, c_j) in W times X times Y$ and $W = X = Y = {0, dots, q - 1}$. Form the disjoint tagged blocks + $ W' = {0, dots, q - 1}, quad X' = {q, dots, 2q - 1}, quad Y' = {2q, dots, 3q - 1}, $ + and target universe $U' = W' union X' union Y'$. For every indexed triple $t_j$, create the three-element target subset + $ S_j = {a_j, q + b_j, 2q + c_j}. $ + The target family is the indexed list $(S_0, dots, S_(t - 1))$; in particular, duplicate source triples remain distinct subset variables. The exact target sizes are $|U'| = 3q$ and $|cal(S)| = t$. + + _Correctness._ ($arrow.r.double$) Suppose source indices $J subset.eq {0, dots, t - 1}$ form a perfect three-dimensional matching. Every coordinate value occurs in exactly one selected triple. Therefore every element of $W'$, $X'$, and $Y'$ occurs in exactly one set $S_j$ with $j in J$. The selected sets are pairwise disjoint and their union is $U'$, so they form an exact cover. + + ($arrow.l.double$) Conversely, suppose target indices $J$ select an exact cover of $U'$. Each $S_j$ contains exactly one element from each of the three tagged blocks. Exact coverage of $W'$ implies that the first coordinates of the selected triples contain every value in $W$ exactly once; exact coverage of $X'$ and $Y'$ gives the same conclusion for the second and third coordinates. Hence the source triples indexed by $J$ are pairwise coordinate-disjoint and cover all three domains, so they form a perfect three-dimensional matching. + + _Solution extraction._ Return the target's binary subset-indicator vector unchanged: target variable $j$ and source variable $j$ both refer to the same indexed triple. +] + #reduction-rule("ThreeDimensionalMatching", "ThreeMatroidIntersection", example: true, example-caption: [$q = 3$, $t = 5$ triples], diff --git a/src/rules/mod.rs b/src/rules/mod.rs index 95eb5f477..f8762f38b 100644 --- a/src/rules/mod.rs +++ b/src/rules/mod.rs @@ -147,6 +147,7 @@ pub(crate) mod subsetsum_integerknapsack; pub(crate) mod subsetsum_partition; #[cfg(test)] pub(crate) mod test_helpers; +pub(crate) mod threedimensionalmatching_exactcoverby3sets; pub(crate) mod threedimensionalmatching_minimumweightdecoding; pub(crate) mod threedimensionalmatching_threematroidintersection; pub(crate) mod threedimensionalmatching_threepartition; @@ -522,6 +523,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec &Self::Target { + &self.target + } + + fn extract_solution(&self, target_solution: &[usize]) -> Vec { + target_solution.to_vec() + } +} + +#[reduction(overhead = { + universe_size = "3 * universe_size", + num_subsets = "num_triples", + num_sets = "num_triples", +})] +impl ReduceTo for ThreeDimensionalMatching { + type Result = ReductionThreeDimensionalMatchingToExactCoverBy3Sets; + + fn reduce_to(&self) -> Self::Result { + let q = self.universe_size(); + let tagged_subsets = self + .triples() + .iter() + .map(|&(w, x, y)| [w, q + x, 2 * q + y]) + .collect(); + + ReductionThreeDimensionalMatchingToExactCoverBy3Sets { + target: ExactCoverBy3Sets::new(3 * q, tagged_subsets), + } + } +} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_rule_example_specs() -> Vec { + use crate::export::SolutionPair; + + vec![crate::example_db::specs::RuleExampleSpec { + id: "threedimensionalmatching_to_exactcoverby3sets", + build: || { + crate::example_db::specs::rule_example_with_witness::<_, ExactCoverBy3Sets>( + ThreeDimensionalMatching::new( + 3, + vec![(0, 0, 0), (1, 1, 1), (2, 2, 2), (0, 1, 2), (1, 2, 0)], + ), + SolutionPair { + source_config: vec![1, 1, 1, 0, 0], + target_config: vec![1, 1, 1, 0, 0], + }, + ) + }, + }] +} + +#[cfg(test)] +#[path = "../unit_tests/rules/threedimensionalmatching_exactcoverby3sets.rs"] +mod tests; diff --git a/src/unit_tests/rules/analysis.rs b/src/unit_tests/rules/analysis.rs index 6088d9d38..f9ccf18b6 100644 --- a/src/unit_tests/rules/analysis.rs +++ b/src/unit_tests/rules/analysis.rs @@ -302,6 +302,8 @@ fn test_find_dominated_rules_returns_known_set() { ), // ExactCoverBy3Sets → MaxSetPacking → ILP is better than direct ExactCoverBy3Sets → ILP ("ExactCoverBy3Sets", "ILP {variable: \"bool\"}"), + // ThreeDimensionalMatching → ExactCoverBy3Sets → ILP is better than direct ThreeDimensionalMatching → ILP + ("ThreeDimensionalMatching", "ILP {variable: \"bool\"}"), // GraphPartitioning → MaxCut → SpinGlass → QUBO is better than direct GraphPartitioning → QUBO ( "GraphPartitioning {graph: \"SimpleGraph\"}", diff --git a/src/unit_tests/rules/threedimensionalmatching_exactcoverby3sets.rs b/src/unit_tests/rules/threedimensionalmatching_exactcoverby3sets.rs new file mode 100644 index 000000000..2782b6ac1 --- /dev/null +++ b/src/unit_tests/rules/threedimensionalmatching_exactcoverby3sets.rs @@ -0,0 +1,152 @@ +use super::*; +use crate::rules::test_helpers::assert_satisfaction_round_trip_from_satisfaction_target; +use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::solvers::BruteForce; +use crate::traits::Problem; +use std::collections::HashSet; + +#[test] +fn test_threedimensionalmatching_to_exactcoverby3sets_closed_loop() { + let source = ThreeDimensionalMatching::new( + 3, + vec![(0, 0, 0), (1, 1, 1), (2, 2, 2), (0, 1, 2), (1, 2, 0)], + ); + let reduction = ReduceTo::::reduce_to(&source); + assert_satisfaction_round_trip_from_satisfaction_target( + &source, + &reduction, + "ThreeDimensionalMatching -> ExactCoverBy3Sets", + ); +} + +#[test] +fn test_all_q2_triple_families_preserve_exact_witnesses() { + let triples: Vec<_> = (0..2) + .flat_map(|w| (0..2).flat_map(move |x| (0..2).map(move |y| (w, x, y)))) + .collect(); + let solver = BruteForce::new(); + + for family_mask in 0..(1usize << triples.len()) { + let family: Vec<_> = triples + .iter() + .enumerate() + .filter_map(|(index, &triple)| ((family_mask >> index) & 1 == 1).then_some(triple)) + .collect(); + let source = ThreeDimensionalMatching::new(2, family); + let reduction = ReduceTo::::reduce_to(&source); + let source_witnesses: HashSet<_> = solver.find_all_witnesses(&source).into_iter().collect(); + let extracted_witnesses: HashSet<_> = solver + .find_all_witnesses(reduction.target_problem()) + .into_iter() + .map(|witness| reduction.extract_solution(&witness)) + .collect(); + + assert_eq!( + extracted_witnesses, source_witnesses, + "witness mismatch for family mask {family_mask:#010b}" + ); + } +} + +#[test] +fn test_target_tagging_and_overhead() { + let source = ThreeDimensionalMatching::new(3, vec![(0, 2, 1), (2, 0, 2)]); + let reduction = ReduceTo::::reduce_to(&source); + let target = reduction.target_problem(); + + assert_eq!(target.universe_size(), 9); + assert_eq!(target.subsets(), &[[0, 5, 7], [2, 3, 8]]); + assert_eq!(target.num_subsets(), 2); + assert_eq!(target.num_sets(), 2); + + let entries: Vec<_> = inventory::iter::() + .filter(|entry| { + entry.source_name == "ThreeDimensionalMatching" + && entry.target_name == "ExactCoverBy3Sets" + }) + .collect(); + assert_eq!(entries.len(), 1); + let overhead = (entries[0].overhead_eval_fn)(&source as &dyn std::any::Any); + assert_eq!(overhead.get("universe_size"), Some(9)); + assert_eq!(overhead.get("num_subsets"), Some(2)); + assert_eq!(overhead.get("num_sets"), Some(2)); +} + +#[test] +fn test_infeasible_instance_has_no_target_witness() { + let source = ThreeDimensionalMatching::new(3, vec![(0, 0, 0), (0, 1, 1), (1, 2, 2)]); + let reduction = ReduceTo::::reduce_to(&source); + + assert!(BruteForce::new().find_witness(&source).is_none()); + assert!(BruteForce::new() + .find_witness(reduction.target_problem()) + .is_none()); +} + +#[test] +fn test_empty_universe_preserves_empty_witness() { + let source = ThreeDimensionalMatching::new(0, vec![]); + let reduction = ReduceTo::::reduce_to(&source); + let target = reduction.target_problem(); + + assert_eq!(target.universe_size(), 0); + assert!(target.subsets().is_empty()); + let target_witness = BruteForce::new().find_witness(target).unwrap(); + assert!(target_witness.is_empty()); + assert!( + source + .evaluate(&reduction.extract_solution(&target_witness)) + .0 + ); +} + +#[test] +fn test_duplicate_triples_preserve_indices() { + let source = ThreeDimensionalMatching::new(2, vec![(0, 0, 0), (0, 0, 0), (1, 1, 1)]); + let reduction = ReduceTo::::reduce_to(&source); + + assert_eq!( + reduction.target_problem().subsets(), + &[[0, 2, 4], [0, 2, 4], [1, 3, 5]] + ); + let witnesses: HashSet<_> = BruteForce::new() + .find_all_witnesses(reduction.target_problem()) + .into_iter() + .collect(); + assert_eq!(witnesses, HashSet::from([vec![0, 1, 1], vec![1, 0, 1]])); + for witness in witnesses { + assert!(source.evaluate(&reduction.extract_solution(&witness)).0); + } +} + +#[test] +fn test_unused_coordinate_makes_both_instances_infeasible() { + let source = ThreeDimensionalMatching::new(2, vec![(0, 0, 0), (1, 0, 1)]); + let reduction = ReduceTo::::reduce_to(&source); + + assert!(BruteForce::new().find_witness(&source).is_none()); + assert!(BruteForce::new() + .find_witness(reduction.target_problem()) + .is_none()); +} + +#[test] +fn test_equal_numeric_coordinates_are_distinct_across_domains() { + let source = ThreeDimensionalMatching::new(2, vec![(0, 0, 0), (1, 1, 1)]); + let reduction = ReduceTo::::reduce_to(&source); + + assert_eq!( + reduction.target_problem().subsets(), + &[[0, 2, 4], [1, 3, 5]] + ); + assert!(reduction.target_problem().evaluate(&[1, 1]).0); + assert!(source.evaluate(&reduction.extract_solution(&[1, 1])).0); +} + +#[test] +fn test_solution_extraction_is_identity() { + let source = ThreeDimensionalMatching::new(2, vec![(0, 0, 0), (1, 1, 1)]); + let reduction = ReduceTo::::reduce_to(&source); + + assert_eq!(reduction.extract_solution(&[1, 0]), vec![1, 0]); +}