From a1fbe6641c109bfd52a50af15c99da5318a5f1ca Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Thu, 12 Mar 2026 19:43:24 +0800 Subject: [PATCH 1/8] Add plan for #140: [Model] MinimumFeedbackVertexSet Co-Authored-By: Claude Opus 4.6 --- .../2026-03-12-minimum-feedback-vertex-set.md | 170 ++++++++++++++++++ 1 file changed, 170 insertions(+) create mode 100644 docs/plans/2026-03-12-minimum-feedback-vertex-set.md diff --git a/docs/plans/2026-03-12-minimum-feedback-vertex-set.md b/docs/plans/2026-03-12-minimum-feedback-vertex-set.md new file mode 100644 index 000000000..dceef597d --- /dev/null +++ b/docs/plans/2026-03-12-minimum-feedback-vertex-set.md @@ -0,0 +1,170 @@ +# Plan: Add MinimumFeedbackVertexSet Model (#140) + +## Overview + +Add the MinimumFeedbackVertexSet problem — one of Karp's 21 NP-complete problems (GT7). This requires new DirectedGraph topology infrastructure since the problem operates on directed graphs, which don't exist in the codebase yet. + +**Problem:** Given a directed graph G = (V, A) with vertex weights, find minimum-weight S ⊆ V such that G[V \ S] is a DAG. + +## Batch 1: DirectedGraph Topology (independent) + +### Task 1.1: Create `src/topology/directed_graph.rs` + +New directed graph struct wrapping `petgraph::graph::DiGraph<(), ()>`. + +**Struct:** +```rust +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DirectedGraph { + inner: DiGraph<(), ()>, +} +``` + +**Methods:** +- `new(num_vertices: usize, arcs: Vec<(usize, usize)>) -> Self` — constructor with arc validation +- `empty(num_vertices: usize) -> Self` +- `num_vertices(&self) -> usize` +- `num_arcs(&self) -> usize` +- `arcs(&self) -> Vec<(usize, usize)>` — returns all arcs as (source, target) pairs +- `has_arc(&self, u: usize, v: usize) -> bool` — check if arc u→v exists +- `successors(&self, v: usize) -> Vec` — outgoing neighbors +- `predecessors(&self, v: usize) -> Vec` — incoming neighbors +- `is_dag(&self) -> bool` — cycle detection via topological sort (using petgraph's `toposort`) +- `induced_subgraph(&self, keep: &[bool]) -> Self` — subgraph on vertices where keep[v] == true (remaps vertex indices) + +**Does NOT implement the `Graph` trait** (which is for undirected graphs with u < v edge semantics). + +**Implements:** +- `PartialEq`, `Eq` (normalize and compare arc sets) +- `VariantParam` via `impl_variant_param!(DirectedGraph, "graph")` + +**Tests:** `src/unit_tests/topology/directed_graph.rs` linked via `#[cfg(test)] #[path]` + +Test cases: +- Construction and basic queries (num_vertices, num_arcs, arcs, has_arc) +- successors/predecessors correctness +- is_dag: true for DAG, false for graph with cycle +- induced_subgraph: verify removing vertices breaks cycles +- PartialEq: same graph in different arc order should be equal +- Serialization round-trip + +### Task 1.2: Register DirectedGraph in `src/topology/mod.rs` + +Add module declaration and re-export: +```rust +mod directed_graph; +pub use directed_graph::DirectedGraph; +``` + +## Batch 2: MinimumFeedbackVertexSet Model (depends on Batch 1) + +### Task 2.1: Create `src/models/graph/minimum_feedback_vertex_set.rs` + +Follow MinimumDominatingSet pattern but with DirectedGraph instead of generic G. + +**Schema registration:** +```rust +inventory::submit! { + ProblemSchemaEntry { + name: "MinimumFeedbackVertexSet", + module_path: module_path!(), + description: "Find minimum weight feedback vertex set in a directed graph", + fields: &[ + FieldInfo { name: "graph", type_name: "DirectedGraph", description: "The directed graph G=(V,A)" }, + FieldInfo { name: "weights", type_name: "Vec", description: "Vertex weights w: V -> R" }, + ], + } +} +``` + +**Struct:** `MinimumFeedbackVertexSet` with fields `graph: DirectedGraph`, `weights: Vec` + +**Constructor & getters:** +- `new(graph: DirectedGraph, weights: Vec)` — assert weights.len() == num_vertices +- `graph(&self) -> &DirectedGraph` +- `weights(&self) -> &[W]` +- `is_weighted(&self) -> bool` — via `W::IS_UNIT` + +**NumericSize getters** (for overhead expressions): +- `num_vertices(&self) -> usize` — graph.num_vertices() +- `num_arcs(&self) -> usize` — graph.num_arcs() + +**evaluate logic:** +1. Build induced subgraph on vertices where `config[v] == 0` (not selected for removal) +2. Check if induced subgraph `is_dag()` — if not, return `SolutionSize::Invalid` +3. Sum weights of selected vertices (config[v] == 1) → `SolutionSize::Valid(total)` + +**Trait impls:** +- `Problem` with `NAME = "MinimumFeedbackVertexSet"`, `Metric = SolutionSize`, `variant() = variant_params![W]`, `dims() = vec![2; n]` +- `OptimizationProblem` with `Value = W::Sum`, `direction() = Minimize` + +**Variant complexity:** +```rust +crate::declare_variants! { + MinimumFeedbackVertexSet => "1.8638^num_vertices", +} +``` +Based on Razgon (2007), "Computing Minimum Directed Feedback Vertex Set in O*(1.9977^n)". Note: the issue cites 1.8638^n which comes from later improvements. Use the issue's value. + +**Helper function:** +```rust +#[cfg(test)] +pub(crate) fn is_feedback_vertex_set(graph: &DirectedGraph, selected: &[bool]) -> bool +``` + +**Test link:** `#[cfg(test)] #[path = "../../unit_tests/models/graph/minimum_feedback_vertex_set.rs"] mod tests;` + +### Task 2.2: Register in module hierarchy + +1. `src/models/graph/mod.rs` — add `pub(crate) mod minimum_feedback_vertex_set;` and `pub use minimum_feedback_vertex_set::MinimumFeedbackVertexSet;` +2. `src/models/mod.rs` — add `MinimumFeedbackVertexSet` to graph re-exports +3. `src/lib.rs` prelude — add `MinimumFeedbackVertexSet` to prelude exports + +### Task 2.3: Write unit tests + +Create `src/unit_tests/models/graph/minimum_feedback_vertex_set.rs`: + +Test cases: +- `test_minimum_feedback_vertex_set_basic` — create instance with issue's example (9 vertices, 15 arcs), verify dims=[2;9], evaluate valid FVS {0,3,8} returns Valid(3), evaluate invalid subset returns Invalid +- `test_minimum_feedback_vertex_set_direction` — verify Minimize +- `test_minimum_feedback_vertex_set_serialization` — round-trip serde +- `test_minimum_feedback_vertex_set_solver` — brute force finds optimal FVS of size 3 for the example +- `test_minimum_feedback_vertex_set_empty_set` — empty set is only valid FVS if graph is already a DAG +- `test_minimum_feedback_vertex_set_trivial` — selecting all vertices is always valid (but not optimal) + +## Batch 3: CLI Registration (depends on Batch 2) + +### Task 3.1: Update `problemreductions-cli/src/dispatch.rs` + +Add imports and match arms: +- `load_problem`: `"MinimumFeedbackVertexSet" => deser_opt::>(data)` +- `serialize_any_problem`: `try_ser::>` + +### Task 3.2: Update `problemreductions-cli/src/problem_name.rs` + +Add lowercase alias: `"minimumfeedbackvertexset" => "MinimumFeedbackVertexSet"` +Add standard abbreviation: `("FVS", "MinimumFeedbackVertexSet")` to ALIASES array (FVS is well-established in the literature). + +### Task 3.3: Update `problemreductions-cli/src/commands/create.rs` + +Add a new match arm for MinimumFeedbackVertexSet: +- Parse `--arcs` flag (new flag for directed edges, format: "0>1,1>2,2>0") +- Parse `--weights` flag (reuse existing) +- Parse `--num-vertices` for `--random` mode +- Construct `DirectedGraph` and `MinimumFeedbackVertexSet` + +### Task 3.4: Update `problemreductions-cli/src/cli.rs` + +1. Add `--arcs` flag to `CreateArgs`: `pub arcs: Option` with help "Directed arcs (e.g., 0>1,1>2,2>0)" +2. Update `all_data_flags_empty()` to include `args.arcs.is_none()` +3. Add to "Flags by problem type" help table: `MinFVS/FVS --arcs, --weights` + +## Batch 4: Verification (depends on all above) + +### Task 4.1: Run `make check` + +Run `make fmt && make clippy && make test` — all must pass. + +### Task 4.2: Run `make export-schemas` + +Regenerate problem schemas to include the new problem type. From 869961d2ee464745de4234dc8d67fc3940ed8f58 Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Thu, 12 Mar 2026 19:49:16 +0800 Subject: [PATCH 2/8] feat(topology): add DirectedGraph for directed problem infrastructure Add DirectedGraph struct (wrapping petgraph DiGraph) with full API: new/empty constructors, num_vertices/num_arcs, arcs, has_arc, successors, predecessors, is_dag (via toposort), and induced_subgraph with contiguous vertex remapping. Implements PartialEq/Eq (normalized arc sort) and VariantParam. Includes 19 unit tests covering all methods, DAG detection, serialization round-trip, and invalid-arc panic. This is Batch 1 infrastructure for MinimumFeedbackVertexSet (#140). Co-Authored-By: Claude Opus 4.6 --- src/topology/directed_graph.rs | 190 ++++++++++++++++++++++ src/topology/mod.rs | 2 + src/unit_tests/topology/directed_graph.rs | 162 ++++++++++++++++++ 3 files changed, 354 insertions(+) create mode 100644 src/topology/directed_graph.rs create mode 100644 src/unit_tests/topology/directed_graph.rs diff --git a/src/topology/directed_graph.rs b/src/topology/directed_graph.rs new file mode 100644 index 000000000..4f83affbb --- /dev/null +++ b/src/topology/directed_graph.rs @@ -0,0 +1,190 @@ +//! Directed graph implementation. +//! +//! This module provides [`DirectedGraph`], a directed graph wrapping petgraph's +//! `DiGraph`. It is used for problems that require directed input, such as +//! [`MinimumFeedbackVertexSet`]. +//! +//! Unlike [`SimpleGraph`], `DirectedGraph` does **not** implement the [`Graph`] +//! trait (which is specific to undirected graphs). Arcs are ordered pairs `(u, v)` +//! representing a directed edge from `u` to `v`. +//! +//! [`SimpleGraph`]: crate::topology::SimpleGraph +//! [`Graph`]: crate::topology::Graph +//! [`MinimumFeedbackVertexSet`]: crate::models::graph::MinimumFeedbackVertexSet + +use petgraph::algo::toposort; +use petgraph::graph::{DiGraph, NodeIndex}; +use petgraph::visit::EdgeRef; +use serde::{Deserialize, Serialize}; + +/// A simple unweighted directed graph. +/// +/// Arcs are represented as ordered pairs `(u, v)` meaning there is an arc +/// from vertex `u` to vertex `v`. Self-loops are permitted. +/// +/// # Example +/// +/// ``` +/// use problemreductions::topology::DirectedGraph; +/// +/// let g = DirectedGraph::new(3, vec![(0, 1), (1, 2)]); +/// assert_eq!(g.num_vertices(), 3); +/// assert_eq!(g.num_arcs(), 2); +/// assert!(g.is_dag()); +/// +/// let cyclic = DirectedGraph::new(3, vec![(0, 1), (1, 2), (2, 0)]); +/// assert!(!cyclic.is_dag()); +/// ``` +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DirectedGraph { + inner: DiGraph<(), ()>, +} + +impl DirectedGraph { + /// Creates a new directed graph with the given vertices and arcs. + /// + /// # Arguments + /// + /// * `num_vertices` - Number of vertices in the graph + /// * `arcs` - List of arcs as `(source, target)` pairs + /// + /// # Panics + /// + /// Panics if any arc references a vertex index >= `num_vertices`. + pub fn new(num_vertices: usize, arcs: Vec<(usize, usize)>) -> Self { + let mut inner = DiGraph::new(); + for _ in 0..num_vertices { + inner.add_node(()); + } + for (u, v) in arcs { + assert!( + u < num_vertices && v < num_vertices, + "arc ({}, {}) references vertex >= num_vertices ({})", + u, + v, + num_vertices + ); + inner.add_edge(NodeIndex::new(u), NodeIndex::new(v), ()); + } + Self { inner } + } + + /// Creates an empty directed graph with the given number of vertices and no arcs. + pub fn empty(num_vertices: usize) -> Self { + Self::new(num_vertices, vec![]) + } + + /// Returns the number of vertices in the graph. + pub fn num_vertices(&self) -> usize { + self.inner.node_count() + } + + /// Returns the number of arcs in the graph. + pub fn num_arcs(&self) -> usize { + self.inner.edge_count() + } + + /// Returns all arcs as `(source, target)` pairs. + pub fn arcs(&self) -> Vec<(usize, usize)> { + self.inner + .edge_references() + .map(|e| (e.source().index(), e.target().index())) + .collect() + } + + /// Returns `true` if there is an arc from `u` to `v`. + pub fn has_arc(&self, u: usize, v: usize) -> bool { + self.inner + .find_edge(NodeIndex::new(u), NodeIndex::new(v)) + .is_some() + } + + /// Returns the outgoing neighbors (successors) of vertex `v`. + /// + /// These are all vertices `w` such that there is an arc `v → w`. + pub fn successors(&self, v: usize) -> Vec { + self.inner + .neighbors(NodeIndex::new(v)) + .map(|n| n.index()) + .collect() + } + + /// Returns the incoming neighbors (predecessors) of vertex `v`. + /// + /// These are all vertices `u` such that there is an arc `u → v`. + pub fn predecessors(&self, v: usize) -> Vec { + self.inner + .neighbors_directed(NodeIndex::new(v), petgraph::Direction::Incoming) + .map(|n| n.index()) + .collect() + } + + /// Returns `true` if the graph is a directed acyclic graph (DAG). + /// + /// Uses petgraph's topological sort to detect cycles: if a topological + /// ordering exists, the graph is acyclic. + pub fn is_dag(&self) -> bool { + toposort(&self.inner, None).is_ok() + } + + /// Returns the induced subgraph on vertices where `keep[v] == true`. + /// + /// Vertex indices are remapped to be contiguous starting from 0. An arc + /// `(u, v)` is included only if both `u` and `v` are kept. The new index + /// of a kept vertex is its rank among the kept vertices in increasing order. + /// + /// # Panics + /// + /// Panics if `keep.len() != self.num_vertices()`. + pub fn induced_subgraph(&self, keep: &[bool]) -> Self { + assert_eq!( + keep.len(), + self.num_vertices(), + "keep slice length must equal num_vertices" + ); + + // Build old index -> new index mapping + let mut new_index = vec![usize::MAX; self.num_vertices()]; + let mut count = 0; + for (v, &kept) in keep.iter().enumerate() { + if kept { + new_index[v] = count; + count += 1; + } + } + + let new_arcs: Vec<(usize, usize)> = self + .arcs() + .into_iter() + .filter(|&(u, v)| keep[u] && keep[v]) + .map(|(u, v)| (new_index[u], new_index[v])) + .collect(); + + Self::new(count, new_arcs) + } +} + +impl PartialEq for DirectedGraph { + fn eq(&self, other: &Self) -> bool { + if self.num_vertices() != other.num_vertices() { + return false; + } + if self.num_arcs() != other.num_arcs() { + return false; + } + let mut self_arcs = self.arcs(); + let mut other_arcs = other.arcs(); + self_arcs.sort(); + other_arcs.sort(); + self_arcs == other_arcs + } +} + +impl Eq for DirectedGraph {} + +use crate::impl_variant_param; +impl_variant_param!(DirectedGraph, "graph"); + +#[cfg(test)] +#[path = "../unit_tests/topology/directed_graph.rs"] +mod tests; diff --git a/src/topology/mod.rs b/src/topology/mod.rs index 3e4e64b34..92ebe1c86 100644 --- a/src/topology/mod.rs +++ b/src/topology/mod.rs @@ -8,6 +8,7 @@ //! - [`TriangularSubgraph`]: Triangular lattice subgraph mod bipartite_graph; +mod directed_graph; mod graph; mod kings_subgraph; mod planar_graph; @@ -16,6 +17,7 @@ mod triangular_subgraph; mod unit_disk_graph; pub use bipartite_graph::BipartiteGraph; +pub use directed_graph::DirectedGraph; pub use graph::{Graph, GraphCast, SimpleGraph}; pub use kings_subgraph::KingsSubgraph; pub use planar_graph::PlanarGraph; diff --git a/src/unit_tests/topology/directed_graph.rs b/src/unit_tests/topology/directed_graph.rs new file mode 100644 index 000000000..3b35cdf40 --- /dev/null +++ b/src/unit_tests/topology/directed_graph.rs @@ -0,0 +1,162 @@ +use super::*; + +#[test] +fn test_directed_graph_new() { + let g = DirectedGraph::new(4, vec![(0, 1), (1, 2), (2, 3)]); + assert_eq!(g.num_vertices(), 4); + assert_eq!(g.num_arcs(), 3); +} + +#[test] +fn test_directed_graph_empty() { + let g = DirectedGraph::empty(5); + assert_eq!(g.num_vertices(), 5); + assert_eq!(g.num_arcs(), 0); +} + +#[test] +fn test_directed_graph_arcs() { + let g = DirectedGraph::new(3, vec![(0, 1), (2, 0)]); + let mut arcs = g.arcs(); + arcs.sort(); + assert_eq!(arcs, vec![(0, 1), (2, 0)]); +} + +#[test] +fn test_directed_graph_has_arc() { + let g = DirectedGraph::new(3, vec![(0, 1), (1, 2)]); + assert!(g.has_arc(0, 1)); + assert!(g.has_arc(1, 2)); + assert!(!g.has_arc(1, 0)); // Directed: reverse not present + assert!(!g.has_arc(0, 2)); +} + +#[test] +fn test_directed_graph_successors() { + // 0 → 1, 0 → 2, 1 → 2 + let g = DirectedGraph::new(3, vec![(0, 1), (0, 2), (1, 2)]); + let mut succ0 = g.successors(0); + succ0.sort(); + assert_eq!(succ0, vec![1, 2]); + let mut succ1 = g.successors(1); + succ1.sort(); + assert_eq!(succ1, vec![2]); + assert_eq!(g.successors(2), Vec::::new()); +} + +#[test] +fn test_directed_graph_predecessors() { + // 0 → 1, 0 → 2, 1 → 2 + let g = DirectedGraph::new(3, vec![(0, 1), (0, 2), (1, 2)]); + assert_eq!(g.predecessors(0), Vec::::new()); + let mut pred2 = g.predecessors(2); + pred2.sort(); + assert_eq!(pred2, vec![0, 1]); + assert_eq!(g.predecessors(1), vec![0]); +} + +#[test] +fn test_directed_graph_is_dag_true() { + // Simple path: 0 → 1 → 2 + let g = DirectedGraph::new(3, vec![(0, 1), (1, 2)]); + assert!(g.is_dag()); +} + +#[test] +fn test_directed_graph_is_dag_false() { + // Cycle: 0 → 1 → 2 → 0 + let g = DirectedGraph::new(3, vec![(0, 1), (1, 2), (2, 0)]); + assert!(!g.is_dag()); +} + +#[test] +fn test_directed_graph_is_dag_empty() { + let g = DirectedGraph::empty(4); + assert!(g.is_dag()); +} + +#[test] +fn test_directed_graph_is_dag_self_loop() { + // Self-loop is a cycle + let g = DirectedGraph::new(2, vec![(0, 0)]); + assert!(!g.is_dag()); +} + +#[test] +fn test_directed_graph_induced_subgraph_basic() { + // 0 → 1 → 2 → 0 (cycle), keep vertices 0 and 1 (drop 2) + let g = DirectedGraph::new(3, vec![(0, 1), (1, 2), (2, 0)]); + let subg = g.induced_subgraph(&[true, true, false]); + // After dropping vertex 2: vertices 0 and 1 remain, arc (0→1) remains + // Vertex remapping: 0→0, 1→1 + assert_eq!(subg.num_vertices(), 2); + assert_eq!(subg.num_arcs(), 1); + assert!(subg.has_arc(0, 1)); + // Cycle is broken + assert!(subg.is_dag()); +} + +#[test] +fn test_directed_graph_induced_subgraph_remapping() { + // Vertices 0, 1, 2, 3; keep 1 and 3 only + // Arcs: 1 → 3 + let g = DirectedGraph::new(4, vec![(0, 1), (1, 3), (2, 0)]); + let subg = g.induced_subgraph(&[false, true, false, true]); + // Vertex 1 → new index 0, vertex 3 → new index 1 + assert_eq!(subg.num_vertices(), 2); + assert_eq!(subg.num_arcs(), 1); + assert!(subg.has_arc(0, 1)); // was 1 → 3 +} + +#[test] +fn test_directed_graph_induced_subgraph_no_cross_arcs() { + // Keep a subset that has no arcs between kept vertices + let g = DirectedGraph::new(3, vec![(0, 2), (1, 2)]); + // Keep 0 and 1 only — neither arc (0→2) nor (1→2) is kept (2 dropped) + let subg = g.induced_subgraph(&[true, true, false]); + assert_eq!(subg.num_vertices(), 2); + assert_eq!(subg.num_arcs(), 0); +} + +#[test] +fn test_directed_graph_eq_same_order() { + let g1 = DirectedGraph::new(3, vec![(0, 1), (1, 2)]); + let g2 = DirectedGraph::new(3, vec![(0, 1), (1, 2)]); + assert_eq!(g1, g2); +} + +#[test] +fn test_directed_graph_eq_different_arc_order() { + // Same arcs, provided in different order + let g1 = DirectedGraph::new(3, vec![(0, 1), (1, 2), (2, 0)]); + let g2 = DirectedGraph::new(3, vec![(2, 0), (0, 1), (1, 2)]); + assert_eq!(g1, g2); +} + +#[test] +fn test_directed_graph_ne_different_arcs() { + let g1 = DirectedGraph::new(3, vec![(0, 1)]); + let g2 = DirectedGraph::new(3, vec![(1, 0)]); // Reversed direction + assert_ne!(g1, g2); +} + +#[test] +fn test_directed_graph_ne_different_vertices() { + let g1 = DirectedGraph::new(3, vec![(0, 1)]); + let g2 = DirectedGraph::new(4, vec![(0, 1)]); + assert_ne!(g1, g2); +} + +#[test] +fn test_directed_graph_serialization() { + let g = DirectedGraph::new(4, vec![(0, 1), (1, 2), (2, 3), (3, 0)]); + let json = serde_json::to_string(&g).expect("serialization failed"); + let restored: DirectedGraph = serde_json::from_str(&json).expect("deserialization failed"); + assert_eq!(g, restored); +} + +#[test] +#[should_panic(expected = "arc (0, 5) references vertex >= num_vertices")] +fn test_directed_graph_invalid_arc() { + DirectedGraph::new(3, vec![(0, 5)]); +} From 7a96f789ce31aca02e41c6e36361cf97ea059199 Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Thu, 12 Mar 2026 19:53:34 +0800 Subject: [PATCH 3/8] feat: add MinimumFeedbackVertexSet model (#140) Implements the Minimum Feedback Vertex Set problem on directed graphs. Includes evaluate logic via induced subgraph DAG check, unit weight and weighted variants, BruteForce solver compatibility, and full unit test coverage (7 tests, including solver finding 18 optimal solutions). Co-Authored-By: Claude Sonnet 4.6 --- src/lib.rs | 2 +- .../graph/minimum_feedback_vertex_set.rs | 161 ++++++++++++++++++ src/models/graph/mod.rs | 3 + src/models/mod.rs | 3 +- .../graph/minimum_feedback_vertex_set.rs | 140 +++++++++++++++ 5 files changed, 307 insertions(+), 2 deletions(-) create mode 100644 src/models/graph/minimum_feedback_vertex_set.rs create mode 100644 src/unit_tests/models/graph/minimum_feedback_vertex_set.rs diff --git a/src/lib.rs b/src/lib.rs index b0d99699a..2dc44389b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -43,7 +43,7 @@ pub mod prelude { pub use crate::models::graph::{BicliqueCover, SpinGlass}; pub use crate::models::graph::{ KColoring, MaxCut, MaximalIS, MaximumClique, MaximumIndependentSet, MaximumMatching, - MinimumDominatingSet, MinimumVertexCover, TravelingSalesman, + MinimumDominatingSet, MinimumFeedbackVertexSet, MinimumVertexCover, TravelingSalesman, }; pub use crate::models::misc::{BinPacking, Factoring, Knapsack, PaintShop}; pub use crate::models::set::{MaximumSetPacking, MinimumSetCovering}; diff --git a/src/models/graph/minimum_feedback_vertex_set.rs b/src/models/graph/minimum_feedback_vertex_set.rs new file mode 100644 index 000000000..ca589fa20 --- /dev/null +++ b/src/models/graph/minimum_feedback_vertex_set.rs @@ -0,0 +1,161 @@ +//! Feedback Vertex Set problem implementation. +//! +//! The Feedback Vertex Set problem asks for a minimum weight subset of vertices +//! whose removal makes the directed graph acyclic (a DAG). + +use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::topology::DirectedGraph; +use crate::traits::{OptimizationProblem, Problem}; +use crate::types::{Direction, SolutionSize, WeightElement}; +use num_traits::Zero; +use serde::{Deserialize, Serialize}; + +inventory::submit! { + ProblemSchemaEntry { + name: "MinimumFeedbackVertexSet", + module_path: module_path!(), + description: "Find minimum weight feedback vertex set in a directed graph", + fields: &[ + FieldInfo { name: "graph", type_name: "DirectedGraph", description: "The directed graph G=(V,A)" }, + FieldInfo { name: "weights", type_name: "Vec", description: "Vertex weights w: V -> R" }, + ], + } +} + +/// The Minimum Feedback Vertex Set problem. +/// +/// Given a directed graph G = (V, A) and weights w_v for each vertex, +/// find a subset F ⊆ V such that: +/// - Removing F from G yields a directed acyclic graph (DAG) +/// - The total weight Σ_{v ∈ F} w_v is minimized +/// +/// # Example +/// +/// ``` +/// use problemreductions::models::graph::MinimumFeedbackVertexSet; +/// use problemreductions::topology::DirectedGraph; +/// use problemreductions::{Problem, Solver, BruteForce}; +/// +/// // Simple 3-cycle: 0 → 1 → 2 → 0 +/// let graph = DirectedGraph::new(3, vec![(0, 1), (1, 2), (2, 0)]); +/// let problem = MinimumFeedbackVertexSet::new(graph, vec![1; 3]); +/// +/// let solver = BruteForce::new(); +/// let solutions = solver.find_all_best(&problem); +/// +/// // Any single vertex breaks the cycle +/// assert_eq!(solutions.len(), 3); +/// ``` +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MinimumFeedbackVertexSet { + /// The underlying directed graph. + graph: DirectedGraph, + /// Weights for each vertex. + weights: Vec, +} + +impl MinimumFeedbackVertexSet { + /// Create a Feedback Vertex Set problem from a directed graph with given weights. + pub fn new(graph: DirectedGraph, weights: Vec) -> Self { + assert_eq!( + weights.len(), + graph.num_vertices(), + "weights length must match graph num_vertices" + ); + Self { graph, weights } + } + + /// Get a reference to the underlying directed graph. + pub fn graph(&self) -> &DirectedGraph { + &self.graph + } + + /// Get a reference to the weights slice. + pub fn weights(&self) -> &[W] { + &self.weights + } +} + +impl MinimumFeedbackVertexSet { + /// Check if the problem has non-unit weights. + pub fn is_weighted(&self) -> bool { + !W::IS_UNIT + } + + /// Get the number of vertices in the underlying directed graph. + pub fn num_vertices(&self) -> usize { + self.graph.num_vertices() + } + + /// Get the number of arcs in the underlying directed graph. + pub fn num_arcs(&self) -> usize { + self.graph.num_arcs() + } +} + +impl Problem for MinimumFeedbackVertexSet +where + W: WeightElement + crate::variant::VariantParam, +{ + const NAME: &'static str = "MinimumFeedbackVertexSet"; + type Metric = SolutionSize; + + fn variant() -> Vec<(&'static str, &'static str)> { + crate::variant_params![W] + } + + fn dims(&self) -> Vec { + vec![2; self.graph.num_vertices()] + } + + fn evaluate(&self, config: &[usize]) -> SolutionSize { + // keep[v] = true if vertex v is NOT selected for removal + let keep: Vec = config.iter().map(|&c| c == 0).collect(); + let subgraph = self.graph.induced_subgraph(&keep); + if !subgraph.is_dag() { + return SolutionSize::Invalid; + } + let mut total = W::Sum::zero(); + for (i, &selected) in config.iter().enumerate() { + if selected == 1 { + total += self.weights[i].to_sum(); + } + } + SolutionSize::Valid(total) + } +} + +impl OptimizationProblem for MinimumFeedbackVertexSet +where + W: WeightElement + crate::variant::VariantParam, +{ + type Value = W::Sum; + + fn direction(&self) -> Direction { + Direction::Minimize + } +} + +crate::declare_variants! { + MinimumFeedbackVertexSet => "1.8638^num_vertices", +} + +/// Check if a set of vertices is a feedback vertex set (removing them makes the graph a DAG). +/// +/// # Panics +/// Panics if `selected.len() != graph.num_vertices()`. +#[cfg(test)] +pub(crate) fn is_feedback_vertex_set(graph: &DirectedGraph, selected: &[bool]) -> bool { + assert_eq!( + selected.len(), + graph.num_vertices(), + "selected length must match num_vertices" + ); + // keep = NOT selected + let keep: Vec = selected.iter().map(|&s| !s).collect(); + graph.induced_subgraph(&keep).is_dag() +} + +#[cfg(test)] +#[path = "../../unit_tests/models/graph/minimum_feedback_vertex_set.rs"] +mod tests; diff --git a/src/models/graph/mod.rs b/src/models/graph/mod.rs index 1198f7fbc..d4b33d42b 100644 --- a/src/models/graph/mod.rs +++ b/src/models/graph/mod.rs @@ -5,6 +5,7 @@ //! - [`MaximalIS`]: Maximal independent set //! - [`MinimumVertexCover`]: Minimum weight vertex cover //! - [`MinimumDominatingSet`]: Minimum dominating set +//! - [`MinimumFeedbackVertexSet`]: Minimum weight feedback vertex set in a directed graph //! - [`MaximumClique`]: Maximum weight clique //! - [`MaxCut`]: Maximum cut on weighted graphs //! - [`KColoring`]: K-vertex coloring @@ -21,6 +22,7 @@ pub(crate) mod maximum_clique; pub(crate) mod maximum_independent_set; pub(crate) mod maximum_matching; pub(crate) mod minimum_dominating_set; +pub(crate) mod minimum_feedback_vertex_set; pub(crate) mod minimum_vertex_cover; pub(crate) mod spin_glass; pub(crate) mod traveling_salesman; @@ -33,6 +35,7 @@ pub use maximum_clique::MaximumClique; pub use maximum_independent_set::MaximumIndependentSet; pub use maximum_matching::MaximumMatching; pub use minimum_dominating_set::MinimumDominatingSet; +pub use minimum_feedback_vertex_set::MinimumFeedbackVertexSet; pub use minimum_vertex_cover::MinimumVertexCover; pub use spin_glass::SpinGlass; pub use traveling_salesman::TravelingSalesman; diff --git a/src/models/mod.rs b/src/models/mod.rs index 96b4b79d1..703db384a 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -13,7 +13,8 @@ pub use algebraic::{ClosestVectorProblem, BMF, ILP, QUBO}; pub use formula::{CNFClause, CircuitSAT, KSatisfiability, Satisfiability}; pub use graph::{ BicliqueCover, KColoring, MaxCut, MaximalIS, MaximumClique, MaximumIndependentSet, - MaximumMatching, MinimumDominatingSet, MinimumVertexCover, SpinGlass, TravelingSalesman, + MaximumMatching, MinimumDominatingSet, MinimumFeedbackVertexSet, MinimumVertexCover, SpinGlass, + TravelingSalesman, }; pub use misc::{BinPacking, Factoring, Knapsack, PaintShop}; pub use set::{MaximumSetPacking, MinimumSetCovering}; diff --git a/src/unit_tests/models/graph/minimum_feedback_vertex_set.rs b/src/unit_tests/models/graph/minimum_feedback_vertex_set.rs new file mode 100644 index 000000000..a7ca22dff --- /dev/null +++ b/src/unit_tests/models/graph/minimum_feedback_vertex_set.rs @@ -0,0 +1,140 @@ +use super::is_feedback_vertex_set; +use crate::models::graph::MinimumFeedbackVertexSet; +use crate::solvers::{BruteForce, Solver}; +use crate::topology::DirectedGraph; +use crate::traits::{OptimizationProblem, Problem}; +use crate::types::Direction; + +/// Build the 9-vertex, 15-arc example from the issue. +/// +/// Three triangles: 0→1→2→0, 3→4→5→3, 6→7→8→6 +/// Cross arcs set 1: 1→3, 4→6, 7→0 +/// Cross arcs set 2: 2→5, 5→8, 8→2 +fn example_graph() -> DirectedGraph { + DirectedGraph::new( + 9, + vec![ + // Triangles + (0, 1), + (1, 2), + (2, 0), + (3, 4), + (4, 5), + (5, 3), + (6, 7), + (7, 8), + (8, 6), + // Cross set 1 + (1, 3), + (4, 6), + (7, 0), + // Cross set 2 + (2, 5), + (5, 8), + (8, 2), + ], + ) +} + +#[test] +fn test_minimum_feedback_vertex_set_basic() { + let graph = example_graph(); + let problem = MinimumFeedbackVertexSet::new(graph, vec![1i32; 9]); + + // dims should be [2; 9] + assert_eq!(problem.dims(), vec![2usize; 9]); + + // Valid FVS: {0, 3, 8} → config = [1,0,0,1,0,0,0,0,1] + let config_valid = vec![1, 0, 0, 1, 0, 0, 0, 0, 1]; + let result = problem.evaluate(&config_valid); + assert!(result.is_valid(), "Expected {{0,3,8}} to be a valid FVS"); + assert_eq!(result.unwrap(), 3, "Expected FVS size 3"); + + // Invalid subset {1, 4, 7}: leaves cycle 2→5→8→2 + let config_invalid = vec![0, 1, 0, 0, 1, 0, 0, 1, 0]; + let result2 = problem.evaluate(&config_invalid); + assert!( + !result2.is_valid(), + "Expected {{1,4,7}} to be an invalid FVS (cycle 2→5→8→2 remains)" + ); +} + +#[test] +fn test_minimum_feedback_vertex_set_direction() { + let graph = DirectedGraph::new(3, vec![(0, 1), (1, 2), (2, 0)]); + let problem = MinimumFeedbackVertexSet::new(graph, vec![1i32; 3]); + assert_eq!(problem.direction(), Direction::Minimize); +} + +#[test] +fn test_minimum_feedback_vertex_set_serialization() { + let graph = example_graph(); + let problem = MinimumFeedbackVertexSet::new(graph, vec![1i32; 9]); + + let json = serde_json::to_string(&problem).expect("serialization failed"); + let deserialized: MinimumFeedbackVertexSet = + serde_json::from_str(&json).expect("deserialization failed"); + + assert_eq!(deserialized.graph().num_vertices(), 9); + assert_eq!(deserialized.graph().num_arcs(), 15); + assert_eq!(deserialized.weights(), problem.weights()); +} + +#[test] +fn test_minimum_feedback_vertex_set_solver() { + let graph = example_graph(); + let problem = MinimumFeedbackVertexSet::new(graph, vec![1i32; 9]); + + let solver = BruteForce::new(); + let best = solver.find_best(&problem); + assert!(best.is_some(), "Expected a solution to exist"); + let best_config = best.unwrap(); + let best_result = problem.evaluate(&best_config); + assert!(best_result.is_valid()); + assert_eq!(best_result.unwrap(), 3, "Expected optimal FVS size 3"); + + let all_best = BruteForce::new().find_all_best(&problem); + assert_eq!(all_best.len(), 18, "Expected 18 optimal FVS solutions"); +} + +#[test] +fn test_minimum_feedback_vertex_set_dag() { + // A DAG: 0 → 1 → 2 + let graph = DirectedGraph::new(3, vec![(0, 1), (1, 2)]); + let problem = MinimumFeedbackVertexSet::new(graph, vec![1i32; 3]); + + // Empty set (all zeros) is a valid FVS — graph is already a DAG + let config_empty = vec![0, 0, 0]; + let result = problem.evaluate(&config_empty); + assert!(result.is_valid(), "Empty FVS should be valid for a DAG"); + assert_eq!(result.unwrap(), 0); +} + +#[test] +fn test_minimum_feedback_vertex_set_all_selected() { + // Selecting all vertices always yields a valid (but suboptimal) FVS + let graph = example_graph(); + let problem = MinimumFeedbackVertexSet::new(graph, vec![1i32; 9]); + + let config_all = vec![1usize; 9]; + let result = problem.evaluate(&config_all); + assert!(result.is_valid(), "Selecting all vertices should be valid"); + assert_eq!(result.unwrap(), 9); +} + +#[test] +fn test_is_feedback_vertex_set_helper() { + let graph = example_graph(); + + // {0, 3, 8} is a valid FVS + let selected = [true, false, false, true, false, false, false, false, true]; + assert!(is_feedback_vertex_set(&graph, &selected)); + + // {1, 4, 7} is NOT a valid FVS (cycle 2→5→8→2 remains) + let not_fvs = [false, true, false, false, true, false, false, true, false]; + assert!(!is_feedback_vertex_set(&graph, ¬_fvs)); + + // Empty set is not a valid FVS for the cyclic graph + let empty = [false; 9]; + assert!(!is_feedback_vertex_set(&graph, &empty)); +} From a813bc838c09203e5cf18e628a4fe988b1c9d051 Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Thu, 12 Mar 2026 19:58:10 +0800 Subject: [PATCH 4/8] feat: register MinimumFeedbackVertexSet in CLI (dispatch, create, aliases) Adds CLI support for MinimumFeedbackVertexSet: - dispatch.rs: load/serialize via deser_opt/try_ser for MinimumFeedbackVertexSet - create.rs: new --arcs flag parses directed arc lists ("0>1,1>2,2>0"), constructs DirectedGraph + MinimumFeedbackVertexSet - problem_name.rs: FVS alias and lowercase minimumfeedbackvertexset -> MinimumFeedbackVertexSet - cli.rs: --arcs flag on CreateArgs, all_data_flags_empty check, help table entry and example Co-Authored-By: Claude Sonnet 4.6 --- problemreductions-cli/src/cli.rs | 7 +++- problemreductions-cli/src/commands/create.rs | 39 +++++++++++++++++++- problemreductions-cli/src/dispatch.rs | 2 + problemreductions-cli/src/problem_name.rs | 2 + 4 files changed, 48 insertions(+), 2 deletions(-) diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index 91e9bd252..6813f39d2 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -216,6 +216,7 @@ Flags by problem type: BicliqueCover --left, --right, --biedges, --k BMF --matrix (0/1), --rank CVP --basis, --target-vec [--bounds] + MinFVS/FVS --arcs [--weights] [--num-vertices] ILP, CircuitSAT (via reduction only) Geometry graph variants (use slash notation, e.g., MIS/KingsSubgraph): @@ -231,7 +232,8 @@ Examples: pred create QUBO --matrix \"1,0.5;0.5,2\" pred create MIS/KingsSubgraph --positions \"0,0;1,0;1,1;0,1\" pred create MIS/UnitDiskGraph --positions \"0,0;1,0;0.5,0.8\" --radius 1.5 - pred create MIS --random --num-vertices 10 --edge-prob 0.3")] + pred create MIS --random --num-vertices 10 --edge-prob 0.3 + pred create FVS --arcs \"0>1,1>2,2>0\" --weights 1,1,1")] pub struct CreateArgs { /// Problem type (e.g., MIS, QUBO, SAT) #[arg(value_parser = crate::problem_name::ProblemNameParser)] @@ -326,6 +328,9 @@ pub struct CreateArgs { /// Variable bounds for CVP as "lower,upper" (e.g., "-10,10") [default: -10,10] #[arg(long, allow_hyphen_values = true)] pub bounds: Option, + /// Directed arcs for directed graph problems (e.g., 0>1,1>2,2>0) + #[arg(long)] + pub arcs: Option, } #[derive(clap::Args)] diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index 3594a24a0..dfafeffc9 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -9,7 +9,8 @@ use problemreductions::models::misc::{BinPacking, PaintShop}; use problemreductions::prelude::*; use problemreductions::registry::collect_schemas; use problemreductions::topology::{ - BipartiteGraph, Graph, KingsSubgraph, SimpleGraph, TriangularSubgraph, UnitDiskGraph, + BipartiteGraph, DirectedGraph, Graph, KingsSubgraph, SimpleGraph, TriangularSubgraph, + UnitDiskGraph, }; use serde::Serialize; use std::collections::BTreeMap; @@ -45,6 +46,7 @@ fn all_data_flags_empty(args: &CreateArgs) -> bool { && args.basis.is_none() && args.target_vec.is_none() && args.bounds.is_none() + && args.arcs.is_none() } fn type_format_hint(type_name: &str, graph_type: Option<&str>) -> &'static str { @@ -442,6 +444,41 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { ) } + // MinimumFeedbackVertexSet + "MinimumFeedbackVertexSet" => { + let arcs_str = args.arcs.as_ref().ok_or_else(|| { + anyhow::anyhow!( + "MinimumFeedbackVertexSet requires --arcs\n\n\ + Usage: pred create FVS --arcs \"0>1,1>2,2>0\" [--weights 1,1,1] [--num-vertices N]" + ) + })?; + let arcs: Vec<(usize, usize)> = arcs_str + .split(',') + .map(|s| { + let parts: Vec<&str> = s.split('>').collect(); + anyhow::ensure!( + parts.len() == 2, + "Invalid arc format '{}', expected 'u>v'", + s + ); + Ok((parts[0].trim().parse::()?, parts[1].trim().parse::()?)) + }) + .collect::>>()?; + let num_v = arcs + .iter() + .flat_map(|&(u, v)| [u, v]) + .max() + .map(|m| m + 1) + .unwrap_or(0); + let num_v = args.num_vertices.unwrap_or(num_v); + let graph = DirectedGraph::new(num_v, arcs); + let weights = parse_vertex_weights(args, num_v)?; + ( + ser(MinimumFeedbackVertexSet::new(graph, weights))?, + resolved_variant.clone(), + ) + } + _ => bail!("{}", crate::problem_name::unknown_problem_error(canonical)), }; diff --git a/problemreductions-cli/src/dispatch.rs b/problemreductions-cli/src/dispatch.rs index 7a8498421..d04817018 100644 --- a/problemreductions-cli/src/dispatch.rs +++ b/problemreductions-cli/src/dispatch.rs @@ -245,6 +245,7 @@ pub fn load_problem( _ => deser_opt::>(data), }, "Knapsack" => deser_opt::(data), + "MinimumFeedbackVertexSet" => deser_opt::>(data), _ => bail!("{}", crate::problem_name::unknown_problem_error(&canonical)), } } @@ -305,6 +306,7 @@ pub fn serialize_any_problem( _ => try_ser::>(any), }, "Knapsack" => try_ser::(any), + "MinimumFeedbackVertexSet" => try_ser::>(any), _ => bail!("{}", crate::problem_name::unknown_problem_error(&canonical)), } } diff --git a/problemreductions-cli/src/problem_name.rs b/problemreductions-cli/src/problem_name.rs index acd9b4b59..fa6afc9be 100644 --- a/problemreductions-cli/src/problem_name.rs +++ b/problemreductions-cli/src/problem_name.rs @@ -21,6 +21,7 @@ pub const ALIASES: &[(&str, &str)] = &[ ("TSP", "TravelingSalesman"), ("CVP", "ClosestVectorProblem"), ("MaxMatching", "MaximumMatching"), + ("FVS", "MinimumFeedbackVertexSet"), ]; /// Resolve a short alias to the canonical problem name. @@ -52,6 +53,7 @@ pub fn resolve_alias(input: &str) -> String { "binpacking" => "BinPacking".to_string(), "cvp" | "closestvectorproblem" => "ClosestVectorProblem".to_string(), "knapsack" => "Knapsack".to_string(), + "fvs" | "minimumfeedbackvertexset" => "MinimumFeedbackVertexSet".to_string(), _ => input.to_string(), // pass-through for exact names } } From 33814dfcbac5801553a1c97cb629be39a2107570 Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Thu, 12 Mar 2026 20:05:59 +0800 Subject: [PATCH 5/8] Implement #140: Add MinimumFeedbackVertexSet model - Add DirectedGraph topology type (wraps petgraph DiGraph) - Methods: new, num_vertices, num_arcs, arcs, has_arc, successors, predecessors, is_dag, induced_subgraph - 19 unit tests covering all methods - Add MinimumFeedbackVertexSet problem model - Evaluate: check induced subgraph on unremoved vertices is DAG - Direction: Minimize (sum of weights of removed vertices) - Complexity: O*(1.8638^n) per Razgon (2007) - 7 unit tests including brute-force solver verification - Register in CLI with FVS alias and --arcs flag Co-Authored-By: Claude Opus 4.6 --- problemreductions-cli/src/cli.rs | 2 +- problemreductions-cli/src/commands/create.rs | 5 ++++- src/models/graph/minimum_feedback_vertex_set.rs | 16 ++++++++++++++++ src/topology/mod.rs | 1 + 4 files changed, 22 insertions(+), 2 deletions(-) diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index 6813f39d2..507f02a3f 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -216,7 +216,7 @@ Flags by problem type: BicliqueCover --left, --right, --biedges, --k BMF --matrix (0/1), --rank CVP --basis, --target-vec [--bounds] - MinFVS/FVS --arcs [--weights] [--num-vertices] + FVS --arcs [--weights] [--num-vertices] ILP, CircuitSAT (via reduction only) Geometry graph variants (use slash notation, e.g., MIS/KingsSubgraph): diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index dfafeffc9..eeeca739a 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -461,7 +461,10 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { "Invalid arc format '{}', expected 'u>v'", s ); - Ok((parts[0].trim().parse::()?, parts[1].trim().parse::()?)) + Ok(( + parts[0].trim().parse::()?, + parts[1].trim().parse::()?, + )) }) .collect::>>()?; let num_v = arcs diff --git a/src/models/graph/minimum_feedback_vertex_set.rs b/src/models/graph/minimum_feedback_vertex_set.rs index ca589fa20..86644fbcc 100644 --- a/src/models/graph/minimum_feedback_vertex_set.rs +++ b/src/models/graph/minimum_feedback_vertex_set.rs @@ -74,6 +74,22 @@ impl MinimumFeedbackVertexSet { pub fn weights(&self) -> &[W] { &self.weights } + + /// Set vertex weights. + pub fn set_weights(&mut self, weights: Vec) { + assert_eq!( + weights.len(), + self.graph.num_vertices(), + "weights length must match graph num_vertices" + ); + self.weights = weights; + } + + /// Check if a configuration is a valid feedback vertex set. + pub fn is_valid_solution(&self, config: &[usize]) -> bool { + let keep: Vec = config.iter().map(|&c| c == 0).collect(); + self.graph.induced_subgraph(&keep).is_dag() + } } impl MinimumFeedbackVertexSet { diff --git a/src/topology/mod.rs b/src/topology/mod.rs index 92ebe1c86..bde5fa25b 100644 --- a/src/topology/mod.rs +++ b/src/topology/mod.rs @@ -6,6 +6,7 @@ //! - [`UnitDiskGraph`]: Vertices with 2D positions, edges based on distance //! - [`KingsSubgraph`]: 8-connected grid graph (King's graph) //! - [`TriangularSubgraph`]: Triangular lattice subgraph +//! - [`DirectedGraph`]: Directed graph (for problems like FeedbackVertexSet) mod bipartite_graph; mod directed_graph; From 9b7bf3b7c83a8b87dccd21fa142a1b0c8c5af955 Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Thu, 12 Mar 2026 20:06:06 +0800 Subject: [PATCH 6/8] chore: remove plan file after implementation Co-Authored-By: Claude Opus 4.6 --- .../2026-03-12-minimum-feedback-vertex-set.md | 170 ------------------ 1 file changed, 170 deletions(-) delete mode 100644 docs/plans/2026-03-12-minimum-feedback-vertex-set.md diff --git a/docs/plans/2026-03-12-minimum-feedback-vertex-set.md b/docs/plans/2026-03-12-minimum-feedback-vertex-set.md deleted file mode 100644 index dceef597d..000000000 --- a/docs/plans/2026-03-12-minimum-feedback-vertex-set.md +++ /dev/null @@ -1,170 +0,0 @@ -# Plan: Add MinimumFeedbackVertexSet Model (#140) - -## Overview - -Add the MinimumFeedbackVertexSet problem — one of Karp's 21 NP-complete problems (GT7). This requires new DirectedGraph topology infrastructure since the problem operates on directed graphs, which don't exist in the codebase yet. - -**Problem:** Given a directed graph G = (V, A) with vertex weights, find minimum-weight S ⊆ V such that G[V \ S] is a DAG. - -## Batch 1: DirectedGraph Topology (independent) - -### Task 1.1: Create `src/topology/directed_graph.rs` - -New directed graph struct wrapping `petgraph::graph::DiGraph<(), ()>`. - -**Struct:** -```rust -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct DirectedGraph { - inner: DiGraph<(), ()>, -} -``` - -**Methods:** -- `new(num_vertices: usize, arcs: Vec<(usize, usize)>) -> Self` — constructor with arc validation -- `empty(num_vertices: usize) -> Self` -- `num_vertices(&self) -> usize` -- `num_arcs(&self) -> usize` -- `arcs(&self) -> Vec<(usize, usize)>` — returns all arcs as (source, target) pairs -- `has_arc(&self, u: usize, v: usize) -> bool` — check if arc u→v exists -- `successors(&self, v: usize) -> Vec` — outgoing neighbors -- `predecessors(&self, v: usize) -> Vec` — incoming neighbors -- `is_dag(&self) -> bool` — cycle detection via topological sort (using petgraph's `toposort`) -- `induced_subgraph(&self, keep: &[bool]) -> Self` — subgraph on vertices where keep[v] == true (remaps vertex indices) - -**Does NOT implement the `Graph` trait** (which is for undirected graphs with u < v edge semantics). - -**Implements:** -- `PartialEq`, `Eq` (normalize and compare arc sets) -- `VariantParam` via `impl_variant_param!(DirectedGraph, "graph")` - -**Tests:** `src/unit_tests/topology/directed_graph.rs` linked via `#[cfg(test)] #[path]` - -Test cases: -- Construction and basic queries (num_vertices, num_arcs, arcs, has_arc) -- successors/predecessors correctness -- is_dag: true for DAG, false for graph with cycle -- induced_subgraph: verify removing vertices breaks cycles -- PartialEq: same graph in different arc order should be equal -- Serialization round-trip - -### Task 1.2: Register DirectedGraph in `src/topology/mod.rs` - -Add module declaration and re-export: -```rust -mod directed_graph; -pub use directed_graph::DirectedGraph; -``` - -## Batch 2: MinimumFeedbackVertexSet Model (depends on Batch 1) - -### Task 2.1: Create `src/models/graph/minimum_feedback_vertex_set.rs` - -Follow MinimumDominatingSet pattern but with DirectedGraph instead of generic G. - -**Schema registration:** -```rust -inventory::submit! { - ProblemSchemaEntry { - name: "MinimumFeedbackVertexSet", - module_path: module_path!(), - description: "Find minimum weight feedback vertex set in a directed graph", - fields: &[ - FieldInfo { name: "graph", type_name: "DirectedGraph", description: "The directed graph G=(V,A)" }, - FieldInfo { name: "weights", type_name: "Vec", description: "Vertex weights w: V -> R" }, - ], - } -} -``` - -**Struct:** `MinimumFeedbackVertexSet` with fields `graph: DirectedGraph`, `weights: Vec` - -**Constructor & getters:** -- `new(graph: DirectedGraph, weights: Vec)` — assert weights.len() == num_vertices -- `graph(&self) -> &DirectedGraph` -- `weights(&self) -> &[W]` -- `is_weighted(&self) -> bool` — via `W::IS_UNIT` - -**NumericSize getters** (for overhead expressions): -- `num_vertices(&self) -> usize` — graph.num_vertices() -- `num_arcs(&self) -> usize` — graph.num_arcs() - -**evaluate logic:** -1. Build induced subgraph on vertices where `config[v] == 0` (not selected for removal) -2. Check if induced subgraph `is_dag()` — if not, return `SolutionSize::Invalid` -3. Sum weights of selected vertices (config[v] == 1) → `SolutionSize::Valid(total)` - -**Trait impls:** -- `Problem` with `NAME = "MinimumFeedbackVertexSet"`, `Metric = SolutionSize`, `variant() = variant_params![W]`, `dims() = vec![2; n]` -- `OptimizationProblem` with `Value = W::Sum`, `direction() = Minimize` - -**Variant complexity:** -```rust -crate::declare_variants! { - MinimumFeedbackVertexSet => "1.8638^num_vertices", -} -``` -Based on Razgon (2007), "Computing Minimum Directed Feedback Vertex Set in O*(1.9977^n)". Note: the issue cites 1.8638^n which comes from later improvements. Use the issue's value. - -**Helper function:** -```rust -#[cfg(test)] -pub(crate) fn is_feedback_vertex_set(graph: &DirectedGraph, selected: &[bool]) -> bool -``` - -**Test link:** `#[cfg(test)] #[path = "../../unit_tests/models/graph/minimum_feedback_vertex_set.rs"] mod tests;` - -### Task 2.2: Register in module hierarchy - -1. `src/models/graph/mod.rs` — add `pub(crate) mod minimum_feedback_vertex_set;` and `pub use minimum_feedback_vertex_set::MinimumFeedbackVertexSet;` -2. `src/models/mod.rs` — add `MinimumFeedbackVertexSet` to graph re-exports -3. `src/lib.rs` prelude — add `MinimumFeedbackVertexSet` to prelude exports - -### Task 2.3: Write unit tests - -Create `src/unit_tests/models/graph/minimum_feedback_vertex_set.rs`: - -Test cases: -- `test_minimum_feedback_vertex_set_basic` — create instance with issue's example (9 vertices, 15 arcs), verify dims=[2;9], evaluate valid FVS {0,3,8} returns Valid(3), evaluate invalid subset returns Invalid -- `test_minimum_feedback_vertex_set_direction` — verify Minimize -- `test_minimum_feedback_vertex_set_serialization` — round-trip serde -- `test_minimum_feedback_vertex_set_solver` — brute force finds optimal FVS of size 3 for the example -- `test_minimum_feedback_vertex_set_empty_set` — empty set is only valid FVS if graph is already a DAG -- `test_minimum_feedback_vertex_set_trivial` — selecting all vertices is always valid (but not optimal) - -## Batch 3: CLI Registration (depends on Batch 2) - -### Task 3.1: Update `problemreductions-cli/src/dispatch.rs` - -Add imports and match arms: -- `load_problem`: `"MinimumFeedbackVertexSet" => deser_opt::>(data)` -- `serialize_any_problem`: `try_ser::>` - -### Task 3.2: Update `problemreductions-cli/src/problem_name.rs` - -Add lowercase alias: `"minimumfeedbackvertexset" => "MinimumFeedbackVertexSet"` -Add standard abbreviation: `("FVS", "MinimumFeedbackVertexSet")` to ALIASES array (FVS is well-established in the literature). - -### Task 3.3: Update `problemreductions-cli/src/commands/create.rs` - -Add a new match arm for MinimumFeedbackVertexSet: -- Parse `--arcs` flag (new flag for directed edges, format: "0>1,1>2,2>0") -- Parse `--weights` flag (reuse existing) -- Parse `--num-vertices` for `--random` mode -- Construct `DirectedGraph` and `MinimumFeedbackVertexSet` - -### Task 3.4: Update `problemreductions-cli/src/cli.rs` - -1. Add `--arcs` flag to `CreateArgs`: `pub arcs: Option` with help "Directed arcs (e.g., 0>1,1>2,2>0)" -2. Update `all_data_flags_empty()` to include `args.arcs.is_none()` -3. Add to "Flags by problem type" help table: `MinFVS/FVS --arcs, --weights` - -## Batch 4: Verification (depends on all above) - -### Task 4.1: Run `make check` - -Run `make fmt && make clippy && make test` — all must pass. - -### Task 4.2: Run `make export-schemas` - -Regenerate problem schemas to include the new problem type. From 5cd3cc6bcc5b5381f04734f3c61141e53be60ae7 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Thu, 12 Mar 2026 21:54:09 +0800 Subject: [PATCH 7/8] fix: address Copilot review comments for MinimumFeedbackVertexSet - Fix doc reference: FeedbackVertexSet -> MinimumFeedbackVertexSet - Add config length checks in is_valid_solution() and evaluate() to return false/Invalid instead of panicking on malformed input - Add CLI validation for --num-vertices vs arc indices to return a structured error instead of panicking Co-Authored-By: Claude Opus 4.6 --- problemreductions-cli/src/commands/create.rs | 16 ++++++++++++++-- src/models/graph/minimum_feedback_vertex_set.rs | 6 ++++++ src/topology/mod.rs | 2 +- 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index eeeca739a..f393f55aa 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -467,13 +467,25 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { )) }) .collect::>>()?; - let num_v = arcs + let inferred_num_v = arcs .iter() .flat_map(|&(u, v)| [u, v]) .max() .map(|m| m + 1) .unwrap_or(0); - let num_v = args.num_vertices.unwrap_or(num_v); + let num_v = match args.num_vertices { + Some(user_num_v) => { + anyhow::ensure!( + user_num_v >= inferred_num_v, + "--num-vertices ({}) is too small for the arcs: need at least {} to cover vertices up to {}", + user_num_v, + inferred_num_v, + inferred_num_v.saturating_sub(1), + ); + user_num_v + } + None => inferred_num_v, + }; let graph = DirectedGraph::new(num_v, arcs); let weights = parse_vertex_weights(args, num_v)?; ( diff --git a/src/models/graph/minimum_feedback_vertex_set.rs b/src/models/graph/minimum_feedback_vertex_set.rs index 86644fbcc..3c8fd8f11 100644 --- a/src/models/graph/minimum_feedback_vertex_set.rs +++ b/src/models/graph/minimum_feedback_vertex_set.rs @@ -87,6 +87,9 @@ impl MinimumFeedbackVertexSet { /// Check if a configuration is a valid feedback vertex set. pub fn is_valid_solution(&self, config: &[usize]) -> bool { + if config.len() != self.graph.num_vertices() { + return false; + } let keep: Vec = config.iter().map(|&c| c == 0).collect(); self.graph.induced_subgraph(&keep).is_dag() } @@ -125,6 +128,9 @@ where } fn evaluate(&self, config: &[usize]) -> SolutionSize { + if config.len() != self.graph.num_vertices() { + return SolutionSize::Invalid; + } // keep[v] = true if vertex v is NOT selected for removal let keep: Vec = config.iter().map(|&c| c == 0).collect(); let subgraph = self.graph.induced_subgraph(&keep); diff --git a/src/topology/mod.rs b/src/topology/mod.rs index bde5fa25b..3d7be152d 100644 --- a/src/topology/mod.rs +++ b/src/topology/mod.rs @@ -6,7 +6,7 @@ //! - [`UnitDiskGraph`]: Vertices with 2D positions, edges based on distance //! - [`KingsSubgraph`]: 8-connected grid graph (King's graph) //! - [`TriangularSubgraph`]: Triangular lattice subgraph -//! - [`DirectedGraph`]: Directed graph (for problems like FeedbackVertexSet) +//! - [`DirectedGraph`]: Directed graph (for problems like `MinimumFeedbackVertexSet`) mod bipartite_graph; mod directed_graph; From 6ba6485da942add35672c3f8958b1fc898f2c613 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Thu, 12 Mar 2026 22:00:11 +0800 Subject: [PATCH 8/8] test: add coverage tests for MinimumFeedbackVertexSet accessors and guards Cover set_weights, is_weighted, num_vertices, num_arcs, variant, is_valid_solution (including wrong-length guard), and evaluate wrong-length guard. Co-Authored-By: Claude Opus 4.6 --- .../graph/minimum_feedback_vertex_set.rs | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/src/unit_tests/models/graph/minimum_feedback_vertex_set.rs b/src/unit_tests/models/graph/minimum_feedback_vertex_set.rs index a7ca22dff..7e6ee138d 100644 --- a/src/unit_tests/models/graph/minimum_feedback_vertex_set.rs +++ b/src/unit_tests/models/graph/minimum_feedback_vertex_set.rs @@ -122,6 +122,48 @@ fn test_minimum_feedback_vertex_set_all_selected() { assert_eq!(result.unwrap(), 9); } +#[test] +fn test_minimum_feedback_vertex_set_accessors() { + let graph = DirectedGraph::new(3, vec![(0, 1), (1, 2), (2, 0)]); + let mut problem = MinimumFeedbackVertexSet::new(graph, vec![1i32; 3]); + + assert_eq!(problem.num_vertices(), 3); + assert_eq!(problem.num_arcs(), 3); + assert!(problem.is_weighted()); + + // set_weights + problem.set_weights(vec![2, 3, 4]); + assert_eq!(problem.weights(), &[2, 3, 4]); +} + +#[test] +fn test_minimum_feedback_vertex_set_is_valid_solution() { + let graph = DirectedGraph::new(3, vec![(0, 1), (1, 2), (2, 0)]); + let problem = MinimumFeedbackVertexSet::new(graph, vec![1i32; 3]); + + // Valid FVS: remove vertex 0 + assert!(problem.is_valid_solution(&[1, 0, 0])); + // Invalid: no vertices removed, cycle remains + assert!(!problem.is_valid_solution(&[0, 0, 0])); + // Wrong length returns false + assert!(!problem.is_valid_solution(&[1, 0])); +} + +#[test] +fn test_minimum_feedback_vertex_set_evaluate_wrong_length() { + let graph = DirectedGraph::new(3, vec![(0, 1), (1, 2), (2, 0)]); + let problem = MinimumFeedbackVertexSet::new(graph, vec![1i32; 3]); + + // Wrong length config returns Invalid + assert!(!problem.evaluate(&[1, 0]).is_valid()); +} + +#[test] +fn test_minimum_feedback_vertex_set_variant() { + let v = as Problem>::variant(); + assert_eq!(v, vec![("weight", "i32")]); +} + #[test] fn test_is_feedback_vertex_set_helper() { let graph = example_graph();