Skip to content

Commit eb63f8e

Browse files
GiggleLiuclaude
andauthored
Fix #294: [Model] UndirectedFlowLowerBounds (#741)
* Add plan for #294: [Model] UndirectedFlowLowerBounds * Implement #294: [Model] UndirectedFlowLowerBounds * chore: remove plan file after implementation * Merge origin/main and fix formatting Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Fix source==sink false positive and restore capacity overflow validation - Add assert!(source != sink) to UndirectedFlowLowerBounds constructor to prevent the circulation demand edge from becoming a self-loop - Restore capacity domain-size check in UndirectedTwoCommodityIntegralFlow create branch, which was removed when parse_capacities was generalized Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Fix duplicate test from merge conflict resolution Restore test_create_longest_path_requires_edge_lengths with its correct name — the merge conflict subagent had renamed it, creating a duplicate of test_create_undirected_flow_lower_bounds_requires_lower_bounds. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 07c0da1 commit eb63f8e

10 files changed

Lines changed: 745 additions & 27 deletions

File tree

docs/paper/reductions.typ

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@
7676
"LongestCircuit": [Longest Circuit],
7777
"LongestPath": [Longest Path],
7878
"ShortestWeightConstrainedPath": [Shortest Weight-Constrained Path],
79+
"UndirectedFlowLowerBounds": [Undirected Flow with Lower Bounds],
7980
"UndirectedTwoCommodityIntegralFlow": [Undirected Two-Commodity Integral Flow],
8081
"PathConstrainedNetworkFlow": [Path-Constrained Network Flow],
8182
"LengthBoundedDisjointPaths": [Length-Bounded Disjoint Paths],
@@ -1134,6 +1135,68 @@ is feasible: each set induces a connected subgraph, the component weights are $2
11341135
]
11351136
]
11361137
}
1138+
#{
1139+
let x = load-model-example("UndirectedFlowLowerBounds")
1140+
let s = x.instance.source
1141+
let t = x.instance.sink
1142+
let R = x.instance.requirement
1143+
let orientation = x.optimal_config
1144+
let edges = x.instance.graph.edges
1145+
let lower = x.instance.lower_bounds
1146+
let caps = x.instance.capacities
1147+
let witness = (2, 1, 1, 1, 1, 2, 1)
1148+
[
1149+
#problem-def("UndirectedFlowLowerBounds")[
1150+
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$.
1151+
][
1152+
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.
1153+
1154+
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.
1155+
1156+
*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.
1157+
1158+
#pred-commands(
1159+
"pred create --example UndirectedFlowLowerBounds -o undirected-flow-lower-bounds.json",
1160+
"pred solve undirected-flow-lower-bounds.json",
1161+
"pred evaluate undirected-flow-lower-bounds.json --config " + x.optimal_config.map(str).join(","),
1162+
)
1163+
1164+
#figure(
1165+
canvas(length: 0.9cm, {
1166+
import draw: *
1167+
let blue = graph-colors.at(0)
1168+
let red = rgb("#e15759")
1169+
let gray = luma(190)
1170+
let verts = ((0, 0), (1.6, 1.2), (1.6, -1.2), (3.4, 0.5), (3.4, -1.5), (5.2, -0.3))
1171+
let labels = (
1172+
[$s = v_0$],
1173+
[$v_1$],
1174+
[$v_2$],
1175+
[$v_3$],
1176+
[$v_4$],
1177+
[$t = v_5$],
1178+
)
1179+
for (u, v) in edges {
1180+
g-edge(verts.at(u), verts.at(v), stroke: 1.8pt + blue)
1181+
}
1182+
for (i, pos) in verts.enumerate() {
1183+
let fill = if i == s { blue } else if i == t { red } else { white }
1184+
let label = if i == s or i == t { text(fill: white)[#labels.at(i)] } else { labels.at(i) }
1185+
g-node(pos, name: "uflb-" + str(i), fill: fill, label: label)
1186+
}
1187+
content((0.75, 0.7), text(7pt, fill: gray)[$f = 2$])
1188+
content((0.75, -0.7), text(7pt, fill: gray)[$f = 1$])
1189+
content((2.45, 1.05), text(7pt, fill: gray)[$f = 1$])
1190+
content((2.45, -0.25), text(7pt, fill: gray)[$f = 1$])
1191+
content((2.45, -1.45), text(7pt, fill: gray)[$f = 1$])
1192+
content((4.35, 0.35), text(7pt, fill: gray)[$f = 2$])
1193+
content((4.35, -1.1), text(7pt, fill: gray)[$f = 1$])
1194+
}),
1195+
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.],
1196+
) <fig:undirected-flow-lower-bounds>
1197+
]
1198+
]
1199+
}
11371200
#{
11381201
let x = load-model-example("UndirectedTwoCommodityIntegralFlow")
11391202
let satisfying_count = 1

docs/paper/references.bib

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -266,14 +266,14 @@ @article{evenItaiShamir1976
266266
doi = {10.1137/0205048}
267267
}
268268

269-
@article{sahni1974,
270-
author = {Sartaj Sahni},
271-
title = {Computationally Related Problems},
272-
journal = {SIAM Journal on Computing},
273-
volume = {3},
269+
@article{itai1978,
270+
author = {Alon Itai},
271+
title = {Two-Commodity Flow},
272+
journal = {Journal of the ACM},
273+
volume = {25},
274274
number = {4},
275-
pages = {262--279},
276-
year = {1974},
275+
pages = {596--611},
276+
year = {1978},
277277
doi = {10.1137/0203021}
278278
}
279279

@@ -288,6 +288,16 @@ @article{jewell1962
288288
doi = {10.1287/opre.10.4.476}
289289
}
290290

291+
@article{sahni1974,
292+
author = {Sartaj Sahni},
293+
title = {Computationally Related Problems},
294+
journal = {SIAM Journal on Computing},
295+
volume = {3},
296+
number = {4},
297+
pages = {262--279},
298+
year = {1974}
299+
}
300+
291301
@article{abdelWahabKameda1978,
292302
author = {H. M. Abdel-Wahab and T. Kameda},
293303
title = {Scheduling to Minimize Maximum Cumulative Cost Subject to Series-Parallel Precedence Constraints},

problemreductions-cli/src/cli.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,7 @@ Flags by problem type:
235235
HamiltonianCircuit, HC --graph
236236
LongestCircuit --graph, --edge-weights, --bound
237237
BoundedComponentSpanningForest --graph, --weights, --k, --bound
238+
UndirectedFlowLowerBounds --graph, --capacities, --lower-bounds, --source, --sink, --requirement
238239
IntegralFlowBundles --arcs, --bundles, --bundle-capacities, --source, --sink, --requirement [--num-vertices]
239240
UndirectedTwoCommodityIntegralFlow --graph, --capacities, --source-1, --sink-1, --source-2, --sink-2, --requirement-1, --requirement-2
240241
IntegralFlowHomologousArcs --arcs, --capacities, --source, --sink, --requirement, --homologous-pairs
@@ -325,6 +326,7 @@ Examples:
325326
pred create MIS/UnitDiskGraph --positions \"0,0;1,0;0.5,0.8\" --radius 1.5
326327
pred create MIS --random --num-vertices 10 --edge-prob 0.3
327328
pred create MultiprocessorScheduling --lengths 4,5,3,2,6 --num-processors 2 --deadline 10
329+
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
328330
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\"
329331
pred create BiconnectivityAugmentation --graph 0-1,1-2,2-3 --potential-edges 0-2:3,0-3:4,1-3:2 --budget 5
330332
pred create FVS --arcs \"0>1,1>2,2>0\" --weights 1,1,1
@@ -363,6 +365,9 @@ pub struct CreateArgs {
363365
/// Edge capacities for multicommodity flow problems (e.g., 1,1,2)
364366
#[arg(long)]
365367
pub capacities: Option<String>,
368+
/// Edge lower bounds for lower-bounded flow problems (e.g., 1,1,0,0,1,0,1)
369+
#[arg(long)]
370+
pub lower_bounds: Option<String>,
366371
/// Bundle capacities for IntegralFlowBundles (e.g., 1,1,1)
367372
#[arg(long)]
368373
pub bundle_capacities: Option<String>,
@@ -375,7 +380,7 @@ pub struct CreateArgs {
375380
/// Sink vertex for path-based graph problems and MinimumCutIntoBoundedSets
376381
#[arg(long)]
377382
pub sink: Option<usize>,
378-
/// Required total flow R for IntegralFlowBundles, IntegralFlowHomologousArcs, IntegralFlowWithMultipliers, and PathConstrainedNetworkFlow
383+
/// Required total flow R for IntegralFlowBundles, IntegralFlowHomologousArcs, IntegralFlowWithMultipliers, PathConstrainedNetworkFlow, and UndirectedFlowLowerBounds
379384
#[arg(long)]
380385
pub requirement: Option<u64>,
381386
/// Required number of paths for LengthBoundedDisjointPaths

problemreductions-cli/src/commands/create.rs

Lines changed: 163 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ fn all_data_flags_empty(args: &CreateArgs) -> bool {
5050
&& args.edge_weights.is_none()
5151
&& args.edge_lengths.is_none()
5252
&& args.capacities.is_none()
53+
&& args.lower_bounds.is_none()
5354
&& args.bundle_capacities.is_none()
5455
&& args.multipliers.is_none()
5556
&& args.source.is_none()
@@ -538,6 +539,9 @@ fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str {
538539
"LongestPath" => {
539540
"--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"
540541
}
542+
"UndirectedFlowLowerBounds" => {
543+
"--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"
544+
}
541545
"UndirectedTwoCommodityIntegralFlow" => {
542546
"--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"
543547
},
@@ -1478,11 +1482,56 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> {
14781482
)
14791483
}
14801484

1485+
// UndirectedFlowLowerBounds (graph + capacities + lower bounds + terminals + requirement)
1486+
"UndirectedFlowLowerBounds" => {
1487+
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";
1488+
let (graph, _) = parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?;
1489+
let capacities = parse_capacities(args, graph.num_edges(), usage)?;
1490+
let lower_bounds = parse_lower_bounds(args, graph.num_edges(), usage)?;
1491+
let num_vertices = graph.num_vertices();
1492+
let source = args.source.ok_or_else(|| {
1493+
anyhow::anyhow!("UndirectedFlowLowerBounds requires --source\n\n{usage}")
1494+
})?;
1495+
let sink = args.sink.ok_or_else(|| {
1496+
anyhow::anyhow!("UndirectedFlowLowerBounds requires --sink\n\n{usage}")
1497+
})?;
1498+
let requirement = args.requirement.ok_or_else(|| {
1499+
anyhow::anyhow!("UndirectedFlowLowerBounds requires --requirement\n\n{usage}")
1500+
})?;
1501+
validate_vertex_index("source", source, num_vertices, usage)?;
1502+
validate_vertex_index("sink", sink, num_vertices, usage)?;
1503+
(
1504+
ser(UndirectedFlowLowerBounds::new(
1505+
graph,
1506+
capacities,
1507+
lower_bounds,
1508+
source,
1509+
sink,
1510+
requirement,
1511+
))?,
1512+
resolved_variant.clone(),
1513+
)
1514+
}
1515+
14811516
// UndirectedTwoCommodityIntegralFlow (graph + capacities + terminals + requirements)
14821517
"UndirectedTwoCommodityIntegralFlow" => {
14831518
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";
14841519
let (graph, _) = parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?;
14851520
let capacities = parse_capacities(args, graph.num_edges(), usage)?;
1521+
for (edge_index, &capacity) in capacities.iter().enumerate() {
1522+
let fits = usize::try_from(capacity)
1523+
.ok()
1524+
.and_then(|value| value.checked_add(1))
1525+
.is_some();
1526+
if !fits {
1527+
bail!(
1528+
"capacity {} at edge index {} is too large for this platform\n\n{}",
1529+
capacity,
1530+
edge_index,
1531+
usage
1532+
);
1533+
}
1534+
}
14861535
let num_vertices = graph.num_vertices();
14871536
let source_1 = args.source_1.ok_or_else(|| {
14881537
anyhow::anyhow!("UndirectedTwoCommodityIntegralFlow requires --source-1\n\n{usage}")
@@ -4479,9 +4528,10 @@ fn validate_vertex_index(
44794528

44804529
/// Parse `--capacities` as edge capacities (u64).
44814530
fn parse_capacities(args: &CreateArgs, num_edges: usize, usage: &str) -> Result<Vec<u64>> {
4482-
let capacities = args.capacities.as_deref().ok_or_else(|| {
4483-
anyhow::anyhow!("UndirectedTwoCommodityIntegralFlow requires --capacities\n\n{usage}")
4484-
})?;
4531+
let capacities = args
4532+
.capacities
4533+
.as_deref()
4534+
.ok_or_else(|| anyhow::anyhow!("This problem requires --capacities\n\n{usage}"))?;
44854535
let capacities: Vec<u64> = capacities
44864536
.split(',')
44874537
.map(|s| {
@@ -4499,23 +4549,34 @@ fn parse_capacities(args: &CreateArgs, num_edges: usize, usage: &str) -> Result<
44994549
usage
45004550
);
45014551
}
4502-
for (edge_index, &capacity) in capacities.iter().enumerate() {
4503-
let fits = usize::try_from(capacity)
4504-
.ok()
4505-
.and_then(|value| value.checked_add(1))
4506-
.is_some();
4507-
if !fits {
4508-
bail!(
4509-
"capacity {} at edge index {} is too large for this platform\n\n{}",
4510-
capacity,
4511-
edge_index,
4512-
usage
4513-
);
4514-
}
4515-
}
45164552
Ok(capacities)
45174553
}
45184554

4555+
/// Parse `--lower-bounds` as edge lower bounds (u64).
4556+
fn parse_lower_bounds(args: &CreateArgs, num_edges: usize, usage: &str) -> Result<Vec<u64>> {
4557+
let lower_bounds = args.lower_bounds.as_deref().ok_or_else(|| {
4558+
anyhow::anyhow!("UndirectedFlowLowerBounds requires --lower-bounds\n\n{usage}")
4559+
})?;
4560+
let lower_bounds: Vec<u64> = lower_bounds
4561+
.split(',')
4562+
.map(|s| {
4563+
let trimmed = s.trim();
4564+
trimmed
4565+
.parse::<u64>()
4566+
.with_context(|| format!("Invalid lower bound `{trimmed}`\n\n{usage}"))
4567+
})
4568+
.collect::<Result<Vec<_>>>()?;
4569+
if lower_bounds.len() != num_edges {
4570+
bail!(
4571+
"Expected {} lower bounds but got {}\n\n{}",
4572+
num_edges,
4573+
lower_bounds.len(),
4574+
usage
4575+
);
4576+
}
4577+
Ok(lower_bounds)
4578+
}
4579+
45194580
fn parse_bundle_capacities(args: &CreateArgs, num_bundles: usize, usage: &str) -> Result<Vec<u64>> {
45204581
let capacities = args.bundle_capacities.as_deref().ok_or_else(|| {
45214582
anyhow::anyhow!("IntegralFlowBundles requires --bundle-capacities\n\n{usage}")
@@ -6462,6 +6523,55 @@ mod tests {
64626523
);
64636524
}
64646525

6526+
#[test]
6527+
fn test_create_undirected_flow_lower_bounds_serializes_problem_json() {
6528+
let output = temp_output_path("undirected_flow_lower_bounds_create");
6529+
let cli = Cli::try_parse_from([
6530+
"pred",
6531+
"-o",
6532+
output.to_str().unwrap(),
6533+
"create",
6534+
"UndirectedFlowLowerBounds",
6535+
"--graph",
6536+
"0-1,0-2,1-3,2-3,1-4,3-5,4-5",
6537+
"--capacities",
6538+
"2,2,2,2,1,3,2",
6539+
"--lower-bounds",
6540+
"1,1,0,0,1,0,1",
6541+
"--source",
6542+
"0",
6543+
"--sink",
6544+
"5",
6545+
"--requirement",
6546+
"3",
6547+
])
6548+
.unwrap();
6549+
let out = OutputConfig {
6550+
output: cli.output.clone(),
6551+
quiet: true,
6552+
json: false,
6553+
auto_json: false,
6554+
};
6555+
let args = match cli.command {
6556+
Commands::Create(args) => args,
6557+
_ => unreachable!(),
6558+
};
6559+
6560+
create(&args, &out).unwrap();
6561+
6562+
let json: serde_json::Value =
6563+
serde_json::from_str(&fs::read_to_string(&output).unwrap()).unwrap();
6564+
fs::remove_file(&output).unwrap();
6565+
assert_eq!(json["type"], "UndirectedFlowLowerBounds");
6566+
assert_eq!(json["data"]["source"], 0);
6567+
assert_eq!(json["data"]["sink"], 5);
6568+
assert_eq!(json["data"]["requirement"], 3);
6569+
assert_eq!(
6570+
json["data"]["lower_bounds"],
6571+
serde_json::json!([1, 1, 0, 0, 1, 0, 1])
6572+
);
6573+
}
6574+
64656575
#[test]
64666576
fn test_create_longest_path_requires_edge_lengths() {
64676577
let cli = Cli::try_parse_from([
@@ -6528,6 +6638,41 @@ mod tests {
65286638
.contains("LongestPath uses --edge-lengths, not --weights"));
65296639
}
65306640

6641+
#[test]
6642+
fn test_create_undirected_flow_lower_bounds_requires_lower_bounds() {
6643+
let cli = Cli::try_parse_from([
6644+
"pred",
6645+
"create",
6646+
"UndirectedFlowLowerBounds",
6647+
"--graph",
6648+
"0-1,0-2,1-3,2-3,1-4,3-5,4-5",
6649+
"--capacities",
6650+
"2,2,2,2,1,3,2",
6651+
"--source",
6652+
"0",
6653+
"--sink",
6654+
"5",
6655+
"--requirement",
6656+
"3",
6657+
])
6658+
.unwrap();
6659+
let out = OutputConfig {
6660+
output: None,
6661+
quiet: true,
6662+
json: false,
6663+
auto_json: false,
6664+
};
6665+
let args = match cli.command {
6666+
Commands::Create(args) => args,
6667+
_ => unreachable!(),
6668+
};
6669+
6670+
let err = create(&args, &out).unwrap_err();
6671+
assert!(err
6672+
.to_string()
6673+
.contains("UndirectedFlowLowerBounds requires --lower-bounds"));
6674+
}
6675+
65316676
fn empty_args() -> CreateArgs {
65326677
CreateArgs {
65336678
problem: Some("BiconnectivityAugmentation".to_string()),
@@ -6539,6 +6684,7 @@ mod tests {
65396684
edge_weights: None,
65406685
edge_lengths: None,
65416686
capacities: None,
6687+
lower_bounds: None,
65426688
bundle_capacities: None,
65436689
multipliers: None,
65446690
source: None,

0 commit comments

Comments
 (0)