diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index c41319616..cec23b61e 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -72,6 +72,7 @@ "HamiltonianCircuit": [Hamiltonian Circuit], "BiconnectivityAugmentation": [Biconnectivity Augmentation], "HamiltonianPath": [Hamiltonian Path], + "IntegralFlowBundles": [Integral Flow with Bundles], "LongestCircuit": [Longest Circuit], "LongestPath": [Longest Path], "ShortestWeightConstrainedPath": [Shortest Weight-Constrained Path], @@ -5490,6 +5491,64 @@ A classical NP-complete problem from Garey and Johnson @garey1979[Ch.~3, p.~76], ) ] +#{ + let x = load-model-example("IntegralFlowBundles") + let source = x.instance.source + let sink = x.instance.sink + [ + #problem-def("IntegralFlowBundles")[ + Given a directed graph $G = (V, A)$, specified vertices $s, t in V$, a family of arc bundles $I_1, dots, I_k subset.eq A$ whose union covers $A$, positive bundle capacities $c_1, dots, c_k$, and a requirement $R in ZZ^+$, determine whether there exists an integral flow $f: A -> ZZ_(>= 0)$ such that (1) $sum_(a in I_j) f(a) <= c_j$ for every bundle $j$, (2) flow is conserved at every vertex in $V backslash {s, t}$, and (3) the net flow into $t$ is at least $R$. + ][ + Integral Flow with Bundles is the shared-capacity single-commodity flow problem listed as ND36 in Garey \& Johnson @garey1979. Sahni introduced it as one of a family of computationally related network problems and showed that the bundled-capacity variant is NP-complete even in a very sparse unit-capacity regime @sahni1974. + + The implementation keeps one non-negative integer variable per directed arc. Unlike ordinary max-flow, the usable range of an arc is not determined by an intrinsic per-arc capacity; it is bounded instead by the smallest bundle capacity among the bundles that contain that arc. The registered $O(2^m)$ catalog bound therefore reflects the unit-capacity case with $m = |A|$, which is exactly the regime highlighted by Garey \& Johnson and Sahni.#footnote[No exact worst-case algorithm improving on brute-force is claimed here for the bundled-capacity formulation.] + + *Example.* The canonical YES instance has source $s = v_#source$, sink $t = v_#sink$, and arcs $(0,1)$, $(0,2)$, $(1,3)$, $(2,3)$, $(1,2)$, $(2,1)$. The three bundles are $I_1 = {(0,1), (0,2)}$, $I_2 = {(1,3), (2,1)}$, and $I_3 = {(2,3), (1,2)}$, each with capacity 1. Sending one unit along the path $0 -> 1 -> 3$ yields the flow vector $(1, 0, 1, 0, 0, 0)$: bundle $I_1$ contributes $1 + 0 = 1$, bundle $I_2$ contributes $1 + 0 = 1$, bundle $I_3$ contributes $0 + 0 = 0$, and the only nonterminal vertices $v_1, v_2$ satisfy conservation. If the requirement is raised from $R = 1$ to $R = 2$, the same gadget becomes infeasible because $I_1$ caps the total outflow leaving the source at one unit. + + #pred-commands( + "pred create --example IntegralFlowBundles -o integral-flow-bundles.json", + "pred solve integral-flow-bundles.json", + "pred evaluate integral-flow-bundles.json --config " + x.optimal_config.map(str).join(","), + ) + + #figure( + canvas(length: 1cm, { + import draw: * + let blue = graph-colors.at(0) + let orange = rgb("#f28e2b") + let teal = rgb("#76b7b2") + let gray = luma(185) + let positions = ( + (0, 0), + (2.2, 1.3), + (2.2, -1.3), + (4.4, 0), + ) + + line(positions.at(0), positions.at(1), stroke: (paint: blue, thickness: 2pt), mark: (end: "straight", scale: 0.5)) + line(positions.at(0), positions.at(2), stroke: (paint: blue.lighten(35%), thickness: 1.0pt), mark: (end: "straight", scale: 0.5)) + line(positions.at(1), positions.at(3), stroke: (paint: orange, thickness: 2pt), mark: (end: "straight", scale: 0.5)) + line(positions.at(2), positions.at(3), stroke: (paint: teal, thickness: 1.0pt), mark: (end: "straight", scale: 0.5)) + line((2.0, 1.0), (3.0, 0.0), (2.0, -1.0), stroke: (paint: teal, thickness: 1.0pt), mark: (end: "straight", scale: 0.5)) + line((2.4, -1.0), (1.4, 0.0), (2.4, 1.0), stroke: (paint: orange, thickness: 1.0pt), mark: (end: "straight", scale: 0.5)) + + for (i, pos) in positions.enumerate() { + let fill = if i == source { blue } else if i == sink { rgb("#e15759") } else { white } + g-node(pos, name: "ifb-" + str(i), fill: fill, label: if i == source or i == sink { text(fill: white)[$v_#i$] } else { [$v_#i$] }) + } + + content((1.0, 1.0), text(8pt, fill: blue)[$I_1, c = 1$]) + content((3.3, 1.0), text(8pt, fill: orange)[$I_2, c = 1$]) + content((3.3, -1.0), text(8pt, fill: teal)[$I_3, c = 1$]) + content((2.2, 1.8), text(8pt)[$f(0,1) = 1$]) + content((3.4, 1.55), text(8pt)[$f(1,3) = 1$]) + }), + caption: [Canonical YES instance for Integral Flow with Bundles. Thick blue/orange arcs carry the satisfying flow $0 -> 1 -> 3$, while the lighter arcs show the two unused alternatives coupled into bundles $I_1$, $I_2$, and $I_3$.], + ) + ] + ] +} + #{ let x = load-model-example("IntegralFlowWithMultipliers") let config = x.optimal_config @@ -7004,6 +7063,27 @@ The following reductions to Integer Linear Programming are straightforward formu _Solution extraction._ For each item $i$, find the unique $j$ with $x_(i j) = 1$; assign item $i$ to bin $j$. ] +#reduction-rule("IntegralFlowBundles", "ILP")[ + The feasibility conditions are already linear: one integer variable per arc, one inequality per bundle, one conservation equality per nonterminal vertex, and one lower bound on sink inflow. +][ + _Construction._ Given Integral Flow with Bundles instance $(G = (V, A), s, t, (I_j, c_j)_(j=1)^k, R)$ with arc set $A = {a_0, dots, a_(m-1)}$, create one non-negative integer variable $x_i$ for each arc $a_i$. The ILP therefore has $m$ variables. + + _Bundle constraints._ For every bundle $I_j$, add + $sum_(a_i in I_j) x_i <= c_j$. + + _Flow conservation._ For every nonterminal vertex $v in V backslash {s, t}$, add + $sum_(a_i = (u, v) in A) x_i - sum_(a_i = (v, w) in A) x_i = 0$. + + _Requirement constraint._ Add the sink inflow lower bound + $sum_(a_i = (u, t) in A) x_i - sum_(a_i = (t, w) in A) x_i >= R$. + + _Objective._ Minimize 0. The target is a pure feasibility ILP, so any constant objective works. + + _Correctness._ ($arrow.r.double$) Any satisfying bundled flow assigns a non-negative integer to each arc, satisfies every bundle inequality by definition, satisfies every nonterminal conservation equality, and yields sink inflow at least $R$, so it is a feasible ILP solution. ($arrow.l.double$) Any feasible ILP solution gives non-negative integral arc values obeying the same bundle, conservation, and sink-inflow constraints, hence it is a satisfying solution to the original Integral Flow with Bundles instance. + + _Solution extraction._ Identity: read the ILP vector $(x_0, dots, x_(m-1))$ directly as the arc-flow vector of the source problem. +] + #reduction-rule("SequencingToMinimizeWeightedCompletionTime", "ILP")[ Completion times are natural integer variables, precedence constraints compare those completion times directly, and one binary order variable per task pair enforces that a single machine cannot overlap two jobs. ][ diff --git a/docs/paper/references.bib b/docs/paper/references.bib index 3dde32f26..3cd4583db 100644 --- a/docs/paper/references.bib +++ b/docs/paper/references.bib @@ -273,7 +273,8 @@ @article{sahni1974 volume = {3}, number = {4}, pages = {262--279}, - year = {1974} + year = {1974}, + doi = {10.1137/0203021} } @article{jewell1962, diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index 73af2c69d..a89446670 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 + 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 IsomorphicSpanningTree --graph, --tree @@ -362,6 +363,9 @@ pub struct CreateArgs { /// Edge capacities for multicommodity flow problems (e.g., 1,1,2) #[arg(long)] pub capacities: Option, + /// Bundle capacities for IntegralFlowBundles (e.g., 1,1,1) + #[arg(long)] + pub bundle_capacities: Option, /// Vertex multipliers in vertex order (e.g., 1,2,3,1) #[arg(long)] pub multipliers: Option, @@ -371,7 +375,7 @@ pub struct CreateArgs { /// Sink vertex for path-based graph problems and MinimumCutIntoBoundedSets #[arg(long)] pub sink: Option, - /// Required total flow R for IntegralFlowHomologousArcs, IntegralFlowWithMultipliers, and PathConstrainedNetworkFlow + /// Required total flow R for IntegralFlowBundles, IntegralFlowHomologousArcs, IntegralFlowWithMultipliers, and PathConstrainedNetworkFlow #[arg(long)] pub requirement: Option, /// Required number of paths for LengthBoundedDisjointPaths @@ -477,6 +481,9 @@ pub struct CreateArgs { /// Partition groups for arc-index partitions (semicolon-separated, e.g., "0,1;2,3") #[arg(long)] pub partition: Option, + /// Arc bundles for IntegralFlowBundles (semicolon-separated groups of arc indices, e.g., "0,1;2,5;3,4") + #[arg(long)] + pub bundles: Option, /// Universe size for set-system problems such as MinimumHittingSet, MinimumSetCovering, and ComparativeContainment #[arg(long)] pub universe: Option, diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index 6cf68acde..69be70183 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -12,7 +12,7 @@ use problemreductions::models::algebraic::{ }; use problemreductions::models::formula::Quantifier; use problemreductions::models::graph::{ - GeneralizedHex, GraphPartitioning, HamiltonianCircuit, HamiltonianPath, + GeneralizedHex, GraphPartitioning, HamiltonianCircuit, HamiltonianPath, IntegralFlowBundles, LengthBoundedDisjointPaths, LongestCircuit, LongestPath, MinimumCutIntoBoundedSets, MinimumMultiwayCut, MixedChinesePostman, MultipleChoiceBranching, PathConstrainedNetworkFlow, SteinerTree, SteinerTreeInGraphs, StrongConnectivityAugmentation, @@ -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.bundle_capacities.is_none() && args.multipliers.is_none() && args.source.is_none() && args.sink.is_none() @@ -88,6 +89,7 @@ fn all_data_flags_empty(args: &CreateArgs) -> bool { && args.r_weights.is_none() && args.s_weights.is_none() && args.partition.is_none() + && args.bundles.is_none() && args.universe.is_none() && args.biedges.is_none() && args.left.is_none() @@ -520,6 +522,9 @@ fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str { "KClique" => "--graph 0-1,0-2,1-3,2-3,2-4,3-4 --k 3", "GraphPartitioning" => "--graph 0-1,1-2,2-3,0-2,1-3,0-3", "GeneralizedHex" => "--graph 0-1,0-2,0-3,1-4,2-4,3-4,4-5 --source 0 --sink 5", + "IntegralFlowBundles" => { + "--arcs \"0>1,0>2,1>3,2>3,1>2,2>1\" --bundles \"0,1;2,5;3,4\" --bundle-capacities 1,1,1 --source 0 --sink 3 --requirement 1 --num-vertices 4" + } "IntegralFlowWithMultipliers" => { "--arcs \"0>1,0>2,1>3,2>3\" --capacities 1,1,2,2 --source 0 --sink 3 --multipliers 1,2,3,1 --requirement 2" } @@ -776,6 +781,8 @@ fn help_flag_hint( ("ConsistencyOfDatabaseFrequencyTables", "known_values") => { "semicolon-separated triples: \"0,0,0;3,0,1;1,2,1\"" } + ("IntegralFlowBundles", "bundles") => "semicolon-separated groups: \"0,1;2,5;3,4\"", + ("IntegralFlowBundles", "bundle_capacities") => "comma-separated capacities: 1,1,1", ("PathConstrainedNetworkFlow", "paths") => { "semicolon-separated arc-index paths: \"0,2,5,8;1,4,7,9\"" } @@ -1522,6 +1529,46 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { ) } + // IntegralFlowBundles (directed graph + bundles + source/sink + requirement) + "IntegralFlowBundles" => { + let usage = "Usage: pred create IntegralFlowBundles --arcs \"0>1,0>2,1>3,2>3,1>2,2>1\" --bundles \"0,1;2,5;3,4\" --bundle-capacities 1,1,1 --source 0 --sink 3 --requirement 1 --num-vertices 4"; + let arcs_str = args + .arcs + .as_deref() + .ok_or_else(|| anyhow::anyhow!("IntegralFlowBundles 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 bundles = parse_bundles(args, num_arcs, usage)?; + let bundle_capacities = parse_bundle_capacities(args, bundles.len(), usage)?; + let source = args.source.ok_or_else(|| { + anyhow::anyhow!("IntegralFlowBundles requires --source\n\n{usage}") + })?; + let sink = args + .sink + .ok_or_else(|| anyhow::anyhow!("IntegralFlowBundles requires --sink\n\n{usage}"))?; + let requirement = args.requirement.ok_or_else(|| { + anyhow::anyhow!("IntegralFlowBundles requires --requirement\n\n{usage}") + })?; + validate_vertex_index("source", source, graph.num_vertices(), usage)?; + validate_vertex_index("sink", sink, graph.num_vertices(), usage)?; + anyhow::ensure!( + source != sink, + "IntegralFlowBundles requires distinct --source and --sink\n\n{usage}" + ); + + ( + ser(IntegralFlowBundles::new( + graph, + source, + sink, + bundles, + bundle_capacities, + requirement, + ))?, + resolved_variant.clone(), + ) + } + // LengthBoundedDisjointPaths (graph + source + sink + path count + bound) "LengthBoundedDisjointPaths" => { let usage = "Usage: pred create 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"; @@ -4469,6 +4516,48 @@ fn parse_capacities(args: &CreateArgs, num_edges: usize, usage: &str) -> Result< Ok(capacities) } +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}") + })?; + let capacities: Vec = capacities + .split(',') + .map(|s| { + let trimmed = s.trim(); + trimmed + .parse::() + .with_context(|| format!("Invalid bundle capacity `{trimmed}`\n\n{usage}")) + }) + .collect::>>()?; + anyhow::ensure!( + capacities.len() == num_bundles, + "Expected {} bundle capacities but got {}\n\n{}", + num_bundles, + capacities.len(), + usage + ); + for (bundle_index, &capacity) in capacities.iter().enumerate() { + let fits = usize::try_from(capacity) + .ok() + .and_then(|value| value.checked_add(1)) + .is_some(); + anyhow::ensure!( + fits, + "bundle capacity {} at bundle index {} is too large for this platform\n\n{}", + capacity, + bundle_index, + usage + ); + anyhow::ensure!( + capacity > 0, + "bundle capacity at bundle index {} must be positive\n\n{}", + bundle_index, + usage + ); + } + Ok(capacities) +} + /// Parse `--couplings` as SpinGlass pairwise couplings (i32), defaulting to all 1s. fn parse_couplings(args: &CreateArgs, num_edges: usize) -> Result> { match &args.couplings { @@ -4740,6 +4829,54 @@ fn parse_partition_groups(args: &CreateArgs, num_arcs: usize) -> Result Result>> { + let bundles_str = args + .bundles + .as_deref() + .ok_or_else(|| anyhow::anyhow!("IntegralFlowBundles requires --bundles\n\n{usage}"))?; + + let bundles: Vec> = bundles_str + .split(';') + .map(|bundle| { + let bundle = bundle.trim(); + anyhow::ensure!( + !bundle.is_empty(), + "IntegralFlowBundles does not allow empty bundle entries\n\n{usage}" + ); + bundle + .split(',') + .map(|s| { + s.trim().parse::().with_context(|| { + format!("Invalid bundle arc index `{}`\n\n{usage}", s.trim()) + }) + }) + .collect::>>() + }) + .collect::>()?; + + let mut seen_overall = vec![false; num_arcs]; + for (bundle_index, bundle) in bundles.iter().enumerate() { + let mut seen_in_bundle = BTreeSet::new(); + for &arc_index in bundle { + anyhow::ensure!( + arc_index < num_arcs, + "bundle {bundle_index} references arc {arc_index}, but num_arcs is {num_arcs}\n\n{usage}" + ); + anyhow::ensure!( + seen_in_bundle.insert(arc_index), + "bundle {bundle_index} contains duplicate arc index {arc_index}\n\n{usage}" + ); + seen_overall[arc_index] = true; + } + } + anyhow::ensure!( + seen_overall.iter().all(|covered| *covered), + "bundles must cover every arc at least once\n\n{usage}" + ); + + Ok(bundles) +} + fn parse_multiple_choice_branching_threshold(args: &CreateArgs, usage: &str) -> Result { let raw_bound = args .bound @@ -6402,6 +6539,7 @@ mod tests { edge_weights: None, edge_lengths: None, capacities: None, + bundle_capacities: None, multipliers: None, source: None, sink: None, @@ -6440,6 +6578,7 @@ mod tests { r_weights: None, s_weights: None, partition: None, + bundles: None, universe: None, biedges: None, left: None, diff --git a/problemreductions-cli/tests/cli_tests.rs b/problemreductions-cli/tests/cli_tests.rs index 7cc95c1c2..13e9cc358 100644 --- a/problemreductions-cli/tests/cli_tests.rs +++ b/problemreductions-cli/tests/cli_tests.rs @@ -668,6 +668,157 @@ fn test_create_undirected_two_commodity_integral_flow_rejects_out_of_range_termi assert!(!stderr.contains("panicked at"), "stderr: {stderr}"); } +#[test] +fn test_create_integral_flow_bundles() { + let output = pred() + .args([ + "create", + "IntegralFlowBundles", + "--arcs", + "0>1,0>2,1>3,2>3,1>2,2>1", + "--bundles", + "0,1;2,5;3,4", + "--bundle-capacities", + "1,1,1", + "--source", + "0", + "--sink", + "3", + "--requirement", + "1", + "--num-vertices", + "4", + ]) + .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"], "IntegralFlowBundles"); + assert_eq!(json["variant"], serde_json::json!({})); + assert_eq!(json["data"]["graph"]["num_vertices"], 4); + assert_eq!(json["data"]["graph"]["arcs"].as_array().unwrap().len(), 6); + assert_eq!( + json["data"]["bundles"], + serde_json::json!([[0, 1], [2, 5], [3, 4]]) + ); + assert_eq!( + json["data"]["bundle_capacities"], + serde_json::json!([1, 1, 1]) + ); + assert_eq!(json["data"]["source"], 0); + assert_eq!(json["data"]["sink"], 3); + assert_eq!(json["data"]["requirement"], 1); +} + +#[test] +fn test_create_integral_flow_bundles_missing_bundles_shows_usage() { + let output = pred() + .args([ + "create", + "IntegralFlowBundles", + "--arcs", + "0>1,0>2,1>3,2>3,1>2,2>1", + "--bundle-capacities", + "1,1,1", + "--source", + "0", + "--sink", + "3", + "--requirement", + "1", + "--num-vertices", + "4", + ]) + .output() + .unwrap(); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("requires --bundles")); + assert!(stderr.contains("Usage: pred create IntegralFlowBundles")); +} + +#[test] +fn test_create_integral_flow_bundles_rejects_wrong_bundle_capacity_count() { + let output = pred() + .args([ + "create", + "IntegralFlowBundles", + "--arcs", + "0>1,0>2,1>3,2>3,1>2,2>1", + "--bundles", + "0,1;2,5;3,4", + "--bundle-capacities", + "1,1", + "--source", + "0", + "--sink", + "3", + "--requirement", + "1", + "--num-vertices", + "4", + ]) + .output() + .unwrap(); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("Expected 3 bundle capacities but got 2")); + assert!(stderr.contains("Usage: pred create IntegralFlowBundles")); +} + +#[test] +fn test_create_integral_flow_bundles_rejects_out_of_range_bundle_arc() { + let output = pred() + .args([ + "create", + "IntegralFlowBundles", + "--arcs", + "0>1,0>2,1>3,2>3,1>2,2>1", + "--bundles", + "0,1;2,7;3,4", + "--bundle-capacities", + "1,1,1", + "--source", + "0", + "--sink", + "3", + "--requirement", + "1", + "--num-vertices", + "4", + ]) + .output() + .unwrap(); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("bundle 1 references arc 7")); + assert!(stderr.contains("Usage: pred create IntegralFlowBundles")); + assert!(!stderr.contains("panicked at"), "stderr: {stderr}"); +} + +#[test] +fn test_create_integral_flow_bundles_example() { + let output = pred() + .args(["create", "--example", "IntegralFlowBundles"]) + .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"], "IntegralFlowBundles"); + assert_eq!(json["data"]["requirement"], 1); + assert_eq!(json["data"]["bundles"].as_array().unwrap().len(), 3); +} + #[test] fn test_create_integral_flow_homologous_arcs() { let output_file = diff --git a/src/lib.rs b/src/lib.rs index 329d0ad8c..95c7ff555 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -51,10 +51,10 @@ pub mod prelude { AcyclicPartition, BalancedCompleteBipartiteSubgraph, BicliqueCover, BiconnectivityAugmentation, BottleneckTravelingSalesman, BoundedComponentSpanningForest, DirectedTwoCommodityIntegralFlow, GeneralizedHex, GraphPartitioning, HamiltonianCircuit, - HamiltonianPath, IntegralFlowHomologousArcs, IntegralFlowWithMultipliers, - IsomorphicSpanningTree, KClique, KthBestSpanningTree, LengthBoundedDisjointPaths, - LongestPath, MixedChinesePostman, SpinGlass, SteinerTree, StrongConnectivityAugmentation, - SubgraphIsomorphism, + HamiltonianPath, IntegralFlowBundles, IntegralFlowHomologousArcs, + IntegralFlowWithMultipliers, IsomorphicSpanningTree, KClique, KthBestSpanningTree, + LengthBoundedDisjointPaths, LongestPath, MixedChinesePostman, SpinGlass, SteinerTree, + StrongConnectivityAugmentation, SubgraphIsomorphism, }; pub use crate::models::graph::{ KColoring, LongestCircuit, MaxCut, MaximalIS, MaximumClique, MaximumIndependentSet, diff --git a/src/models/graph/integral_flow_bundles.rs b/src/models/graph/integral_flow_bundles.rs new file mode 100644 index 000000000..383c2171d --- /dev/null +++ b/src/models/graph/integral_flow_bundles.rs @@ -0,0 +1,292 @@ +//! Integral Flow with Bundles problem implementation. +//! +//! Given a directed graph with overlapping bundle-capacity constraints on arcs, +//! determine whether an integral flow can deliver a required amount to the sink. + +use crate::registry::{FieldInfo, ProblemSchemaEntry, ProblemSizeFieldEntry}; +use crate::topology::DirectedGraph; +use crate::traits::{Problem, SatisfactionProblem}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeSet; + +inventory::submit! { + ProblemSchemaEntry { + name: "IntegralFlowBundles", + display_name: "Integral Flow with Bundles", + aliases: &[], + dimensions: &[], + module_path: module_path!(), + description: "Integral flow feasibility on a directed graph with overlapping bundle capacities", + fields: &[ + FieldInfo { name: "graph", type_name: "DirectedGraph", description: "Directed graph G=(V,A)" }, + FieldInfo { name: "source", type_name: "usize", description: "Source vertex s" }, + FieldInfo { name: "sink", type_name: "usize", description: "Sink vertex t" }, + FieldInfo { name: "bundles", type_name: "Vec>", description: "Bundles of arc indices covering A" }, + FieldInfo { name: "bundle_capacities", type_name: "Vec", description: "Capacity c_j for each bundle I_j" }, + FieldInfo { name: "requirement", type_name: "u64", description: "Required net inflow R at the sink" }, + ], + } +} + +inventory::submit! { + ProblemSizeFieldEntry { + name: "IntegralFlowBundles", + fields: &["num_vertices", "num_arcs", "num_bundles"], + } +} + +/// Integral Flow with Bundles (Garey & Johnson ND36). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct IntegralFlowBundles { + graph: DirectedGraph, + source: usize, + sink: usize, + bundles: Vec>, + bundle_capacities: Vec, + requirement: u64, +} + +impl IntegralFlowBundles { + /// Create a new Integral Flow with Bundles instance. + pub fn new( + graph: DirectedGraph, + source: usize, + sink: usize, + bundles: Vec>, + bundle_capacities: Vec, + requirement: u64, + ) -> Self { + let num_vertices = graph.num_vertices(); + let num_arcs = graph.num_arcs(); + + assert!( + source < num_vertices, + "source ({source}) >= num_vertices ({num_vertices})" + ); + assert!( + sink < num_vertices, + "sink ({sink}) >= num_vertices ({num_vertices})" + ); + assert!(source != sink, "source and sink must be distinct"); + assert_eq!( + bundles.len(), + bundle_capacities.len(), + "bundles length must match bundle_capacities length" + ); + assert!(requirement > 0, "requirement must be positive"); + + let mut arc_covered = vec![false; num_arcs]; + let mut arc_upper_bounds = vec![u64::MAX; num_arcs]; + + for (bundle_index, (bundle, &capacity)) in + bundles.iter().zip(&bundle_capacities).enumerate() + { + assert!( + capacity > 0, + "bundle capacity at index {bundle_index} must be positive" + ); + + let mut seen = BTreeSet::new(); + for &arc_index in bundle { + assert!( + arc_index < num_arcs, + "bundle {bundle_index} references arc {arc_index}, but num_arcs is {num_arcs}" + ); + assert!( + seen.insert(arc_index), + "bundle {bundle_index} contains duplicate arc index {arc_index}" + ); + arc_covered[arc_index] = true; + arc_upper_bounds[arc_index] = arc_upper_bounds[arc_index].min(capacity); + } + } + + for (arc_index, covered) in arc_covered.iter().copied().enumerate() { + assert!( + covered, + "arc {arc_index} must belong to at least one bundle" + ); + let domain = usize::try_from(arc_upper_bounds[arc_index]) + .ok() + .and_then(|bound| bound.checked_add(1)); + assert!( + domain.is_some(), + "bundle-derived upper bound for arc {arc_index} must fit into usize for dims()" + ); + } + + Self { + graph, + source, + sink, + bundles, + bundle_capacities, + requirement, + } + } + + /// Get the underlying directed graph. + pub fn graph(&self) -> &DirectedGraph { + &self.graph + } + + /// Get the source vertex. + pub fn source(&self) -> usize { + self.source + } + + /// Get the sink vertex. + pub fn sink(&self) -> usize { + self.sink + } + + /// Get the bundles. + pub fn bundles(&self) -> &[Vec] { + &self.bundles + } + + /// Get the bundle capacities. + pub fn bundle_capacities(&self) -> &[u64] { + &self.bundle_capacities + } + + /// Get the required net inflow at the sink. + pub fn requirement(&self) -> u64 { + self.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 bundles. + pub fn num_bundles(&self) -> usize { + self.bundles.len() + } + + /// Check whether a configuration is feasible. + pub fn is_valid_solution(&self, config: &[usize]) -> bool { + self.evaluate(config) + } + + fn arc_upper_bounds(&self) -> Vec { + let mut upper_bounds = vec![u64::MAX; self.num_arcs()]; + for (bundle, &capacity) in self.bundles.iter().zip(&self.bundle_capacities) { + for &arc_index in bundle { + upper_bounds[arc_index] = upper_bounds[arc_index].min(capacity); + } + } + upper_bounds + } + + fn vertex_balance(&self, config: &[usize], vertex: usize) -> Option { + let mut balance = 0i128; + for (arc_index, (u, v)) in self.graph.arcs().into_iter().enumerate() { + let flow = i128::from(u64::try_from(*config.get(arc_index)?).ok()?); + if vertex == u { + balance -= flow; + } + if vertex == v { + balance += flow; + } + } + Some(balance) + } +} + +impl Problem for IntegralFlowBundles { + const NAME: &'static str = "IntegralFlowBundles"; + type Metric = bool; + + fn dims(&self) -> Vec { + self.arc_upper_bounds() + .into_iter() + .map(|bound| { + usize::try_from(bound) + .ok() + .and_then(|bound| bound.checked_add(1)) + .expect("bundle-derived arc upper bounds are validated in the constructor") + }) + .collect() + } + + fn evaluate(&self, config: &[usize]) -> bool { + if config.len() != self.num_arcs() { + return false; + } + + let upper_bounds = self.arc_upper_bounds(); + for (&value, &upper_bound) in config.iter().zip(&upper_bounds) { + if u64::try_from(value).map_or(true, |value| value > upper_bound) { + return false; + } + } + + for (bundle, &capacity) in self.bundles.iter().zip(&self.bundle_capacities) { + let mut total = 0u64; + for &arc_index in bundle { + let Ok(flow) = u64::try_from(config[arc_index]) else { + return false; + }; + let Some(next_total) = total.checked_add(flow) else { + return false; + }; + total = next_total; + } + if total > capacity { + return false; + } + } + + for vertex in 0..self.num_vertices() { + if vertex == self.source || vertex == self.sink { + continue; + } + if self.vertex_balance(config, vertex) != Some(0) { + return false; + } + } + + matches!( + self.vertex_balance(config, self.sink), + Some(balance) if balance >= i128::from(self.requirement) + ) + } + + fn variant() -> Vec<(&'static str, &'static str)> { + crate::variant_params![] + } +} + +impl SatisfactionProblem for IntegralFlowBundles {} + +crate::declare_variants! { + default sat IntegralFlowBundles => "2^num_arcs", +} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_model_example_specs() -> Vec { + vec![crate::example_db::specs::ModelExampleSpec { + id: "integral_flow_bundles", + instance: Box::new(IntegralFlowBundles::new( + DirectedGraph::new(4, vec![(0, 1), (0, 2), (1, 3), (2, 3), (1, 2), (2, 1)]), + 0, + 3, + vec![vec![0, 1], vec![2, 5], vec![3, 4]], + vec![1, 1, 1], + 1, + )), + optimal_config: vec![1, 0, 1, 0, 0, 0], + optimal_value: serde_json::json!(true), + }] +} + +#[cfg(test)] +#[path = "../../unit_tests/models/graph/integral_flow_bundles.rs"] +mod tests; diff --git a/src/models/graph/mod.rs b/src/models/graph/mod.rs index 640c6b84f..bcb2c27a2 100644 --- a/src/models/graph/mod.rs +++ b/src/models/graph/mod.rs @@ -44,6 +44,7 @@ //! - [`SteinerTree`]: Minimum-weight tree spanning all required terminals //! - [`SubgraphIsomorphism`]: Subgraph isomorphism (decision problem) //! - [`DirectedTwoCommodityIntegralFlow`]: Directed two-commodity integral flow (satisfaction) +//! - [`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 //! - [`UndirectedTwoCommodityIntegralFlow`]: Two-commodity integral flow on undirected graphs @@ -60,6 +61,7 @@ pub(crate) mod generalized_hex; pub(crate) mod graph_partitioning; pub(crate) mod hamiltonian_circuit; pub(crate) mod hamiltonian_path; +pub(crate) mod integral_flow_bundles; pub(crate) mod integral_flow_homologous_arcs; pub(crate) mod integral_flow_with_multipliers; pub(crate) mod isomorphic_spanning_tree; @@ -110,6 +112,7 @@ pub use generalized_hex::GeneralizedHex; pub use graph_partitioning::GraphPartitioning; pub use hamiltonian_circuit::HamiltonianCircuit; pub use hamiltonian_path::HamiltonianPath; +pub use integral_flow_bundles::IntegralFlowBundles; pub use integral_flow_homologous_arcs::IntegralFlowHomologousArcs; pub use integral_flow_with_multipliers::IntegralFlowWithMultipliers; pub use isomorphic_spanning_tree::IsomorphicSpanningTree; @@ -159,6 +162,7 @@ pub(crate) fn canonical_model_example_specs() -> Vec, +} + +impl ReductionResult for ReductionIFBToILP { + type Source = IntegralFlowBundles; + type Target = ILP; + + fn target_problem(&self) -> &ILP { + &self.target + } + + fn extract_solution(&self, target_solution: &[usize]) -> Vec { + target_solution.to_vec() + } +} + +#[reduction( + overhead = { + num_vars = "num_arcs", + num_constraints = "num_bundles + num_vertices - 1", + } +)] +impl ReduceTo> for IntegralFlowBundles { + type Result = ReductionIFBToILP; + + fn reduce_to(&self) -> Self::Result { + let arcs = self.graph().arcs(); + let mut constraints = Vec::with_capacity(self.num_bundles() + self.num_vertices() - 1); + + for (bundle, &capacity) in self.bundles().iter().zip(self.bundle_capacities()) { + let terms = bundle.iter().map(|&arc_index| (arc_index, 1.0)).collect(); + constraints.push(LinearConstraint::le(terms, capacity as f64)); + } + + for vertex in 0..self.num_vertices() { + if vertex == self.source() || vertex == self.sink() { + continue; + } + + let mut terms = Vec::new(); + for (arc_index, (u, v)) in arcs.iter().copied().enumerate() { + if vertex == u { + terms.push((arc_index, -1.0)); + } + if vertex == v { + terms.push((arc_index, 1.0)); + } + } + constraints.push(LinearConstraint::eq(terms, 0.0)); + } + + let mut sink_terms = Vec::new(); + for (arc_index, (u, v)) in arcs.iter().copied().enumerate() { + if self.sink() == u { + sink_terms.push((arc_index, -1.0)); + } + if self.sink() == v { + sink_terms.push((arc_index, 1.0)); + } + } + constraints.push(LinearConstraint::ge(sink_terms, self.requirement() as f64)); + + ReductionIFBToILP { + target: ILP::new( + self.num_arcs(), + constraints, + vec![], + ObjectiveSense::Minimize, + ), + } + } +} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_rule_example_specs() -> Vec { + use crate::export::SolutionPair; + use crate::topology::DirectedGraph; + + vec![crate::example_db::specs::RuleExampleSpec { + id: "integralflowbundles_to_ilp", + build: || { + crate::example_db::specs::rule_example_with_witness::<_, ILP>( + IntegralFlowBundles::new( + DirectedGraph::new(4, vec![(0, 1), (0, 2), (1, 3), (2, 3), (1, 2), (2, 1)]), + 0, + 3, + vec![vec![0, 1], vec![2, 5], vec![3, 4]], + vec![1, 1, 1], + 1, + ), + SolutionPair { + source_config: vec![1, 0, 1, 0, 0, 0], + target_config: vec![1, 0, 1, 0, 0, 0], + }, + ) + }, + }] +} + +#[cfg(test)] +#[path = "../unit_tests/rules/integralflowbundles_ilp.rs"] +mod tests; diff --git a/src/rules/mod.rs b/src/rules/mod.rs index e9fbdb280..c3971e0f7 100644 --- a/src/rules/mod.rs +++ b/src/rules/mod.rs @@ -66,6 +66,8 @@ mod ilp_bool_ilp_i32; #[cfg(feature = "ilp-solver")] pub(crate) mod ilp_qubo; #[cfg(feature = "ilp-solver")] +pub(crate) mod integralflowbundles_ilp; +#[cfg(feature = "ilp-solver")] pub(crate) mod knapsack_ilp; #[cfg(feature = "ilp-solver")] pub(crate) mod longestcommonsubsequence_ilp; @@ -140,6 +142,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec ILP exists"); + assert_eq!(example.source.problem, "IntegralFlowBundles"); + assert_eq!(example.target.problem, "ILP"); + assert!(example.source.instance.get("graph").is_some()); + assert_eq!(example.solutions[0].source_config, vec![1, 0, 1, 0, 0, 0]); + assert_eq!(example.solutions[0].target_config, vec![1, 0, 1, 0, 0, 0]); +} + #[test] fn test_build_rule_db_has_unique_structural_keys() { let db = build_rule_db().expect("rule db should build"); diff --git a/src/unit_tests/models/graph/integral_flow_bundles.rs b/src/unit_tests/models/graph/integral_flow_bundles.rs new file mode 100644 index 000000000..67a6595e5 --- /dev/null +++ b/src/unit_tests/models/graph/integral_flow_bundles.rs @@ -0,0 +1,100 @@ +use super::*; +use crate::solvers::BruteForce; +use crate::topology::DirectedGraph; +use crate::traits::Problem; + +fn yes_instance() -> IntegralFlowBundles { + IntegralFlowBundles::new( + DirectedGraph::new(4, vec![(0, 1), (0, 2), (1, 3), (2, 3), (1, 2), (2, 1)]), + 0, + 3, + vec![vec![0, 1], vec![2, 5], vec![3, 4]], + vec![1, 1, 1], + 1, + ) +} + +fn no_instance() -> IntegralFlowBundles { + IntegralFlowBundles::new( + DirectedGraph::new(4, vec![(0, 1), (0, 2), (1, 3), (2, 3), (1, 2), (2, 1)]), + 0, + 3, + vec![vec![0, 1], vec![2, 5], vec![3, 4]], + vec![1, 1, 1], + 2, + ) +} + +fn satisfying_config() -> Vec { + vec![1, 0, 1, 0, 0, 0] +} + +#[test] +fn test_integral_flow_bundles_creation_and_getters() { + let problem = yes_instance(); + assert_eq!(problem.num_vertices(), 4); + assert_eq!(problem.num_arcs(), 6); + assert_eq!(problem.num_bundles(), 3); + assert_eq!(problem.source(), 0); + assert_eq!(problem.sink(), 3); + assert_eq!(problem.requirement(), 1); + assert_eq!(problem.bundle_capacities(), &[1, 1, 1]); + assert_eq!(problem.graph().arcs().len(), 6); +} + +#[test] +fn test_integral_flow_bundles_dims_use_tight_arc_bounds() { + let problem = yes_instance(); + assert_eq!(problem.dims(), vec![2, 2, 2, 2, 2, 2]); +} + +#[test] +fn test_integral_flow_bundles_evaluate_yes_and_no_examples() { + let yes = yes_instance(); + let no = no_instance(); + let config = satisfying_config(); + assert!(yes.evaluate(&config)); + assert!(!no.evaluate(&config)); + assert!(yes.is_valid_solution(&config)); +} + +#[test] +fn test_integral_flow_bundles_rejects_bad_bundle_sum_or_conservation() { + let problem = yes_instance(); + + let mut bundle_violation = satisfying_config(); + bundle_violation[1] = 1; + assert!(!problem.evaluate(&bundle_violation)); + + let conservation_violation = vec![1, 0, 0, 0, 0, 0]; + assert!(!problem.evaluate(&conservation_violation)); +} + +#[test] +fn test_integral_flow_bundles_solver_and_paper_example() { + let problem = yes_instance(); + let solver = BruteForce::new(); + let all = solver.find_all_satisfying(&problem); + assert!(!all.is_empty()); + assert!(all.contains(&satisfying_config())); + assert!(problem.evaluate(&satisfying_config())); +} + +#[test] +fn test_integral_flow_bundles_serialization() { + let problem = yes_instance(); + let json = serde_json::to_string(&problem).unwrap(); + let roundtrip: IntegralFlowBundles = serde_json::from_str(&json).unwrap(); + assert_eq!(roundtrip.num_vertices(), 4); + assert_eq!(roundtrip.num_arcs(), 6); + assert_eq!(roundtrip.num_bundles(), 3); + assert_eq!(roundtrip.requirement(), 1); +} + +#[test] +fn test_integral_flow_bundles_problem_name() { + assert_eq!( + ::NAME, + "IntegralFlowBundles" + ); +} diff --git a/src/unit_tests/rules/integralflowbundles_ilp.rs b/src/unit_tests/rules/integralflowbundles_ilp.rs new file mode 100644 index 000000000..10455d9c7 --- /dev/null +++ b/src/unit_tests/rules/integralflowbundles_ilp.rs @@ -0,0 +1,113 @@ +use super::*; +use crate::models::algebraic::{Comparison, ObjectiveSense, ILP}; +use crate::solvers::{BruteForce, ILPSolver, Solver}; +use crate::topology::DirectedGraph; +use crate::traits::Problem; + +fn yes_instance() -> IntegralFlowBundles { + IntegralFlowBundles::new( + DirectedGraph::new(4, vec![(0, 1), (0, 2), (1, 3), (2, 3), (1, 2), (2, 1)]), + 0, + 3, + vec![vec![0, 1], vec![2, 5], vec![3, 4]], + vec![1, 1, 1], + 1, + ) +} + +fn no_instance() -> IntegralFlowBundles { + IntegralFlowBundles::new( + DirectedGraph::new(4, vec![(0, 1), (0, 2), (1, 3), (2, 3), (1, 2), (2, 1)]), + 0, + 3, + vec![vec![0, 1], vec![2, 5], vec![3, 4]], + vec![1, 1, 1], + 2, + ) +} + +fn satisfying_config() -> Vec { + vec![1, 0, 1, 0, 0, 0] +} + +#[test] +fn test_integral_flow_bundles_to_ilp_structure() { + let problem = yes_instance(); + let reduction: ReductionIFBToILP = ReduceTo::>::reduce_to(&problem); + let ilp = reduction.target_problem(); + + assert_eq!(ilp.num_vars, 6); + assert_eq!(ilp.constraints.len(), 6); + assert_eq!(ilp.sense, ObjectiveSense::Minimize); + assert!(ilp.objective.is_empty()); + assert_eq!( + ilp.constraints + .iter() + .filter(|constraint| constraint.cmp == Comparison::Le) + .count(), + 3 + ); + assert_eq!( + ilp.constraints + .iter() + .filter(|constraint| constraint.cmp == Comparison::Eq) + .count(), + 2 + ); + assert_eq!( + ilp.constraints + .iter() + .filter(|constraint| constraint.cmp == Comparison::Ge) + .count(), + 1 + ); +} + +#[test] +fn test_integral_flow_bundles_to_ilp_closed_loop() { + let problem = yes_instance(); + let direct = BruteForce::new() + .find_satisfying(&problem) + .expect("source instance should be satisfiable"); + assert!(problem.evaluate(&direct)); + + let reduction: ReductionIFBToILP = ReduceTo::>::reduce_to(&problem); + let ilp_solution = ILPSolver::new() + .solve(reduction.target_problem()) + .expect("ILP should be feasible"); + let extracted = reduction.extract_solution(&ilp_solution); + + assert!(problem.evaluate(&extracted)); +} + +#[test] +fn test_integral_flow_bundles_to_ilp_extract_solution_is_identity() { + let problem = yes_instance(); + let reduction: ReductionIFBToILP = ReduceTo::>::reduce_to(&problem); + assert_eq!( + reduction.extract_solution(&satisfying_config()), + satisfying_config() + ); +} + +#[test] +fn test_integral_flow_bundles_to_ilp_unsat_instance_is_infeasible() { + let problem = no_instance(); + let reduction: ReductionIFBToILP = ReduceTo::>::reduce_to(&problem); + assert!(ILPSolver::new().solve(reduction.target_problem()).is_none()); +} + +#[test] +fn test_integral_flow_bundles_to_ilp_sink_requirement_constraint() { + let problem = yes_instance(); + let reduction: ReductionIFBToILP = ReduceTo::>::reduce_to(&problem); + let ilp = reduction.target_problem(); + + let sink_constraint = ilp + .constraints + .iter() + .find(|constraint| constraint.cmp == Comparison::Ge) + .expect("expected one sink inflow lower bound"); + assert_eq!(sink_constraint.rhs, 1.0); + assert_eq!(sink_constraint.terms, vec![(2, 1.0), (3, 1.0)]); +}