diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index 114d25dbd..c41319616 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -76,6 +76,7 @@ "LongestPath": [Longest Path], "ShortestWeightConstrainedPath": [Shortest Weight-Constrained Path], "UndirectedTwoCommodityIntegralFlow": [Undirected Two-Commodity Integral Flow], + "PathConstrainedNetworkFlow": [Path-Constrained Network Flow], "LengthBoundedDisjointPaths": [Length-Bounded Disjoint Paths], "IsomorphicSpanningTree": [Isomorphic Spanning Tree], "KthBestSpanningTree": [Kth Best Spanning Tree], @@ -1188,6 +1189,99 @@ is feasible: each set induces a connected subgraph, the component weights are $2 ] ] } +#{ + let x = load-model-example("PathConstrainedNetworkFlow") + let arcs = x.instance.graph.arcs.map(a => (a.at(0), a.at(1))) + let requirement = x.instance.requirement + let p1 = (0, 2, 5, 8) + let p2 = (0, 3, 6, 8) + let p5 = (1, 4, 7, 9) + [ + #problem-def("PathConstrainedNetworkFlow")[ + Given a directed graph $G = (V, A)$, designated vertices $s, t in V$, arc capacities $c: A -> ZZ^+$, a prescribed collection $cal(P)$ of directed simple $s$-$t$ paths, and a requirement $R in ZZ^+$, determine whether there exists an integral path-flow function $g: cal(P) -> ZZ_(>= 0)$ such that $sum_(p in cal(P): a in p) g(p) <= c(a)$ for every arc $a in A$ and $sum_(p in cal(P)) g(p) >= R$. + ][ + Path-Constrained Network Flow appears as problem ND34 in Garey \& Johnson @garey1979. Unlike ordinary single-commodity flow, the admissible routes are fixed in advance: every unit of flow must be assigned to one of the listed $s$-$t$ paths. This prescribed-path viewpoint is standard in line planning and unsplittable routing, and Büsing and Stiller give a modern published NP-completeness and inapproximability treatment for exactly this integral formulation @busingstiller2011. + + The implementation uses one integer variable per prescribed path, bounded by that path's bottleneck capacity. Exhaustive search over those path-flow variables gives the registered worst-case bound $O^*((C + 1)^(|cal(P)|))$, where $C = max_(a in A) c(a)$. #footnote[This is the brute-force bound induced by the representation used in the library; no sharper general exact algorithm is claimed here for the integral prescribed-path formulation.] + + *Example.* The canonical fixture uses the directed network with arcs $(0,1)$, $(0,2)$, $(1,3)$, $(1,4)$, $(2,4)$, $(3,5)$, $(4,5)$, $(4,6)$, $(5,7)$, and $(6,7)$, capacities $(2,1,1,1,1,1,1,1,2,1)$, source $s = 0$, sink $t = 7$, and required flow $R = #requirement$. The prescribed paths are $p_1 = 0 arrow 1 arrow 3 arrow 5 arrow 7$, $p_2 = 0 arrow 1 arrow 4 arrow 5 arrow 7$, $p_3 = 0 arrow 1 arrow 4 arrow 6 arrow 7$, $p_4 = 0 arrow 2 arrow 4 arrow 5 arrow 7$, and $p_5 = 0 arrow 2 arrow 4 arrow 6 arrow 7$. The fixture's satisfying configuration is $g = (#x.optimal_config.at(0), #x.optimal_config.at(1), #x.optimal_config.at(2), #x.optimal_config.at(3), #x.optimal_config.at(4)) = (1, 1, 0, 0, 1)$, so one unit is sent along $p_1$, one along $p_2$, and one along $p_5$. The shared arcs $(0,1)$ and $(5,7)$ each carry exactly two units of flow, matching their capacity 2, while every other used arc carries one unit. Therefore the total flow into $t$ is $3 = R$, so the instance is feasible. + + #pred-commands( + "pred create --example " + problem-spec(x) + " -o path-constrained-network-flow.json", + "pred solve path-constrained-network-flow.json --solver brute-force", + "pred evaluate path-constrained-network-flow.json --config " + x.optimal_config.map(str).join(","), + ) + + #figure( + canvas(length: 0.95cm, { + import draw: * + let blue = graph-colors.at(0) + let orange = rgb("#f28e2b") + let teal = rgb("#76b7b2") + let gray = luma(185) + let verts = ( + (0, 0), + (1.4, 1.2), + (1.4, -1.2), + (2.8, 1.9), + (2.8, 0), + (4.2, 1.2), + (4.2, -1.2), + (5.6, 0), + ) + for (u, v) in arcs { + line( + verts.at(u), + verts.at(v), + stroke: 0.8pt + gray, + mark: (end: "straight", scale: 0.45), + ) + } + for idx in p1 { + let (u, v) = arcs.at(idx) + line( + verts.at(u), + verts.at(v), + stroke: 1.8pt + blue, + mark: (end: "straight", scale: 0.5), + ) + } + for idx in p2 { + let (u, v) = arcs.at(idx) + line( + verts.at(u), + verts.at(v), + stroke: (paint: orange, thickness: 1.7pt, dash: "dashed"), + mark: (end: "straight", scale: 0.48), + ) + } + for idx in p5 { + let (u, v) = arcs.at(idx) + line( + verts.at(u), + verts.at(v), + stroke: 1.6pt + teal, + mark: (end: "straight", scale: 0.46), + ) + } + for (i, pos) in verts.enumerate() { + let fill = if i == 0 or i == 7 { rgb("#e15759").lighten(75%) } else { white } + g-node(pos, name: "pcnf-" + str(i), fill: fill, label: [$v_#i$]) + } + content((0.65, 0.78), text(8pt, fill: gray)[$2 / 2$]) + content((4.9, 0.78), text(8pt, fill: gray)[$2 / 2$]) + line((0.2, -2.15), (0.8, -2.15), stroke: 1.8pt + blue, mark: (end: "straight", scale: 0.42)) + content((1.15, -2.15), text(8pt)[$p_1$]) + line((1.95, -2.15), (2.55, -2.15), stroke: (paint: orange, thickness: 1.7pt, dash: "dashed"), mark: (end: "straight", scale: 0.42)) + content((2.9, -2.15), text(8pt)[$p_2$]) + line((3.75, -2.15), (4.35, -2.15), stroke: 1.6pt + teal, mark: (end: "straight", scale: 0.42)) + content((4.7, -2.15), text(8pt)[$p_5$]) + }), + caption: [Canonical YES instance for Path-Constrained Network Flow. Blue, dashed orange, and teal show the three prescribed paths used by $g = (1, 1, 0, 0, 1)$. The labels $2 / 2$ mark the shared arcs $(0,1)$ and $(5,7)$, whose flow exactly saturates capacity 2.], + ) + ] + ] +} #{ let x = load-model-example("IsomorphicSpanningTree") let g-edges = x.instance.graph.edges diff --git a/docs/paper/references.bib b/docs/paper/references.bib index 0d9948cfa..3dde32f26 100644 --- a/docs/paper/references.bib +++ b/docs/paper/references.bib @@ -180,6 +180,17 @@ @book{garey1979 year = {1979} } +@article{busingstiller2011, + author = {Christina Büsing and Sebastian Stiller}, + title = {Line planning, path constrained network flow and inapproximability}, + journal = {Networks}, + volume = {57}, + number = {1}, + pages = {106--113}, + year = {2011}, + doi = {10.1002/net.20386} +} + @article{bruckerGareyJohnson1977, author = {Peter Brucker and Michael R. Garey and David S. Johnson}, title = {Scheduling equal-length tasks under tree-like precedence constraints to minimize maximum lateness}, diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index b25ffc2c0..73af2c69d 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -240,6 +240,7 @@ Flags by problem type: IsomorphicSpanningTree --graph, --tree KthBestSpanningTree --graph, --edge-weights, --k, --bound LengthBoundedDisjointPaths --graph, --source, --sink, --num-paths-required, --bound + PathConstrainedNetworkFlow --arcs, --capacities, --source, --sink, --paths, --requirement Factoring --target, --m, --n BinPacking --sizes, --capacity SubsetSum --sizes, --target @@ -370,12 +371,15 @@ pub struct CreateArgs { /// Sink vertex for path-based graph problems and MinimumCutIntoBoundedSets #[arg(long)] pub sink: Option, - /// Required sink inflow for IntegralFlowHomologousArcs and IntegralFlowWithMultipliers + /// Required total flow R for IntegralFlowHomologousArcs, IntegralFlowWithMultipliers, and PathConstrainedNetworkFlow #[arg(long)] pub requirement: Option, /// Required number of paths for LengthBoundedDisjointPaths #[arg(long)] pub num_paths_required: Option, + /// Prescribed directed s-t paths as semicolon-separated arc-index sequences (e.g., "0,2,5;1,4,6") + #[arg(long)] + pub paths: Option, /// Pairwise couplings J_ij for SpinGlass (e.g., 1,-1,1) [default: all 1s] #[arg(long)] pub couplings: Option, diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index e54c829ad..6cf68acde 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -14,8 +14,8 @@ use problemreductions::models::formula::Quantifier; use problemreductions::models::graph::{ GeneralizedHex, GraphPartitioning, HamiltonianCircuit, HamiltonianPath, LengthBoundedDisjointPaths, LongestCircuit, LongestPath, MinimumCutIntoBoundedSets, - MinimumMultiwayCut, MixedChinesePostman, MultipleChoiceBranching, SteinerTree, - SteinerTreeInGraphs, StrongConnectivityAugmentation, + MinimumMultiwayCut, MixedChinesePostman, MultipleChoiceBranching, PathConstrainedNetworkFlow, + SteinerTree, SteinerTreeInGraphs, StrongConnectivityAugmentation, }; use problemreductions::models::misc::{ AdditionalKey, BinPacking, BoyceCoddNormalFormViolation, CbqRelation, ConjunctiveBooleanQuery, @@ -55,6 +55,7 @@ fn all_data_flags_empty(args: &CreateArgs) -> bool { && args.sink.is_none() && args.requirement.is_none() && args.num_paths_required.is_none() + && args.paths.is_none() && args.couplings.is_none() && args.fields.is_none() && args.clauses.is_none() @@ -77,6 +78,7 @@ fn all_data_flags_empty(args: &CreateArgs) -> bool { && args.sink_2.is_none() && args.requirement_1.is_none() && args.requirement_2.is_none() + && args.requirement.is_none() && args.sizes.is_none() && args.capacity.is_none() && args.sequence.is_none() @@ -540,6 +542,9 @@ fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str { "LengthBoundedDisjointPaths" => { "--graph 0-1,1-6,0-2,2-3,3-6,0-4,4-5,5-6 --source 0 --sink 6 --num-paths-required 2 --bound 3" } + "PathConstrainedNetworkFlow" => { + "--arcs \"0>1,0>2,1>3,1>4,2>4,3>5,4>5,4>6,5>7,6>7\" --capacities 2,1,1,1,1,1,1,1,2,1 --source 0 --sink 7 --paths \"0,2,5,8;0,3,6,8;0,3,7,9;1,4,6,8;1,4,7,9\" --requirement 3" + } "IsomorphicSpanningTree" => "--graph 0-1,1-2,0-2 --tree 0-1,1-2", "KthBestSpanningTree" => "--graph 0-1,0-2,1-2 --edge-weights 2,3,1 --k 1 --bound 3", "LongestCircuit" => { @@ -771,6 +776,9 @@ fn help_flag_hint( ("ConsistencyOfDatabaseFrequencyTables", "known_values") => { "semicolon-separated triples: \"0,0,0;3,0,1;1,2,1\"" } + ("PathConstrainedNetworkFlow", "paths") => { + "semicolon-separated arc-index paths: \"0,2,5,8;1,4,7,9\"" + } ("ConsecutiveOnesSubmatrix", "matrix") => "semicolon-separated 0/1 rows: \"1,0;0,1\"", ("TimetableDesign", "craftsman_avail") | ("TimetableDesign", "task_avail") => { "semicolon-separated 0/1 rows: \"1,1,0;0,1,1\"" @@ -3319,6 +3327,47 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { ) } + // PathConstrainedNetworkFlow + "PathConstrainedNetworkFlow" => { + let usage = "Usage: pred create PathConstrainedNetworkFlow --arcs \"0>1,0>2,1>3,1>4,2>4,3>5,4>5,4>6,5>7,6>7\" --capacities 2,1,1,1,1,1,1,1,2,1 --source 0 --sink 7 --paths \"0,2,5,8;0,3,6,8;0,3,7,9;1,4,6,8;1,4,7,9\" --requirement 3"; + let arcs_str = args.arcs.as_deref().ok_or_else(|| { + anyhow::anyhow!("PathConstrainedNetworkFlow requires --arcs\n\n{usage}") + })?; + let (graph, num_arcs) = parse_directed_graph(arcs_str, args.num_vertices) + .map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; + let capacities: Vec = if let Some(ref s) = args.capacities { + util::parse_comma_list(s)? + } else { + vec![1; num_arcs] + }; + anyhow::ensure!( + capacities.len() == num_arcs, + "capacities length ({}) must match number of arcs ({num_arcs})", + capacities.len() + ); + let source = args.source.ok_or_else(|| { + anyhow::anyhow!("PathConstrainedNetworkFlow requires --source\n\n{usage}") + })?; + let sink = args.sink.ok_or_else(|| { + anyhow::anyhow!("PathConstrainedNetworkFlow requires --sink\n\n{usage}") + })?; + let requirement = args.requirement.ok_or_else(|| { + anyhow::anyhow!("PathConstrainedNetworkFlow requires --requirement\n\n{usage}") + })?; + let paths = parse_prescribed_paths(args, num_arcs, usage)?; + ( + ser(PathConstrainedNetworkFlow::new( + graph, + capacities, + source, + sink, + paths, + requirement, + ))?, + resolved_variant.clone(), + ) + } + // MinimumFeedbackArcSet "MinimumFeedbackArcSet" => { let arcs_str = args.arcs.as_deref().ok_or_else(|| { @@ -5164,6 +5213,40 @@ fn parse_directed_graph( Ok((DirectedGraph::new(num_v, arcs), num_arcs)) } +fn parse_prescribed_paths( + args: &CreateArgs, + num_arcs: usize, + usage: &str, +) -> Result>> { + let paths_str = args + .paths + .as_deref() + .ok_or_else(|| anyhow::anyhow!("PathConstrainedNetworkFlow requires --paths\n\n{usage}"))?; + + paths_str + .split(';') + .map(|path_str| { + let trimmed = path_str.trim(); + anyhow::ensure!( + !trimmed.is_empty(), + "PathConstrainedNetworkFlow paths must be non-empty\n\n{usage}" + ); + let path: Vec = util::parse_comma_list(trimmed)?; + anyhow::ensure!( + !path.is_empty(), + "PathConstrainedNetworkFlow paths must be non-empty\n\n{usage}" + ); + for &arc_idx in &path { + anyhow::ensure!( + arc_idx < num_arcs, + "Path arc index {arc_idx} out of bounds for {num_arcs} arcs\n\n{usage}" + ); + } + Ok(path) + }) + .collect() +} + 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}"))?; @@ -5865,6 +5948,90 @@ mod tests { std::fs::remove_file(output_path).unwrap(); } + #[test] + fn test_create_path_constrained_network_flow_outputs_problem_json() { + let cli = Cli::try_parse_from([ + "pred", + "create", + "PathConstrainedNetworkFlow", + "--arcs", + "0>1,0>2,1>3,1>4,2>4,3>5,4>5,4>6,5>7,6>7", + "--capacities", + "2,1,1,1,1,1,1,1,2,1", + "--source", + "0", + "--sink", + "7", + "--paths", + "0,2,5,8;0,3,6,8;0,3,7,9;1,4,6,8;1,4,7,9", + "--requirement", + "3", + ]) + .expect("parse create command"); + + let args = match cli.command { + Commands::Create(args) => args, + _ => panic!("expected create command"), + }; + + let output_path = temp_output_path("path_constrained_network_flow"); + let out = OutputConfig { + output: Some(output_path.clone()), + quiet: true, + json: false, + auto_json: false, + }; + + create(&args, &out).expect("create PathConstrainedNetworkFlow JSON"); + + let created: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&output_path).unwrap()).unwrap(); + fs::remove_file(output_path).ok(); + + assert_eq!(created["type"], "PathConstrainedNetworkFlow"); + assert_eq!(created["data"]["source"], 0); + assert_eq!(created["data"]["sink"], 7); + assert_eq!(created["data"]["requirement"], 3); + assert_eq!(created["data"]["paths"][0], serde_json::json!([0, 2, 5, 8])); + } + + #[test] + fn test_create_path_constrained_network_flow_rejects_invalid_paths() { + let cli = Cli::try_parse_from([ + "pred", + "create", + "PathConstrainedNetworkFlow", + "--arcs", + "0>1,1>2,2>3", + "--capacities", + "1,1,1", + "--source", + "0", + "--sink", + "3", + "--paths", + "0,3", + "--requirement", + "1", + ]) + .expect("parse create command"); + + let args = match cli.command { + Commands::Create(args) => args, + _ => panic!("expected create command"), + }; + + let out = OutputConfig { + output: None, + quiet: true, + json: false, + auto_json: false, + }; + + let err = create(&args, &out).unwrap_err().to_string(); + assert!(err.contains("out of bounds") || err.contains("not contiguous")); + } + #[test] fn test_create_staff_scheduling_reports_invalid_schedule_without_panic() { let cli = Cli::try_parse_from([ @@ -5916,6 +6083,13 @@ mod tests { ); } + #[test] + fn test_example_for_path_constrained_network_flow_mentions_paths_flag() { + let example = example_for("PathConstrainedNetworkFlow", None); + assert!(example.contains("--paths")); + assert!(example.contains("--requirement")); + } + #[test] fn test_create_timetable_design_outputs_problem_json() { let cli = Cli::try_parse_from([ @@ -6233,6 +6407,7 @@ mod tests { sink: None, requirement: None, num_paths_required: None, + paths: None, couplings: None, fields: None, clauses: None, diff --git a/src/lib.rs b/src/lib.rs index d6939b41f..329d0ad8c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -62,8 +62,8 @@ pub mod prelude { MinimumFeedbackArcSet, MinimumFeedbackVertexSet, MinimumMultiwayCut, MinimumSumMulticenter, MinimumVertexCover, MultipleChoiceBranching, MultipleCopyFileAllocation, OptimalLinearArrangement, PartitionIntoPathsOfLength2, PartitionIntoTriangles, - RuralPostman, ShortestWeightConstrainedPath, SteinerTreeInGraphs, TravelingSalesman, - UndirectedTwoCommodityIntegralFlow, + PathConstrainedNetworkFlow, RuralPostman, ShortestWeightConstrainedPath, + SteinerTreeInGraphs, TravelingSalesman, UndirectedTwoCommodityIntegralFlow, }; pub use crate::models::misc::{ AdditionalKey, BinPacking, BoyceCoddNormalFormViolation, CbqRelation, diff --git a/src/models/graph/mod.rs b/src/models/graph/mod.rs index ecd77c9f2..640c6b84f 100644 --- a/src/models/graph/mod.rs +++ b/src/models/graph/mod.rs @@ -38,6 +38,7 @@ //! - [`MinimumSumMulticenter`]: Min-sum multicenter (p-median) //! - [`MultipleChoiceBranching`]: Directed branching with partition constraints //! - [`LengthBoundedDisjointPaths`]: Length-bounded internally disjoint s-t paths +//! - [`PathConstrainedNetworkFlow`]: Integral flow on a prescribed collection of directed 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 @@ -87,6 +88,7 @@ pub(crate) mod multiple_copy_file_allocation; pub(crate) mod optimal_linear_arrangement; pub(crate) mod partition_into_paths_of_length_2; pub(crate) mod partition_into_triangles; +pub(crate) mod path_constrained_network_flow; pub(crate) mod rural_postman; pub(crate) mod shortest_weight_constrained_path; pub(crate) mod spin_glass; @@ -136,6 +138,7 @@ pub use multiple_copy_file_allocation::MultipleCopyFileAllocation; pub use optimal_linear_arrangement::OptimalLinearArrangement; pub use partition_into_paths_of_length_2::PartitionIntoPathsOfLength2; pub use partition_into_triangles::PartitionIntoTriangles; +pub use path_constrained_network_flow::PathConstrainedNetworkFlow; pub use rural_postman::RuralPostman; pub use shortest_weight_constrained_path::ShortestWeightConstrainedPath; pub use spin_glass::SpinGlass; @@ -185,6 +188,7 @@ pub(crate) fn canonical_model_example_specs() -> Vec", description: "Capacity c(a) for each arc" }, + FieldInfo { name: "source", type_name: "usize", description: "Source vertex s" }, + FieldInfo { name: "sink", type_name: "usize", description: "Sink vertex t" }, + FieldInfo { name: "paths", type_name: "Vec>", description: "Prescribed directed s-t paths as arc-index sequences" }, + FieldInfo { name: "requirement", type_name: "u64", description: "Required total flow R" }, + ], + } +} + +/// Path-Constrained Network Flow. +/// +/// A configuration contains one integer variable per prescribed path. If +/// `config[i] = x`, then `x` units of flow are routed along the i-th prescribed +/// path. A configuration is feasible when: +/// - each path variable stays within its bottleneck capacity +/// - the induced arc loads do not exceed the arc capacities +/// - the total delivered flow reaches the requirement +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PathConstrainedNetworkFlow { + graph: DirectedGraph, + capacities: Vec, + source: usize, + sink: usize, + paths: Vec>, + requirement: u64, +} + +impl PathConstrainedNetworkFlow { + /// Create a new Path-Constrained Network Flow instance. + /// + /// # Panics + /// + /// Panics if: + /// - `capacities.len() != graph.num_arcs()` + /// - `source` or `sink` are out of range or identical + /// - any prescribed path is not a valid directed simple s-t path + pub fn new( + graph: DirectedGraph, + capacities: Vec, + source: usize, + sink: usize, + paths: Vec>, + requirement: u64, + ) -> Self { + let num_vertices = graph.num_vertices(); + assert_eq!( + capacities.len(), + graph.num_arcs(), + "capacities length must match graph num_arcs" + ); + assert!( + source < num_vertices, + "source ({source}) >= num_vertices ({num_vertices})" + ); + assert!( + sink < num_vertices, + "sink ({sink}) >= num_vertices ({num_vertices})" + ); + assert_ne!(source, sink, "source and sink must be distinct"); + + for path in &paths { + Self::assert_valid_path(&graph, path, source, sink); + } + + Self { + graph, + capacities, + source, + sink, + paths, + requirement, + } + } + + fn assert_valid_path(graph: &DirectedGraph, path: &[usize], source: usize, sink: usize) { + assert!(!path.is_empty(), "prescribed paths must be non-empty"); + + let arcs = graph.arcs(); + let mut visited_vertices = HashSet::from([source]); + let mut current = source; + + for &arc_idx in path { + let &(tail, head) = arcs + .get(arc_idx) + .unwrap_or_else(|| panic!("path arc index {arc_idx} out of bounds")); + assert_eq!( + tail, current, + "prescribed path is not contiguous: expected arc leaving vertex {current}, got {tail}->{head}" + ); + assert!( + visited_vertices.insert(head), + "prescribed path repeats vertex {head}, so it is not a simple path" + ); + current = head; + } + + assert_eq!( + current, sink, + "prescribed path must end at sink {sink}, ended at {current}" + ); + } + + fn path_bottleneck(&self, path: &[usize]) -> u64 { + path.iter() + .map(|&arc_idx| self.capacities[arc_idx]) + .min() + .unwrap_or(0) + } + + /// Get a reference to the underlying graph. + pub fn graph(&self) -> &DirectedGraph { + &self.graph + } + + /// Get the arc capacities. + pub fn capacities(&self) -> &[u64] { + &self.capacities + } + + /// Get the prescribed path collection. + pub fn paths(&self) -> &[Vec] { + &self.paths + } + + /// Get the source vertex. + pub fn source(&self) -> usize { + self.source + } + + /// Get the sink vertex. + pub fn sink(&self) -> usize { + self.sink + } + + /// Get the required total flow. + pub fn requirement(&self) -> u64 { + self.requirement + } + + /// Update the required total flow. + pub fn set_requirement(&mut self, requirement: u64) { + self.requirement = requirement; + } + + /// Get the number of vertices. + pub fn num_vertices(&self) -> usize { + self.graph.num_vertices() + } + + /// Get the number of arcs. + pub fn num_arcs(&self) -> usize { + self.graph.num_arcs() + } + + /// Get the number of prescribed paths. + pub fn num_paths(&self) -> usize { + self.paths.len() + } + + /// Get the maximum arc capacity. + pub fn max_capacity(&self) -> u64 { + self.capacities.iter().copied().max().unwrap_or(0) + } + + /// Check whether a path-flow assignment is feasible. + pub fn is_feasible(&self, config: &[usize]) -> bool { + if config.len() != self.paths.len() { + return false; + } + + let mut arc_loads = vec![0_u64; self.capacities.len()]; + let mut total_flow = 0_u64; + + for (flow_value, path) in config.iter().copied().zip(&self.paths) { + let path_flow = flow_value as u64; + if path_flow > self.path_bottleneck(path) { + return false; + } + + total_flow += path_flow; + for &arc_idx in path { + arc_loads[arc_idx] += path_flow; + if arc_loads[arc_idx] > self.capacities[arc_idx] { + return false; + } + } + } + + total_flow >= self.requirement + } +} + +impl Problem for PathConstrainedNetworkFlow { + const NAME: &'static str = "PathConstrainedNetworkFlow"; + type Metric = bool; + + fn dims(&self) -> Vec { + self.paths + .iter() + .map(|path| (self.path_bottleneck(path) as usize) + 1) + .collect() + } + + fn evaluate(&self, config: &[usize]) -> bool { + self.is_feasible(config) + } + + fn variant() -> Vec<(&'static str, &'static str)> { + crate::variant_params![] + } +} + +impl SatisfactionProblem for PathConstrainedNetworkFlow {} + +crate::declare_variants! { + default sat PathConstrainedNetworkFlow => "(max_capacity + 1)^num_paths", +} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_model_example_specs() -> Vec { + vec![crate::example_db::specs::ModelExampleSpec { + id: "path_constrained_network_flow", + instance: Box::new(PathConstrainedNetworkFlow::new( + DirectedGraph::new( + 8, + vec![ + (0, 1), + (0, 2), + (1, 3), + (1, 4), + (2, 4), + (3, 5), + (4, 5), + (4, 6), + (5, 7), + (6, 7), + ], + ), + vec![2, 1, 1, 1, 1, 1, 1, 1, 2, 1], + 0, + 7, + vec![ + vec![0, 2, 5, 8], + vec![0, 3, 6, 8], + vec![0, 3, 7, 9], + vec![1, 4, 6, 8], + vec![1, 4, 7, 9], + ], + 3, + )), + optimal_config: vec![1, 1, 0, 0, 1], + optimal_value: serde_json::json!(true), + }] +} + +#[cfg(test)] +#[path = "../../unit_tests/models/graph/path_constrained_network_flow.rs"] +mod tests; diff --git a/src/models/mod.rs b/src/models/mod.rs index 5f56e599e..709410d9f 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -27,10 +27,10 @@ pub use graph::{ MinimumCutIntoBoundedSets, MinimumDominatingSet, MinimumFeedbackArcSet, MinimumFeedbackVertexSet, MinimumMultiwayCut, MinimumSumMulticenter, MinimumVertexCover, MixedChinesePostman, MultipleChoiceBranching, MultipleCopyFileAllocation, - OptimalLinearArrangement, PartitionIntoPathsOfLength2, PartitionIntoTriangles, RuralPostman, - ShortestWeightConstrainedPath, SpinGlass, SteinerTree, SteinerTreeInGraphs, - StrongConnectivityAugmentation, SubgraphIsomorphism, TravelingSalesman, - UndirectedTwoCommodityIntegralFlow, + OptimalLinearArrangement, PartitionIntoPathsOfLength2, PartitionIntoTriangles, + PathConstrainedNetworkFlow, RuralPostman, ShortestWeightConstrainedPath, SpinGlass, + SteinerTree, SteinerTreeInGraphs, StrongConnectivityAugmentation, SubgraphIsomorphism, + TravelingSalesman, UndirectedTwoCommodityIntegralFlow, }; pub use misc::PartiallyOrderedKnapsack; pub use misc::{ diff --git a/src/unit_tests/models/graph/path_constrained_network_flow.rs b/src/unit_tests/models/graph/path_constrained_network_flow.rs new file mode 100644 index 000000000..0fb6625b7 --- /dev/null +++ b/src/unit_tests/models/graph/path_constrained_network_flow.rs @@ -0,0 +1,154 @@ +use super::*; +use crate::solvers::{BruteForce, Solver}; +use crate::topology::DirectedGraph; +use crate::traits::Problem; + +fn yes_instance() -> PathConstrainedNetworkFlow { + let graph = DirectedGraph::new( + 8, + vec![ + (0, 1), + (0, 2), + (1, 3), + (1, 4), + (2, 4), + (3, 5), + (4, 5), + (4, 6), + (5, 7), + (6, 7), + ], + ); + + PathConstrainedNetworkFlow::new( + graph, + vec![2, 1, 1, 1, 1, 1, 1, 1, 2, 1], + 0, + 7, + vec![ + vec![0, 2, 5, 8], + vec![0, 3, 6, 8], + vec![0, 3, 7, 9], + vec![1, 4, 6, 8], + vec![1, 4, 7, 9], + ], + 3, + ) +} + +fn no_instance() -> PathConstrainedNetworkFlow { + let mut problem = yes_instance(); + problem.set_requirement(4); + problem +} + +#[test] +fn test_path_constrained_network_flow_creation() { + let problem = yes_instance(); + assert_eq!(problem.num_vertices(), 8); + assert_eq!(problem.num_arcs(), 10); + assert_eq!(problem.num_paths(), 5); + assert_eq!(problem.max_capacity(), 2); + assert_eq!(problem.requirement(), 3); + assert_eq!(problem.source(), 0); + assert_eq!(problem.sink(), 7); + assert_eq!(problem.graph().num_vertices(), 8); + assert_eq!(problem.capacities().len(), 10); + assert_eq!(problem.paths().len(), 5); +} + +#[test] +fn test_path_constrained_network_flow_dims_use_path_bottlenecks() { + let problem = yes_instance(); + assert_eq!(problem.dims(), vec![2, 2, 2, 2, 2]); +} + +#[test] +fn test_path_constrained_network_flow_evaluation_satisfying() { + let problem = yes_instance(); + assert!(problem.evaluate(&[1, 1, 0, 0, 1])); + assert!(problem.evaluate(&[1, 0, 1, 1, 0])); +} + +#[test] +fn test_path_constrained_network_flow_evaluation_unsatisfying() { + let problem = yes_instance(); + assert!(!problem.evaluate(&[1, 1, 0, 0, 0])); + assert!(!problem.evaluate(&[1, 1, 1, 0, 0])); + assert!(!problem.evaluate(&[1, 1, 0, 0])); +} + +#[test] +fn test_path_constrained_network_flow_solver_yes_and_no() { + let yes = yes_instance(); + let no = no_instance(); + let solver = BruteForce::new(); + + let satisfying = solver.find_all_satisfying(&yes); + assert_eq!(satisfying.len(), 2); + assert!(satisfying.iter().all(|config| yes.evaluate(config))); + + assert!(solver.find_satisfying(&no).is_none()); +} + +#[test] +fn test_path_constrained_network_flow_serialization() { + let problem = yes_instance(); + let json = serde_json::to_string(&problem).unwrap(); + let restored: PathConstrainedNetworkFlow = serde_json::from_str(&json).unwrap(); + assert_eq!(restored.num_vertices(), 8); + assert_eq!(restored.num_arcs(), 10); + assert_eq!(restored.num_paths(), 5); + assert_eq!(restored.requirement(), 3); +} + +#[test] +fn test_path_constrained_network_flow_rejects_non_contiguous_path() { + let graph = DirectedGraph::new(4, vec![(0, 1), (1, 2), (2, 3)]); + let result = std::panic::catch_unwind(|| { + PathConstrainedNetworkFlow::new(graph, vec![1, 1, 1], 0, 3, vec![vec![0, 2]], 1) + }); + assert!(result.is_err()); +} + +#[test] +fn test_path_constrained_network_flow_rejects_empty_path() { + let graph = DirectedGraph::new(4, vec![(0, 1), (1, 2), (2, 3)]); + let result = std::panic::catch_unwind(|| { + PathConstrainedNetworkFlow::new(graph, vec![1, 1, 1], 0, 3, vec![vec![]], 1) + }); + assert!(result.is_err()); +} + +#[test] +fn test_path_constrained_network_flow_rejects_path_not_ending_at_sink() { + let graph = DirectedGraph::new(4, vec![(0, 1), (1, 2), (2, 3)]); + let result = std::panic::catch_unwind(|| { + PathConstrainedNetworkFlow::new(graph, vec![1, 1, 1], 0, 3, vec![vec![0, 1]], 1) + }); + assert!(result.is_err()); +} + +#[test] +fn test_path_constrained_network_flow_rejects_path_with_repeated_vertex() { + // Graph: 0->1, 1->2, 2->1, 1->3 (arcs 0,1,2,3) + let graph = DirectedGraph::new(4, vec![(0, 1), (1, 2), (2, 1), (1, 3)]); + let result = std::panic::catch_unwind(|| { + // Path [0, 1, 2, 3]: 0->1->2->1->3 revisits vertex 1 + PathConstrainedNetworkFlow::new(graph, vec![1, 1, 1, 1], 0, 3, vec![vec![0, 1, 2, 3]], 1) + }); + assert!(result.is_err()); +} + +#[test] +fn test_path_constrained_network_flow_paper_example() { + let problem = yes_instance(); + let solver = BruteForce::new(); + let config = vec![1, 1, 0, 0, 1]; + + assert!(problem.evaluate(&config)); + + let all = solver.find_all_satisfying(&problem); + assert_eq!(all.len(), 2); + assert!(all.contains(&config)); +}