Skip to content

Commit c0694a2

Browse files
GiggleLiuclaude
andauthored
Fix #291: [Model] PathConstrainedNetworkFlow (#738)
* Add plan for #291: [Model] PathConstrainedNetworkFlow * Implement #291: [Model] PathConstrainedNetworkFlow * chore: remove plan file after implementation * fix formatting after merge * improve test coverage for PathConstrainedNetworkFlow Cover getter methods (graph, capacities, paths) and all assertion branches in assert_valid_path: empty path, repeated vertex, and path not ending at sink. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix formatting after merge * fix duplicate requirement field in CLI after merge Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix duplicate requirement field in empty_args() test helper 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 fa3e24b commit c0694a2

9 files changed

Lines changed: 733 additions & 9 deletions

File tree

docs/paper/reductions.typ

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@
7676
"LongestPath": [Longest Path],
7777
"ShortestWeightConstrainedPath": [Shortest Weight-Constrained Path],
7878
"UndirectedTwoCommodityIntegralFlow": [Undirected Two-Commodity Integral Flow],
79+
"PathConstrainedNetworkFlow": [Path-Constrained Network Flow],
7980
"LengthBoundedDisjointPaths": [Length-Bounded Disjoint Paths],
8081
"IsomorphicSpanningTree": [Isomorphic Spanning Tree],
8182
"KthBestSpanningTree": [Kth Best Spanning Tree],
@@ -1188,6 +1189,99 @@ is feasible: each set induces a connected subgraph, the component weights are $2
11881189
]
11891190
]
11901191
}
1192+
#{
1193+
let x = load-model-example("PathConstrainedNetworkFlow")
1194+
let arcs = x.instance.graph.arcs.map(a => (a.at(0), a.at(1)))
1195+
let requirement = x.instance.requirement
1196+
let p1 = (0, 2, 5, 8)
1197+
let p2 = (0, 3, 6, 8)
1198+
let p5 = (1, 4, 7, 9)
1199+
[
1200+
#problem-def("PathConstrainedNetworkFlow")[
1201+
Given a directed graph $G = (V, A)$, designated vertices $s, t in V$, arc capacities $c: A -> ZZ^+$, a prescribed collection $cal(P)$ of directed simple $s$-$t$ paths, and a requirement $R in ZZ^+$, determine whether there exists an integral path-flow function $g: cal(P) -> ZZ_(>= 0)$ such that $sum_(p in cal(P): a in p) g(p) <= c(a)$ for every arc $a in A$ and $sum_(p in cal(P)) g(p) >= R$.
1202+
][
1203+
Path-Constrained Network Flow appears as problem ND34 in Garey \& Johnson @garey1979. Unlike ordinary single-commodity flow, the admissible routes are fixed in advance: every unit of flow must be assigned to one of the listed $s$-$t$ paths. This prescribed-path viewpoint is standard in line planning and unsplittable routing, and Büsing and Stiller give a modern published NP-completeness and inapproximability treatment for exactly this integral formulation @busingstiller2011.
1204+
1205+
The implementation uses one integer variable per prescribed path, bounded by that path's bottleneck capacity. Exhaustive search over those path-flow variables gives the registered worst-case bound $O^*((C + 1)^(|cal(P)|))$, where $C = max_(a in A) c(a)$. #footnote[This is the brute-force bound induced by the representation used in the library; no sharper general exact algorithm is claimed here for the integral prescribed-path formulation.]
1206+
1207+
*Example.* The canonical fixture uses the directed network with arcs $(0,1)$, $(0,2)$, $(1,3)$, $(1,4)$, $(2,4)$, $(3,5)$, $(4,5)$, $(4,6)$, $(5,7)$, and $(6,7)$, capacities $(2,1,1,1,1,1,1,1,2,1)$, source $s = 0$, sink $t = 7$, and required flow $R = #requirement$. The prescribed paths are $p_1 = 0 arrow 1 arrow 3 arrow 5 arrow 7$, $p_2 = 0 arrow 1 arrow 4 arrow 5 arrow 7$, $p_3 = 0 arrow 1 arrow 4 arrow 6 arrow 7$, $p_4 = 0 arrow 2 arrow 4 arrow 5 arrow 7$, and $p_5 = 0 arrow 2 arrow 4 arrow 6 arrow 7$. The fixture's satisfying configuration is $g = (#x.optimal_config.at(0), #x.optimal_config.at(1), #x.optimal_config.at(2), #x.optimal_config.at(3), #x.optimal_config.at(4)) = (1, 1, 0, 0, 1)$, so one unit is sent along $p_1$, one along $p_2$, and one along $p_5$. The shared arcs $(0,1)$ and $(5,7)$ each carry exactly two units of flow, matching their capacity 2, while every other used arc carries one unit. Therefore the total flow into $t$ is $3 = R$, so the instance is feasible.
1208+
1209+
#pred-commands(
1210+
"pred create --example " + problem-spec(x) + " -o path-constrained-network-flow.json",
1211+
"pred solve path-constrained-network-flow.json --solver brute-force",
1212+
"pred evaluate path-constrained-network-flow.json --config " + x.optimal_config.map(str).join(","),
1213+
)
1214+
1215+
#figure(
1216+
canvas(length: 0.95cm, {
1217+
import draw: *
1218+
let blue = graph-colors.at(0)
1219+
let orange = rgb("#f28e2b")
1220+
let teal = rgb("#76b7b2")
1221+
let gray = luma(185)
1222+
let verts = (
1223+
(0, 0),
1224+
(1.4, 1.2),
1225+
(1.4, -1.2),
1226+
(2.8, 1.9),
1227+
(2.8, 0),
1228+
(4.2, 1.2),
1229+
(4.2, -1.2),
1230+
(5.6, 0),
1231+
)
1232+
for (u, v) in arcs {
1233+
line(
1234+
verts.at(u),
1235+
verts.at(v),
1236+
stroke: 0.8pt + gray,
1237+
mark: (end: "straight", scale: 0.45),
1238+
)
1239+
}
1240+
for idx in p1 {
1241+
let (u, v) = arcs.at(idx)
1242+
line(
1243+
verts.at(u),
1244+
verts.at(v),
1245+
stroke: 1.8pt + blue,
1246+
mark: (end: "straight", scale: 0.5),
1247+
)
1248+
}
1249+
for idx in p2 {
1250+
let (u, v) = arcs.at(idx)
1251+
line(
1252+
verts.at(u),
1253+
verts.at(v),
1254+
stroke: (paint: orange, thickness: 1.7pt, dash: "dashed"),
1255+
mark: (end: "straight", scale: 0.48),
1256+
)
1257+
}
1258+
for idx in p5 {
1259+
let (u, v) = arcs.at(idx)
1260+
line(
1261+
verts.at(u),
1262+
verts.at(v),
1263+
stroke: 1.6pt + teal,
1264+
mark: (end: "straight", scale: 0.46),
1265+
)
1266+
}
1267+
for (i, pos) in verts.enumerate() {
1268+
let fill = if i == 0 or i == 7 { rgb("#e15759").lighten(75%) } else { white }
1269+
g-node(pos, name: "pcnf-" + str(i), fill: fill, label: [$v_#i$])
1270+
}
1271+
content((0.65, 0.78), text(8pt, fill: gray)[$2 / 2$])
1272+
content((4.9, 0.78), text(8pt, fill: gray)[$2 / 2$])
1273+
line((0.2, -2.15), (0.8, -2.15), stroke: 1.8pt + blue, mark: (end: "straight", scale: 0.42))
1274+
content((1.15, -2.15), text(8pt)[$p_1$])
1275+
line((1.95, -2.15), (2.55, -2.15), stroke: (paint: orange, thickness: 1.7pt, dash: "dashed"), mark: (end: "straight", scale: 0.42))
1276+
content((2.9, -2.15), text(8pt)[$p_2$])
1277+
line((3.75, -2.15), (4.35, -2.15), stroke: 1.6pt + teal, mark: (end: "straight", scale: 0.42))
1278+
content((4.7, -2.15), text(8pt)[$p_5$])
1279+
}),
1280+
caption: [Canonical YES instance for Path-Constrained Network Flow. Blue, dashed orange, and teal show the three prescribed paths used by $g = (1, 1, 0, 0, 1)$. The labels $2 / 2$ mark the shared arcs $(0,1)$ and $(5,7)$, whose flow exactly saturates capacity 2.],
1281+
) <fig:path-constrained-network-flow>
1282+
]
1283+
]
1284+
}
11911285
#{
11921286
let x = load-model-example("IsomorphicSpanningTree")
11931287
let g-edges = x.instance.graph.edges

docs/paper/references.bib

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,17 @@ @book{garey1979
180180
year = {1979}
181181
}
182182

183+
@article{busingstiller2011,
184+
author = {Christina Büsing and Sebastian Stiller},
185+
title = {Line planning, path constrained network flow and inapproximability},
186+
journal = {Networks},
187+
volume = {57},
188+
number = {1},
189+
pages = {106--113},
190+
year = {2011},
191+
doi = {10.1002/net.20386}
192+
}
193+
183194
@article{bruckerGareyJohnson1977,
184195
author = {Peter Brucker and Michael R. Garey and David S. Johnson},
185196
title = {Scheduling equal-length tasks under tree-like precedence constraints to minimize maximum lateness},

problemreductions-cli/src/cli.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,7 @@ Flags by problem type:
240240
IsomorphicSpanningTree --graph, --tree
241241
KthBestSpanningTree --graph, --edge-weights, --k, --bound
242242
LengthBoundedDisjointPaths --graph, --source, --sink, --num-paths-required, --bound
243+
PathConstrainedNetworkFlow --arcs, --capacities, --source, --sink, --paths, --requirement
243244
Factoring --target, --m, --n
244245
BinPacking --sizes, --capacity
245246
SubsetSum --sizes, --target
@@ -370,12 +371,15 @@ pub struct CreateArgs {
370371
/// Sink vertex for path-based graph problems and MinimumCutIntoBoundedSets
371372
#[arg(long)]
372373
pub sink: Option<usize>,
373-
/// Required sink inflow for IntegralFlowHomologousArcs and IntegralFlowWithMultipliers
374+
/// Required total flow R for IntegralFlowHomologousArcs, IntegralFlowWithMultipliers, and PathConstrainedNetworkFlow
374375
#[arg(long)]
375376
pub requirement: Option<u64>,
376377
/// Required number of paths for LengthBoundedDisjointPaths
377378
#[arg(long)]
378379
pub num_paths_required: Option<usize>,
380+
/// Prescribed directed s-t paths as semicolon-separated arc-index sequences (e.g., "0,2,5;1,4,6")
381+
#[arg(long)]
382+
pub paths: Option<String>,
379383
/// Pairwise couplings J_ij for SpinGlass (e.g., 1,-1,1) [default: all 1s]
380384
#[arg(long)]
381385
pub couplings: Option<String>,

problemreductions-cli/src/commands/create.rs

Lines changed: 177 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@ use problemreductions::models::formula::Quantifier;
1414
use problemreductions::models::graph::{
1515
GeneralizedHex, GraphPartitioning, HamiltonianCircuit, HamiltonianPath,
1616
LengthBoundedDisjointPaths, LongestCircuit, LongestPath, MinimumCutIntoBoundedSets,
17-
MinimumMultiwayCut, MixedChinesePostman, MultipleChoiceBranching, SteinerTree,
18-
SteinerTreeInGraphs, StrongConnectivityAugmentation,
17+
MinimumMultiwayCut, MixedChinesePostman, MultipleChoiceBranching, PathConstrainedNetworkFlow,
18+
SteinerTree, SteinerTreeInGraphs, StrongConnectivityAugmentation,
1919
};
2020
use problemreductions::models::misc::{
2121
AdditionalKey, BinPacking, BoyceCoddNormalFormViolation, CbqRelation, ConjunctiveBooleanQuery,
@@ -55,6 +55,7 @@ fn all_data_flags_empty(args: &CreateArgs) -> bool {
5555
&& args.sink.is_none()
5656
&& args.requirement.is_none()
5757
&& args.num_paths_required.is_none()
58+
&& args.paths.is_none()
5859
&& args.couplings.is_none()
5960
&& args.fields.is_none()
6061
&& args.clauses.is_none()
@@ -77,6 +78,7 @@ fn all_data_flags_empty(args: &CreateArgs) -> bool {
7778
&& args.sink_2.is_none()
7879
&& args.requirement_1.is_none()
7980
&& args.requirement_2.is_none()
81+
&& args.requirement.is_none()
8082
&& args.sizes.is_none()
8183
&& args.capacity.is_none()
8284
&& args.sequence.is_none()
@@ -540,6 +542,9 @@ fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str {
540542
"LengthBoundedDisjointPaths" => {
541543
"--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"
542544
}
545+
"PathConstrainedNetworkFlow" => {
546+
"--arcs \"0>1,0>2,1>3,1>4,2>4,3>5,4>5,4>6,5>7,6>7\" --capacities 2,1,1,1,1,1,1,1,2,1 --source 0 --sink 7 --paths \"0,2,5,8;0,3,6,8;0,3,7,9;1,4,6,8;1,4,7,9\" --requirement 3"
547+
}
543548
"IsomorphicSpanningTree" => "--graph 0-1,1-2,0-2 --tree 0-1,1-2",
544549
"KthBestSpanningTree" => "--graph 0-1,0-2,1-2 --edge-weights 2,3,1 --k 1 --bound 3",
545550
"LongestCircuit" => {
@@ -771,6 +776,9 @@ fn help_flag_hint(
771776
("ConsistencyOfDatabaseFrequencyTables", "known_values") => {
772777
"semicolon-separated triples: \"0,0,0;3,0,1;1,2,1\""
773778
}
779+
("PathConstrainedNetworkFlow", "paths") => {
780+
"semicolon-separated arc-index paths: \"0,2,5,8;1,4,7,9\""
781+
}
774782
("ConsecutiveOnesSubmatrix", "matrix") => "semicolon-separated 0/1 rows: \"1,0;0,1\"",
775783
("TimetableDesign", "craftsman_avail") | ("TimetableDesign", "task_avail") => {
776784
"semicolon-separated 0/1 rows: \"1,1,0;0,1,1\""
@@ -3319,6 +3327,47 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> {
33193327
)
33203328
}
33213329

3330+
// PathConstrainedNetworkFlow
3331+
"PathConstrainedNetworkFlow" => {
3332+
let usage = "Usage: pred create PathConstrainedNetworkFlow --arcs \"0>1,0>2,1>3,1>4,2>4,3>5,4>5,4>6,5>7,6>7\" --capacities 2,1,1,1,1,1,1,1,2,1 --source 0 --sink 7 --paths \"0,2,5,8;0,3,6,8;0,3,7,9;1,4,6,8;1,4,7,9\" --requirement 3";
3333+
let arcs_str = args.arcs.as_deref().ok_or_else(|| {
3334+
anyhow::anyhow!("PathConstrainedNetworkFlow requires --arcs\n\n{usage}")
3335+
})?;
3336+
let (graph, num_arcs) = parse_directed_graph(arcs_str, args.num_vertices)
3337+
.map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?;
3338+
let capacities: Vec<u64> = if let Some(ref s) = args.capacities {
3339+
util::parse_comma_list(s)?
3340+
} else {
3341+
vec![1; num_arcs]
3342+
};
3343+
anyhow::ensure!(
3344+
capacities.len() == num_arcs,
3345+
"capacities length ({}) must match number of arcs ({num_arcs})",
3346+
capacities.len()
3347+
);
3348+
let source = args.source.ok_or_else(|| {
3349+
anyhow::anyhow!("PathConstrainedNetworkFlow requires --source\n\n{usage}")
3350+
})?;
3351+
let sink = args.sink.ok_or_else(|| {
3352+
anyhow::anyhow!("PathConstrainedNetworkFlow requires --sink\n\n{usage}")
3353+
})?;
3354+
let requirement = args.requirement.ok_or_else(|| {
3355+
anyhow::anyhow!("PathConstrainedNetworkFlow requires --requirement\n\n{usage}")
3356+
})?;
3357+
let paths = parse_prescribed_paths(args, num_arcs, usage)?;
3358+
(
3359+
ser(PathConstrainedNetworkFlow::new(
3360+
graph,
3361+
capacities,
3362+
source,
3363+
sink,
3364+
paths,
3365+
requirement,
3366+
))?,
3367+
resolved_variant.clone(),
3368+
)
3369+
}
3370+
33223371
// MinimumFeedbackArcSet
33233372
"MinimumFeedbackArcSet" => {
33243373
let arcs_str = args.arcs.as_deref().ok_or_else(|| {
@@ -5164,6 +5213,40 @@ fn parse_directed_graph(
51645213
Ok((DirectedGraph::new(num_v, arcs), num_arcs))
51655214
}
51665215

5216+
fn parse_prescribed_paths(
5217+
args: &CreateArgs,
5218+
num_arcs: usize,
5219+
usage: &str,
5220+
) -> Result<Vec<Vec<usize>>> {
5221+
let paths_str = args
5222+
.paths
5223+
.as_deref()
5224+
.ok_or_else(|| anyhow::anyhow!("PathConstrainedNetworkFlow requires --paths\n\n{usage}"))?;
5225+
5226+
paths_str
5227+
.split(';')
5228+
.map(|path_str| {
5229+
let trimmed = path_str.trim();
5230+
anyhow::ensure!(
5231+
!trimmed.is_empty(),
5232+
"PathConstrainedNetworkFlow paths must be non-empty\n\n{usage}"
5233+
);
5234+
let path: Vec<usize> = util::parse_comma_list(trimmed)?;
5235+
anyhow::ensure!(
5236+
!path.is_empty(),
5237+
"PathConstrainedNetworkFlow paths must be non-empty\n\n{usage}"
5238+
);
5239+
for &arc_idx in &path {
5240+
anyhow::ensure!(
5241+
arc_idx < num_arcs,
5242+
"Path arc index {arc_idx} out of bounds for {num_arcs} arcs\n\n{usage}"
5243+
);
5244+
}
5245+
Ok(path)
5246+
})
5247+
.collect()
5248+
}
5249+
51675250
fn parse_mixed_graph(args: &CreateArgs, usage: &str) -> Result<MixedGraph> {
51685251
let (undirected_graph, num_vertices) =
51695252
parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?;
@@ -5865,6 +5948,90 @@ mod tests {
58655948
std::fs::remove_file(output_path).unwrap();
58665949
}
58675950

5951+
#[test]
5952+
fn test_create_path_constrained_network_flow_outputs_problem_json() {
5953+
let cli = Cli::try_parse_from([
5954+
"pred",
5955+
"create",
5956+
"PathConstrainedNetworkFlow",
5957+
"--arcs",
5958+
"0>1,0>2,1>3,1>4,2>4,3>5,4>5,4>6,5>7,6>7",
5959+
"--capacities",
5960+
"2,1,1,1,1,1,1,1,2,1",
5961+
"--source",
5962+
"0",
5963+
"--sink",
5964+
"7",
5965+
"--paths",
5966+
"0,2,5,8;0,3,6,8;0,3,7,9;1,4,6,8;1,4,7,9",
5967+
"--requirement",
5968+
"3",
5969+
])
5970+
.expect("parse create command");
5971+
5972+
let args = match cli.command {
5973+
Commands::Create(args) => args,
5974+
_ => panic!("expected create command"),
5975+
};
5976+
5977+
let output_path = temp_output_path("path_constrained_network_flow");
5978+
let out = OutputConfig {
5979+
output: Some(output_path.clone()),
5980+
quiet: true,
5981+
json: false,
5982+
auto_json: false,
5983+
};
5984+
5985+
create(&args, &out).expect("create PathConstrainedNetworkFlow JSON");
5986+
5987+
let created: serde_json::Value =
5988+
serde_json::from_str(&fs::read_to_string(&output_path).unwrap()).unwrap();
5989+
fs::remove_file(output_path).ok();
5990+
5991+
assert_eq!(created["type"], "PathConstrainedNetworkFlow");
5992+
assert_eq!(created["data"]["source"], 0);
5993+
assert_eq!(created["data"]["sink"], 7);
5994+
assert_eq!(created["data"]["requirement"], 3);
5995+
assert_eq!(created["data"]["paths"][0], serde_json::json!([0, 2, 5, 8]));
5996+
}
5997+
5998+
#[test]
5999+
fn test_create_path_constrained_network_flow_rejects_invalid_paths() {
6000+
let cli = Cli::try_parse_from([
6001+
"pred",
6002+
"create",
6003+
"PathConstrainedNetworkFlow",
6004+
"--arcs",
6005+
"0>1,1>2,2>3",
6006+
"--capacities",
6007+
"1,1,1",
6008+
"--source",
6009+
"0",
6010+
"--sink",
6011+
"3",
6012+
"--paths",
6013+
"0,3",
6014+
"--requirement",
6015+
"1",
6016+
])
6017+
.expect("parse create command");
6018+
6019+
let args = match cli.command {
6020+
Commands::Create(args) => args,
6021+
_ => panic!("expected create command"),
6022+
};
6023+
6024+
let out = OutputConfig {
6025+
output: None,
6026+
quiet: true,
6027+
json: false,
6028+
auto_json: false,
6029+
};
6030+
6031+
let err = create(&args, &out).unwrap_err().to_string();
6032+
assert!(err.contains("out of bounds") || err.contains("not contiguous"));
6033+
}
6034+
58686035
#[test]
58696036
fn test_create_staff_scheduling_reports_invalid_schedule_without_panic() {
58706037
let cli = Cli::try_parse_from([
@@ -5916,6 +6083,13 @@ mod tests {
59166083
);
59176084
}
59186085

6086+
#[test]
6087+
fn test_example_for_path_constrained_network_flow_mentions_paths_flag() {
6088+
let example = example_for("PathConstrainedNetworkFlow", None);
6089+
assert!(example.contains("--paths"));
6090+
assert!(example.contains("--requirement"));
6091+
}
6092+
59196093
#[test]
59206094
fn test_create_timetable_design_outputs_problem_json() {
59216095
let cli = Cli::try_parse_from([
@@ -6233,6 +6407,7 @@ mod tests {
62336407
sink: None,
62346408
requirement: None,
62356409
num_paths_required: None,
6410+
paths: None,
62366411
couplings: None,
62376412
fields: None,
62386413
clauses: None,

0 commit comments

Comments
 (0)