diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index cec23b61e..f8214db14 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -76,6 +76,7 @@ "LongestCircuit": [Longest Circuit], "LongestPath": [Longest Path], "ShortestWeightConstrainedPath": [Shortest Weight-Constrained Path], + "UndirectedFlowLowerBounds": [Undirected Flow with Lower Bounds], "UndirectedTwoCommodityIntegralFlow": [Undirected Two-Commodity Integral Flow], "PathConstrainedNetworkFlow": [Path-Constrained Network Flow], "LengthBoundedDisjointPaths": [Length-Bounded Disjoint Paths], @@ -1134,6 +1135,68 @@ is feasible: each set induces a connected subgraph, the component weights are $2 ] ] } +#{ + let x = load-model-example("UndirectedFlowLowerBounds") + let s = x.instance.source + let t = x.instance.sink + let R = x.instance.requirement + let orientation = x.optimal_config + let edges = x.instance.graph.edges + let lower = x.instance.lower_bounds + let caps = x.instance.capacities + let witness = (2, 1, 1, 1, 1, 2, 1) + [ + #problem-def("UndirectedFlowLowerBounds")[ + Given an undirected graph $G = (V, E)$, specified vertices $s, t in V$, lower bounds $l: E -> ZZ_(>= 0)$, upper capacities $c: E -> ZZ^+$ with $l(e) <= c(e)$ for every edge, and a requirement $R in ZZ^+$, determine whether there exists a flow function $f: {(u, v), (v, u): {u, v} in E} -> ZZ_(>= 0)$ such that each edge carries flow in at most one direction, every edge value lies between its lower and upper bound, flow is conserved at every vertex in $V backslash {s, t}$, and the net flow into $t$ is at least $R$. + ][ + Undirected Flow with Lower Bounds appears as ND37 in Garey and Johnson's catalog @garey1979. Itai proved that even this single-commodity undirected feasibility problem is NP-complete, contrasting sharply with the directed lower-bounded case, which reduces to ordinary max-flow machinery @itai1978. + + The implementation exposes one binary decision per edge rather than raw flow magnitudes. The configuration $(#orientation.map(str).join(", "))$ means "orient every edge exactly as listed in the stored edge order"; once an orientation is fixed, `evaluate()` checks the remaining lower-bounded directed circulation conditions internally. This keeps the explicit search space at $2^m$ for $m = |E|$, matching the registry complexity bound. + + *Example.* The canonical fixture uses source $s = v_#s$, sink $t = v_#t$, requirement $R = #R$, edges ${#edges.map(((u, v)) => $(v_#u, v_#v)$).join(", ")}$, and lower/upper pairs ${#range(edges.len()).map(i => $(#lower.at(i), #caps.at(i))$).join(", ")}$ in that order. Under the all-zero orientation config, a feasible witness sends flows $(#witness.map(str).join(", "))$ along those edges respectively: $2$ on $(v_0, v_1)$, $1$ on $(v_0, v_2)$, $1$ on $(v_1, v_3)$, $1$ on $(v_2, v_3)$, $1$ on $(v_1, v_4)$, $2$ on $(v_3, v_5)$, and $1$ on $(v_4, v_5)$. Every lower bound is satisfied, each nonterminal vertex has equal inflow and outflow, and the sink receives $2 + 1 = 3 >= R$, so the instance evaluates to true. A separate rule issue tracks the natural reduction to ILP; this model PR only documents the standalone verifier. + + #pred-commands( + "pred create --example UndirectedFlowLowerBounds -o undirected-flow-lower-bounds.json", + "pred solve undirected-flow-lower-bounds.json", + "pred evaluate undirected-flow-lower-bounds.json --config " + x.optimal_config.map(str).join(","), + ) + + #figure( + canvas(length: 0.9cm, { + import draw: * + let blue = graph-colors.at(0) + let red = rgb("#e15759") + let gray = luma(190) + let verts = ((0, 0), (1.6, 1.2), (1.6, -1.2), (3.4, 0.5), (3.4, -1.5), (5.2, -0.3)) + let labels = ( + [$s = v_0$], + [$v_1$], + [$v_2$], + [$v_3$], + [$v_4$], + [$t = v_5$], + ) + for (u, v) in edges { + g-edge(verts.at(u), verts.at(v), stroke: 1.8pt + blue) + } + for (i, pos) in verts.enumerate() { + let fill = if i == s { blue } else if i == t { red } else { white } + let label = if i == s or i == t { text(fill: white)[#labels.at(i)] } else { labels.at(i) } + g-node(pos, name: "uflb-" + str(i), fill: fill, label: label) + } + content((0.75, 0.7), text(7pt, fill: gray)[$f = 2$]) + content((0.75, -0.7), text(7pt, fill: gray)[$f = 1$]) + content((2.45, 1.05), text(7pt, fill: gray)[$f = 1$]) + content((2.45, -0.25), text(7pt, fill: gray)[$f = 1$]) + content((2.45, -1.45), text(7pt, fill: gray)[$f = 1$]) + content((4.35, 0.35), text(7pt, fill: gray)[$f = 2$]) + content((4.35, -1.1), text(7pt, fill: gray)[$f = 1$]) + }), + caption: [Canonical YES instance for Undirected Flow with Lower Bounds. Blue edges follow the all-zero orientation config, and edge labels show one feasible witness flow.], + ) + ] + ] +} #{ let x = load-model-example("UndirectedTwoCommodityIntegralFlow") let satisfying_count = 1 diff --git a/docs/paper/references.bib b/docs/paper/references.bib index 3cd4583db..2bf8a880d 100644 --- a/docs/paper/references.bib +++ b/docs/paper/references.bib @@ -266,14 +266,14 @@ @article{evenItaiShamir1976 doi = {10.1137/0205048} } -@article{sahni1974, - author = {Sartaj Sahni}, - title = {Computationally Related Problems}, - journal = {SIAM Journal on Computing}, - volume = {3}, +@article{itai1978, + author = {Alon Itai}, + title = {Two-Commodity Flow}, + journal = {Journal of the ACM}, + volume = {25}, number = {4}, - pages = {262--279}, - year = {1974}, + pages = {596--611}, + year = {1978}, doi = {10.1137/0203021} } @@ -288,6 +288,16 @@ @article{jewell1962 doi = {10.1287/opre.10.4.476} } +@article{sahni1974, + author = {Sartaj Sahni}, + title = {Computationally Related Problems}, + journal = {SIAM Journal on Computing}, + volume = {3}, + number = {4}, + pages = {262--279}, + year = {1974} +} + @article{abdelWahabKameda1978, author = {H. M. Abdel-Wahab and T. Kameda}, title = {Scheduling to Minimize Maximum Cumulative Cost Subject to Series-Parallel Precedence Constraints}, diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index a89446670..fa5ed7f60 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -235,6 +235,7 @@ Flags by problem type: HamiltonianCircuit, HC --graph LongestCircuit --graph, --edge-weights, --bound BoundedComponentSpanningForest --graph, --weights, --k, --bound + UndirectedFlowLowerBounds --graph, --capacities, --lower-bounds, --source, --sink, --requirement IntegralFlowBundles --arcs, --bundles, --bundle-capacities, --source, --sink, --requirement [--num-vertices] UndirectedTwoCommodityIntegralFlow --graph, --capacities, --source-1, --sink-1, --source-2, --sink-2, --requirement-1, --requirement-2 IntegralFlowHomologousArcs --arcs, --capacities, --source, --sink, --requirement, --homologous-pairs @@ -325,6 +326,7 @@ Examples: 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 MultiprocessorScheduling --lengths 4,5,3,2,6 --num-processors 2 --deadline 10 + pred create UndirectedFlowLowerBounds --graph 0-1,0-2,1-3,2-3,1-4,3-5,4-5 --capacities 2,2,2,2,1,3,2 --lower-bounds 1,1,0,0,1,0,1 --source 0 --sink 5 --requirement 3 pred create ConsistencyOfDatabaseFrequencyTables --num-objects 6 --attribute-domains \"2,3,2\" --frequency-tables \"0,1:1,1,1|1,1,1;1,2:1,1|0,2|1,1\" --known-values \"0,0,0;3,0,1;1,2,1\" pred create BiconnectivityAugmentation --graph 0-1,1-2,2-3 --potential-edges 0-2:3,0-3:4,1-3:2 --budget 5 pred create FVS --arcs \"0>1,1>2,2>0\" --weights 1,1,1 @@ -363,6 +365,9 @@ pub struct CreateArgs { /// Edge capacities for multicommodity flow problems (e.g., 1,1,2) #[arg(long)] pub capacities: Option, + /// Edge lower bounds for lower-bounded flow problems (e.g., 1,1,0,0,1,0,1) + #[arg(long)] + pub lower_bounds: Option, /// Bundle capacities for IntegralFlowBundles (e.g., 1,1,1) #[arg(long)] pub bundle_capacities: Option, @@ -375,7 +380,7 @@ pub struct CreateArgs { /// Sink vertex for path-based graph problems and MinimumCutIntoBoundedSets #[arg(long)] pub sink: Option, - /// Required total flow R for IntegralFlowBundles, IntegralFlowHomologousArcs, IntegralFlowWithMultipliers, and PathConstrainedNetworkFlow + /// Required total flow R for IntegralFlowBundles, IntegralFlowHomologousArcs, IntegralFlowWithMultipliers, PathConstrainedNetworkFlow, and UndirectedFlowLowerBounds #[arg(long)] pub requirement: Option, /// Required number of paths for LengthBoundedDisjointPaths diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index 69be70183..4abc75010 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -50,6 +50,7 @@ fn all_data_flags_empty(args: &CreateArgs) -> bool { && args.edge_weights.is_none() && args.edge_lengths.is_none() && args.capacities.is_none() + && args.lower_bounds.is_none() && args.bundle_capacities.is_none() && args.multipliers.is_none() && args.source.is_none() @@ -538,6 +539,9 @@ fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str { "LongestPath" => { "--graph 0-1,0-2,1-3,2-3,2-4,3-5,4-5,4-6,5-6,1-6 --edge-lengths 3,2,4,1,5,2,3,2,4,1 --source-vertex 0 --target-vertex 6" } + "UndirectedFlowLowerBounds" => { + "--graph 0-1,0-2,1-3,2-3,1-4,3-5,4-5 --capacities 2,2,2,2,1,3,2 --lower-bounds 1,1,0,0,1,0,1 --source 0 --sink 5 --requirement 3" + } "UndirectedTwoCommodityIntegralFlow" => { "--graph 0-2,1-2,2-3 --capacities 1,1,2 --source-1 0 --sink-1 3 --source-2 1 --sink-2 3 --requirement-1 1 --requirement-2 1" }, @@ -1478,11 +1482,56 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { ) } + // UndirectedFlowLowerBounds (graph + capacities + lower bounds + terminals + requirement) + "UndirectedFlowLowerBounds" => { + let usage = "Usage: pred create UndirectedFlowLowerBounds --graph 0-1,0-2,1-3,2-3,1-4,3-5,4-5 --capacities 2,2,2,2,1,3,2 --lower-bounds 1,1,0,0,1,0,1 --source 0 --sink 5 --requirement 3"; + let (graph, _) = parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; + let capacities = parse_capacities(args, graph.num_edges(), usage)?; + let lower_bounds = parse_lower_bounds(args, graph.num_edges(), usage)?; + let num_vertices = graph.num_vertices(); + let source = args.source.ok_or_else(|| { + anyhow::anyhow!("UndirectedFlowLowerBounds requires --source\n\n{usage}") + })?; + let sink = args.sink.ok_or_else(|| { + anyhow::anyhow!("UndirectedFlowLowerBounds requires --sink\n\n{usage}") + })?; + let requirement = args.requirement.ok_or_else(|| { + anyhow::anyhow!("UndirectedFlowLowerBounds requires --requirement\n\n{usage}") + })?; + validate_vertex_index("source", source, num_vertices, usage)?; + validate_vertex_index("sink", sink, num_vertices, usage)?; + ( + ser(UndirectedFlowLowerBounds::new( + graph, + capacities, + lower_bounds, + source, + sink, + requirement, + ))?, + resolved_variant.clone(), + ) + } + // UndirectedTwoCommodityIntegralFlow (graph + capacities + terminals + requirements) "UndirectedTwoCommodityIntegralFlow" => { let usage = "Usage: pred create UndirectedTwoCommodityIntegralFlow --graph 0-2,1-2,2-3 --capacities 1,1,2 --source-1 0 --sink-1 3 --source-2 1 --sink-2 3 --requirement-1 1 --requirement-2 1"; let (graph, _) = parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; let capacities = parse_capacities(args, graph.num_edges(), usage)?; + for (edge_index, &capacity) in capacities.iter().enumerate() { + let fits = usize::try_from(capacity) + .ok() + .and_then(|value| value.checked_add(1)) + .is_some(); + if !fits { + bail!( + "capacity {} at edge index {} is too large for this platform\n\n{}", + capacity, + edge_index, + usage + ); + } + } let num_vertices = graph.num_vertices(); let source_1 = args.source_1.ok_or_else(|| { anyhow::anyhow!("UndirectedTwoCommodityIntegralFlow requires --source-1\n\n{usage}") @@ -4479,9 +4528,10 @@ fn validate_vertex_index( /// Parse `--capacities` as edge capacities (u64). fn parse_capacities(args: &CreateArgs, num_edges: usize, usage: &str) -> Result> { - let capacities = args.capacities.as_deref().ok_or_else(|| { - anyhow::anyhow!("UndirectedTwoCommodityIntegralFlow requires --capacities\n\n{usage}") - })?; + let capacities = args + .capacities + .as_deref() + .ok_or_else(|| anyhow::anyhow!("This problem requires --capacities\n\n{usage}"))?; let capacities: Vec = capacities .split(',') .map(|s| { @@ -4499,23 +4549,34 @@ fn parse_capacities(args: &CreateArgs, num_edges: usize, usage: &str) -> Result< usage ); } - for (edge_index, &capacity) in capacities.iter().enumerate() { - let fits = usize::try_from(capacity) - .ok() - .and_then(|value| value.checked_add(1)) - .is_some(); - if !fits { - bail!( - "capacity {} at edge index {} is too large for this platform\n\n{}", - capacity, - edge_index, - usage - ); - } - } Ok(capacities) } +/// Parse `--lower-bounds` as edge lower bounds (u64). +fn parse_lower_bounds(args: &CreateArgs, num_edges: usize, usage: &str) -> Result> { + let lower_bounds = args.lower_bounds.as_deref().ok_or_else(|| { + anyhow::anyhow!("UndirectedFlowLowerBounds requires --lower-bounds\n\n{usage}") + })?; + let lower_bounds: Vec = lower_bounds + .split(',') + .map(|s| { + let trimmed = s.trim(); + trimmed + .parse::() + .with_context(|| format!("Invalid lower bound `{trimmed}`\n\n{usage}")) + }) + .collect::>>()?; + if lower_bounds.len() != num_edges { + bail!( + "Expected {} lower bounds but got {}\n\n{}", + num_edges, + lower_bounds.len(), + usage + ); + } + Ok(lower_bounds) +} + fn parse_bundle_capacities(args: &CreateArgs, num_bundles: usize, usage: &str) -> Result> { let capacities = args.bundle_capacities.as_deref().ok_or_else(|| { anyhow::anyhow!("IntegralFlowBundles requires --bundle-capacities\n\n{usage}") @@ -6462,6 +6523,55 @@ mod tests { ); } + #[test] + fn test_create_undirected_flow_lower_bounds_serializes_problem_json() { + let output = temp_output_path("undirected_flow_lower_bounds_create"); + let cli = Cli::try_parse_from([ + "pred", + "-o", + output.to_str().unwrap(), + "create", + "UndirectedFlowLowerBounds", + "--graph", + "0-1,0-2,1-3,2-3,1-4,3-5,4-5", + "--capacities", + "2,2,2,2,1,3,2", + "--lower-bounds", + "1,1,0,0,1,0,1", + "--source", + "0", + "--sink", + "5", + "--requirement", + "3", + ]) + .unwrap(); + let out = OutputConfig { + output: cli.output.clone(), + quiet: true, + json: false, + auto_json: false, + }; + let args = match cli.command { + Commands::Create(args) => args, + _ => unreachable!(), + }; + + create(&args, &out).unwrap(); + + let json: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&output).unwrap()).unwrap(); + fs::remove_file(&output).unwrap(); + assert_eq!(json["type"], "UndirectedFlowLowerBounds"); + assert_eq!(json["data"]["source"], 0); + assert_eq!(json["data"]["sink"], 5); + assert_eq!(json["data"]["requirement"], 3); + assert_eq!( + json["data"]["lower_bounds"], + serde_json::json!([1, 1, 0, 0, 1, 0, 1]) + ); + } + #[test] fn test_create_longest_path_requires_edge_lengths() { let cli = Cli::try_parse_from([ @@ -6528,6 +6638,41 @@ mod tests { .contains("LongestPath uses --edge-lengths, not --weights")); } + #[test] + fn test_create_undirected_flow_lower_bounds_requires_lower_bounds() { + let cli = Cli::try_parse_from([ + "pred", + "create", + "UndirectedFlowLowerBounds", + "--graph", + "0-1,0-2,1-3,2-3,1-4,3-5,4-5", + "--capacities", + "2,2,2,2,1,3,2", + "--source", + "0", + "--sink", + "5", + "--requirement", + "3", + ]) + .unwrap(); + let out = OutputConfig { + output: None, + quiet: true, + json: false, + auto_json: false, + }; + let args = match cli.command { + Commands::Create(args) => args, + _ => unreachable!(), + }; + + let err = create(&args, &out).unwrap_err(); + assert!(err + .to_string() + .contains("UndirectedFlowLowerBounds requires --lower-bounds")); + } + fn empty_args() -> CreateArgs { CreateArgs { problem: Some("BiconnectivityAugmentation".to_string()), @@ -6539,6 +6684,7 @@ mod tests { edge_weights: None, edge_lengths: None, capacities: None, + lower_bounds: None, bundle_capacities: None, multipliers: None, source: None, diff --git a/problemreductions-cli/src/problem_name.rs b/problemreductions-cli/src/problem_name.rs index cea49403b..92e2265a2 100644 --- a/problemreductions-cli/src/problem_name.rs +++ b/problemreductions-cli/src/problem_name.rs @@ -14,6 +14,9 @@ pub struct ProblemSpec { /// /// Uses the catalog for both aliases and canonical names. pub fn resolve_alias(input: &str) -> String { + if input.eq_ignore_ascii_case("UndirectedFlowLowerBounds") { + return "UndirectedFlowLowerBounds".to_string(); + } if let Some(pt) = problemreductions::registry::find_problem_type_by_alias(input) { return pt.canonical_name.to_string(); } @@ -319,6 +322,18 @@ mod tests { ); } + #[test] + fn test_resolve_alias_pass_through_undirected_flow_lower_bounds() { + assert_eq!( + resolve_alias("UndirectedFlowLowerBounds"), + "UndirectedFlowLowerBounds" + ); + assert_eq!( + resolve_alias("undirectedflowlowerbounds"), + "UndirectedFlowLowerBounds" + ); + } + #[test] fn test_parse_problem_spec_ksat_alias() { let spec = parse_problem_spec("KSAT").unwrap(); diff --git a/src/lib.rs b/src/lib.rs index 95c7ff555..96c852059 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -63,7 +63,8 @@ pub mod prelude { MinimumVertexCover, MultipleChoiceBranching, MultipleCopyFileAllocation, OptimalLinearArrangement, PartitionIntoPathsOfLength2, PartitionIntoTriangles, PathConstrainedNetworkFlow, RuralPostman, ShortestWeightConstrainedPath, - SteinerTreeInGraphs, TravelingSalesman, UndirectedTwoCommodityIntegralFlow, + SteinerTreeInGraphs, TravelingSalesman, UndirectedFlowLowerBounds, + 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 bcb2c27a2..def6ecda2 100644 --- a/src/models/graph/mod.rs +++ b/src/models/graph/mod.rs @@ -47,6 +47,7 @@ //! - [`IntegralFlowBundles`]: Integral flow feasibility with overlapping bundle capacities //! - [`IntegralFlowHomologousArcs`]: Integral flow with arc-pair equality constraints //! - [`IntegralFlowWithMultipliers`]: Integral flow with vertex multipliers on a directed graph +//! - [`UndirectedFlowLowerBounds`]: Feasible s-t flow in an undirected graph with lower/upper bounds //! - [`UndirectedTwoCommodityIntegralFlow`]: Two-commodity integral flow on undirected graphs //! - [`StrongConnectivityAugmentation`]: Strong connectivity augmentation with weighted candidate arcs @@ -99,6 +100,7 @@ pub(crate) mod steiner_tree_in_graphs; pub(crate) mod strong_connectivity_augmentation; pub(crate) mod subgraph_isomorphism; pub(crate) mod traveling_salesman; +pub(crate) mod undirected_flow_lower_bounds; pub(crate) mod undirected_two_commodity_integral_flow; pub use acyclic_partition::AcyclicPartition; @@ -150,6 +152,7 @@ pub use steiner_tree_in_graphs::SteinerTreeInGraphs; pub use strong_connectivity_augmentation::StrongConnectivityAugmentation; pub use subgraph_isomorphism::SubgraphIsomorphism; pub use traveling_salesman::TravelingSalesman; +pub use undirected_flow_lower_bounds::UndirectedFlowLowerBounds; pub use undirected_two_commodity_integral_flow::UndirectedTwoCommodityIntegralFlow; #[cfg(feature = "example-db")] @@ -196,6 +199,7 @@ pub(crate) fn canonical_model_example_specs() -> Vec v` +//! - `1` means orient it as `v -> u` +//! +//! For a fixed orientation, feasibility reduces to a directed circulation with +//! lower bounds, so the registered exact complexity matches brute-force +//! enumeration over the `2^|E|` edge orientations. + +use crate::registry::{FieldInfo, ProblemSchemaEntry, ProblemSizeFieldEntry}; +use crate::topology::{Graph, SimpleGraph}; +use crate::traits::{Problem, SatisfactionProblem}; +use serde::{Deserialize, Serialize}; +use std::collections::VecDeque; + +inventory::submit! { + ProblemSchemaEntry { + name: "UndirectedFlowLowerBounds", + display_name: "Undirected Flow with Lower Bounds", + aliases: &[], + dimensions: &[], + module_path: module_path!(), + description: "Determine whether an undirected lower-bounded flow of value at least R exists", + fields: &[ + FieldInfo { name: "graph", type_name: "SimpleGraph", description: "Undirected graph G=(V,E)" }, + FieldInfo { name: "capacities", type_name: "Vec", description: "Upper capacities c(e) in graph edge order" }, + FieldInfo { name: "lower_bounds", type_name: "Vec", description: "Lower bounds l(e) in graph edge order" }, + FieldInfo { name: "source", type_name: "usize", description: "Source vertex s" }, + FieldInfo { name: "sink", type_name: "usize", description: "Sink vertex t" }, + FieldInfo { name: "requirement", type_name: "u64", description: "Required net inflow R at sink t" }, + ], + } +} + +inventory::submit! { + ProblemSizeFieldEntry { + name: "UndirectedFlowLowerBounds", + fields: &["num_vertices", "num_edges"], + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UndirectedFlowLowerBounds { + graph: SimpleGraph, + capacities: Vec, + lower_bounds: Vec, + source: usize, + sink: usize, + requirement: u64, +} + +impl UndirectedFlowLowerBounds { + pub fn new( + graph: SimpleGraph, + capacities: Vec, + lower_bounds: Vec, + source: usize, + sink: usize, + requirement: u64, + ) -> Self { + assert_eq!( + capacities.len(), + graph.num_edges(), + "capacities length must match graph num_edges" + ); + assert_eq!( + lower_bounds.len(), + graph.num_edges(), + "lower_bounds length must match graph num_edges" + ); + + let num_vertices = graph.num_vertices(); + assert!( + source < num_vertices, + "source must be less than num_vertices ({num_vertices})" + ); + assert!( + sink < num_vertices, + "sink must be less than num_vertices ({num_vertices})" + ); + assert!(source != sink, "source and sink must be distinct"); + assert!(requirement >= 1, "requirement must be at least 1"); + + for (edge_index, (&lower, &upper)) in lower_bounds.iter().zip(&capacities).enumerate() { + assert!( + lower <= upper, + "lower bound at edge {edge_index} must be at most its capacity" + ); + } + + Self { + graph, + capacities, + lower_bounds, + source, + sink, + requirement, + } + } + + pub fn graph(&self) -> &SimpleGraph { + &self.graph + } + + pub fn capacities(&self) -> &[u64] { + &self.capacities + } + + pub fn lower_bounds(&self) -> &[u64] { + &self.lower_bounds + } + + pub fn source(&self) -> usize { + self.source + } + + pub fn sink(&self) -> usize { + self.sink + } + + pub fn requirement(&self) -> u64 { + self.requirement + } + + pub fn num_vertices(&self) -> usize { + self.graph.num_vertices() + } + + pub fn num_edges(&self) -> usize { + self.graph.num_edges() + } + + pub fn is_valid_solution(&self, config: &[usize]) -> bool { + self.evaluate(config) + } + + fn total_capacity(&self) -> Option { + self.capacities.iter().try_fold(0_u128, |acc, &capacity| { + acc.checked_add(u128::from(capacity)) + }) + } + + fn has_feasible_orientation(&self, config: &[usize]) -> bool { + if config.len() != self.num_edges() { + return false; + } + + let Some(total_capacity) = self.total_capacity() else { + return false; + }; + let requirement = u128::from(self.requirement); + if requirement > total_capacity { + return false; + } + + let node_count = self.num_vertices(); + let super_source = node_count; + let super_sink = node_count + 1; + let mut network = ResidualNetwork::new(node_count + 2); + let mut balances = vec![0_i128; node_count]; + + for (edge_index, ((u, v), &orientation)) in self + .graph + .edges() + .into_iter() + .zip(config.iter()) + .enumerate() + { + let (from, to) = match orientation { + 0 => (u, v), + 1 => (v, u), + _ => return false, + }; + let lower = u128::from(self.lower_bounds[edge_index]); + let upper = u128::from(self.capacities[edge_index]); + if !add_lower_bounded_edge(&mut network, &mut balances, from, to, lower, upper) { + return false; + } + } + + if !add_lower_bounded_edge( + &mut network, + &mut balances, + self.sink, + self.source, + requirement, + total_capacity, + ) { + return false; + } + + let mut demand = 0_u128; + for (vertex, balance) in balances.into_iter().enumerate() { + if balance > 0 { + let needed = u128::try_from(balance).expect("positive i128 balance fits u128"); + demand = match demand.checked_add(needed) { + Some(value) => value, + None => return false, + }; + network.add_edge(super_source, vertex, needed); + } else if balance < 0 { + let needed = u128::try_from(-balance).expect("negative i128 balance fits u128"); + network.add_edge(vertex, super_sink, needed); + } + } + + network.max_flow(super_source, super_sink) == demand + } +} + +impl Problem for UndirectedFlowLowerBounds { + const NAME: &'static str = "UndirectedFlowLowerBounds"; + type Metric = bool; + + fn variant() -> Vec<(&'static str, &'static str)> { + crate::variant_params![] + } + + fn dims(&self) -> Vec { + vec![2; self.num_edges()] + } + + fn evaluate(&self, config: &[usize]) -> bool { + self.has_feasible_orientation(config) + } +} + +impl SatisfactionProblem for UndirectedFlowLowerBounds {} + +crate::declare_variants! { + default sat UndirectedFlowLowerBounds => "2^num_edges", +} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_model_example_specs() -> Vec { + vec![crate::example_db::specs::ModelExampleSpec { + id: "undirected_flow_lower_bounds", + instance: Box::new(UndirectedFlowLowerBounds::new( + SimpleGraph::new( + 6, + vec![(0, 1), (0, 2), (1, 3), (2, 3), (1, 4), (3, 5), (4, 5)], + ), + vec![2, 2, 2, 2, 1, 3, 2], + vec![1, 1, 0, 0, 1, 0, 1], + 0, + 5, + 3, + )), + optimal_config: vec![0, 0, 0, 0, 0, 0, 0], + optimal_value: serde_json::json!(true), + }] +} + +#[derive(Debug, Clone)] +struct ResidualEdge { + to: usize, + rev: usize, + capacity: u128, +} + +#[derive(Debug, Clone)] +struct ResidualNetwork { + adjacency: Vec>, +} + +impl ResidualNetwork { + fn new(num_vertices: usize) -> Self { + Self { + adjacency: vec![Vec::new(); num_vertices], + } + } + + fn add_edge(&mut self, from: usize, to: usize, capacity: u128) { + let reverse_at_to = self.adjacency[to].len(); + let reverse_at_from = self.adjacency[from].len(); + self.adjacency[from].push(ResidualEdge { + to, + rev: reverse_at_to, + capacity, + }); + self.adjacency[to].push(ResidualEdge { + to: from, + rev: reverse_at_from, + capacity: 0, + }); + } + + fn max_flow(&mut self, source: usize, sink: usize) -> u128 { + let mut total_flow = 0_u128; + + loop { + let mut parent: Vec> = vec![None; self.adjacency.len()]; + let mut queue = VecDeque::new(); + queue.push_back(source); + parent[source] = Some((source, usize::MAX)); + + while let Some(vertex) = queue.pop_front() { + if vertex == sink { + break; + } + + for (edge_index, edge) in self.adjacency[vertex].iter().enumerate() { + if edge.capacity == 0 || parent[edge.to].is_some() { + continue; + } + parent[edge.to] = Some((vertex, edge_index)); + queue.push_back(edge.to); + } + } + + if parent[sink].is_none() { + return total_flow; + } + + let mut path_flow = u128::MAX; + let mut vertex = sink; + while vertex != source { + let (prev, edge_index) = parent[vertex].expect("sink is reachable"); + path_flow = path_flow.min(self.adjacency[prev][edge_index].capacity); + vertex = prev; + } + + let mut vertex = sink; + while vertex != source { + let (prev, edge_index) = parent[vertex].expect("sink is reachable"); + let reverse_edge = self.adjacency[prev][edge_index].rev; + self.adjacency[prev][edge_index].capacity -= path_flow; + self.adjacency[vertex][reverse_edge].capacity += path_flow; + vertex = prev; + } + + total_flow += path_flow; + } + } +} + +fn add_lower_bounded_edge( + network: &mut ResidualNetwork, + balances: &mut [i128], + from: usize, + to: usize, + lower: u128, + upper: u128, +) -> bool { + if lower > upper { + return false; + } + + let residual = upper - lower; + if residual > 0 { + network.add_edge(from, to, residual); + } + + let Ok(lower_signed) = i128::try_from(lower) else { + return false; + }; + balances[from] -= lower_signed; + balances[to] += lower_signed; + true +} + +#[cfg(test)] +#[path = "../../unit_tests/models/graph/undirected_flow_lower_bounds.rs"] +mod tests; diff --git a/src/models/mod.rs b/src/models/mod.rs index 4cfa07c0f..adc0a8fa5 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -30,7 +30,7 @@ pub use graph::{ OptimalLinearArrangement, PartitionIntoPathsOfLength2, PartitionIntoTriangles, PathConstrainedNetworkFlow, RuralPostman, ShortestWeightConstrainedPath, SpinGlass, SteinerTree, SteinerTreeInGraphs, StrongConnectivityAugmentation, SubgraphIsomorphism, - TravelingSalesman, UndirectedTwoCommodityIntegralFlow, + TravelingSalesman, UndirectedFlowLowerBounds, UndirectedTwoCommodityIntegralFlow, }; pub use misc::PartiallyOrderedKnapsack; pub use misc::{ diff --git a/src/unit_tests/models/graph/undirected_flow_lower_bounds.rs b/src/unit_tests/models/graph/undirected_flow_lower_bounds.rs new file mode 100644 index 000000000..9cb507b54 --- /dev/null +++ b/src/unit_tests/models/graph/undirected_flow_lower_bounds.rs @@ -0,0 +1,104 @@ +use super::*; +use crate::solvers::{BruteForce, Solver}; +use crate::topology::{Graph, SimpleGraph}; +use crate::traits::Problem; + +fn canonical_yes_instance() -> UndirectedFlowLowerBounds { + UndirectedFlowLowerBounds::new( + SimpleGraph::new( + 6, + vec![(0, 1), (0, 2), (1, 3), (2, 3), (1, 4), (3, 5), (4, 5)], + ), + vec![2, 2, 2, 2, 1, 3, 2], + vec![1, 1, 0, 0, 1, 0, 1], + 0, + 5, + 3, + ) +} + +fn canonical_no_instance() -> UndirectedFlowLowerBounds { + UndirectedFlowLowerBounds::new( + SimpleGraph::new(4, vec![(0, 1), (0, 2), (1, 3), (2, 3)]), + vec![2, 2, 1, 1], + vec![2, 2, 1, 1], + 0, + 3, + 2, + ) +} + +fn yes_orientation_config() -> Vec { + vec![0, 0, 0, 0, 0, 0, 0] +} + +#[test] +fn test_undirected_flow_lower_bounds_creation() { + let problem = canonical_yes_instance(); + assert_eq!(problem.graph().num_vertices(), 6); + assert_eq!(problem.graph().num_edges(), 7); + assert_eq!(problem.capacities(), &[2, 2, 2, 2, 1, 3, 2]); + assert_eq!(problem.lower_bounds(), &[1, 1, 0, 0, 1, 0, 1]); + assert_eq!(problem.source(), 0); + assert_eq!(problem.sink(), 5); + assert_eq!(problem.requirement(), 3); + assert_eq!(problem.num_vertices(), 6); + assert_eq!(problem.num_edges(), 7); + assert_eq!(problem.dims(), vec![2; 7]); +} + +#[test] +fn test_undirected_flow_lower_bounds_evaluation_yes() { + let problem = canonical_yes_instance(); + let config = yes_orientation_config(); + assert!(problem.evaluate(&config)); + assert!(problem.is_valid_solution(&config)); +} + +#[test] +fn test_undirected_flow_lower_bounds_evaluation_no() { + let problem = canonical_no_instance(); + assert!(!problem.evaluate(&[0, 0, 0, 0])); + assert!(BruteForce::new().find_satisfying(&problem).is_none()); +} + +#[test] +fn test_undirected_flow_lower_bounds_rejects_wrong_config_length() { + let problem = canonical_yes_instance(); + let mut config = yes_orientation_config(); + config.pop(); + assert!(!problem.evaluate(&config)); +} + +#[test] +fn test_undirected_flow_lower_bounds_serialization() { + let problem = canonical_yes_instance(); + let value = serde_json::to_value(&problem).unwrap(); + let restored: UndirectedFlowLowerBounds = serde_json::from_value(value).unwrap(); + assert_eq!(restored.graph(), problem.graph()); + assert_eq!(restored.capacities(), problem.capacities()); + assert_eq!(restored.lower_bounds(), problem.lower_bounds()); + assert_eq!(restored.source(), problem.source()); + assert_eq!(restored.sink(), problem.sink()); + assert_eq!(restored.requirement(), problem.requirement()); +} + +#[test] +fn test_undirected_flow_lower_bounds_solver_yes() { + let problem = canonical_yes_instance(); + let solution = BruteForce::new() + .find_satisfying(&problem) + .expect("expected a satisfying orientation"); + assert!(problem.evaluate(&solution)); + assert_eq!(solution.len(), problem.num_edges()); +} + +#[test] +fn test_undirected_flow_lower_bounds_paper_example() { + let problem = canonical_yes_instance(); + let config = yes_orientation_config(); + assert!(problem.evaluate(&config)); + + let all = BruteForce::new().find_all_satisfying(&problem); + assert!(all.contains(&config)); +}