diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index 34d0f83b3..bd036ad5c 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -117,6 +117,7 @@ "MinimumMultiwayCut": [Minimum Multiway Cut], "OptimalLinearArrangement": [Optimal Linear Arrangement], "RuralPostman": [Rural Postman], + "MixedChinesePostman": [Mixed Chinese Postman], "LongestCommonSubsequence": [Longest Common Subsequence], "ExactCoverBy3Sets": [Exact Cover by 3-Sets], "SubsetSum": [Subset Sum], @@ -3570,6 +3571,97 @@ A classical NP-complete problem from Garey and Johnson @garey1979[Ch.~3, p.~76], ] } +#{ + let x = load-model-example("MixedChinesePostman", variant: (weight: "i32")) + let nv = x.instance.graph.num_vertices + let arcs = x.instance.graph.arcs + let edges = x.instance.graph.edges + let arc-weights = x.instance.arc_weights + let edge-weights = x.instance.edge_weights + let B = x.instance.bound + let config = x.optimal_config + let oriented = edges.enumerate().map(((i, e)) => if config.at(i) == 0 { e } else { (e.at(1), e.at(0)) }) + let base-cost = arc-weights.sum() + edge-weights.sum() + let total-cost = 22 + [ + #problem-def("MixedChinesePostman")[ + Given a mixed graph $G = (V, A, E)$ with directed arcs $A$, undirected edges $E$, integer lengths $l(e) >= 0$ for every $e in A union E$, and a bound $B in ZZ^+$, determine whether there exists a closed walk in $G$ that traverses every arc in its prescribed direction and every undirected edge at least once in some direction with total length at most $B$. + ][ + Mixed Chinese Postman is the mixed-graph arc-routing problem ND25 in Garey and Johnson @garey1979. Papadimitriou proved the mixed case NP-complete even when all lengths are 1, the graph is planar, and the maximum degree is 3 @papadimitriou1976edge. In contrast, the pure undirected and pure directed cases are polynomial-time solvable via matching / circulation machinery @edmondsjohnson1973. The implementation here uses one binary variable per undirected edge orientation, so the search space contributes the $2^|E|$ factor visible in the registered exact bound. + + *Example.* Consider the instance on #nv vertices with directed arcs $(v_0, v_1)$, $(v_1, v_2)$, $(v_2, v_3)$, $(v_3, v_0)$ of lengths $2, 3, 1, 4$ and undirected edges $\{v_0, v_2\}$, $\{v_1, v_3\}$, $\{v_0, v_4\}$, $\{v_4, v_2\}$ of lengths $2, 3, 1, 2$. The config $(1, 1, 0, 0)$ orients those edges as $(v_2, v_0)$, $(v_3, v_1)$, $(v_0, v_4)$, and $(v_4, v_2)$, producing a strongly connected digraph. The base traversal cost is #base-cost, and duplicating the shortest path $v_1 arrow v_2 arrow v_3$ adds 4 more, so the total cost is $#total-cost <= B = #B$, proving the answer is YES. + + #pred-commands( + "pred create --example MixedChinesePostman/i32 -o mixed-chinese-postman.json", + "pred solve mixed-chinese-postman.json --solver brute-force", + "pred evaluate mixed-chinese-postman.json --config " + x.optimal_config.map(str).join(","), + ) + + #figure( + canvas(length: 1cm, { + import draw: * + let positions = ( + (-1.25, 0.85), + (1.25, 0.85), + (1.25, -0.85), + (-1.25, -0.85), + (0.25, 0.0), + ) + + for (idx, (u, v)) in arcs.enumerate() { + line( + positions.at(u), + positions.at(v), + stroke: 0.8pt + luma(80), + mark: (end: "straight", scale: 0.45), + ) + let mid = ( + (positions.at(u).at(0) + positions.at(v).at(0)) / 2, + (positions.at(u).at(1) + positions.at(v).at(1)) / 2, + ) + content( + mid, + text(6pt, fill: luma(40))[#arc-weights.at(idx)], + fill: white, + frame: "rect", + padding: 0.04, + stroke: none, + ) + } + + for (idx, (u, v)) in oriented.enumerate() { + line( + positions.at(u), + positions.at(v), + stroke: 1.3pt + graph-colors.at(0), + mark: (end: "straight", scale: 0.5), + ) + let mid = ( + (positions.at(u).at(0) + positions.at(v).at(0)) / 2, + (positions.at(u).at(1) + positions.at(v).at(1)) / 2, + ) + let offset = if idx == 0 { (-0.18, 0.12) } else if idx == 1 { (0.18, 0.12) } else if idx == 2 { (-0.12, -0.1) } else { (0.12, -0.1) } + content( + (mid.at(0) + offset.at(0), mid.at(1) + offset.at(1)), + text(6pt, fill: graph-colors.at(0))[#edge-weights.at(idx)], + fill: white, + frame: "rect", + padding: 0.04, + stroke: none, + ) + } + + for (i, pos) in positions.enumerate() { + circle(pos, radius: 0.18, fill: white, stroke: 0.6pt + black) + content(pos, text(7pt)[$v_#i$]) + } + }), + caption: [Mixed Chinese Postman example. Gray arrows are the original directed arcs, while blue arrows are the chosen orientations of the former undirected edges under config $(1, 1, 0, 0)$. Duplicating the path $v_1 arrow v_2 arrow v_3$ yields total cost #total-cost.], + ) + ] + ] +} + #{ let x = load-model-example("SubgraphIsomorphism") let nv-host = x.instance.host_graph.num_vertices diff --git a/docs/paper/references.bib b/docs/paper/references.bib index fd8106120..6681affce 100644 --- a/docs/paper/references.bib +++ b/docs/paper/references.bib @@ -1282,3 +1282,22 @@ @inproceedings{williams2002 pages = {299--307}, year = {2002} } + +@article{papadimitriou1976edge, + author = {Christos H. Papadimitriou}, + title = {On the Complexity of Edge Traversing}, + journal = {Journal of the ACM}, + volume = {23}, + number = {3}, + pages = {544--554}, + year = {1976} +} + +@article{edmondsjohnson1973, + author = {Jack Edmonds and Ellis L. Johnson}, + title = {Matching, Euler Tours and the Chinese Postman}, + journal = {Mathematical Programming}, + volume = {5}, + pages = {88--124}, + year = {1973} +} diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index 5a26d50c5..9643be372 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -265,6 +265,7 @@ Flags by problem type: SequencingWithinIntervals --release-times, --deadlines, --lengths OptimalLinearArrangement --graph, --bound MinMaxMulticenter (pCenter) --graph, --weights, --edge-weights, --k, --bound + MixedChinesePostman (MCPP) --graph, --arcs, --edge-weights, --arc-costs, --bound [--num-vertices] RuralPostman (RPP) --graph, --edge-weights, --required-edges, --bound MultipleChoiceBranching --arcs [--weights] --partition --bound [--num-vertices] AdditionalKey --num-attributes, --dependencies, --relation-attrs [--known-keys] diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index 73e3f6b52..65bb31d50 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -13,7 +13,7 @@ use problemreductions::models::algebraic::{ use problemreductions::models::formula::Quantifier; use problemreductions::models::graph::{ GeneralizedHex, GraphPartitioning, HamiltonianCircuit, HamiltonianPath, - LengthBoundedDisjointPaths, MinimumCutIntoBoundedSets, MinimumMultiwayCut, + LengthBoundedDisjointPaths, MinimumCutIntoBoundedSets, MinimumMultiwayCut, MixedChinesePostman, MultipleChoiceBranching, SteinerTree, SteinerTreeInGraphs, StrongConnectivityAugmentation, }; use problemreductions::models::misc::{ @@ -31,8 +31,8 @@ use problemreductions::models::BiconnectivityAugmentation; use problemreductions::prelude::*; use problemreductions::registry::collect_schemas; use problemreductions::topology::{ - BipartiteGraph, DirectedGraph, Graph, KingsSubgraph, SimpleGraph, TriangularSubgraph, - UnitDiskGraph, + BipartiteGraph, DirectedGraph, Graph, KingsSubgraph, MixedGraph, SimpleGraph, + TriangularSubgraph, UnitDiskGraph, }; use serde::Serialize; use std::collections::{BTreeMap, BTreeSet}; @@ -584,6 +584,9 @@ fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str { "StrongConnectivityAugmentation" => { "--arcs \"0>1,1>2\" --candidate-arcs \"2>0:1\" --bound 1" } + "MixedChinesePostman" => { + "--graph 0-2,1-3,0-4,4-2 --arcs \"0>1,1>2,2>3,3>0\" --edge-weights 2,3,1,2 --arc-costs 2,3,1,4 --bound 24" + } "RuralPostman" => { "--graph 0-1,1-2,2-3,3-0 --edge-weights 1,1,1,1 --required-edges 0,2 --bound 4" } @@ -643,7 +646,12 @@ fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str { fn uses_edge_weights_flag(canonical: &str) -> bool { matches!( canonical, - "KthBestSpanningTree" | "MaxCut" | "MaximumMatching" | "TravelingSalesman" | "RuralPostman" + "KthBestSpanningTree" + | "MaxCut" + | "MaximumMatching" + | "TravelingSalesman" + | "RuralPostman" + | "MixedChinesePostman" ) } @@ -661,6 +669,7 @@ fn help_flag_name(canonical: &str, field_name: &str) -> String { ("PrimeAttributeName", "num_attributes") => return "universe".to_string(), ("PrimeAttributeName", "dependencies") => return "deps".to_string(), ("PrimeAttributeName", "query_attribute") => return "query".to_string(), + ("MixedChinesePostman", "arc_weights") => return "arc-costs".to_string(), ("ConsecutiveOnesSubmatrix", "bound") => return "bound".to_string(), ("StaffScheduling", "shifts_per_schedule") => return "k".to_string(), ("TimetableDesign", "num_tasks") => return "num-tasks".to_string(), @@ -827,6 +836,15 @@ fn print_problem_help(canonical: &str, graph_type: Option<&str>) -> Result<()> { // DirectedGraph fields use --arcs, not --graph let hint = type_format_hint(&field.type_name, graph_type); eprintln!(" --{:<16} {} ({})", "arcs", field.description, hint); + } else if field.type_name == "MixedGraph" { + eprintln!( + " --{:<16} {} ({})", + "graph", "Undirected edges E of the mixed graph", "edge list: 0-1,1-2,2-3" + ); + eprintln!( + " --{:<16} {} ({})", + "arcs", "Directed arcs A of the mixed graph", "directed arcs: 0>1,1>2,2>0" + ); } else if field.type_name == "BipartiteGraph" { eprintln!( " --{:<16} {} ({})", @@ -876,6 +894,9 @@ fn problem_help_flag_name( if field_type == "DirectedGraph" { return "arcs".to_string(); } + if field_type == "MixedGraph" { + return "graph".to_string(); + } if canonical == "LengthBoundedDisjointPaths" && field_name == "max_length" { return "bound".to_string(); } @@ -3003,9 +3024,10 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { // AcyclicPartition "AcyclicPartition" => { let usage = "Usage: pred create AcyclicPartition/i32 --arcs \"0>1,0>2,1>3,1>4,2>4,2>5,3>5,4>5\" --weights 2,3,2,1,3,1 --arc-costs 1,1,1,1,1,1,1,1 --weight-bound 5 --cost-bound 5"; - let arcs_str = args.arcs.as_deref().ok_or_else(|| { - anyhow::anyhow!("AcyclicPartition requires --arcs\n\n{usage}") - })?; + let arcs_str = args + .arcs + .as_deref() + .ok_or_else(|| anyhow::anyhow!("AcyclicPartition requires --arcs\n\n{usage}"))?; let (graph, num_arcs) = parse_directed_graph(arcs_str, args.num_vertices)?; let vertex_weights = parse_vertex_weights(args, graph.num_vertices())?; let arc_costs = parse_arc_costs(args, num_arcs)?; @@ -3109,6 +3131,46 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { ) } + // MixedChinesePostman + "MixedChinesePostman" => { + let usage = "Usage: pred create MixedChinesePostman --graph 0-2,1-3,0-4,4-2 --arcs \"0>1,1>2,2>3,3>0\" --edge-weights 2,3,1,2 --arc-costs 2,3,1,4 --bound 24 [--num-vertices N]"; + let graph = parse_mixed_graph(args, usage)?; + let arc_costs = parse_arc_costs(args, graph.num_arcs())?; + let edge_weights = parse_edge_weights(args, graph.num_edges())?; + let bound = args.bound.ok_or_else(|| { + anyhow::anyhow!("MixedChinesePostman requires --bound\n\n{usage}") + })?; + let bound = i32::try_from(bound).map_err(|_| { + anyhow::anyhow!( + "MixedChinesePostman --bound must fit in i32 (got {bound})\n\n{usage}" + ) + })?; + if arc_costs.iter().any(|&cost| cost < 0) { + bail!("MixedChinesePostman --arc-costs must be non-negative\n\n{usage}"); + } + if edge_weights.iter().any(|&weight| weight < 0) { + bail!("MixedChinesePostman --edge-weights must be non-negative\n\n{usage}"); + } + if resolved_variant.get("weight").map(|w| w.as_str()) == Some("One") + && (arc_costs.iter().any(|&cost| cost != 1) + || edge_weights.iter().any(|&weight| weight != 1)) + { + bail!( + "Non-unit lengths are not supported for MixedChinesePostman/One.\n\n\ + Use the weighted variant instead:\n pred create MixedChinesePostman/i32 --graph ... --arcs ... --edge-weights ... --arc-costs ..." + ); + } + ( + ser(MixedChinesePostman::new( + graph, + arc_costs, + edge_weights, + bound, + ))?, + resolved_variant.clone(), + ) + } + // MinimumSumMulticenter (p-median) "MinimumSumMulticenter" => { let (graph, n) = parse_graph(args).map_err(|e| { @@ -4759,6 +4821,22 @@ fn parse_directed_graph( Ok((DirectedGraph::new(num_v, arcs), num_arcs)) } +fn parse_mixed_graph(args: &CreateArgs, usage: &str) -> Result { + let (undirected_graph, num_vertices) = + parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; + let arcs_str = args + .arcs + .as_deref() + .ok_or_else(|| anyhow::anyhow!("MixedChinesePostman requires --arcs\n\n{usage}"))?; + let (directed_graph, _) = parse_directed_graph(arcs_str, Some(num_vertices)) + .map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; + Ok(MixedGraph::new( + num_vertices, + directed_graph.arcs(), + undirected_graph.edges(), + )) +} + /// Parse `--weights` as arc weights (i32), defaulting to all 1s. fn parse_arc_weights(args: &CreateArgs, num_arcs: usize) -> Result> { match &args.weights { @@ -4789,11 +4867,7 @@ fn parse_arc_costs(args: &CreateArgs, num_arcs: usize) -> Result> { .map(|s| s.trim().parse::()) .collect::, _>>()?; if parsed.len() != num_arcs { - bail!( - "Expected {} arc costs but got {}", - num_arcs, - parsed.len() - ); + bail!("Expected {} arc costs but got {}", num_arcs, parsed.len()); } Ok(parsed) } diff --git a/problemreductions-cli/tests/cli_tests.rs b/problemreductions-cli/tests/cli_tests.rs index a5e4abe85..b904cf85e 100644 --- a/problemreductions-cli/tests/cli_tests.rs +++ b/problemreductions-cli/tests/cli_tests.rs @@ -1980,8 +1980,14 @@ fn test_create_acyclic_partition() { let json: serde_json::Value = serde_json::from_str(&stdout).unwrap(); assert_eq!(json["type"], "AcyclicPartition"); assert_eq!(json["variant"]["weight"], "i32"); - assert_eq!(json["data"]["vertex_weights"], serde_json::json!([2, 3, 2, 1, 3, 1])); - assert_eq!(json["data"]["arc_costs"], serde_json::json!([1, 1, 1, 1, 1, 1, 1, 1])); + assert_eq!( + json["data"]["vertex_weights"], + serde_json::json!([2, 3, 2, 1, 3, 1]) + ); + assert_eq!( + json["data"]["arc_costs"], + serde_json::json!([1, 1, 1, 1, 1, 1, 1, 1]) + ); assert_eq!(json["data"]["weight_bound"], 5); assert_eq!(json["data"]["cost_bound"], 5); } @@ -2045,6 +2051,121 @@ fn test_create_model_example_acyclic_partition_round_trips_into_solve() { std::fs::remove_file(&path).ok(); } +#[test] +fn test_create_mixed_chinese_postman() { + let output = pred() + .args([ + "create", + "MixedChinesePostman", + "--graph", + "0-2,1-3,0-4,4-2", + "--arcs", + "0>1,1>2,2>3,3>0", + "--edge-weights", + "2,3,1,2", + "--arc-costs", + "2,3,1,4", + "--bound", + "24", + ]) + .output() + .unwrap(); + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let stdout = String::from_utf8(output.stdout).unwrap(); + let json: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + assert_eq!(json["type"], "MixedChinesePostman"); + assert_eq!(json["variant"]["weight"], "i32"); + assert_eq!(json["data"]["graph"]["num_vertices"], 5); + assert_eq!(json["data"]["arc_weights"], serde_json::json!([2, 3, 1, 4])); + assert_eq!( + json["data"]["edge_weights"], + serde_json::json!([2, 3, 1, 2]) + ); + assert_eq!(json["data"]["bound"], 24); +} + +#[test] +fn test_create_model_example_mixed_chinese_postman() { + let output = pred() + .args(["create", "--example", "MixedChinesePostman/i32"]) + .output() + .unwrap(); + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let stdout = String::from_utf8(output.stdout).unwrap(); + let json: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + assert_eq!(json["type"], "MixedChinesePostman"); + assert_eq!(json["variant"]["weight"], "i32"); + assert_eq!(json["data"]["bound"], 24); +} + +#[test] +fn test_create_mixed_chinese_postman_missing_arcs_shows_usage() { + let output = pred() + .args([ + "create", + "MixedChinesePostman", + "--graph", + "0-2,1-3,0-4,4-2", + "--edge-weights", + "2,3,1,2", + "--arc-costs", + "2,3,1,4", + "--bound", + "24", + ]) + .output() + .unwrap(); + assert!(!output.status.success()); + + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("MixedChinesePostman requires --arcs"), + "expected missing --arcs error, got: {stderr}" + ); + assert!( + stderr.contains("Usage: pred create MixedChinesePostman"), + "expected recovery usage hint, got: {stderr}" + ); +} + +#[test] +fn test_create_mixed_chinese_postman_rejects_edge_weight_length_mismatch() { + let output = pred() + .args([ + "create", + "MixedChinesePostman", + "--graph", + "0-2,1-3,0-4,4-2", + "--arcs", + "0>1,1>2,2>3,3>0", + "--edge-weights", + "2,3", + "--arc-costs", + "2,3,1,4", + "--bound", + "24", + ]) + .output() + .unwrap(); + assert!(!output.status.success()); + + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("Expected 4 edge weight"), + "expected edge-weight mismatch diagnostic, got: {stderr}" + ); +} + #[test] fn test_create_multiple_choice_branching_rejects_negative_bound() { let output = pred() diff --git a/src/lib.rs b/src/lib.rs index 9c8bd8664..c2bb7220d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -48,10 +48,11 @@ pub mod prelude { Satisfiability, }; pub use crate::models::graph::{ - AcyclicPartition, BalancedCompleteBipartiteSubgraph, BicliqueCover, BiconnectivityAugmentation, - BoundedComponentSpanningForest, DirectedTwoCommodityIntegralFlow, GeneralizedHex, - GraphPartitioning, HamiltonianCircuit, HamiltonianPath, IsomorphicSpanningTree, KClique, - KthBestSpanningTree, LengthBoundedDisjointPaths, SpinGlass, SteinerTree, + AcyclicPartition, BalancedCompleteBipartiteSubgraph, BicliqueCover, + BiconnectivityAugmentation, BoundedComponentSpanningForest, + DirectedTwoCommodityIntegralFlow, GeneralizedHex, GraphPartitioning, HamiltonianCircuit, + HamiltonianPath, IsomorphicSpanningTree, KClique, KthBestSpanningTree, + LengthBoundedDisjointPaths, MixedChinesePostman, SpinGlass, SteinerTree, StrongConnectivityAugmentation, SubgraphIsomorphism, }; pub use crate::models::graph::{ diff --git a/src/models/graph/acyclic_partition.rs b/src/models/graph/acyclic_partition.rs index 6660d76a7..c81725854 100644 --- a/src/models/graph/acyclic_partition.rs +++ b/src/models/graph/acyclic_partition.rs @@ -178,7 +178,10 @@ where } } -impl SatisfactionProblem for AcyclicPartition where W: WeightElement + crate::variant::VariantParam {} +impl SatisfactionProblem for AcyclicPartition where + W: WeightElement + crate::variant::VariantParam +{ +} fn is_valid_acyclic_partition( graph: &DirectedGraph, diff --git a/src/models/graph/mixed_chinese_postman.rs b/src/models/graph/mixed_chinese_postman.rs new file mode 100644 index 000000000..d79188ba4 --- /dev/null +++ b/src/models/graph/mixed_chinese_postman.rs @@ -0,0 +1,452 @@ +//! Mixed Chinese Postman problem implementation. +//! +//! Given a mixed graph with directed arcs and undirected edges, determine +//! whether there exists a closed walk of bounded total length that traverses +//! every directed arc in its prescribed direction and every undirected edge in +//! at least one direction. + +use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::topology::{DirectedGraph, MixedGraph}; +use crate::traits::{Problem, SatisfactionProblem}; +use crate::types::{One, WeightElement}; +use num_traits::Zero; +use serde::{Deserialize, Serialize}; +use std::cmp::Ordering; + +const INF_COST: i64 = i64::MAX / 4; + +inventory::submit! { + ProblemSchemaEntry { + name: "MixedChinesePostman", + display_name: "Mixed Chinese Postman", + aliases: &["MCPP"], + dimensions: &[ + VariantDimension::new("weight", "i32", &["i32", "One"]), + ], + module_path: module_path!(), + description: "Determine whether a mixed graph has a bounded closed walk covering all arcs and edges", + fields: &[ + FieldInfo { name: "graph", type_name: "MixedGraph", description: "The mixed graph G=(V,A,E)" }, + FieldInfo { name: "arc_weights", type_name: "Vec", description: "Lengths for the directed arcs in A" }, + FieldInfo { name: "edge_weights", type_name: "Vec", description: "Lengths for the undirected edges in E" }, + FieldInfo { name: "bound", type_name: "W::Sum", description: "Upper bound B on the total walk length" }, + ], + } +} + +/// Mixed Chinese Postman. +/// +/// Each configuration picks a required traversal direction for every undirected +/// edge. The minimum-cost closed walk is then computed via the directed Chinese +/// Postman subproblem, using all available arcs (including both directions of +/// every undirected edge) for degree-balancing detours. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MixedChinesePostman> { + graph: MixedGraph, + arc_weights: Vec, + edge_weights: Vec, + bound: W::Sum, +} + +impl> MixedChinesePostman { + /// Create a new mixed Chinese postman instance. + /// + /// # Panics + /// + /// Panics if the weight-vector lengths do not match the graph shape or if + /// any weight or the bound is negative. + pub fn new( + graph: MixedGraph, + arc_weights: Vec, + edge_weights: Vec, + bound: W::Sum, + ) -> Self { + assert_eq!( + arc_weights.len(), + graph.num_arcs(), + "arc_weights length must match num_arcs" + ); + assert_eq!( + edge_weights.len(), + graph.num_edges(), + "edge_weights length must match num_edges" + ); + assert!( + matches!( + bound.partial_cmp(&W::Sum::zero()), + Some(Ordering::Equal | Ordering::Greater) + ), + "bound must be nonnegative" + ); + for (index, weight) in arc_weights.iter().enumerate() { + assert!( + matches!( + weight.to_sum().partial_cmp(&W::Sum::zero()), + Some(Ordering::Equal | Ordering::Greater) + ), + "arc weight at index {} must be nonnegative", + index + ); + } + for (index, weight) in edge_weights.iter().enumerate() { + assert!( + matches!( + weight.to_sum().partial_cmp(&W::Sum::zero()), + Some(Ordering::Equal | Ordering::Greater) + ), + "edge weight at index {} must be nonnegative", + index + ); + } + + Self { + graph, + arc_weights, + edge_weights, + bound, + } + } + + /// Return the mixed graph. + pub fn graph(&self) -> &MixedGraph { + &self.graph + } + + /// Return the directed-arc lengths. + pub fn arc_weights(&self) -> &[W] { + &self.arc_weights + } + + /// Return the undirected-edge lengths. + pub fn edge_weights(&self) -> &[W] { + &self.edge_weights + } + + /// Return the bound. + pub fn bound(&self) -> &W::Sum { + &self.bound + } + + /// Return the number of vertices. + pub fn num_vertices(&self) -> usize { + self.graph.num_vertices() + } + + /// Return the number of directed arcs. + pub fn num_arcs(&self) -> usize { + self.graph.num_arcs() + } + + /// Return the number of undirected edges. + pub fn num_edges(&self) -> usize { + self.graph.num_edges() + } + + /// Return whether this instance uses non-unit lengths. + pub fn is_weighted(&self) -> bool { + !W::IS_UNIT + } + + fn oriented_arc_pairs(&self, config: &[usize]) -> Option> { + if config.len() != self.graph.num_edges() { + return None; + } + + let mut arcs = self.graph.arcs(); + for ((u, v), &direction) in self.graph.edges().iter().zip(config.iter()) { + match direction { + 0 => arcs.push((*u, *v)), + 1 => arcs.push((*v, *u)), + _ => return None, + } + } + Some(arcs) + } + + fn available_arc_pairs(&self) -> Vec<(usize, usize)> { + let mut arcs = self.graph.arcs(); + for &(u, v) in self.graph.edges().iter() { + arcs.push((u, v)); + arcs.push((v, u)); + } + arcs + } + + fn weighted_available_arcs(&self) -> Vec<(usize, usize, i64)> { + let mut arcs: Vec<(usize, usize, i64)> = self + .graph + .arcs() + .into_iter() + .zip(self.arc_weights.iter()) + .map(|((u, v), weight)| (u, v, i64::from(weight.to_sum()))) + .collect(); + + for ((u, v), weight) in self.graph.edges().iter().zip(self.edge_weights.iter()) { + let cost = i64::from(weight.to_sum()); + arcs.push((*u, *v, cost)); + arcs.push((*v, *u, cost)); + } + + arcs + } + + fn base_cost(&self) -> i64 { + self.arc_weights + .iter() + .map(|weight| i64::from(weight.to_sum())) + .sum::() + + self + .edge_weights + .iter() + .map(|weight| i64::from(weight.to_sum())) + .sum::() + } +} + +impl MixedChinesePostman +where + W: WeightElement + crate::variant::VariantParam, +{ + /// Check whether a configuration is satisfying. + pub fn is_valid_solution(&self, config: &[usize]) -> bool { + self.evaluate(config) + } +} + +impl Problem for MixedChinesePostman +where + W: WeightElement + crate::variant::VariantParam, +{ + const NAME: &'static str = "MixedChinesePostman"; + type Metric = bool; + + fn variant() -> Vec<(&'static str, &'static str)> { + crate::variant_params![W] + } + + fn dims(&self) -> Vec { + vec![2; self.graph.num_edges()] + } + + fn evaluate(&self, config: &[usize]) -> bool { + let Some(oriented_pairs) = self.oriented_arc_pairs(config) else { + return false; + }; + + // Connectivity uses the full available graph: original arcs plus both + // directions of every undirected edge. + if !DirectedGraph::new(self.graph.num_vertices(), self.available_arc_pairs()) + .is_strongly_connected() + { + return false; + } + + // Shortest paths also use the full available graph so that balancing + // can route through undirected edges in either direction. + let distances = + all_pairs_shortest_paths(self.graph.num_vertices(), &self.weighted_available_arcs()); + // Degree imbalance is computed from the required arcs only (original + // arcs plus the chosen orientation of each undirected edge). + let balance = degree_imbalances(self.graph.num_vertices(), &oriented_pairs); + let Some(extra_cost) = minimum_balancing_cost(&balance, &distances) else { + return false; + }; + + self.base_cost() + extra_cost <= i64::from(self.bound) + } +} + +impl SatisfactionProblem for MixedChinesePostman where + W: WeightElement + crate::variant::VariantParam +{ +} + +crate::declare_variants! { + default sat MixedChinesePostman => "2^num_edges * num_vertices^3", + sat MixedChinesePostman => "2^num_edges * num_vertices^3", +} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_model_example_specs() -> Vec { + vec![crate::example_db::specs::ModelExampleSpec { + id: "mixed_chinese_postman_i32", + instance: Box::new(MixedChinesePostman::new( + MixedGraph::new( + 5, + vec![(0, 1), (1, 2), (2, 3), (3, 0)], + vec![(0, 2), (1, 3), (0, 4), (4, 2)], + ), + vec![2, 3, 1, 4], + vec![2, 3, 1, 2], + 24, + )), + optimal_config: vec![1, 1, 0, 0], + optimal_value: serde_json::json!(true), + }] +} + +fn all_pairs_shortest_paths(num_vertices: usize, arcs: &[(usize, usize, i64)]) -> Vec> { + let mut distances = vec![vec![INF_COST; num_vertices]; num_vertices]; + + for (vertex, row) in distances.iter_mut().enumerate() { + row[vertex] = 0; + } + + for &(u, v, cost) in arcs { + if cost < distances[u][v] { + distances[u][v] = cost; + } + } + + for via in 0..num_vertices { + for src in 0..num_vertices { + if distances[src][via] == INF_COST { + continue; + } + for dst in 0..num_vertices { + if distances[via][dst] == INF_COST { + continue; + } + let through = distances[src][via] + distances[via][dst]; + if through < distances[src][dst] { + distances[src][dst] = through; + } + } + } + } + + distances +} + +fn degree_imbalances(num_vertices: usize, arcs: &[(usize, usize)]) -> Vec { + let mut balance = vec![0_i32; num_vertices]; + for &(u, v) in arcs { + balance[u] += 1; + balance[v] -= 1; + } + balance +} + +fn minimum_balancing_cost(balance: &[i32], distances: &[Vec]) -> Option { + let mut deficits = Vec::new(); + let mut surpluses = Vec::new(); + + for (vertex, &value) in balance.iter().enumerate() { + if value < 0 { + for _ in 0..usize::try_from(-value).ok()? { + deficits.push(vertex); + } + } else if value > 0 { + for _ in 0..usize::try_from(value).ok()? { + surpluses.push(vertex); + } + } + } + + if deficits.len() != surpluses.len() { + return None; + } + if deficits.is_empty() { + return Some(0); + } + + let mut costs = vec![vec![INF_COST; surpluses.len()]; deficits.len()]; + for (row, &src) in deficits.iter().enumerate() { + for (col, &dst) in surpluses.iter().enumerate() { + costs[row][col] = distances[src][dst]; + } + } + + hungarian_min_cost(&costs) +} + +fn hungarian_min_cost(costs: &[Vec]) -> Option { + let size = costs.len(); + if size == 0 { + return Some(0); + } + if costs.iter().any(|row| row.len() != size) { + return None; + } + + let mut u = vec![0_i64; size + 1]; + let mut v = vec![0_i64; size + 1]; + let mut p = vec![0_usize; size + 1]; + let mut way = vec![0_usize; size + 1]; + + for row in 1..=size { + p[0] = row; + let mut column0 = 0; + let mut minv = vec![INF_COST; size + 1]; + let mut used = vec![false; size + 1]; + + loop { + used[column0] = true; + let row0 = p[column0]; + let mut delta = INF_COST; + let mut column1 = 0; + + for column in 1..=size { + if used[column] { + continue; + } + + let current = costs[row0 - 1][column - 1] - u[row0] - v[column]; + if current < minv[column] { + minv[column] = current; + way[column] = column0; + } + if minv[column] < delta { + delta = minv[column]; + column1 = column; + } + } + + if delta == INF_COST { + return None; + } + + for column in 0..=size { + if used[column] { + u[p[column]] += delta; + v[column] -= delta; + } else { + minv[column] -= delta; + } + } + + column0 = column1; + if p[column0] == 0 { + break; + } + } + + loop { + let column1 = way[column0]; + p[column0] = p[column1]; + column0 = column1; + if column0 == 0 { + break; + } + } + } + + let mut assignment = vec![0_usize; size + 1]; + for column in 1..=size { + assignment[p[column]] = column; + } + + let mut total = 0_i64; + for row in 1..=size { + let cost = costs[row - 1][assignment[row] - 1]; + if cost == INF_COST { + return None; + } + total += cost; + } + Some(total) +} + +#[cfg(test)] +#[path = "../../unit_tests/models/graph/mixed_chinese_postman.rs"] +mod tests; diff --git a/src/models/graph/mod.rs b/src/models/graph/mod.rs index c971b7746..0c95f91cf 100644 --- a/src/models/graph/mod.rs +++ b/src/models/graph/mod.rs @@ -37,14 +37,15 @@ //! - [`MultipleChoiceBranching`]: Directed branching with partition constraints //! - [`LengthBoundedDisjointPaths`]: Length-bounded internally disjoint s-t paths //! - [`RuralPostman`]: Rural Postman (circuit covering required edges) +//! - [`MixedChinesePostman`]: Mixed-graph postman tour with bounded total length //! - [`SteinerTree`]: Minimum-weight tree spanning all required terminals //! - [`SubgraphIsomorphism`]: Subgraph isomorphism (decision problem) //! - [`DirectedTwoCommodityIntegralFlow`]: Directed two-commodity integral flow (satisfaction) //! - [`UndirectedTwoCommodityIntegralFlow`]: Two-commodity integral flow on undirected graphs //! - [`StrongConnectivityAugmentation`]: Strong connectivity augmentation with weighted candidate arcs -pub(crate) mod balanced_complete_bipartite_subgraph; pub(crate) mod acyclic_partition; +pub(crate) mod balanced_complete_bipartite_subgraph; pub(crate) mod biclique_cover; pub(crate) mod biconnectivity_augmentation; pub(crate) mod bounded_component_spanning_forest; @@ -71,6 +72,7 @@ pub(crate) mod minimum_feedback_vertex_set; pub(crate) mod minimum_multiway_cut; pub(crate) mod minimum_sum_multicenter; pub(crate) mod minimum_vertex_cover; +pub(crate) mod mixed_chinese_postman; pub(crate) mod multiple_choice_branching; pub(crate) mod multiple_copy_file_allocation; pub(crate) mod optimal_linear_arrangement; @@ -114,6 +116,7 @@ pub use minimum_feedback_vertex_set::MinimumFeedbackVertexSet; pub use minimum_multiway_cut::MinimumMultiwayCut; pub use minimum_sum_multicenter::MinimumSumMulticenter; pub use minimum_vertex_cover::MinimumVertexCover; +pub use mixed_chinese_postman::MixedChinesePostman; pub use multiple_choice_branching::MultipleChoiceBranching; pub use multiple_copy_file_allocation::MultipleCopyFileAllocation; pub use optimal_linear_arrangement::OptimalLinearArrangement; @@ -173,6 +176,7 @@ pub(crate) fn canonical_model_example_specs() -> Vec, + edges: Vec<(usize, usize)>, +} + +impl MixedGraph { + /// Create a new mixed graph. + /// + /// # Panics + /// + /// Panics if any endpoint references a vertex outside `0..num_vertices`. + pub fn new(num_vertices: usize, arcs: Vec<(usize, usize)>, edges: Vec<(usize, usize)>) -> Self { + for &(u, v) in &arcs { + assert!( + u < num_vertices && v < num_vertices, + "arc ({}, {}) references vertex >= num_vertices ({})", + u, + v, + num_vertices + ); + } + + for &(u, v) in &edges { + assert!( + u < num_vertices && v < num_vertices, + "edge ({}, {}) references vertex >= num_vertices ({})", + u, + v, + num_vertices + ); + } + + Self { + num_vertices, + arcs, + edges, + } + } + + /// Create an empty mixed graph with no arcs or undirected edges. + pub fn empty(num_vertices: usize) -> Self { + Self::new(num_vertices, vec![], vec![]) + } + + /// Return the number of vertices. + pub fn num_vertices(&self) -> usize { + self.num_vertices + } + + /// Return the number of directed arcs. + pub fn num_arcs(&self) -> usize { + self.arcs.len() + } + + /// Return the number of undirected edges. + pub fn num_edges(&self) -> usize { + self.edges.len() + } + + /// Return the directed arcs. + pub fn arcs(&self) -> Vec<(usize, usize)> { + self.arcs.clone() + } + + /// Return the undirected edges. + pub fn edges(&self) -> Vec<(usize, usize)> { + self.edges.clone() + } + + /// Return true when the directed arc `(u, v)` is present. + pub fn has_arc(&self, u: usize, v: usize) -> bool { + self.arcs.iter().any(|&(src, dst)| src == u && dst == v) + } + + /// Return true when the undirected edge `{u, v}` is present. + pub fn has_edge(&self, u: usize, v: usize) -> bool { + let edge = normalize_edge(u, v); + self.edges + .iter() + .any(|&(a, b)| normalize_edge(a, b) == edge) + } + + /// Return the outgoing arc count of vertex `v`. + pub fn out_degree(&self, v: usize) -> usize { + self.arcs.iter().filter(|&&(u, _)| u == v).count() + } + + /// Return the incoming arc count of vertex `v`. + pub fn in_degree(&self, v: usize) -> usize { + self.arcs.iter().filter(|&&(_, w)| w == v).count() + } + + /// Return the undirected-edge count incident to vertex `v`. + pub fn undirected_degree(&self, v: usize) -> usize { + self.edges + .iter() + .filter(|&&(u, w)| u == v || w == v) + .count() + } + + /// Return true if the graph has no vertices. + pub fn is_empty(&self) -> bool { + self.num_vertices == 0 + } +} + +fn normalize_edge(u: usize, v: usize) -> (usize, usize) { + if u <= v { + (u, v) + } else { + (v, u) + } +} + +impl PartialEq for MixedGraph { + fn eq(&self, other: &Self) -> bool { + if self.num_vertices != other.num_vertices { + return false; + } + + let mut self_arcs = self.arcs.clone(); + let mut other_arcs = other.arcs.clone(); + self_arcs.sort(); + other_arcs.sort(); + if self_arcs != other_arcs { + return false; + } + + let mut self_edges = self.edges.clone(); + let mut other_edges = other.edges.clone(); + for edge in &mut self_edges { + *edge = normalize_edge(edge.0, edge.1); + } + for edge in &mut other_edges { + *edge = normalize_edge(edge.0, edge.1); + } + self_edges.sort(); + other_edges.sort(); + self_edges == other_edges + } +} + +impl Eq for MixedGraph {} + +use crate::impl_variant_param; +impl_variant_param!(MixedGraph, "graph"); + +#[cfg(test)] +#[path = "../unit_tests/topology/mixed_graph.rs"] +mod tests; diff --git a/src/topology/mod.rs b/src/topology/mod.rs index 8881ca9f7..4e7eed829 100644 --- a/src/topology/mod.rs +++ b/src/topology/mod.rs @@ -4,6 +4,7 @@ //! - [`PlanarGraph`]: Planar graph //! - [`BipartiteGraph`]: Bipartite graph //! - [`DirectedGraph`]: Directed graph (digraph) +//! - [`MixedGraph`]: Mixed graph with directed arcs and undirected edges //! - [`UnitDiskGraph`]: Vertices with 2D positions, edges based on distance //! - [`KingsSubgraph`]: 8-connected grid graph (King's graph) //! - [`TriangularSubgraph`]: Triangular lattice subgraph @@ -13,6 +14,7 @@ mod bipartite_graph; mod directed_graph; mod graph; mod kings_subgraph; +mod mixed_graph; mod planar_graph; pub mod small_graphs; mod triangular_subgraph; @@ -22,6 +24,7 @@ pub use bipartite_graph::BipartiteGraph; pub use directed_graph::DirectedGraph; pub use graph::{Graph, GraphCast, SimpleGraph}; pub use kings_subgraph::KingsSubgraph; +pub use mixed_graph::MixedGraph; pub use planar_graph::PlanarGraph; pub use small_graphs::{available_graphs, smallgraph}; pub use triangular_subgraph::TriangularSubgraph; diff --git a/src/unit_tests/models/graph/acyclic_partition.rs b/src/unit_tests/models/graph/acyclic_partition.rs index 0abb21887..c15582f30 100644 --- a/src/unit_tests/models/graph/acyclic_partition.rs +++ b/src/unit_tests/models/graph/acyclic_partition.rs @@ -1,6 +1,6 @@ use super::*; -use crate::solvers::{BruteForce, Solver}; use crate::registry::declared_size_fields; +use crate::solvers::{BruteForce, Solver}; use crate::topology::DirectedGraph; use crate::traits::Problem; use serde_json; @@ -98,13 +98,7 @@ fn test_acyclic_partition_creation_and_accessors() { #[test] fn test_acyclic_partition_rejects_weight_length_mismatch() { let result = std::panic::catch_unwind(|| { - AcyclicPartition::new( - DirectedGraph::new(2, vec![(0, 1)]), - vec![1], - vec![1], - 2, - 1, - ) + AcyclicPartition::new(DirectedGraph::new(2, vec![(0, 1)]), vec![1], vec![1], 2, 1) }); assert!(result.is_err()); } diff --git a/src/unit_tests/models/graph/mixed_chinese_postman.rs b/src/unit_tests/models/graph/mixed_chinese_postman.rs new file mode 100644 index 000000000..e73da1ff6 --- /dev/null +++ b/src/unit_tests/models/graph/mixed_chinese_postman.rs @@ -0,0 +1,139 @@ +use super::*; +use crate::solvers::{BruteForce, Solver}; +use crate::topology::MixedGraph; +use crate::traits::Problem; + +fn yes_instance() -> MixedChinesePostman { + MixedChinesePostman::new( + MixedGraph::new( + 5, + vec![(0, 1), (1, 2), (2, 3), (3, 0)], + vec![(0, 2), (1, 3), (0, 4), (4, 2)], + ), + vec![2, 3, 1, 4], + vec![2, 3, 1, 2], + 24, + ) +} + +fn no_instance() -> MixedChinesePostman { + MixedChinesePostman::new( + MixedGraph::new( + 6, + vec![(0, 1), (1, 0), (2, 3)], + vec![(0, 2), (1, 3), (3, 4), (4, 5), (5, 2)], + ), + vec![1, 1, 1], + vec![1, 1, 5, 5, 5], + 10, + ) +} + +#[test] +fn test_mixed_chinese_postman_creation_and_accessors() { + let problem = yes_instance(); + + assert_eq!(problem.num_vertices(), 5); + assert_eq!(problem.num_arcs(), 4); + assert_eq!(problem.num_edges(), 4); + assert_eq!(problem.dims(), vec![2, 2, 2, 2]); + assert_eq!(problem.arc_weights(), &[2, 3, 1, 4]); + assert_eq!(problem.edge_weights(), &[2, 3, 1, 2]); + assert_eq!(*problem.bound(), 24); +} + +#[test] +fn test_mixed_chinese_postman_evaluate_yes_issue_example() { + let problem = yes_instance(); + + // Reverse (0,2) and (1,3), keep (0,4) and (4,2) forward. + assert!(problem.evaluate(&[1, 1, 0, 0])); +} + +#[test] +fn test_mixed_chinese_postman_evaluate_no_issue_example() { + let problem = no_instance(); + + assert!(!problem.evaluate(&[0, 0, 0, 0, 0])); +} + +#[test] +fn test_mixed_chinese_postman_single_edge_walk() { + // V={0,1}, A=∅, E={{0,1}}, weight=1, B=2. + // Walk 0→1→0 is valid: traverses the edge at least once, total cost 2 ≤ 2. + let problem = + MixedChinesePostman::new(MixedGraph::new(2, vec![], vec![(0, 1)]), vec![], vec![1], 2); + + assert!(problem.evaluate(&[0])); + assert!(problem.evaluate(&[1])); + + let solver = BruteForce::new(); + assert!(solver.find_satisfying(&problem).is_some()); +} + +#[test] +fn test_mixed_chinese_postman_rejects_disconnected_graph() { + // Two disconnected components {0,1} and {2,3}: no closed walk can cover all edges. + let problem = MixedChinesePostman::new( + MixedGraph::new(4, vec![], vec![(0, 1), (2, 3)]), + vec![], + vec![1, 1], + 100, + ); + + assert!(!problem.evaluate(&[0, 0])); + assert!(!problem.evaluate(&[0, 1])); + assert!(!problem.evaluate(&[1, 0])); + assert!(!problem.evaluate(&[1, 1])); +} + +#[test] +fn test_mixed_chinese_postman_rejects_wrong_config_length() { + let problem = yes_instance(); + + assert!(!problem.evaluate(&[])); + assert!(!problem.evaluate(&[1, 1, 0])); + assert!(!problem.evaluate(&[1, 1, 0, 0, 1])); +} + +#[test] +fn test_mixed_chinese_postman_solver_finds_satisfying_orientation() { + let problem = yes_instance(); + let solver = BruteForce::new(); + + let solution = solver + .find_satisfying(&problem) + .expect("expected a satisfying orientation"); + assert!(problem.evaluate(&solution)); +} + +#[test] +fn test_mixed_chinese_postman_solver_reports_unsat_issue_example() { + let problem = no_instance(); + let solver = BruteForce::new(); + + assert!(solver.find_satisfying(&problem).is_none()); +} + +#[test] +fn test_mixed_chinese_postman_serialization_roundtrip() { + let problem = yes_instance(); + + let json = serde_json::to_string(&problem).unwrap(); + let restored: MixedChinesePostman = serde_json::from_str(&json).unwrap(); + + assert_eq!(restored.num_vertices(), 5); + assert_eq!(restored.num_arcs(), 4); + assert_eq!(restored.num_edges(), 4); + assert_eq!(restored.arc_weights(), &[2, 3, 1, 4]); + assert_eq!(restored.edge_weights(), &[2, 3, 1, 2]); + assert_eq!(*restored.bound(), 24); +} + +#[test] +fn test_mixed_chinese_postman_problem_name() { + assert_eq!( + as Problem>::NAME, + "MixedChinesePostman" + ); +} diff --git a/src/unit_tests/topology/mixed_graph.rs b/src/unit_tests/topology/mixed_graph.rs new file mode 100644 index 000000000..316e4ac02 --- /dev/null +++ b/src/unit_tests/topology/mixed_graph.rs @@ -0,0 +1,58 @@ +use crate::topology::MixedGraph; + +#[test] +fn test_mixed_graph_creation_and_counts() { + let graph = MixedGraph::new(4, vec![(0, 1), (2, 3)], vec![(0, 2), (1, 3)]); + + assert_eq!(graph.num_vertices(), 4); + assert_eq!(graph.num_arcs(), 2); + assert_eq!(graph.num_edges(), 2); + + let mut arcs = graph.arcs(); + arcs.sort(); + assert_eq!(arcs, vec![(0, 1), (2, 3)]); + + let mut edges = graph.edges(); + edges.sort(); + assert_eq!(edges, vec![(0, 2), (1, 3)]); +} + +#[test] +fn test_mixed_graph_incidence_queries() { + let graph = MixedGraph::new(4, vec![(0, 1), (2, 1)], vec![(1, 3), (0, 2)]); + + assert!(graph.has_arc(0, 1)); + assert!(!graph.has_arc(1, 0)); + assert!(graph.has_edge(1, 3)); + assert!(graph.has_edge(3, 1)); + assert!(!graph.has_edge(0, 3)); + + assert_eq!(graph.out_degree(0), 1); + assert_eq!(graph.in_degree(1), 2); + assert_eq!(graph.undirected_degree(1), 1); + assert_eq!(graph.undirected_degree(0), 1); +} + +#[test] +fn test_mixed_graph_has_edge_is_order_insensitive() { + let graph = MixedGraph::new(3, vec![], vec![(2, 0)]); + + assert!(graph.has_edge(0, 2)); + assert!(graph.has_edge(2, 0)); +} + +#[test] +fn test_mixed_graph_serialization_roundtrip() { + let graph = MixedGraph::new(5, vec![(0, 1), (1, 4)], vec![(0, 2), (2, 3), (3, 4)]); + + let json = serde_json::to_string(&graph).unwrap(); + let restored: MixedGraph = serde_json::from_str(&json).unwrap(); + + assert_eq!(restored, graph); +} + +#[test] +#[should_panic(expected = "references vertex >= num_vertices")] +fn test_mixed_graph_panics_on_out_of_bounds_arc() { + MixedGraph::new(3, vec![(0, 3)], vec![]); +}