From 8af72e2a02ffc4cf2609016331621e538bb44672 Mon Sep 17 00:00:00 2001 From: Xiwei Pan <90967972+isPANN@users.noreply.github.com> Date: Wed, 4 Nov 2026 03:07:43 -0800 Subject: [PATCH] Fix #1199: [Rule] DecisionMinimumVertexCover to DecisionMaximumLeafSpanningTree Co-Authored-By: Claude Sonnet 5.5 --- docs/paper/reductions.typ | 165 +++++++ docs/paper/references.bib | 23 + .../graph/maximum_leaf_spanning_tree.rs | 74 ++- src/models/graph/mod.rs | 1 + ...excover_decisionmaximumleafspanningtree.rs | 145 ++++++ src/rules/mod.rs | 7 + src/solvers/pipelines.rs | 6 + .../decision_maximum_leaf_spanning_tree.rs | 138 ++++++ ...excover_decisionmaximumleafspanningtree.rs | 427 ++++++++++++++++++ 9 files changed, 985 insertions(+), 1 deletion(-) create mode 100644 src/rules/decisionminimumvertexcover_decisionmaximumleafspanningtree.rs create mode 100644 src/unit_tests/models/graph/decision_maximum_leaf_spanning_tree.rs create mode 100644 src/unit_tests/rules/decisionminimumvertexcover_decisionmaximumleafspanningtree.rs diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index 8e0462b7a..85917b1c8 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -218,6 +218,7 @@ "MaximumIndependentSet": [Maximum Independent Set], "DecisionMaximumIndependentSet": [Decision Maximum Independent Set], "MaximumLeafSpanningTree": [Maximum Leaf Spanning Tree], + "DecisionMaximumLeafSpanningTree": [Decision Maximum Leaf Spanning Tree], "MinimumVertexCover": [Minimum Vertex Cover], "MaxCut": [Max-Cut], "GeneralizedHex": [Generalized Hex], @@ -6574,6 +6575,52 @@ In all graph problems below, $G = (V, E)$ denotes an undirected graph with $|V| ] ] } +// DecisionMaximumLeafSpanningTree +#{ + let x = load-model-example("DecisionMaximumLeafSpanningTree") + let nv = graph-num-vertices(x.instance) + let edges = x.instance.inner.graph.edges + let selected = edges.enumerate().filter(((i, _)) => x.optimal_config.at(i)).map(((_, e)) => e) + let degrees = range(nv).map(v => selected.filter(e => e.contains(v)).len()) + let leaves = range(nv).filter(v => degrees.at(v) == 1) + assert(selected.len() == nv - 1) + assert(selected.all(e => e.contains(0))) + assert(leaves.len() >= x.instance.bound) + assert(x.optimal_value) + [ + #problem-def("DecisionMaximumLeafSpanningTree")[ + Given a simple undirected graph $G=(V,E)$ with $|V| >= 2$ and a signed + integer bound $b$, decide whether a spanning tree of $G$ has at least $b$ + vertices of degree one. A witness selects edges with a Boolean vector + in stored edge order; disconnected graphs have no spanning tree. + ][ + This is the decision version of @def:MaximumLeafSpanningTree. It models + a minimum number of terminal nodes in a broadcast network, leaving few + vertices to act as relays. Fernau et al.'s branching algorithm decides + the bound in $O^*(1.8966^n)$ time, where $n=|V|$ @fernau2011LeafTree. + + *Example.* The loaded graph has $#nv$ vertices and $#edges.len()$ edges. + Selecting $(#fmt-values(x.optimal_config))$ gives tree degrees + $(#degrees.map(str).join(", "))$. Its $#leaves.len()$ leaves meet the bound + $#x.instance.bound$, so the answer is YES. + + #pred-commands( + "pred create --example " + problem-spec(x) + " -o leaf-decision.json", + "pred solve leaf-decision.json", + "pred evaluate leaf-decision.json --config " + cli-config(x.optimal_config), + ) + + #figure({ + let positions = range(nv).map(i => if i == 0 { (0, 0) } else { + let angle = 360deg * (i - 1) / (nv - 1) + (1.3 * calc.cos(angle), 1.3 * calc.sin(angle)) + }) + draw-edge-highlight(positions, edges, selected, leaves) + }, caption: [A spanning tree with #leaves.len() leaves (blue), meeting bound #x.instance.bound.]) + ] + ] +} + #{ let x = load-model-example("MonochromaticTriangle") let nv = graph-num-vertices(x.instance) @@ -17992,6 +18039,124 @@ The following table shows concrete target-variable counts for example instances, _Solution extraction._ For each variable $x_i$, inspect the truth-setting pair. Set $x_i = 1$ when the cover contains $u_i$, and set $x_i = 0$ otherwise. ] +// DecisionMinimumVertexCover → DecisionMaximumLeafSpanningTree +#{ + let ex = load-example("DecisionMinimumVertexCover", "DecisionMaximumLeafSpanningTree") + let sol = ex.solutions.at(0) + let graph = ex.source.instance.inner.graph + let n = graph.num_vertices + let m = graph.edges.len() + let k = ex.source.instance.bound + let h = ex.target.instance.inner.graph + let N = h.num_vertices + let M = h.edges.len() + let root = n + m + let b = ex.target.instance.bound + let cover = range(n).filter(v => sol.source_config.at(v)) + let selected = h.edges.enumerate().filter(((i, _)) => sol.target_config.at(i)).map(((_, e)) => e) + let degrees = range(N).map(v => selected.filter(e => e.contains(v)).len()) + let leaves = range(N).filter(v => degrees.at(v) == 1) + let extracted = range(n).map(v => degrees.at(v) >= 2) + let matching = (graph.edges.first(), graph.edges.at(2)) + let endpoints = matching.flatten() + assert(endpoints.dedup().len() == 2 * matching.len()) + assert(matching.len() == cover.len()) + assert(graph.edges.all(e => e.any(v => cover.contains(v)))) + assert(cover.len() == k) + assert(N == n + m + 3 and M == n + 2*m + 2) + assert(b == N - 1 - calc.min(n, calc.max(-1, k))) + assert(sol.source_config == extracted) + assert(selected.len() == N - 1) + let reached = (root,) + for _ in range(N) { + for e in selected { + if e.any(v => reached.contains(v)) { reached = (reached + e).dedup() } + } + } + assert(reached.len() == N) + assert(leaves.len() == N - 1 - cover.len() and leaves.len() >= b) + let no-k = matching.len() - 1 + let no-b = N - 1 - no-k + assert(no-b > leaves.len()) + [ + #reduction-rule("DecisionMinimumVertexCover", "DecisionMaximumLeafSpanningTree", + example: true, + example-caption: [A cover of size #cover.len() becomes a spanning tree with #leaves.len() leaves], + extra: [ + #pred-commands( + "pred create --example " + rule-spec(ex) + " -o leaf-cover.json", + "pred path " + keyed-spec(ex.source) + " " + keyed-spec(ex.target) + " -o leaf-paths.json", + "jq '.paths[] | select(.steps == 1)' leaf-paths.json > leaf-route.json", + "pred reduce leaf-cover.json --via leaf-route.json -o leaf-bundle.json", + "pred solve leaf-bundle.json", + "pred evaluate leaf-cover.json --config " + cli-config(sol.source_config), + ) + + *YES example.* The source edges are #graph.edges.map(e => [$({#e.at(0)}, {#e.at(1)})$]).join(", "). + The cover ${#cover.map(str).join(", ")}$ has size $#cover.len() <= #k$. + The $#matching.len()$ disjoint edges + #matching.map(e => [$({#e.at(0)}, {#e.at(1)})$]).join(" and ") + prove this cover minimum. Subdivision vertices are + ${#range(n, root).map(str).join(", ")}$, root $#root$, private leaves + $#(root+1), #(root+2)$. The target has $N=#N$ vertices and $M=#M$ edges + #h.edges.map(e => [$({#e.at(0)}, {#e.at(1)})$]).join(", "). + Selecting $(#fmt-values(sol.target_config))$ gives $#selected.len()$ edges, + degrees $(#degrees.map(str).join(", "))$, and $#leaves.len()$ leaves, + meeting $b=#b$. Extraction gives $(#fmt-values(extracted))$. + + *NO example.* Keep this graph and set the source bound to $#no-k$. + The $#matching.len()$ disjoint edges require at least $#matching.len()$ + selected originals, so the source is NO. The target bound becomes + $b = #N - 1 - #no-k = #no-b$. A tree with that many leaves would extract a + cover of size at most $#no-k$, contradicting the disjoint edges. + The optimum leaf count is $#leaves.len() < #no-b$, so the target is NO. + ], + )[ + The universal-root/subdivision construction described by Bonsma for cubic + vertex cover @bonsma2012MaxLeaves extends to arbitrary simple graphs by + adding two private root leaves. Unit Decision Minimum Vertex Cover reduces + to Decision Maximum Leaf Spanning Tree in linear time, with exact sizes + $|V(H)|=n+m+3$ and $|E(H)|=n+2m+2$. + ][ + _Construction._ Let $G=(V,E)$, $n=|V|$, $m=|E|$, and signed bound $k$. + Keep originals $0, dots,n-1$. For each stored edge $e_j=(u_j,v_j)$ add + $a_j=n+j$ and replace the edge by $(u_j,a_j),(v_j,a_j)$. Add root $r=n+m$ + adjacent to every original and private leaves $p=r+1,q=r+2$ adjacent only + to $r$. Store root-to-original edges first, then incidence pairs in source + edge order, then $(r,p),(r,q)$. Let $N=n+m+3$, $k'=min(n,max(-1,k))$, + and $b=N-1-k'$. Disjoint vertex and edge blocks give the exact sizes. + + _Correctness._ ($arrow.r.double$) A cover $C$ with $|C|<=k$ also has + $|C|<=k'$. Select all root spokes, both private edges, and one incidence + to a covered endpoint for each $a_j$. This is connected with $N-1$ edges, + hence a spanning tree. Only $r$ and vertices in $C$ can be internal, so + it has at least $N-1-|C|>=b$ leaves. + + ($arrow.l.double$) In any spanning tree, both private edges are forced, + making $r$ internal. The tree path from $a_j$ to $r$ first visits an + original endpoint of $e_j$ that is internal on that path. Thus originals + of degree at least two form a cover $C_T$. If the tree has $L>=b$ leaves, + then $|C_T|<=N-L-1<=k'$. For $k>=0$ this is at most $k$; for $k<0$, + $b=N$, impossible because $r$ is internal, so both instances are NO. + Applying the two inequalities to + optimal witnesses gives $"maxleaf"(H)=n+m+2-"minVC"(G)$. + + _Solution extraction._ Validate once that the edge vector is a spanning + tree meeting $b$, then select precisely the originals of tree degree + at least two. This works for nonoptimal trees and internal subdivision + vertices as well. Both decision values are `Or` and map identically. + + _Boundary cases._ For $n=m=0$, the target is $p-r-q$ with two leaves. + An edgeless input gives a star with $n+2$ leaves; isolated originals stay + leaves, and the root connects disconnected sources. Negative signed bounds, + including the minimum representable integer, normalize to $k'=-1$; + bounds above $n$ normalize to $n$. In particular $k'<=n MaximumLeafSpanningTree { Self::try_new(graph).unwrap_or_else(|error| panic!("{error}")) } - fn try_new(graph: G) -> Result { + /// Construct a problem, rejecting graphs with fewer than two vertices. + pub fn try_new(graph: G) -> Result { if graph.num_vertices() < 2 { return Err("graph must have at least 2 vertices".into()); } @@ -228,6 +230,72 @@ crate::register_brute_force! { MaximumLeafSpanningTree decode |_, indices: Vec| crate::config::config_to_bits(&indices), } +crate::decision_problem_meta!( + MaximumLeafSpanningTree, + "DecisionMaximumLeafSpanningTree" +); + +impl Decision> { + /// Number of vertices in the underlying graph. + pub fn num_vertices(&self) -> usize { + self.inner().num_vertices() + } + + /// Number of edges in the underlying graph. + pub fn num_edges(&self) -> usize { + self.inner().num_edges() + } +} + +crate::register_decision_variant!( + MaximumLeafSpanningTree, + "DecisionMaximumLeafSpanningTree", + "1.8966^num_vertices", + &[], + "Decision version: does a spanning tree with >= bound leaves exist?", + category: crate::registry::ProblemCategory::Graph, + dims: [VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"])], + fields: [ + FieldInfo { name: "graph", type_name: "SimpleGraph", description: "The underlying graph G=(V,E)" }, + FieldInfo { name: "bound", type_name: "i64", description: "Minimum number of degree-one tree vertices" }, + ], + decode: |_, indices: Vec| crate::config::config_to_bits(&indices) +); + +#[cfg(feature = "example-db")] +pub(crate) fn decision_canonical_model_example_specs() +-> Vec { + vec![crate::example_db::specs::ModelExampleSpec { + id: "decision_maximum_leaf_spanning_tree_simplegraph", + instance: Box::new(Decision::new( + MaximumLeafSpanningTree::new(SimpleGraph::star(4)), + 3, + )), + optimal_config: serde_json::json!([true, true, true]), + optimal_value: serde_json::json!(true), + }] +} + +#[cfg(feature = "example-db")] +pub(crate) fn decision_canonical_rule_example_specs() +-> Vec { + vec![crate::example_db::specs::RuleExampleSpec { + id: "decision_maximum_leaf_spanning_tree_to_maximum_leaf_spanning_tree", + build: || { + crate::example_db::specs::rule_example_with_witness::< + _, + MaximumLeafSpanningTree, + >( + Decision::new(MaximumLeafSpanningTree::new(SimpleGraph::star(4)), 3), + crate::export::SolutionPair { + source_config: serde_json::json!([true, true, true]), + target_config: serde_json::json!([true, true, true]), + }, + ) + }, + }] +} + #[cfg(feature = "example-db")] pub(crate) fn canonical_model_example_specs() -> Vec { vec![crate::example_db::specs::ModelExampleSpec { @@ -256,3 +324,7 @@ pub(crate) fn canonical_model_example_specs() -> Vec Vec>; +type Target = Decision>; + +/// A target decision instance and the original vertex prefix used in extraction. +#[derive(Debug, Clone)] +pub struct ReductionDecisionMinimumVertexCoverToDecisionMaximumLeafSpanningTree { + target: Target, + source_vertices: usize, +} + +impl ReductionResult for ReductionDecisionMinimumVertexCoverToDecisionMaximumLeafSpanningTree { + type Source = Source; + type Target = Target; + + fn target_problem(&self) -> &Target { + &self.target + } + + fn extract_solution(&self, solution: &Vec) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_witness( + &self.target, + solution, + |value| value.0, + "target selection is not a spanning tree meeting the leaf bound", + )?; + let mut degrees = vec![0_usize; self.target.num_vertices()]; + for ((u, v), selected) in self + .target + .inner() + .graph() + .edges() + .into_iter() + .zip(solution) + { + if *selected { + degrees[u] += 1; + degrees[v] += 1; + } + } + Ok(degrees[..self.source_vertices] + .iter() + .map(|°ree| degree >= 2) + .collect()) + } +} + +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult + for ReductionDecisionMinimumVertexCoverToDecisionMaximumLeafSpanningTree +{ +} + +fn target_sizes(n: usize, m: usize) -> Result<(usize, usize), ReductionError> { + let overflow = + || ReductionError::integer_overflow::("counting target vertices and edges"); + let vertices = n + .checked_add(m) + .and_then(|value| value.checked_add(3)) + .ok_or_else(overflow)?; + let edges = m + .checked_mul(2) + .and_then(|value| value.checked_add(n)) + .and_then(|value| value.checked_add(2)) + .ok_or_else(overflow)?; + Ok((vertices, edges)) +} + +fn leaf_bound(n: usize, vertices: usize, k: i64) -> Result { + let n = >::exact_i64(n, "converting the cover-size limit")?; + let non_root = + >::exact_i64(vertices - 1, "converting the target tree size")?; + let normalized = k.clamp(-1, n); + non_root.checked_sub(normalized).ok_or_else(|| { + ReductionError::integer_overflow::("computing the target leaf bound") + }) +} + +#[crate::reduction(transform = exact { + num_vertices = "num_vertices + num_edges + 3", + num_edges = "num_vertices + 2 * num_edges + 2", +})] +impl ReduceTo>> + for Decision> +{ + type Result = ReductionDecisionMinimumVertexCoverToDecisionMaximumLeafSpanningTree; + + fn reduce_to(&self) -> Result { + let graph = self.inner().graph(); + let n = graph.num_vertices(); + let m = graph.num_edges(); + let (vertices, edge_count) = target_sizes(n, m)?; + let bound = leaf_bound(n, vertices, *self.bound())?; + let root = vertices - 3; + let mut edges = Vec::with_capacity(edge_count); + edges.extend((0..n).map(|v| (root, v))); + for (j, (u, v)) in graph.edges().into_iter().enumerate() { + let subdivision = n + j; + edges.extend([(u, subdivision), (v, subdivision)]); + } + edges.extend([(root, root + 1), (root, root + 2)]); + let graph = SimpleGraph::try_new(vertices, edges) + .map_err(>::target_construction)?; + let inner = MaximumLeafSpanningTree::try_new(graph) + .map_err(>::target_construction)?; + Ok( + ReductionDecisionMinimumVertexCoverToDecisionMaximumLeafSpanningTree { + target: Decision::new(inner, bound), + source_vertices: n, + }, + ) + } +} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_rule_example_specs() -> Vec { + vec![crate::example_db::specs::RuleExampleSpec { + id: "decisionminimumvertexcover_to_decisionmaximumleafspanningtree", + build: || { + crate::example_db::specs::rule_example_with_witness::<_, Target>( + Decision::new( + MinimumVertexCover::new(SimpleGraph::cycle(4), vec![One; 4]), + 2, + ), + crate::export::SolutionPair { + source_config: serde_json::json!([true, false, true, false]), + target_config: serde_json::json!([ + true, true, true, true, true, false, false, true, true, false, false, true, + true, true, + ]), + }, + ) + }, + }] +} + +#[cfg(test)] +#[path = "../unit_tests/rules/decisionminimumvertexcover_decisionmaximumleafspanningtree.rs"] +mod tests; diff --git a/src/rules/mod.rs b/src/rules/mod.rs index 7606f4923..7400a37ab 100644 --- a/src/rules/mod.rs +++ b/src/rules/mod.rs @@ -28,6 +28,7 @@ pub(crate) mod cyclicordering_ilp; pub(crate) mod decisionmaximumindependentset_integralflowbundles; pub(crate) mod decisionminimumdominatingset_minimumsummulticenter; pub(crate) mod decisionminimumdominatingset_minmaxmulticenter; +pub(crate) mod decisionminimumvertexcover_decisionmaximumleafspanningtree; pub(crate) mod decisionminimumvertexcover_hamiltoniancircuit; pub(crate) mod ensemblecomputation_ilp; pub(crate) mod exactcoverby3sets_algebraicequationsovergf2; @@ -351,6 +352,9 @@ pub(crate) fn canonical_rule_example_specs() -> Vec Vec>; + +fn star(bound: i64) -> LeafDecision { + Decision::new(MaximumLeafSpanningTree::new(SimpleGraph::star(4)), bound) +} + +#[test] +fn test_decision_maximum_leaf_spanning_tree_creation() { + let variant = BTreeMap::from([("graph".to_owned(), "SimpleGraph".to_owned())]); + let problem = construct_dyn( + "DecisionMaximumLeafSpanningTree", + &variant, + json!({"graph": {"num_vertices": 4, "edges": [[0,1], [0,2], [0,3]]}, "bound": 3}), + ) + .unwrap(); + assert_eq!(problem.serialize_json()["bound"], 3); + assert_eq!( + problem.serialize_json()["inner"]["graph"]["num_vertices"], + 4 + ); +} + +#[test] +fn test_decision_maximum_leaf_spanning_tree_evaluation() { + let witness = vec![true; 3]; + for (bound, expected) in [ + (i64::MIN, true), + (2, true), + (3, true), + (4, false), + (i64::MAX, false), + ] { + let problem = star(bound); + assert_eq!(problem.evaluate(&witness).unwrap(), Or(expected)); + assert_eq!(*problem.bound(), bound); + assert_eq!(problem.num_vertices(), 4); + assert_eq!(problem.num_edges(), 3); + assert_eq!(problem.dimensions(), vec![2; 3]); + assert_eq!(problem.parameters(), problem.inner().parameters()); + } + assert_eq!( + star(0).evaluate(&vec![true, false, true]).unwrap(), + Or(false) + ); + assert!(star(0).evaluate(&vec![true; 2]).is_err()); + let disconnected = Decision::new( + MaximumLeafSpanningTree::new(SimpleGraph::new(4, vec![(0, 1), (1, 2), (2, 0)])), + -1, + ); + assert_eq!(disconnected.evaluate(&vec![true; 3]).unwrap(), Or(false)); + let pair = Decision::new(MaximumLeafSpanningTree::new(SimpleGraph::path(2)), 2); + assert_eq!(pair.evaluate(&vec![true]).unwrap(), Or(true)); +} + +#[test] +fn test_decision_maximum_leaf_spanning_tree_serialization() { + let problem = star(3); + let encoded = serde_json::to_value(&problem).unwrap(); + let restored: LeafDecision = serde_json::from_value(encoded.clone()).unwrap(); + assert_eq!( + restored.inner().graph().edges(), + problem.inner().graph().edges() + ); + assert_eq!(restored.evaluate(&vec![true; 3]).unwrap(), Or(true)); + assert_eq!(*restored.bound(), 3); + assert_eq!(encoded["inner"]["graph"]["num_vertices"], 4); + let variant = BTreeMap::from([("graph".to_owned(), "SimpleGraph".to_owned())]); + for n in [0, 1] { + let flat = json!({"graph": {"num_vertices": n, "edges": []}, "bound": 0}); + assert!(construct_dyn("DecisionMaximumLeafSpanningTree", &variant, flat).is_err()); + assert!( + serde_json::from_value::( + json!({"inner": {"graph": {"num_vertices": n, "edges": []}}, "bound": 0}) + ) + .is_err() + ); + assert!(MaximumLeafSpanningTree::try_new(SimpleGraph::new(n, vec![])).is_err()); + } + assert!(serde_json::from_value::(json!({"inner": {"graph": {"num_vertices": 2, "edges": [[0,1]]}}, "bound": 9223372036854775808_u64})).is_err()); +} + +#[test] +fn test_decision_maximum_leaf_spanning_tree_solver() { + for bound in [2, 3, 4] { + let problem = star(bound); + let solution = BruteForce::new().solve(&problem).unwrap(); + assert_eq!(solution.is_some(), bound <= 3); + if let Some(witness) = solution { + assert_eq!(problem.evaluate(&witness).unwrap(), Or(true)); + } + } +} + +#[test] +fn test_decision_maximum_leaf_spanning_tree_ilp_solver() { + use crate::solvers::ILPSolver; + for bound in [2, 3, 4] { + let problem = star(bound); + let result = ILPSolver::new().solve(&problem); + if bound <= 3 { + let witness = result.unwrap(); + assert_eq!(problem.evaluate(&witness).unwrap(), Or(true)); + } else { + assert!(matches!( + result, + Err(crate::solvers::ILPSolveError::Infeasible) + )); + } + } +} + +#[test] +#[cfg(feature = "example-db")] +fn test_decision_maximum_leaf_spanning_tree_paper_example() { + let spec = super::decision_canonical_model_example_specs() + .pop() + .unwrap(); + let instance = spec.instance.serialize_json(); + let problem: LeafDecision = serde_json::from_value(instance).unwrap(); + let witness: Vec = serde_json::from_value(spec.optimal_config).unwrap(); + assert_eq!( + problem.inner().graph().edges(), + vec![(0, 1), (0, 2), (0, 3)] + ); + assert_eq!(witness, vec![true; 3]); + assert_eq!(*problem.bound(), 3); + assert_eq!(problem.evaluate(&witness).unwrap(), Or(true)); + assert_eq!(spec.optimal_value, json!(true)); +} diff --git a/src/unit_tests/rules/decisionminimumvertexcover_decisionmaximumleafspanningtree.rs b/src/unit_tests/rules/decisionminimumvertexcover_decisionmaximumleafspanningtree.rs new file mode 100644 index 000000000..40a9d8151 --- /dev/null +++ b/src/unit_tests/rules/decisionminimumvertexcover_decisionmaximumleafspanningtree.rs @@ -0,0 +1,427 @@ +use super::{leaf_bound, target_sizes}; +use crate::models::{Decision, MaximumLeafSpanningTree, MinimumVertexCover}; +use crate::rules::{ReduceTo, ReductionResult}; +use crate::solvers::BruteForce; +use crate::topology::{Graph, SimpleGraph}; +use crate::traits::Problem; +use crate::types::One; +use crate::types::Or; + +type Source = Decision>; +type Target = Decision>; + +fn source(n: usize, edges: Vec<(usize, usize)>, bound: i64) -> Source { + Decision::new( + MinimumVertexCover::new(SimpleGraph::new(n, edges), vec![One; n]), + bound, + ) +} + +fn cycle(bound: i64) -> Source { + source(4, vec![(0, 1), (1, 2), (2, 3), (3, 0)], bound) +} + +fn reduce(source: &Source) -> >::Result { + ReduceTo::::reduce_to(source).unwrap() +} + +fn degrees(target: &Target, bits: &[bool]) -> Vec { + let mut degree = vec![0; target.num_vertices()]; + for ((u, v), selected) in target.inner().graph().edges().iter().zip(bits) { + if *selected { + degree[*u] += 1; + degree[*v] += 1; + } + } + degree +} + +#[test] +fn test_decisionminimumvertexcover_to_decisionmaximumleafspanningtree_closed_loop() { + let source = cycle(2); + let reduction = reduce(&source); + crate::rules::test_helpers::assert_satisfaction_round_trip_from_satisfaction_target( + &source, + &reduction, + "four-cycle cover to leaf tree", + ); + crate::rules::test_helpers::assert_parameter_predictions(&source, &reduction); +} + +#[test] +fn test_paper_example_and_infeasible_bound() { + let witness = vec![ + true, true, true, true, true, false, false, true, true, false, false, true, true, true, + ]; + for (bound, expected) in [(2, true), (1, false)] { + let source = cycle(bound); + let reduction = reduce(&source); + let target = reduction.target_problem(); + assert_eq!(target.num_vertices(), 11); + assert_eq!(target.num_edges(), 14); + assert_eq!(*target.bound(), 10 - bound); + assert_eq!( + degrees(target, &witness), + vec![3, 1, 3, 1, 1, 1, 1, 1, 6, 1, 1] + ); + let (answer, solutions) = BruteForce::new().solve_with_witnesses(target).unwrap(); + let direct = BruteForce::new().solve_with_witnesses(&source).unwrap().0; + assert_eq!(answer, Or(expected)); + assert_eq!( + crate::rules::AggregateReductionResult::extract_value(&reduction, answer), + direct + ); + assert_eq!(solutions.is_empty(), !expected); + if expected { + assert_eq!( + reduction.extract_solution(&witness).unwrap(), + vec![true, false, true, false] + ); + } else { + assert!(reduction.extract_solution(&witness).is_err()); + } + } + #[cfg(feature = "example-db")] + { + let example = (super::canonical_rule_example_specs().pop().unwrap().build)(); + assert_eq!( + example.solutions[0].target_config, + serde_json::json!(witness) + ); + assert_eq!( + example.solutions[0].source_config, + serde_json::json!([true, false, true, false]) + ); + } +} + +#[test] +fn test_exact_extraction_with_internal_subdivision_vertex() { + let source = source(2, vec![(0, 1)], 2); + let reduction = reduce(&source); + let witness = vec![true, false, true, true, true, true]; + assert_eq!( + reduction.target_problem().evaluate(&witness).unwrap(), + Or(true) + ); + assert_eq!( + reduction.extract_solution(&witness).unwrap(), + vec![true, false] + ); +} + +#[test] +fn test_extraction_rejects_non_tree_meeting_leaf_threshold() { + let source = source(6, vec![(0, 1), (1, 2), (0, 2)], 6); + let reduction = reduce(&source); + let target = reduction.target_problem(); + // A cycle on {r,1,a_1,2}; the second private vertex is disconnected. + let mut bits = vec![true; 6]; + bits.extend([true, false, true, true, true, false, true, false]); + assert_eq!( + bits.iter().filter(|&&x| x).count(), + target.num_vertices() - 1 + ); + assert!( + i64::try_from(degrees(target, &bits).iter().filter(|&&d| d == 1).count()).unwrap() + >= *target.bound() + ); + assert_eq!(target.evaluate(&bits).unwrap(), Or(false)); + assert!(reduction.extract_solution(&bits).is_err()); +} + +#[test] +fn test_extraction_rejects_wrong_lengths() { + let reduction = reduce(&source(2, vec![(0, 1)], 2)); + let valid = vec![true, false, true, true, true, true]; + let mut long = valid.clone(); + long.push(false); + for bits in [vec![], valid[..5].to_vec(), long] { + assert!(reduction.extract_solution(&bits).is_err()); + } + assert!(reduction.extract_solution(&vec![false; 6]).is_err()); + assert!(reduction.extract_solution(&vec![true; 6]).is_err()); +} + +#[test] +fn test_suboptimal_trees_obey_decision_bound() { + for (parents, leaves) in [([0, 1, 2, 0], 7), ([0, 1, 2, 3], 6)] { + let mut bits = vec![true; 4]; + for ((u, v), parent) in cycle(2).inner().graph().edges().into_iter().zip(parents) { + bits.extend([parent == u, parent == v]); + } + bits.extend([true, true]); + let strict = reduce(&cycle(2)); + assert_eq!( + degrees(strict.target_problem(), &bits) + .iter() + .filter(|&&d| d == 1) + .count(), + leaves + ); + assert!(strict.target_problem().inner().is_valid_solution(&bits)); + assert!(strict.extract_solution(&bits).is_err()); + let loose_source = cycle(i64::try_from(10 - leaves).unwrap()); + let loose = reduce(&loose_source); + let cover = loose.extract_solution(&bits).unwrap(); + assert_eq!(loose_source.evaluate(&cover).unwrap(), Or(true)); + } +} + +#[test] +fn test_empty_edgeless_disconnected_and_signed_bounds() { + for (n, edges, minimum) in [ + (0, vec![], 0), + (1, vec![], 0), + (5, vec![], 0), + (5, vec![(0, 1), (2, 3)], 2), + ] { + for k in [i64::MIN, -1, 0, 1, 2, 5, 6, i64::MAX] { + let source = source(n, edges.clone(), k); + let reduction = reduce(&source); + let target = reduction.target_problem(); + assert_eq!(target.num_vertices(), n + edges.len() + 3); + assert_eq!(target.num_edges(), n + 2 * edges.len() + 2); + let solution = BruteForce::new().solve(target).unwrap(); + assert_eq!(solution.is_some(), k >= minimum); + if let Some(bits) = solution { + let extracted = reduction.extract_solution(&bits).unwrap(); + assert_eq!(source.evaluate(&extracted).unwrap(), Or(true)); + for (v, &selected) in extracted.iter().enumerate() { + if !edges.iter().any(|&(u, w)| u == v || w == v) { + assert!(!selected); + } + } + } + crate::rules::test_helpers::assert_parameter_predictions(&source, &reduction); + if n == 0 { + assert_eq!(target.inner().graph().edges(), vec![(0, 1), (0, 2)]); + } + } + } +} + +#[test] +fn test_size_and_signed_arithmetic_boundaries() { + let max = usize::MAX; + for (n, m, expected) in [ + (max - 3, 0, Some((max, max - 1))), + (max - 2, 0, None), + (max, 1, None), + (0, max / 2 - 1, Some((max / 2 + 2, max - 1))), + (0, max / 2, None), + (0, max / 2 + 1, None), + (3, max / 2 - 2, Some((max / 2 + 4, max))), + (4, max / 2 - 2, None), + (5, max / 2 - 2, None), + (6, max / 2 - 2, None), + ] { + assert_eq!(target_sizes(n, m).ok(), expected, "n={n},m={m}"); + } + let signed_max = usize::try_from(i64::MAX).unwrap(); + assert_eq!(leaf_bound(0, signed_max, -1).unwrap(), i64::MAX); + assert_eq!(leaf_bound(0, signed_max + 1, 0).unwrap(), i64::MAX); + assert!(leaf_bound(0, signed_max + 1, -1).is_err()); + assert!(leaf_bound(0, signed_max + 2, 0).is_err()); + assert!(leaf_bound(signed_max + 1, 3, 0).is_err()); + for k in [1, 2, 3] { + assert_eq!(leaf_bound(10, 3, k).unwrap(), 2 - k); + } + for k in [i64::MIN, -1, 0, 2, 3, i64::MAX] { + assert_eq!(leaf_bound(2, 6, k).unwrap(), 5 - k.clamp(-1, 2)); + } +} + +// Grow arbitrary trees using the actual target adjacency, with no gadget assumptions. +fn spanning_trees(edges: &[(usize, usize)], n: usize, visit: &mut impl FnMut(&[bool])) { + assert!(edges.len() < 64 && n < 64); + let mut incident = vec![0_u64; n]; + for (i, &(u, v)) in edges.iter().enumerate() { + incident[u] |= 1 << i; + incident[v] |= 1 << i; + } + fn grow( + edges: &[(usize, usize)], + incident: &[u64], + seen: u64, + touching_seen: u64, + frontier: u64, + chosen: &mut [bool], + visit: &mut impl FnMut(&[bool]), + ) { + if seen.count_ones() == u32::try_from(incident.len()).unwrap() { + visit(chosen); + return; + } + if frontier == 0 { + return; + } + let edge = usize::try_from(frontier.trailing_zeros()).unwrap(); + let rest = frontier & !(1 << edge); + let (u, v) = edges[edge]; + let new_vertex = if seen >> u & 1 == 1 { v } else { u }; + let next_seen = seen | (1 << new_vertex); + // Remove edges closing a cycle; add edges from the new vertex to unseen ones. + let next = (rest & !incident[new_vertex]) | (incident[new_vertex] & !touching_seen); + chosen[edge] = true; + grow( + edges, + incident, + next_seen, + touching_seen | incident[new_vertex], + next, + chosen, + visit, + ); + chosen[edge] = false; + grow(edges, incident, seen, touching_seen, rest, chosen, visit); + } + grow( + edges, + &incident, + 1, + incident[0], + incident[0], + &mut vec![false; edges.len()], + visit, + ); +} + +fn exhaustive(n: usize, edge_count: Option) { + let pairs: Vec<_> = (0..n) + .flat_map(|u| (u + 1..n).map(move |v| (u, v))) + .collect(); + for mask in 0_usize..1 << pairs.len() { + if edge_count.is_some_and(|m| usize::try_from(mask.count_ones()).unwrap() != m) { + continue; + } + let edges: Vec<_> = pairs + .iter() + .enumerate() + .filter_map(|(i, &e)| (mask >> i & 1 == 1).then_some(e)) + .collect(); + let source = source(n, edges.clone(), i64::try_from(n).unwrap()); + let covers = BruteForce::new().find_all_witnesses(&source).unwrap(); + let minimum = covers + .iter() + .map(|c| c.iter().filter(|&&x| x).count()) + .min() + .unwrap(); + let sources: Vec<_> = (-1..=i64::try_from(n).unwrap()) + .map(|k| Decision::new(source.inner().clone(), k)) + .collect(); + let reductions: Vec<_> = sources.iter().map(reduce).collect(); + let target = reductions.last().unwrap().target_problem(); + assert_eq!(target.num_vertices(), n + edges.len() + 3); + assert_eq!(target.num_edges(), n + 2 * edges.len() + 2); + for cover in covers { + let mut bits = vec![true; n]; + for &(u, _) in &edges { + bits.extend([cover[u], !cover[u]]); + } + bits.extend([true, true]); + let cost = cover.iter().filter(|&&x| x).count(); + assert_eq!( + reductions[cost + 1] + .target_problem() + .evaluate(&bits) + .unwrap(), + Or(true) + ); + } + let mut maximum = 0; + let target_edges = target.inner().graph().edges(); + spanning_trees(&target_edges, target.num_vertices(), &mut |bits| { + let mut degree = vec![0; target.num_vertices()]; + for (&(u, v), &selected) in target_edges.iter().zip(bits) { + if selected { + degree[u] += 1; + degree[v] += 1; + } + } + let leaves = degree.iter().filter(|&&d| d == 1).count(); + maximum = maximum.max(leaves); + if i64::try_from(leaves).unwrap() >= *target.bound() { + let k = target.num_vertices() - 1 - leaves; + let cover = reductions[k + 1].extract_solution(&bits.to_vec()).unwrap(); + assert_eq!( + cover, + degree[..n].iter().map(|&d| d >= 2).collect::>() + ); + assert_eq!(sources[k + 1].evaluate(&cover).unwrap(), Or(true)); + } + }); + assert_eq!(maximum, target.num_vertices() - 1 - minimum); + for (i, reduction) in reductions.iter().enumerate() { + assert_eq!( + i64::try_from(maximum).unwrap() >= *reduction.target_problem().bound(), + i > minimum + ); + } + } +} + +#[test] +fn test_exhaustive_graphs_through_four_vertices() { + for n in 0..=4 { + exhaustive(n, None); + } +} + +macro_rules! exhaustive_five_vertices { + ($($name:ident: $edges:literal),* $(,)?) => {$( + #[test] + fn $name() {exhaustive(5,Some($edges));} + )*}; +} +exhaustive_five_vertices! { + test_five_vertices_edgeless: 0, test_five_vertices_one_edge: 1, + test_five_vertices_two_edges: 2, test_five_vertices_three_edges: 3, + test_five_vertices_four_edges: 4, test_five_vertices_five_edges: 5, + test_five_vertices_six_edges: 6, test_five_vertices_seven_edges: 7, + test_five_vertices_eight_edges: 8, test_five_vertices_nine_edges: 9, + test_five_vertices_complete: 10, +} + +#[test] +fn test_registered_cycle_target() { + let source = Decision::new( + MinimumVertexCover::new(SimpleGraph::cycle(4), vec![One; 4]), + 2, + ); + let entry = crate::rules::registry::reduction_entries() + .into_iter() + .find(|entry| { + entry.source_name == "DecisionMinimumVertexCover" + && entry.source_variant() == vec![("graph", "SimpleGraph"), ("weight", "One")] + && entry.target_name == "DecisionMaximumLeafSpanningTree" + }) + .expect("direct decision reduction must be registered"); + assert!(entry.reduce_aggregate_fn.is_some()); + let reduced = entry.reduce_fn.unwrap()(&source).unwrap(); + let target = reduced + .target_problem_any() + .downcast_ref::>>() + .unwrap(); + assert_eq!(*target.bound(), 8); + assert_eq!(target.inner().num_vertices(), 11); + assert_eq!( + target.inner().graph().edges(), + vec![ + (8, 0), + (8, 1), + (8, 2), + (8, 3), + (0, 4), + (1, 4), + (1, 5), + (2, 5), + (2, 6), + (3, 6), + (3, 7), + (0, 7), + (8, 9), + (8, 10), + ] + ); +}