diff --git a/.claude/skills/add-model/SKILL.md b/.claude/skills/add-model/SKILL.md index 4dca3b80..670dd5c0 100644 --- a/.claude/skills/add-model/SKILL.md +++ b/.claude/skills/add-model/SKILL.md @@ -25,7 +25,7 @@ Before any implementation, collect all required information. If called from `iss | 8 | **Objective function** | How to compute the metric | "Sum of weights of selected vertices" | | 9 | **Best known exact algorithm** | Complexity with variable definitions | "O(1.1996^n) by Xiao & Nagamochi (2017), where n = \|V\|" | | 10 | **Solving strategy** | How it can be solved | "BruteForce works; ILP reduction available" | -| 11 | **Category** | Which sub-module under `src/models/` | `graph`, `optimization`, `satisfiability`, `set`, `specialized` | +| 11 | **Category** | Which sub-module under `src/models/` | `graph`, `formula`, `set`, `algebraic`, `misc` | If any item is missing, ask the user to provide it. Do NOT proceed until the checklist is complete. @@ -33,7 +33,7 @@ If any item is missing, ask the user to provide it. Do NOT proceed until the che Read these first to understand the patterns: - **Optimization problem:** `src/models/graph/maximum_independent_set.rs` -- **Satisfaction problem:** `src/models/satisfiability/sat.rs` +- **Satisfaction problem:** `src/models/formula/sat.rs` - **Model tests:** `src/unit_tests/models/graph/maximum_independent_set.rs` - **Trait definitions:** `src/traits.rs` (`Problem`, `OptimizationProblem`, `SatisfactionProblem`) - **CLI dispatch:** `problemreductions-cli/src/dispatch.rs` @@ -42,11 +42,11 @@ Read these first to understand the patterns: ## Step 1: Determine the category Choose the appropriate sub-module under `src/models/`: -- `graph/` -- problems defined on graphs (vertex/edge selection) -- `optimization/` -- generic optimization formulations (QUBO, ILP, SpinGlass) -- `satisfiability/` -- boolean satisfaction problems (SAT, k-SAT) +- `graph/` -- problems defined on graphs (vertex/edge selection, SpinGlass, etc.) +- `formula/` -- logical formulas and circuits (SAT, k-SAT, CircuitSAT) - `set/` -- set-based problems (set packing, set cover) -- `specialized/` -- problems that don't fit other categories (factoring, circuit, paintshop) +- `algebraic/` -- matrices, linear systems, lattices (QUBO, ILP, CVP, BMF) +- `misc/` -- unique input structures that don't fit other categories (BinPacking, PaintShop, Factoring) ## Step 1.5: Infer problem size getters diff --git a/.claude/skills/review-implementation/SKILL.md b/.claude/skills/review-implementation/SKILL.md index ed8a4467..09ac3b00 100644 --- a/.claude/skills/review-implementation/SKILL.md +++ b/.claude/skills/review-implementation/SKILL.md @@ -63,13 +63,14 @@ if [ -n "$PR_NUM" ]; then ISSUE_NUM=$(gh pr view $PR_NUM --json body -q .body | grep -oE '#[0-9]+' | head -1 | tr -d '#') fi -# Fetch the issue body if found +# Fetch the issue body and comments if found if [ -n "$ISSUE_NUM" ]; then ISSUE_BODY=$(gh issue view $ISSUE_NUM --json title,body -q '"# " + .title + "\n\n" + .body') + ISSUE_COMMENTS=$(gh issue view $ISSUE_NUM --json comments -q '.comments[] | "**" + .author.login + "** (" + .createdAt + "):\n" + .body + "\n"') fi ``` -If an issue is found, pass it as `{ISSUE_CONTEXT}` to both subagents. If not, set `{ISSUE_CONTEXT}` to "No linked issue found." +If an issue is found, pass `{ISSUE_CONTEXT}` (title + body + comments) to both subagents. If not, set `{ISSUE_CONTEXT}` to "No linked issue found." Comments often contain clarifications, corrections, or additional requirements from maintainers. ## Step 3: Dispatch Subagents in Parallel @@ -83,7 +84,7 @@ Dispatch using `Agent` tool with `subagent_type="superpowers:code-reviewer"`: - `{REVIEW_PARAMS}` -> summary of what's being reviewed - `{PROBLEM_NAME}`, `{CATEGORY}`, `{FILE_STEM}` -> for model reviews - `{SOURCE}`, `{TARGET}`, `{RULE_STEM}`, `{EXAMPLE_STEM}` -> for rule reviews - - `{ISSUE_CONTEXT}` -> full issue title + body (or "No linked issue found.") + - `{ISSUE_CONTEXT}` -> full issue title + body + comments (or "No linked issue found.") - Prompt = filled template ### Quality Reviewer (always) @@ -96,7 +97,7 @@ Dispatch using `Agent` tool with `subagent_type="superpowers:code-reviewer"`: - `{CHANGED_FILES}` -> list of changed files - `{PLAN_STEP}` -> description of what was implemented (or "standalone review") - `{BASE_SHA}`, `{HEAD_SHA}` -> git range - - `{ISSUE_CONTEXT}` -> full issue title + body (or "No linked issue found.") + - `{ISSUE_CONTEXT}` -> full issue title + body + comments (or "No linked issue found.") - Prompt = filled template **Both subagents must be dispatched in parallel** (single message with two Agent tool calls — use `run_in_background: true` on one, foreground on the other, then read the background result with `TaskOutput`). diff --git a/docs/agent-profiles/pred-sym-prof-yuki-tanaka.md b/docs/agent-profiles/pred-sym-prof-yuki-tanaka.md new file mode 100644 index 00000000..10ad103e --- /dev/null +++ b/docs/agent-profiles/pred-sym-prof-yuki-tanaka.md @@ -0,0 +1,27 @@ +# pred-sym-prof-yuki-tanaka + +## Target +pred-sym (symbolic expression CLI) + +## Use Case +Three combined scenarios: +1. **Complexity comparison** — Compare algorithm complexity expressions to determine asymptotic equivalence (e.g., O(n^2 + n) == O(n^2), O(n log n) != O(n^2)). +2. **Reduction overhead audit** — Parse and simplify overhead expressions from reduction rules to verify they match expected growth (e.g., '3*num_vertices + num_edges^2'). +3. **Teaching complexity notation** — Use pred-sym as a learning/demonstration tool to explore how expressions simplify, evaluate at concrete sizes, and compare growth rates. + +## Expected Outcome +All subcommands (parse, canon, big-o, eval, compare) produce mathematically correct, clear output. Canonical and Big-O forms are rigorous. Edge cases (zero exponents, nested functions, multi-variable expressions) are handled gracefully with precise error messages. + +## Agent + +### Background +Prof. Yuki Tanaka is a theoretical computer science professor at a research university, specializing in computational complexity and approximation algorithms. She regularly teaches graduate courses on NP-hard problems and writes textbooks. She evaluates tools against textbook definitions and mathematical rigor standards. + +### Experience Level +Expert + +### Decision Tendencies +Stress-tests edge cases systematically — zero, negative, nested, degenerate inputs. Expects mathematically rigorous output and will flag any algebraically incorrect simplification. Compares results against textbook definitions of canonical forms and asymptotic notation. Tests all subcommands in sequence, then tries to compose them in shell pipelines. + +### Quirks +Will try expressions with Unicode math symbols to see what happens. Tests the boundary between polynomial and exponential complexity deliberately. Expects `--help` to be precise and well-organized — gets annoyed by imprecise language like "simplify" when "canonicalize" is meant. Will attempt to pipe output of one subcommand into another. diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index a30d1b73..ab67b7c1 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -54,6 +54,7 @@ "BinPacking": [Bin Packing], "ClosestVectorProblem": [Closest Vector Problem], "SubsetSum": [Subset Sum], + "MinimumFeedbackVertexSet": [Minimum Feedback Vertex Set], ) // Definition label: "def:" — each definition block must have a matching label @@ -537,6 +538,37 @@ caption: [Path $P_5$ with maximal IS $S = {v_1, v_3}$ (blue, $w(S) = 2$). $S$ is ) ] +#problem-def("MinimumFeedbackVertexSet")[ + Given a directed graph $G = (V, A)$ with vertex weights $w: V -> RR$, find $S subset.eq V$ minimizing $sum_(v in S) w(v)$ such that the induced subgraph $G[V backslash S]$ is a directed acyclic graph (DAG). +][ +One of Karp's 21 NP-complete problems ("Feedback Node Set") @karp1972. Applications include deadlock detection in operating systems, loop breaking in circuit design, and Bayesian network structure learning. The directed version is strictly harder than undirected FVS: the best known exact algorithm runs in $O^*(1.9977^n)$ @razgon2007, compared to $O^*(1.7548^n)$ for undirected graphs. An $O(log n dot log log n)$-approximation exists @even1998. + +*Example.* Consider the directed graph $G$ with $n = 5$ vertices, $|A| = 7$ arcs, and unit weights. The arcs form two overlapping directed cycles: $C_1 = v_0 -> v_1 -> v_2 -> v_0$ and $C_2 = v_0 -> v_3 -> v_4 -> v_1$. The set $S = {v_0}$ with $w(S) = 1$ is a minimum feedback vertex set: removing $v_0$ breaks both cycles, leaving a DAG with topological order $(v_3, v_4, v_1, v_2)$. No 0-vertex set suffices since $C_1$ and $C_2$ overlap only at $v_0$ and $v_1$, and removing $v_1$ alone leaves $C_1' = v_0 -> v_3 -> v_4 -> v_1 -> v_2 -> v_0$. + +#figure({ + // Directed graph: 5 vertices, 7 arcs, two overlapping cycles + let verts = ((0, 1), (2, 1), (1, 0), (-0.5, -0.2), (0.8, -0.5)) + let arcs = ((0, 1), (1, 2), (2, 0), (0, 3), (3, 4), (4, 1), (2, 4)) + let highlights = (0,) // FVS = {v_0} + canvas(length: 1cm, { + // Draw directed arcs with arrows + for (u, v) in arcs { + draw.line(verts.at(u), verts.at(v), + stroke: 1pt + black, + mark: (end: "straight", scale: 0.4)) + } + // Draw nodes on top + for (k, pos) in verts.enumerate() { + let s = highlights.contains(k) + g-node(pos, name: "v" + str(k), + fill: if s { graph-colors.at(0) } else { white }, + label: if s { text(fill: white)[$v_#k$] } else { [$v_#k$] }) + } + }) +}, +caption: [A directed graph with FVS $S = {v_0}$ (blue, $w(S) = 1$). Removing $v_0$ breaks both directed cycles $v_0 -> v_1 -> v_2 -> v_0$ and $v_0 -> v_3 -> v_4 -> v_1$, leaving a DAG.], +) +] == Set Problems diff --git a/docs/paper/references.bib b/docs/paper/references.bib index a68de5c4..0ec874f6 100644 --- a/docs/paper/references.bib +++ b/docs/paper/references.bib @@ -344,6 +344,25 @@ @inproceedings{aggarwal2015 doi = {10.1109/FOCS.2015.41} } +@inproceedings{razgon2007, + author = {Igor Razgon}, + title = {Computing Minimum Directed Feedback Vertex Set in {$O^*(1.9977^n)$}}, + booktitle = {Proceedings of the 10th Italian Conference on Theoretical Computer Science (ICTCS)}, + pages = {70--81}, + year = {2007} +} + +@article{even1998, + author = {Guy Even and Joseph Naor and Baruch Schieber and Madhu Sudan}, + title = {Approximating Minimum Feedback Sets and Multicuts in Directed Graphs}, + journal = {Algorithmica}, + volume = {20}, + number = {2}, + pages = {151--174}, + year = {1998}, + doi = {10.1007/PL00009191} +} + @article{shannon1956, author = {Claude E. Shannon}, title = {The zero error capacity of a noisy channel}, diff --git a/docs/src/reductions/problem_schemas.json b/docs/src/reductions/problem_schemas.json index 4e60ebdc..ef694f9d 100644 --- a/docs/src/reductions/problem_schemas.json +++ b/docs/src/reductions/problem_schemas.json @@ -307,6 +307,22 @@ } ] }, + { + "name": "MinimumFeedbackVertexSet", + "description": "Find minimum weight feedback vertex set in a directed graph", + "fields": [ + { + "name": "graph", + "type_name": "DirectedGraph", + "description": "The directed graph G=(V,A)" + }, + { + "name": "weights", + "type_name": "Vec", + "description": "Vertex weights w: V -> R" + } + ] + }, { "name": "MinimumSetCovering", "description": "Find minimum weight collection covering the universe", diff --git a/docs/src/reductions/reduction_graph.json b/docs/src/reductions/reduction_graph.json index ce418d78..f3213152 100644 --- a/docs/src/reductions/reduction_graph.json +++ b/docs/src/reductions/reduction_graph.json @@ -322,6 +322,15 @@ "doc_path": "models/graph/struct.MinimumDominatingSet.html", "complexity": "1.4969^num_vertices" }, + { + "name": "MinimumFeedbackVertexSet", + "variant": { + "weight": "i32" + }, + "category": "graph", + "doc_path": "models/graph/struct.MinimumFeedbackVertexSet.html", + "complexity": "1.9977^num_vertices" + }, { "name": "MinimumSetCovering", "variant": { @@ -420,7 +429,7 @@ }, { "source": 4, - "target": 41, + "target": 42, "overhead": [ { "field": "num_spins", @@ -480,7 +489,7 @@ }, { "source": 9, - "target": 38, + "target": 39, "overhead": [ { "field": "num_vars", @@ -521,7 +530,7 @@ }, { "source": 15, - "target": 38, + "target": 39, "overhead": [ { "field": "num_vars", @@ -547,7 +556,7 @@ }, { "source": 16, - "target": 38, + "target": 39, "overhead": [ { "field": "num_vars", @@ -573,7 +582,7 @@ }, { "source": 17, - "target": 38, + "target": 39, "overhead": [ { "field": "num_vars", @@ -584,7 +593,7 @@ }, { "source": 17, - "target": 42, + "target": 43, "overhead": [ { "field": "num_elements", @@ -595,7 +604,7 @@ }, { "source": 18, - "target": 39, + "target": 40, "overhead": [ { "field": "num_clauses", @@ -614,7 +623,7 @@ }, { "source": 20, - "target": 41, + "target": 42, "overhead": [ { "field": "num_spins", @@ -764,7 +773,7 @@ }, { "source": 26, - "target": 36, + "target": 37, "overhead": [ { "field": "num_vertices", @@ -899,7 +908,7 @@ }, { "source": 32, - "target": 38, + "target": 39, "overhead": [ { "field": "num_vars", @@ -969,7 +978,7 @@ "doc_path": "rules/minimumdominatingset_ilp/index.html" }, { - "source": 35, + "source": 36, "target": 9, "overhead": [ { @@ -984,7 +993,7 @@ "doc_path": "rules/minimumsetcovering_ilp/index.html" }, { - "source": 36, + "source": 37, "target": 26, "overhead": [ { @@ -999,8 +1008,8 @@ "doc_path": "rules/minimumvertexcover_maximumindependentset/index.html" }, { - "source": 36, - "target": 35, + "source": 37, + "target": 36, "overhead": [ { "field": "num_sets", @@ -1014,7 +1023,7 @@ "doc_path": "rules/minimumvertexcover_minimumsetcovering/index.html" }, { - "source": 38, + "source": 39, "target": 9, "overhead": [ { @@ -1029,8 +1038,8 @@ "doc_path": "rules/qubo_ilp/index.html" }, { - "source": 38, - "target": 40, + "source": 39, + "target": 41, "overhead": [ { "field": "num_spins", @@ -1040,7 +1049,7 @@ "doc_path": "rules/spinglass_qubo/index.html" }, { - "source": 39, + "source": 40, "target": 4, "overhead": [ { @@ -1055,7 +1064,7 @@ "doc_path": "rules/sat_circuitsat/index.html" }, { - "source": 39, + "source": 40, "target": 12, "overhead": [ { @@ -1070,7 +1079,7 @@ "doc_path": "rules/sat_coloring/index.html" }, { - "source": 39, + "source": 40, "target": 17, "overhead": [ { @@ -1085,7 +1094,7 @@ "doc_path": "rules/sat_ksat/index.html" }, { - "source": 39, + "source": 40, "target": 25, "overhead": [ { @@ -1100,7 +1109,7 @@ "doc_path": "rules/sat_maximumindependentset/index.html" }, { - "source": 39, + "source": 40, "target": 34, "overhead": [ { @@ -1115,8 +1124,8 @@ "doc_path": "rules/sat_minimumdominatingset/index.html" }, { - "source": 40, - "target": 38, + "source": 41, + "target": 39, "overhead": [ { "field": "num_vars", @@ -1126,7 +1135,7 @@ "doc_path": "rules/spinglass_qubo/index.html" }, { - "source": 41, + "source": 42, "target": 20, "overhead": [ { @@ -1141,8 +1150,8 @@ "doc_path": "rules/spinglass_maxcut/index.html" }, { - "source": 41, - "target": 40, + "source": 42, + "target": 41, "overhead": [ { "field": "num_spins", @@ -1156,7 +1165,7 @@ "doc_path": "rules/spinglass_casts/index.html" }, { - "source": 43, + "source": 44, "target": 9, "overhead": [ { diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index 44dfa163..91792e20 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -217,6 +217,7 @@ Flags by problem type: BicliqueCover --left, --right, --biedges, --k BMF --matrix (0/1), --rank CVP --basis, --target-vec [--bounds] + FVS --arcs [--weights] [--num-vertices] ILP, CircuitSAT (via reduction only) Geometry graph variants (use slash notation, e.g., MIS/KingsSubgraph): @@ -232,7 +233,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)] @@ -327,6 +329,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 06d460a0..2df4f099 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -10,7 +10,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; @@ -46,6 +47,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 { @@ -457,6 +459,56 @@ 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 inferred_num_v = arcs + .iter() + .flat_map(|&(u, v)| [u, v]) + .max() + .map(|m| m + 1) + .unwrap_or(0); + 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)?; + ( + 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 75bce502..49dd523c 100644 --- a/problemreductions-cli/src/dispatch.rs +++ b/problemreductions-cli/src/dispatch.rs @@ -246,6 +246,7 @@ pub fn load_problem( _ => deser_opt::>(data), }, "Knapsack" => deser_opt::(data), + "MinimumFeedbackVertexSet" => deser_opt::>(data), "SubsetSum" => deser_sat::(data), _ => bail!("{}", crate::problem_name::unknown_problem_error(&canonical)), } @@ -308,6 +309,7 @@ pub fn serialize_any_problem( _ => try_ser::>(any), }, "Knapsack" => try_ser::(any), + "MinimumFeedbackVertexSet" => try_ser::>(any), "SubsetSum" => 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 6baef311..2b6c8c73 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. @@ -53,6 +54,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(), "subsetsum" => "SubsetSum".to_string(), _ => input.to_string(), // pass-through for exact names } diff --git a/src/lib.rs b/src/lib.rs index 599feefa..a74c906f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -43,7 +43,7 @@ pub mod prelude { pub use crate::models::graph::{BicliqueCover, GraphPartitioning, 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, SubsetSum}; 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 00000000..16347981 --- /dev/null +++ b/src/models/graph/minimum_feedback_vertex_set.rs @@ -0,0 +1,183 @@ +//! 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 + } + + /// 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 { + 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() + } +} + +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 { + 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); + 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.9977^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 2e7cb23e..42f46a15 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 //! - [`GraphPartitioning`]: Minimum bisection (balanced graph partitioning) @@ -23,6 +24,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; @@ -36,6 +38,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 ee441c20..ceb584ce 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -13,8 +13,8 @@ pub use algebraic::{ClosestVectorProblem, BMF, ILP, QUBO}; pub use formula::{CNFClause, CircuitSAT, KSatisfiability, Satisfiability}; pub use graph::{ BicliqueCover, GraphPartitioning, KColoring, MaxCut, MaximalIS, MaximumClique, - MaximumIndependentSet, MaximumMatching, MinimumDominatingSet, MinimumVertexCover, SpinGlass, - TravelingSalesman, + MaximumIndependentSet, MaximumMatching, MinimumDominatingSet, MinimumFeedbackVertexSet, + MinimumVertexCover, SpinGlass, TravelingSalesman, }; pub use misc::{BinPacking, Factoring, Knapsack, PaintShop, SubsetSum}; pub use set::{MaximumSetPacking, MinimumSetCovering}; diff --git a/src/topology/directed_graph.rs b/src/topology/directed_graph.rs new file mode 100644 index 00000000..4f83affb --- /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 3e4e64b3..3d7be152 100644 --- a/src/topology/mod.rs +++ b/src/topology/mod.rs @@ -6,8 +6,10 @@ //! - [`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 `MinimumFeedbackVertexSet`) mod bipartite_graph; +mod directed_graph; mod graph; mod kings_subgraph; mod planar_graph; @@ -16,6 +18,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/models/graph/minimum_feedback_vertex_set.rs b/src/unit_tests/models/graph/minimum_feedback_vertex_set.rs new file mode 100644 index 00000000..7e6ee138 --- /dev/null +++ b/src/unit_tests/models/graph/minimum_feedback_vertex_set.rs @@ -0,0 +1,182 @@ +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_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(); + + // {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)); +} diff --git a/src/unit_tests/topology/directed_graph.rs b/src/unit_tests/topology/directed_graph.rs new file mode 100644 index 00000000..3b35cdf4 --- /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)]); +}