diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index 56dc2b6d..707822b0 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -67,6 +67,7 @@ "MaxCut": [Max-Cut], "GraphPartitioning": [Graph Partitioning], "HamiltonianPath": [Hamiltonian Path], + "UndirectedTwoCommodityIntegralFlow": [Undirected Two-Commodity Integral Flow], "LengthBoundedDisjointPaths": [Length-Bounded Disjoint Paths], "IsomorphicSpanningTree": [Isomorphic Spanning Tree], "KColoring": [$k$-Coloring], @@ -637,6 +638,57 @@ Graph Partitioning is a core NP-hard problem arising in VLSI design, parallel co ] ] } +#{ + let x = load-model-example("UndirectedTwoCommodityIntegralFlow") + let sample = x.samples.at(0) + let satisfying_count = x.optimal.len() + let source1 = x.instance.source_1 + let source2 = x.instance.source_2 + let sink1 = x.instance.sink_1 + [ + #problem-def("UndirectedTwoCommodityIntegralFlow")[ + Given an undirected graph $G = (V, E)$, specified terminals $s_1, s_2, t_1, t_2 in V$, edge capacities $c: E -> ZZ^+$, and requirements $R_1, R_2 in ZZ^+$, determine whether there exist two integral flow functions $f_1, f_2$ that orient each used edge for each commodity, respect the shared edge capacities, conserve flow at every vertex in $V backslash {s_1, s_2, t_1, t_2}$, and deliver at least $R_i$ units of net flow into $t_i$ for each commodity $i in {1, 2}$. + ][ + Undirected Two-Commodity Integral Flow is the undirected counterpart of the classical two-commodity integral flow problem from Garey \& Johnson (ND39) @garey1979. Even, Itai, and Shamir proved that it remains NP-complete even when every capacity is 1, but becomes polynomial-time solvable when all capacities are even, giving a rare parity-driven complexity dichotomy @evenItaiShamir1976. + + The implementation uses four variables per undirected edge ${u, v}$: $f_1(u, v)$, $f_1(v, u)$, $f_2(u, v)$, and $f_2(v, u)$. In the unit-capacity regime, each edge has exactly five meaningful local states: unused, commodity 1 in either direction, or commodity 2 in either direction, which matches the catalog bound $O(5^m)$ for $m = |E|$. + + *Example.* Consider the graph with edges $(0, 2)$, $(1, 2)$, and $(2, 3)$, capacities $(1, 1, 2)$, sources $s_1 = v_#source1$, $s_2 = v_#source2$, and shared sink $t_1 = t_2 = v_#sink1$. The sample configuration in the fixture database sets $f_1(0, 2) = 1$, $f_2(1, 2) = 1$, and $f_1(2, 3) = f_2(2, 3) = 1$, with all reverse-direction variables zero. The only nonterminal vertex is $v_2$, where each commodity has one unit of inflow and one unit of outflow, so conservation holds. Vertex $v_3$ receives one unit of net inflow from each commodity, and the shared edge $(2,3)$ uses its full capacity 2. The fixture database contains exactly #satisfying_count satisfying configurations for this instance: the one shown below and the symmetric variant that swaps which commodity uses the two left edges. + + #figure( + canvas(length: 1cm, { + import draw: * + let blue = graph-colors.at(0) + let teal = rgb("#76b7b2") + let gray = luma(190) + let verts = ((0, 1.2), (0, -1.2), (2.0, 0), (4.0, 0)) + let labels = ( + [$s_1 = v_0$], + [$s_2 = v_1$], + [$v_2$], + [$t_1 = t_2 = v_3$], + ) + let edges = ((0, 2), (1, 2), (2, 3)) + for (u, v) in edges { + g-edge(verts.at(u), verts.at(v), stroke: 1pt + gray) + } + g-edge(verts.at(0), verts.at(2), stroke: 1.8pt + blue) + g-edge(verts.at(1), verts.at(2), stroke: (paint: teal, thickness: 1.8pt, dash: "dashed")) + g-edge(verts.at(2), verts.at(3), stroke: 1.8pt + blue) + g-edge(verts.at(2), verts.at(3), stroke: (paint: teal, thickness: 1.8pt, dash: "dashed")) + for (i, pos) in verts.enumerate() { + let fill = if i == 0 { blue } else if i == 1 { teal } else if i == 3 { rgb("#e15759") } else { white } + g-node(pos, name: "utcif-" + str(i), fill: fill, label: if i == 2 { labels.at(i) } else { text(fill: white)[#labels.at(i)] }) + } + content((1.0, 0.95), text(8pt, fill: gray)[$c = 1$]) + content((1.0, -0.95), text(8pt, fill: gray)[$c = 1$]) + content((3.0, 0.35), text(8pt, fill: gray)[$c = 2$]) + }), + caption: [Canonical shared-capacity YES instance for Undirected Two-Commodity Integral Flow. Solid blue carries commodity 1 and dashed teal carries commodity 2; both commodities share the edge $(v_2, v_3)$ of capacity 2.], + ) + ] + ] +} #{ let x = load-model-example("IsomorphicSpanningTree") let g-edges = x.instance.graph.inner.edges.map(e => (e.at(0), e.at(1))) diff --git a/docs/paper/references.bib b/docs/paper/references.bib index 53e868b8..05740229 100644 --- a/docs/paper/references.bib +++ b/docs/paper/references.bib @@ -74,6 +74,17 @@ @article{gareyJohnsonStockmeyer1976 year = {1976} } +@article{evenItaiShamir1976, + author = {Shimon Even and Alon Itai and Adi Shamir}, + title = {On the Complexity of Timetable and Multicommodity Flow Problems}, + journal = {SIAM Journal on Computing}, + volume = {5}, + number = {4}, + pages = {691--703}, + year = {1976}, + doi = {10.1137/0205048} +} + @article{glover2019, author = {Fred Glover and Gary Kochenberger and Yu Du}, title = {Quantum Bridge Analytics {I}: a tutorial on formulating and using {QUBO} models}, diff --git a/docs/src/cli.md b/docs/src/cli.md index eba08d5b..99e70904 100644 --- a/docs/src/cli.md +++ b/docs/src/cli.md @@ -292,6 +292,7 @@ pred create KColoring --k 3 --graph 0-1,1-2,2-0 -o kcol.json pred create SpinGlass --graph 0-1,1-2 -o sg.json pred create MaxCut --graph 0-1,1-2,2-0 -o maxcut.json pred create SteinerTree --graph 0-1,0-3,1-2,1-3,2-3,2-4,3-4 --edge-weights 2,5,2,1,5,6,1 --terminals 0,2,4 -o steiner.json +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 -o utcif.json 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 -o lbdp.json pred create Factoring --target 15 --bits-m 4 --bits-n 4 -o factoring.json pred create Factoring --target 21 --bits-m 3 --bits-n 3 -o factoring2.json diff --git a/docs/src/reductions/problem_schemas.json b/docs/src/reductions/problem_schemas.json index a4ea4b48..7d60308b 100644 --- a/docs/src/reductions/problem_schemas.json +++ b/docs/src/reductions/problem_schemas.json @@ -756,5 +756,51 @@ "description": "Edge weights w: E -> R" } ] + }, + { + "name": "UndirectedTwoCommodityIntegralFlow", + "description": "Determine whether two integral commodities can satisfy sink demands in an undirected capacitated graph", + "fields": [ + { + "name": "graph", + "type_name": "SimpleGraph", + "description": "Undirected graph G=(V,E)" + }, + { + "name": "capacities", + "type_name": "Vec", + "description": "Edge capacities c(e) in graph edge order" + }, + { + "name": "source_1", + "type_name": "usize", + "description": "Source vertex s_1 for commodity 1" + }, + { + "name": "sink_1", + "type_name": "usize", + "description": "Sink vertex t_1 for commodity 1" + }, + { + "name": "source_2", + "type_name": "usize", + "description": "Source vertex s_2 for commodity 2" + }, + { + "name": "sink_2", + "type_name": "usize", + "description": "Sink vertex t_2 for commodity 2" + }, + { + "name": "requirement_1", + "type_name": "u64", + "description": "Required net inflow R_1 at sink t_1" + }, + { + "name": "requirement_2", + "type_name": "u64", + "description": "Required net inflow R_2 at sink t_2" + } + ] } ] \ No newline at end of file diff --git a/docs/src/reductions/reduction_graph.json b/docs/src/reductions/reduction_graph.json index 112c0231..eb200845 100644 --- a/docs/src/reductions/reduction_graph.json +++ b/docs/src/reductions/reduction_graph.json @@ -550,6 +550,13 @@ "category": "graph", "doc_path": "models/graph/struct.TravelingSalesman.html", "complexity": "2^num_vertices" + }, + { + "name": "UndirectedTwoCommodityIntegralFlow", + "variant": {}, + "category": "graph", + "doc_path": "models/graph/struct.UndirectedTwoCommodityIntegralFlow.html", + "complexity": "5^num_edges" } ], "edges": [ diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index 839efbc7..a289e915 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -223,6 +223,7 @@ Flags by problem type: KColoring --graph, --k PartitionIntoTriangles --graph GraphPartitioning --graph + UndirectedTwoCommodityIntegralFlow --graph, --capacities, --source-1, --sink-1, --source-2, --sink-2, --requirement-1, --requirement-2 IsomorphicSpanningTree --graph, --tree LengthBoundedDisjointPaths --graph, --source, --sink, --num-paths-required, --bound Factoring --target, --m, --n @@ -266,6 +267,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 FVS --arcs \"0>1,1>2,2>0\" --weights 1,1,1 + 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 pred create X3C --universe 9 --sets \"0,1,2;0,2,4;3,4,5;3,5,7;6,7,8;1,4,6;2,5,8\" pred create SetBasis --universe 4 --sets \"0,1;1,2;0,2;0,1,2\" --k 3")] pub struct CreateArgs { @@ -290,6 +292,9 @@ pub struct CreateArgs { /// Edge weights (e.g., 2,3,1) [default: all 1s] #[arg(long)] pub edge_weights: Option, + /// Edge capacities for multicommodity flow problems (e.g., 1,1,2) + #[arg(long)] + pub capacities: Option, /// Source vertex for path-based graph problems #[arg(long)] pub source: Option, @@ -344,6 +349,24 @@ pub struct CreateArgs { /// Radius for UnitDiskGraph [default: 1.0] #[arg(long)] pub radius: Option, + /// Source vertex s_1 for commodity 1 + #[arg(long)] + pub source_1: Option, + /// Sink vertex t_1 for commodity 1 + #[arg(long)] + pub sink_1: Option, + /// Source vertex s_2 for commodity 2 + #[arg(long)] + pub source_2: Option, + /// Sink vertex t_2 for commodity 2 + #[arg(long)] + pub sink_2: Option, + /// Required flow R_1 for commodity 1 + #[arg(long)] + pub requirement_1: Option, + /// Required flow R_2 for commodity 2 + #[arg(long)] + pub requirement_2: Option, /// Item sizes for BinPacking (comma-separated, e.g., "3,3,2,2") #[arg(long)] pub sizes: Option, diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index 1bd76080..a6261ac0 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -27,6 +27,7 @@ fn all_data_flags_empty(args: &CreateArgs) -> bool { args.graph.is_none() && args.weights.is_none() && args.edge_weights.is_none() + && args.capacities.is_none() && args.source.is_none() && args.sink.is_none() && args.num_paths_required.is_none() @@ -44,6 +45,12 @@ fn all_data_flags_empty(args: &CreateArgs) -> bool { && args.seed.is_none() && args.positions.is_none() && args.radius.is_none() + && args.source_1.is_none() + && args.sink_1.is_none() + && args.source_2.is_none() + && args.sink_2.is_none() + && args.requirement_1.is_none() + && args.requirement_2.is_none() && args.sizes.is_none() && args.capacity.is_none() && args.sequence.is_none() @@ -199,11 +206,13 @@ fn create_from_example(args: &CreateArgs, out: &OutputConfig) -> Result<()> { fn type_format_hint(type_name: &str, graph_type: Option<&str>) -> &'static str { match type_name { + "SimpleGraph" => "edge list: 0-1,1-2,2-3", "G" => match graph_type { Some("KingsSubgraph" | "TriangularSubgraph") => "integer positions: \"0,0;1,0;1,1\"", Some("UnitDiskGraph") => "float positions: \"0.0,0.0;1.0,0.0\"", _ => "edge list: 0-1,1-2,2-3", }, + "Vec" => "comma-separated integers: 1,1,2", "Vec" => "comma-separated: 1,2,3", "Vec" => "semicolon-separated clauses: \"1,2;-1,3\"", "Vec>" => "semicolon-separated rows: \"1,0.5;0.5,2\"", @@ -246,6 +255,9 @@ fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str { }, "GraphPartitioning" => "--graph 0-1,1-2,2-3,0-2,1-3,0-3", "HamiltonianPath" => "--graph 0-1,1-2,2-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" + }, "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" } @@ -485,6 +497,57 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { (ser(HamiltonianPath::new(graph))?, 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)?; + let num_vertices = graph.num_vertices(); + let source_1 = args.source_1.ok_or_else(|| { + anyhow::anyhow!("UndirectedTwoCommodityIntegralFlow requires --source-1\n\n{usage}") + })?; + let sink_1 = args.sink_1.ok_or_else(|| { + anyhow::anyhow!("UndirectedTwoCommodityIntegralFlow requires --sink-1\n\n{usage}") + })?; + let source_2 = args.source_2.ok_or_else(|| { + anyhow::anyhow!("UndirectedTwoCommodityIntegralFlow requires --source-2\n\n{usage}") + })?; + let sink_2 = args.sink_2.ok_or_else(|| { + anyhow::anyhow!("UndirectedTwoCommodityIntegralFlow requires --sink-2\n\n{usage}") + })?; + let requirement_1 = args.requirement_1.ok_or_else(|| { + anyhow::anyhow!( + "UndirectedTwoCommodityIntegralFlow requires --requirement-1\n\n{usage}" + ) + })?; + let requirement_2 = args.requirement_2.ok_or_else(|| { + anyhow::anyhow!( + "UndirectedTwoCommodityIntegralFlow requires --requirement-2\n\n{usage}" + ) + })?; + for (label, vertex) in [ + ("source-1", source_1), + ("sink-1", sink_1), + ("source-2", source_2), + ("sink-2", sink_2), + ] { + validate_vertex_index(label, vertex, num_vertices, usage)?; + } + ( + ser(UndirectedTwoCommodityIntegralFlow::new( + graph, + capacities, + source_1, + sink_1, + source_2, + sink_2, + requirement_1, + requirement_2, + ))?, + 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"; @@ -1523,6 +1586,58 @@ fn parse_edge_weights(args: &CreateArgs, num_edges: usize) -> Result> { } } +fn validate_vertex_index( + label: &str, + vertex: usize, + num_vertices: usize, + usage: &str, +) -> Result<()> { + if vertex < num_vertices { + return Ok(()); + } + + bail!("{label} must be less than num_vertices ({num_vertices})\n\n{usage}"); +} + +/// 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: Vec = capacities + .split(',') + .map(|s| { + let trimmed = s.trim(); + trimmed + .parse::() + .with_context(|| format!("Invalid capacity `{trimmed}`\n\n{usage}")) + }) + .collect::>>()?; + if capacities.len() != num_edges { + bail!( + "Expected {} capacities but got {}\n\n{}", + num_edges, + capacities.len(), + 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 `--couplings` as SpinGlass pairwise couplings (i32), defaulting to all 1s. fn parse_couplings(args: &CreateArgs, num_edges: usize) -> Result> { match &args.couplings { diff --git a/problemreductions-cli/src/problem_name.rs b/problemreductions-cli/src/problem_name.rs index 8750855c..12404ab7 100644 --- a/problemreductions-cli/src/problem_name.rs +++ b/problemreductions-cli/src/problem_name.rs @@ -307,6 +307,14 @@ mod tests { assert_eq!(spec.variant_values, vec!["SimpleGraph", "f64"]); } + #[test] + fn test_resolve_alias_pass_through_undirected_two_commodity_integral_flow() { + assert_eq!( + resolve_alias("UndirectedTwoCommodityIntegralFlow"), + "UndirectedTwoCommodityIntegralFlow" + ); + } + #[test] fn test_parse_problem_spec_ksat_alias() { let spec = parse_problem_spec("KSAT").unwrap(); diff --git a/problemreductions-cli/tests/cli_tests.rs b/problemreductions-cli/tests/cli_tests.rs index 063d1e8b..419e690a 100644 --- a/problemreductions-cli/tests/cli_tests.rs +++ b/problemreductions-cli/tests/cli_tests.rs @@ -21,6 +21,18 @@ fn test_list() { assert!(stdout.contains("QUBO")); } +#[test] +fn test_list_includes_undirected_two_commodity_integral_flow() { + let output = pred().args(["list"]).output().unwrap(); + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8(output.stdout).unwrap(); + assert!(stdout.contains("UndirectedTwoCommodityIntegralFlow")); +} + #[test] fn test_list_rules() { let output = pred().args(["list", "--rules"]).output().unwrap(); @@ -64,6 +76,24 @@ fn test_show() { assert!(stdout.contains("Outgoing reductions")); } +#[test] +fn test_show_undirected_two_commodity_integral_flow() { + let output = pred() + .args(["show", "UndirectedTwoCommodityIntegralFlow"]) + .output() + .unwrap(); + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8(output.stdout).unwrap(); + assert!(stdout.contains("UndirectedTwoCommodityIntegralFlow")); + assert!(stdout.contains("capacities")); + assert!(stdout.contains("source_1")); + assert!(stdout.contains("requirement_2")); +} + #[test] fn test_show_variant_info() { let output = pred().args(["show", "MIS"]).output().unwrap(); @@ -280,6 +310,206 @@ fn test_evaluate_sat() { std::fs::remove_file(&tmp).ok(); } +#[test] +fn test_create_undirected_two_commodity_integral_flow() { + let output = pred() + .args([ + "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", + ]) + .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"], "UndirectedTwoCommodityIntegralFlow"); + assert_eq!(json["variant"], serde_json::json!({})); + assert_eq!(json["data"]["capacities"], serde_json::json!([1, 1, 2])); + assert_eq!(json["data"]["source_1"], 0); + assert_eq!(json["data"]["sink_1"], 3); + assert_eq!(json["data"]["source_2"], 1); + assert_eq!(json["data"]["sink_2"], 3); + assert_eq!(json["data"]["requirement_1"], 1); + assert_eq!(json["data"]["requirement_2"], 1); +} + +#[test] +fn test_create_undirected_two_commodity_integral_flow_missing_capacities_shows_usage() { + let output = pred() + .args([ + "create", + "UndirectedTwoCommodityIntegralFlow", + "--graph", + "0-2,1-2,2-3", + "--source-1", + "0", + "--sink-1", + "3", + "--source-2", + "1", + "--sink-2", + "3", + "--requirement-1", + "1", + "--requirement-2", + "1", + ]) + .output() + .unwrap(); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("requires --capacities")); + assert!(stderr.contains("Usage: pred create UndirectedTwoCommodityIntegralFlow")); +} + +#[test] +fn test_create_undirected_two_commodity_integral_flow_rejects_invalid_capacity_token() { + let output = pred() + .args([ + "create", + "UndirectedTwoCommodityIntegralFlow", + "--graph", + "0-2,1-2,2-3", + "--capacities", + "1,x,2", + "--source-1", + "0", + "--sink-1", + "3", + "--source-2", + "1", + "--sink-2", + "3", + "--requirement-1", + "1", + "--requirement-2", + "1", + ]) + .output() + .unwrap(); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("Invalid capacity `x`")); + assert!(stderr.contains("Usage: pred create UndirectedTwoCommodityIntegralFlow")); +} + +#[test] +fn test_create_undirected_two_commodity_integral_flow_rejects_wrong_capacity_count() { + let output = pred() + .args([ + "create", + "UndirectedTwoCommodityIntegralFlow", + "--graph", + "0-2,1-2,2-3", + "--capacities", + "1,2", + "--source-1", + "0", + "--sink-1", + "3", + "--source-2", + "1", + "--sink-2", + "3", + "--requirement-1", + "1", + "--requirement-2", + "1", + ]) + .output() + .unwrap(); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("Expected 3 capacities but got 2")); + assert!(stderr.contains("Usage: pred create UndirectedTwoCommodityIntegralFlow")); +} + +#[test] +fn test_create_undirected_two_commodity_integral_flow_rejects_oversized_capacity() { + let oversized = ((usize::MAX as u128) + 1).to_string(); + let capacities = format!("1,1,{oversized}"); + let output = pred() + .args([ + "create", + "UndirectedTwoCommodityIntegralFlow", + "--graph", + "0-2,1-2,2-3", + "--capacities", + capacities.as_str(), + "--source-1", + "0", + "--sink-1", + "3", + "--source-2", + "1", + "--sink-2", + "3", + "--requirement-1", + "1", + "--requirement-2", + "1", + ]) + .output() + .unwrap(); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains(format!("Invalid capacity `{oversized}`").as_str())); + assert!(stderr.contains("number too large to fit in target type")); + assert!(stderr.contains("Usage: pred create UndirectedTwoCommodityIntegralFlow")); +} + +#[test] +fn test_create_undirected_two_commodity_integral_flow_rejects_out_of_range_terminal() { + let output = pred() + .args([ + "create", + "UndirectedTwoCommodityIntegralFlow", + "--graph", + "0-2,1-2,2-3", + "--capacities", + "1,1,2", + "--source-1", + "99", + "--sink-1", + "3", + "--source-2", + "1", + "--sink-2", + "3", + "--requirement-1", + "1", + "--requirement-2", + "1", + ]) + .output() + .unwrap(); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("source-1 must be less than num_vertices (4)")); + assert!(stderr.contains("Usage: pred create UndirectedTwoCommodityIntegralFlow")); + assert!(!stderr.contains("panicked at"), "stderr: {stderr}"); +} + #[test] fn test_reduce() { let problem_json = r#"{ @@ -2845,6 +3075,65 @@ fn test_inspect_json_output() { std::fs::remove_file(&result_file).ok(); } +#[test] +fn test_inspect_undirected_two_commodity_integral_flow_reports_size_fields() { + let problem_file = std::env::temp_dir().join("pred_test_utcif_inspect_in.json"); + let result_file = std::env::temp_dir().join("pred_test_utcif_inspect_out.json"); + let create_out = pred() + .args([ + "-o", + problem_file.to_str().unwrap(), + "create", + "--example", + "UndirectedTwoCommodityIntegralFlow", + ]) + .output() + .unwrap(); + assert!( + create_out.status.success(), + "stderr: {}", + String::from_utf8_lossy(&create_out.stderr) + ); + + let output = pred() + .args([ + "-o", + result_file.to_str().unwrap(), + "inspect", + problem_file.to_str().unwrap(), + ]) + .output() + .unwrap(); + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(result_file.exists()); + + let content = std::fs::read_to_string(&result_file).unwrap(); + let json: serde_json::Value = serde_json::from_str(&content).unwrap(); + let size_fields: Vec<&str> = json["size_fields"] + .as_array() + .expect("size_fields should be an array") + .iter() + .map(|v| v.as_str().unwrap()) + .collect(); + assert!( + size_fields.contains(&"num_vertices"), + "UndirectedTwoCommodityIntegralFlow size_fields should contain num_vertices, got: {:?}", + size_fields + ); + assert!( + size_fields.contains(&"num_edges"), + "UndirectedTwoCommodityIntegralFlow size_fields should contain num_edges, got: {:?}", + size_fields + ); + + std::fs::remove_file(&problem_file).ok(); + std::fs::remove_file(&result_file).ok(); +} + // ---- Random generation tests ---- #[test] diff --git a/src/example_db/fixtures/examples.json b/src/example_db/fixtures/examples.json index ed63228e..891fe8e7 100644 --- a/src/example_db/fixtures/examples.json +++ b/src/example_db/fixtures/examples.json @@ -33,7 +33,8 @@ {"problem":"ShortestCommonSupersequence","variant":{},"instance":{"alphabet_size":3,"bound":4,"strings":[[0,1,2],[1,0,2]]},"samples":[{"config":[1,0,1,2],"metric":true}],"optimal":[{"config":[0,1,0,2],"metric":true},{"config":[1,0,1,2],"metric":true}]}, {"problem":"SpinGlass","variant":{"graph":"SimpleGraph","weight":"i32"},"instance":{"couplings":[1,1,1,1,1,1,1],"fields":[0,0,0,0,0],"graph":{"inner":{"edge_property":"undirected","edges":[[0,1,null],[1,2,null],[3,4,null],[0,3,null],[1,3,null],[1,4,null],[2,4,null]],"node_holes":[],"nodes":[null,null,null,null,null]}}},"samples":[{"config":[1,0,1,1,0],"metric":{"Valid":-3}}],"optimal":[{"config":[0,0,1,1,0],"metric":{"Valid":-3}},{"config":[0,1,0,0,1],"metric":{"Valid":-3}},{"config":[0,1,0,1,0],"metric":{"Valid":-3}},{"config":[0,1,1,1,0],"metric":{"Valid":-3}},{"config":[1,0,0,0,1],"metric":{"Valid":-3}},{"config":[1,0,1,0,1],"metric":{"Valid":-3}},{"config":[1,0,1,1,0],"metric":{"Valid":-3}},{"config":[1,1,0,0,1],"metric":{"Valid":-3}}]}, {"problem":"SteinerTree","variant":{"graph":"SimpleGraph","weight":"i32"},"instance":{"edge_weights":[2,5,2,1,5,6,1],"graph":{"inner":{"edge_property":"undirected","edges":[[0,1,null],[0,3,null],[1,2,null],[1,3,null],[2,3,null],[2,4,null],[3,4,null]],"node_holes":[],"nodes":[null,null,null,null,null]}},"terminals":[0,2,4]},"samples":[{"config":[1,0,1,1,0,0,1],"metric":{"Valid":6}}],"optimal":[{"config":[1,0,1,1,0,0,1],"metric":{"Valid":6}}]}, - {"problem":"TravelingSalesman","variant":{"graph":"SimpleGraph","weight":"i32"},"instance":{"edge_weights":[1,3,2,2,3,1],"graph":{"inner":{"edge_property":"undirected","edges":[[0,1,null],[0,2,null],[0,3,null],[1,2,null],[1,3,null],[2,3,null]],"node_holes":[],"nodes":[null,null,null,null]}}},"samples":[{"config":[1,0,1,1,0,1],"metric":{"Valid":6}}],"optimal":[{"config":[1,0,1,1,0,1],"metric":{"Valid":6}}]} + {"problem":"TravelingSalesman","variant":{"graph":"SimpleGraph","weight":"i32"},"instance":{"edge_weights":[1,3,2,2,3,1],"graph":{"inner":{"edge_property":"undirected","edges":[[0,1,null],[0,2,null],[0,3,null],[1,2,null],[1,3,null],[2,3,null]],"node_holes":[],"nodes":[null,null,null,null]}}},"samples":[{"config":[1,0,1,1,0,1],"metric":{"Valid":6}}],"optimal":[{"config":[1,0,1,1,0,1],"metric":{"Valid":6}}]}, + {"problem":"UndirectedTwoCommodityIntegralFlow","variant":{},"instance":{"capacities":[1,1,2],"graph":{"inner":{"edge_property":"undirected","edges":[[0,2,null],[1,2,null],[2,3,null]],"node_holes":[],"nodes":[null,null,null,null]}},"requirement_1":1,"requirement_2":1,"sink_1":3,"sink_2":3,"source_1":0,"source_2":1},"samples":[{"config":[1,0,0,0,0,0,1,0,1,0,1,0],"metric":true}],"optimal":[{"config":[0,0,1,0,1,0,0,0,1,0,1,0],"metric":true},{"config":[1,0,0,0,0,0,1,0,1,0,1,0],"metric":true}]} ], "rules": [ {"source":{"problem":"BinPacking","variant":{"weight":"i32"},"instance":{"capacity":10,"sizes":[6,5,5,4,3]}},"target":{"problem":"ILP","variant":{"variable":"bool"},"instance":{"constraints":[{"cmp":"Eq","rhs":1.0,"terms":[[0,1.0],[1,1.0],[2,1.0],[3,1.0],[4,1.0]]},{"cmp":"Eq","rhs":1.0,"terms":[[5,1.0],[6,1.0],[7,1.0],[8,1.0],[9,1.0]]},{"cmp":"Eq","rhs":1.0,"terms":[[10,1.0],[11,1.0],[12,1.0],[13,1.0],[14,1.0]]},{"cmp":"Eq","rhs":1.0,"terms":[[15,1.0],[16,1.0],[17,1.0],[18,1.0],[19,1.0]]},{"cmp":"Eq","rhs":1.0,"terms":[[20,1.0],[21,1.0],[22,1.0],[23,1.0],[24,1.0]]},{"cmp":"Le","rhs":0.0,"terms":[[0,6.0],[5,5.0],[10,5.0],[15,4.0],[20,3.0],[25,-10.0]]},{"cmp":"Le","rhs":0.0,"terms":[[1,6.0],[6,5.0],[11,5.0],[16,4.0],[21,3.0],[26,-10.0]]},{"cmp":"Le","rhs":0.0,"terms":[[2,6.0],[7,5.0],[12,5.0],[17,4.0],[22,3.0],[27,-10.0]]},{"cmp":"Le","rhs":0.0,"terms":[[3,6.0],[8,5.0],[13,5.0],[18,4.0],[23,3.0],[28,-10.0]]},{"cmp":"Le","rhs":0.0,"terms":[[4,6.0],[9,5.0],[14,5.0],[19,4.0],[24,3.0],[29,-10.0]]}],"num_vars":30,"objective":[[25,1.0],[26,1.0],[27,1.0],[28,1.0],[29,1.0]],"sense":"Minimize"}},"solutions":[{"source_config":[2,1,0,0,2],"target_config":[0,0,1,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,1,0,0,1,1,1,0,0]}]}, diff --git a/src/example_db/model_builders.rs b/src/example_db/model_builders.rs index d6c79a47..a866a953 100644 --- a/src/example_db/model_builders.rs +++ b/src/example_db/model_builders.rs @@ -1,6 +1,8 @@ use crate::export::ModelExample; pub fn build_model_examples() -> Vec { + // Graph model examples, including UndirectedTwoCommodityIntegralFlow, are + // gathered from the graph module's canonical example registry. crate::models::graph::canonical_model_example_specs() .into_iter() .chain(crate::models::formula::canonical_model_example_specs()) diff --git a/src/lib.rs b/src/lib.rs index 0b750bba..acbd1d76 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -53,6 +53,7 @@ pub mod prelude { MinimumDominatingSet, MinimumFeedbackArcSet, MinimumFeedbackVertexSet, MinimumSumMulticenter, MinimumVertexCover, OptimalLinearArrangement, PartitionIntoTriangles, RuralPostman, TravelingSalesman, + UndirectedTwoCommodityIntegralFlow, }; pub use crate::models::misc::{ BinPacking, Factoring, FlowShopScheduling, Knapsack, LongestCommonSubsequence, diff --git a/src/models/graph/mod.rs b/src/models/graph/mod.rs index f0559b89..bde7b663 100644 --- a/src/models/graph/mod.rs +++ b/src/models/graph/mod.rs @@ -22,7 +22,9 @@ //! - [`MinimumSumMulticenter`]: Min-sum multicenter (p-median) //! - [`LengthBoundedDisjointPaths`]: Length-bounded internally disjoint s-t paths //! - [`RuralPostman`]: Rural Postman (circuit covering required edges) +//! - [`SteinerTree`]: Minimum-weight tree spanning all required terminals //! - [`SubgraphIsomorphism`]: Subgraph isomorphism (decision problem) +//! - [`UndirectedTwoCommodityIntegralFlow`]: Two-commodity integral flow on undirected graphs pub(crate) mod biclique_cover; pub(crate) mod graph_partitioning; @@ -47,6 +49,7 @@ pub(crate) mod spin_glass; pub(crate) mod steiner_tree; pub(crate) mod subgraph_isomorphism; pub(crate) mod traveling_salesman; +pub(crate) mod undirected_two_commodity_integral_flow; pub use biclique_cover::BicliqueCover; pub use graph_partitioning::GraphPartitioning; @@ -71,6 +74,7 @@ pub use spin_glass::SpinGlass; pub use steiner_tree::SteinerTree; pub use subgraph_isomorphism::SubgraphIsomorphism; pub use traveling_salesman::TravelingSalesman; +pub use undirected_two_commodity_integral_flow::UndirectedTwoCommodityIntegralFlow; #[cfg(feature = "example-db")] pub(crate) fn canonical_model_example_specs() -> Vec { @@ -93,5 +97,6 @@ pub(crate) fn canonical_model_example_specs() -> Vec", description: "Edge capacities c(e) in graph edge order" }, + FieldInfo { name: "source_1", type_name: "usize", description: "Source vertex s_1 for commodity 1" }, + FieldInfo { name: "sink_1", type_name: "usize", description: "Sink vertex t_1 for commodity 1" }, + FieldInfo { name: "source_2", type_name: "usize", description: "Source vertex s_2 for commodity 2" }, + FieldInfo { name: "sink_2", type_name: "usize", description: "Sink vertex t_2 for commodity 2" }, + FieldInfo { name: "requirement_1", type_name: "u64", description: "Required net inflow R_1 at sink t_1" }, + FieldInfo { name: "requirement_2", type_name: "u64", description: "Required net inflow R_2 at sink t_2" }, + ], + } +} + +inventory::submit! { + ProblemSizeFieldEntry { + name: "UndirectedTwoCommodityIntegralFlow", + fields: &["num_vertices", "num_edges"], + } +} + +/// Undirected two-commodity integral flow on a capacitated graph. +/// +/// For each undirected edge `{u, v}`, a configuration stores four variables in +/// the graph's edge order: +/// - `f1(u, v)` +/// - `f1(v, u)` +/// - `f2(u, v)` +/// - `f2(v, u)` +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UndirectedTwoCommodityIntegralFlow { + graph: SimpleGraph, + capacities: Vec, + source_1: usize, + sink_1: usize, + source_2: usize, + sink_2: usize, + requirement_1: u64, + requirement_2: u64, +} + +impl UndirectedTwoCommodityIntegralFlow { + #[allow(clippy::too_many_arguments)] + pub fn new( + graph: SimpleGraph, + capacities: Vec, + source_1: usize, + sink_1: usize, + source_2: usize, + sink_2: usize, + requirement_1: u64, + requirement_2: u64, + ) -> Self { + assert_eq!( + capacities.len(), + graph.num_edges(), + "capacities length must match graph num_edges" + ); + + let num_vertices = graph.num_vertices(); + for (label, vertex) in [ + ("source_1", source_1), + ("sink_1", sink_1), + ("source_2", source_2), + ("sink_2", sink_2), + ] { + assert!( + vertex < num_vertices, + "{label} must be less than num_vertices ({num_vertices})" + ); + } + + for &capacity in &capacities { + let domain = usize::try_from(capacity) + .ok() + .and_then(|value| value.checked_add(1)); + assert!( + domain.is_some(), + "edge capacities must fit into usize for dims()" + ); + } + + Self { + graph, + capacities, + source_1, + sink_1, + source_2, + sink_2, + requirement_1, + requirement_2, + } + } + + pub fn graph(&self) -> &SimpleGraph { + &self.graph + } + + pub fn capacities(&self) -> &[u64] { + &self.capacities + } + + pub fn source_1(&self) -> usize { + self.source_1 + } + + pub fn sink_1(&self) -> usize { + self.sink_1 + } + + pub fn source_2(&self) -> usize { + self.source_2 + } + + pub fn sink_2(&self) -> usize { + self.sink_2 + } + + pub fn requirement_1(&self) -> u64 { + self.requirement_1 + } + + pub fn requirement_2(&self) -> u64 { + self.requirement_2 + } + + 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 config_len(&self) -> usize { + self.num_edges() * 4 + } + + fn domain_size(capacity: u64) -> usize { + usize::try_from(capacity) + .ok() + .and_then(|value| value.checked_add(1)) + .expect("capacity already validated to fit into usize") + } + + fn edge_flows(&self, config: &[usize], edge_index: usize) -> Option<[usize; 4]> { + let start = edge_index.checked_mul(4)?; + Some([ + *config.get(start)?, + *config.get(start + 1)?, + *config.get(start + 2)?, + *config.get(start + 3)?, + ]) + } + + fn is_terminal(&self, vertex: usize) -> bool { + [self.source_1, self.sink_1, self.source_2, self.sink_2].contains(&vertex) + } + + fn flow_pair_for_commodity(flows: [usize; 4], commodity: usize) -> (usize, usize) { + match commodity { + 1 => (flows[0], flows[1]), + 2 => (flows[2], flows[3]), + _ => unreachable!("commodity must be 1 or 2"), + } + } + + fn commodity_balance(&self, config: &[usize], commodity: usize, vertex: usize) -> Option { + let mut balance = 0i128; + for (edge_index, (u, v)) in self.graph.edges().into_iter().enumerate() { + let flows = self.edge_flows(config, edge_index)?; + let (uv, vu) = Self::flow_pair_for_commodity(flows, commodity); + let uv = i128::from(u64::try_from(uv).ok()?); + let vu = i128::from(u64::try_from(vu).ok()?); + + if vertex == u { + balance -= uv; + balance += vu; + } else if vertex == v { + balance += uv; + balance -= vu; + } + } + Some(balance) + } + + fn net_flow_into_sink(&self, config: &[usize], commodity: usize) -> Option { + let sink = match commodity { + 1 => self.sink_1, + 2 => self.sink_2, + _ => unreachable!("commodity must be 1 or 2"), + }; + let balance = self.commodity_balance(config, commodity, sink)?; + u64::try_from(balance).ok() + } +} + +impl Problem for UndirectedTwoCommodityIntegralFlow { + const NAME: &'static str = "UndirectedTwoCommodityIntegralFlow"; + type Metric = bool; + + fn variant() -> Vec<(&'static str, &'static str)> { + crate::variant_params![] + } + + fn dims(&self) -> Vec { + self.capacities + .iter() + .flat_map(|&capacity| { + let domain = Self::domain_size(capacity); + std::iter::repeat_n(domain, 4) + }) + .collect() + } + + fn evaluate(&self, config: &[usize]) -> bool { + if config.len() != self.config_len() { + return false; + } + + for (edge_index, &capacity) in self.capacities.iter().enumerate() { + let Some(flows) = self.edge_flows(config, edge_index) else { + return false; + }; + + if flows + .iter() + .any(|&value| u64::try_from(value).map_or(true, |value| value > capacity)) + { + return false; + } + + if flows[0] > 0 && flows[1] > 0 { + return false; + } + if flows[2] > 0 && flows[3] > 0 { + return false; + } + + let commodity_1 = u64::try_from(std::cmp::max(flows[0], flows[1])) + .expect("flow values already validated against u64 capacities"); + let commodity_2 = u64::try_from(std::cmp::max(flows[2], flows[3])) + .expect("flow values already validated against u64 capacities"); + let Some(shared) = commodity_1.checked_add(commodity_2) else { + return false; + }; + if shared > capacity { + return false; + } + } + + for vertex in 0..self.num_vertices() { + if self.is_terminal(vertex) { + continue; + } + + if self.commodity_balance(config, 1, vertex) != Some(0) + || self.commodity_balance(config, 2, vertex) != Some(0) + { + return false; + } + } + + self.net_flow_into_sink(config, 1) + .is_some_and(|flow| flow >= self.requirement_1) + && self + .net_flow_into_sink(config, 2) + .is_some_and(|flow| flow >= self.requirement_2) + } +} + +impl SatisfactionProblem for UndirectedTwoCommodityIntegralFlow {} + +crate::declare_variants! { + default sat UndirectedTwoCommodityIntegralFlow => "5^num_edges", +} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_model_example_specs() -> Vec { + vec![crate::example_db::specs::ModelExampleSpec { + id: "undirected_two_commodity_integral_flow", + build: || { + let problem = UndirectedTwoCommodityIntegralFlow::new( + SimpleGraph::new(4, vec![(0, 2), (1, 2), (2, 3)]), + vec![1, 1, 2], + 0, + 3, + 1, + 3, + 1, + 1, + ); + crate::example_db::specs::satisfaction_example( + problem, + vec![vec![1, 0, 0, 0, 0, 0, 1, 0, 1, 0, 1, 0]], + ) + }, + }] +} + +#[cfg(test)] +#[path = "../../unit_tests/models/graph/undirected_two_commodity_integral_flow.rs"] +mod tests; diff --git a/src/models/mod.rs b/src/models/mod.rs index 8fc13a04..3031a2d9 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -17,6 +17,7 @@ pub use graph::{ MaximumMatching, MinimumDominatingSet, MinimumFeedbackArcSet, MinimumFeedbackVertexSet, MinimumSumMulticenter, MinimumVertexCover, OptimalLinearArrangement, PartitionIntoTriangles, RuralPostman, SpinGlass, SteinerTree, SubgraphIsomorphism, TravelingSalesman, + UndirectedTwoCommodityIntegralFlow, }; pub use misc::{ BinPacking, Factoring, FlowShopScheduling, Knapsack, LongestCommonSubsequence, diff --git a/src/registry/mod.rs b/src/registry/mod.rs index 9732f97b..d7260972 100644 --- a/src/registry/mod.rs +++ b/src/registry/mod.rs @@ -56,7 +56,8 @@ pub use info::{ComplexityClass, FieldInfo, ProblemInfo, ProblemMetadata}; pub use problem_ref::{parse_catalog_problem_ref, require_graph_variant, ProblemRef}; pub use problem_type::{find_problem_type, find_problem_type_by_alias, problem_types, ProblemType}; pub use schema::{ - collect_schemas, FieldInfoJson, ProblemSchemaEntry, ProblemSchemaJson, VariantDimension, + collect_schemas, declared_size_fields, FieldInfoJson, ProblemSchemaEntry, ProblemSchemaJson, + ProblemSizeFieldEntry, VariantDimension, }; pub use variant::{find_variant_entry, VariantEntry}; diff --git a/src/registry/schema.rs b/src/registry/schema.rs index d78cebfa..00f20291 100644 --- a/src/registry/schema.rs +++ b/src/registry/schema.rs @@ -52,6 +52,19 @@ pub struct ProblemSchemaEntry { inventory::collect!(ProblemSchemaEntry); +/// Optional static size-field metadata for problem types. +/// +/// This is used when a problem has meaningful size fields even before it +/// participates in any reduction overhead expressions. +pub struct ProblemSizeFieldEntry { + /// Problem name (e.g., "MaximumIndependentSet"). + pub name: &'static str, + /// Size field names (e.g., `&["num_vertices", "num_edges"]`). + pub fields: &'static [&'static str], +} + +inventory::collect!(ProblemSizeFieldEntry); + /// JSON-serializable problem schema. #[derive(Debug, Clone, Serialize)] pub struct ProblemSchemaJson { @@ -96,6 +109,14 @@ pub fn collect_schemas() -> Vec { schemas } +/// Collect explicitly declared size fields for a problem type. +pub fn declared_size_fields(name: &str) -> Vec<&'static str> { + inventory::iter::() + .filter(|entry| entry.name == name) + .flat_map(|entry| entry.fields.iter().copied()) + .collect() +} + #[cfg(test)] #[path = "../unit_tests/registry/schema.rs"] mod tests; diff --git a/src/rules/graph.rs b/src/rules/graph.rs index dc54d8b2..fff45b23 100644 --- a/src/rules/graph.rs +++ b/src/rules/graph.rs @@ -741,7 +741,10 @@ impl ReductionGraph { /// source, its size fields are the input variables referenced in the overhead /// expressions. When it's a target, its size fields are the output field names. pub fn size_field_names(&self, name: &str) -> Vec<&'static str> { - let mut fields = std::collections::HashSet::new(); + let mut fields: std::collections::HashSet<&'static str> = + crate::registry::declared_size_fields(name) + .into_iter() + .collect(); for entry in inventory::iter:: { if entry.source_name == name { // Source's size fields are the input variables of the overhead. diff --git a/src/unit_tests/models/graph/undirected_two_commodity_integral_flow.rs b/src/unit_tests/models/graph/undirected_two_commodity_integral_flow.rs new file mode 100644 index 00000000..e34f7d92 --- /dev/null +++ b/src/unit_tests/models/graph/undirected_two_commodity_integral_flow.rs @@ -0,0 +1,212 @@ +use super::*; +use crate::solvers::{BruteForce, Solver}; +use crate::topology::{Graph, SimpleGraph}; +use crate::traits::Problem; + +fn canonical_instance() -> UndirectedTwoCommodityIntegralFlow { + UndirectedTwoCommodityIntegralFlow::new( + SimpleGraph::new(4, vec![(0, 2), (1, 2), (2, 3)]), + vec![1, 1, 2], + 0, + 3, + 1, + 3, + 1, + 1, + ) +} + +fn shared_bottleneck_instance() -> UndirectedTwoCommodityIntegralFlow { + UndirectedTwoCommodityIntegralFlow::new( + SimpleGraph::new(4, vec![(0, 2), (1, 2), (2, 3)]), + vec![1, 1, 1], + 0, + 3, + 1, + 3, + 1, + 1, + ) +} + +fn example_config() -> Vec { + // Edge order matches insertion order: + // (0,2): commodity 1 sends 1 from 0 -> 2 + // (1,2): commodity 2 sends 1 from 1 -> 2 + // (2,3): both commodities send 1 from 2 -> 3 + vec![1, 0, 0, 0, 0, 0, 1, 0, 1, 0, 1, 0] +} + +#[test] +fn test_undirected_two_commodity_integral_flow_creation() { + let problem = canonical_instance(); + assert_eq!(problem.graph().num_vertices(), 4); + assert_eq!(problem.graph().num_edges(), 3); + assert_eq!(problem.capacities(), &[1, 1, 2]); + assert_eq!(problem.source_1(), 0); + assert_eq!(problem.sink_1(), 3); + assert_eq!(problem.source_2(), 1); + assert_eq!(problem.sink_2(), 3); + assert_eq!(problem.requirement_1(), 1); + assert_eq!(problem.requirement_2(), 1); + assert_eq!(problem.num_vertices(), 4); + assert_eq!(problem.num_edges(), 3); + assert_eq!(problem.dims(), vec![2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 3]); +} + +#[test] +fn test_undirected_two_commodity_integral_flow_evaluation_yes() { + let problem = canonical_instance(); + assert!(problem.evaluate(&example_config())); + assert!(problem.is_valid_solution(&example_config())); +} + +#[test] +fn test_undirected_two_commodity_integral_flow_evaluation_no_shared_bottleneck() { + let problem = shared_bottleneck_instance(); + assert!(!problem.evaluate(&example_config())); + assert!(!problem.is_valid_solution(&example_config())); + assert!(BruteForce::new().find_satisfying(&problem).is_none()); +} + +#[test] +fn test_undirected_two_commodity_integral_flow_rejects_wrong_config_length() { + let problem = canonical_instance(); + let mut config = example_config(); + config.pop(); + + assert!(!problem.evaluate(&config)); +} + +#[test] +fn test_undirected_two_commodity_integral_flow_rejects_value_above_capacity_domain() { + let problem = canonical_instance(); + let mut config = example_config(); + config[8] = 3; + + assert!(!problem.evaluate(&config)); +} + +#[test] +fn test_undirected_two_commodity_integral_flow_rejects_antisymmetry_violation() { + let problem = canonical_instance(); + let mut config = example_config(); + config[0] = 1; + config[1] = 1; + + assert!(!problem.evaluate(&config)); +} + +#[test] +fn test_undirected_two_commodity_integral_flow_serialization() { + let problem = canonical_instance(); + let value = serde_json::to_value(&problem).unwrap(); + let deserialized: UndirectedTwoCommodityIntegralFlow = serde_json::from_value(value).unwrap(); + assert_eq!(deserialized.graph(), problem.graph()); + assert_eq!(deserialized.capacities(), problem.capacities()); + assert_eq!(deserialized.source_1(), problem.source_1()); + assert_eq!(deserialized.sink_1(), problem.sink_1()); + assert_eq!(deserialized.source_2(), problem.source_2()); + assert_eq!(deserialized.sink_2(), problem.sink_2()); + assert_eq!(deserialized.requirement_1(), problem.requirement_1()); + assert_eq!(deserialized.requirement_2(), problem.requirement_2()); +} + +#[test] +fn test_undirected_two_commodity_integral_flow_paper_example() { + let problem = canonical_instance(); + let config = example_config(); + assert!(problem.evaluate(&config)); + + let all = BruteForce::new().find_all_satisfying(&problem); + assert_eq!(all.len(), 2); + assert!(all.contains(&config)); +} + +#[test] +fn test_undirected_two_commodity_integral_flow_large_capacity_sink_balance() { + // Use a moderately large capacity that fits in usize on all platforms. + let large: u64 = 1_000_000; + let large_usize = large as usize; + let problem = UndirectedTwoCommodityIntegralFlow::new( + SimpleGraph::new(2, vec![(0, 1)]), + vec![large], + 0, + 1, + 0, + 1, + large, + 0, + ); + + assert!(problem.evaluate(&[large_usize, 0, 0, 0])); +} + +#[test] +fn test_undirected_two_commodity_integral_flow_shared_capacity_exceeded() { + // Two commodities each sending 2 units on an edge with capacity 3. + let problem = UndirectedTwoCommodityIntegralFlow::new( + SimpleGraph::new(2, vec![(0, 1)]), + vec![3], + 0, + 1, + 0, + 1, + 2, + 2, + ); + + // f1(0->1)=2, f1(1->0)=0, f2(0->1)=2, f2(1->0)=0 => shared = 4 > 3 + assert!(!problem.evaluate(&[2, 0, 2, 0])); +} + +#[test] +#[should_panic(expected = "capacities length must match")] +fn test_undirected_two_commodity_integral_flow_panics_wrong_capacity_count() { + UndirectedTwoCommodityIntegralFlow::new( + SimpleGraph::new(3, vec![(0, 1), (1, 2)]), + vec![1], // 1 capacity but 2 edges + 0, + 2, + 0, + 2, + 1, + 1, + ); +} + +#[test] +#[should_panic(expected = "must be less than num_vertices")] +fn test_undirected_two_commodity_integral_flow_panics_vertex_out_of_bounds() { + UndirectedTwoCommodityIntegralFlow::new( + SimpleGraph::new(3, vec![(0, 1), (1, 2)]), + vec![1, 1], + 0, + 5, // out of bounds + 0, + 2, + 1, + 1, + ); +} + +#[test] +fn test_undirected_two_commodity_integral_flow_flow_conservation_violated() { + // 0 -- 1 -- 2, commodity 1: s=0 t=2, commodity 2: s=0 t=2 + let problem = UndirectedTwoCommodityIntegralFlow::new( + SimpleGraph::new(3, vec![(0, 1), (1, 2)]), + vec![2, 2], + 0, + 2, + 0, + 2, + 1, + 1, + ); + + // Flow conservation violated at vertex 1: commodity 1 enters but doesn't leave. + // Edge (0,1): f1(0->1)=1, f1(1->0)=0, f2=0,0 + // Edge (1,2): f1(1->2)=0, f1(2->1)=0, f2=0,0 + // Vertex 1 gets +1 for commodity 1 from edge (0,1) but no outflow on edge (1,2) + assert!(!problem.evaluate(&[1, 0, 0, 0, 0, 0, 0, 0])); +} diff --git a/src/unit_tests/trait_consistency.rs b/src/unit_tests/trait_consistency.rs index e2eefe7d..e5117e24 100644 --- a/src/unit_tests/trait_consistency.rs +++ b/src/unit_tests/trait_consistency.rs @@ -119,6 +119,19 @@ fn test_all_problems_implement_trait_correctly() { &HamiltonianPath::new(SimpleGraph::new(3, vec![(0, 1), (1, 2)])), "HamiltonianPath", ); + check_problem_trait( + &UndirectedTwoCommodityIntegralFlow::new( + SimpleGraph::new(4, vec![(0, 2), (1, 2), (2, 3)]), + vec![1, 1, 2], + 0, + 3, + 1, + 3, + 1, + 1, + ), + "UndirectedTwoCommodityIntegralFlow", + ); check_problem_trait( &LengthBoundedDisjointPaths::new( SimpleGraph::new(4, vec![(0, 1), (1, 3), (0, 2), (2, 3)]),