Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 94 additions & 0 deletions docs/paper/reductions.typ
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@
"LongestPath": [Longest Path],
"ShortestWeightConstrainedPath": [Shortest Weight-Constrained Path],
"UndirectedTwoCommodityIntegralFlow": [Undirected Two-Commodity Integral Flow],
"PathConstrainedNetworkFlow": [Path-Constrained Network Flow],
"LengthBoundedDisjointPaths": [Length-Bounded Disjoint Paths],
"IsomorphicSpanningTree": [Isomorphic Spanning Tree],
"KthBestSpanningTree": [Kth Best Spanning Tree],
Expand Down Expand Up @@ -1188,6 +1189,99 @@ is feasible: each set induces a connected subgraph, the component weights are $2
]
]
}
#{
let x = load-model-example("PathConstrainedNetworkFlow")
let arcs = x.instance.graph.arcs.map(a => (a.at(0), a.at(1)))
let requirement = x.instance.requirement
let p1 = (0, 2, 5, 8)
let p2 = (0, 3, 6, 8)
let p5 = (1, 4, 7, 9)
[
#problem-def("PathConstrainedNetworkFlow")[
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$.
][
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.

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.]

*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.

#pred-commands(
"pred create --example " + problem-spec(x) + " -o path-constrained-network-flow.json",
"pred solve path-constrained-network-flow.json --solver brute-force",
"pred evaluate path-constrained-network-flow.json --config " + x.optimal_config.map(str).join(","),
)

#figure(
canvas(length: 0.95cm, {
import draw: *
let blue = graph-colors.at(0)
let orange = rgb("#f28e2b")
let teal = rgb("#76b7b2")
let gray = luma(185)
let verts = (
(0, 0),
(1.4, 1.2),
(1.4, -1.2),
(2.8, 1.9),
(2.8, 0),
(4.2, 1.2),
(4.2, -1.2),
(5.6, 0),
)
for (u, v) in arcs {
line(
verts.at(u),
verts.at(v),
stroke: 0.8pt + gray,
mark: (end: "straight", scale: 0.45),
)
}
for idx in p1 {
let (u, v) = arcs.at(idx)
line(
verts.at(u),
verts.at(v),
stroke: 1.8pt + blue,
mark: (end: "straight", scale: 0.5),
)
}
for idx in p2 {
let (u, v) = arcs.at(idx)
line(
verts.at(u),
verts.at(v),
stroke: (paint: orange, thickness: 1.7pt, dash: "dashed"),
mark: (end: "straight", scale: 0.48),
)
}
for idx in p5 {
let (u, v) = arcs.at(idx)
line(
verts.at(u),
verts.at(v),
stroke: 1.6pt + teal,
mark: (end: "straight", scale: 0.46),
)
}
for (i, pos) in verts.enumerate() {
let fill = if i == 0 or i == 7 { rgb("#e15759").lighten(75%) } else { white }
g-node(pos, name: "pcnf-" + str(i), fill: fill, label: [$v_#i$])
}
content((0.65, 0.78), text(8pt, fill: gray)[$2 / 2$])
content((4.9, 0.78), text(8pt, fill: gray)[$2 / 2$])
line((0.2, -2.15), (0.8, -2.15), stroke: 1.8pt + blue, mark: (end: "straight", scale: 0.42))
content((1.15, -2.15), text(8pt)[$p_1$])
line((1.95, -2.15), (2.55, -2.15), stroke: (paint: orange, thickness: 1.7pt, dash: "dashed"), mark: (end: "straight", scale: 0.42))
content((2.9, -2.15), text(8pt)[$p_2$])
line((3.75, -2.15), (4.35, -2.15), stroke: 1.6pt + teal, mark: (end: "straight", scale: 0.42))
content((4.7, -2.15), text(8pt)[$p_5$])
}),
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.],
) <fig:path-constrained-network-flow>
]
]
}
#{
let x = load-model-example("IsomorphicSpanningTree")
let g-edges = x.instance.graph.edges
Expand Down
11 changes: 11 additions & 0 deletions docs/paper/references.bib
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,17 @@ @book{garey1979
year = {1979}
}

@article{busingstiller2011,
author = {Christina Büsing and Sebastian Stiller},
title = {Line planning, path constrained network flow and inapproximability},
journal = {Networks},
volume = {57},
number = {1},
pages = {106--113},
year = {2011},
doi = {10.1002/net.20386}
}

@article{bruckerGareyJohnson1977,
author = {Peter Brucker and Michael R. Garey and David S. Johnson},
title = {Scheduling equal-length tasks under tree-like precedence constraints to minimize maximum lateness},
Expand Down
6 changes: 5 additions & 1 deletion problemreductions-cli/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,7 @@ Flags by problem type:
IsomorphicSpanningTree --graph, --tree
KthBestSpanningTree --graph, --edge-weights, --k, --bound
LengthBoundedDisjointPaths --graph, --source, --sink, --num-paths-required, --bound
PathConstrainedNetworkFlow --arcs, --capacities, --source, --sink, --paths, --requirement
Factoring --target, --m, --n
BinPacking --sizes, --capacity
SubsetSum --sizes, --target
Expand Down Expand Up @@ -370,12 +371,15 @@ pub struct CreateArgs {
/// Sink vertex for path-based graph problems and MinimumCutIntoBoundedSets
#[arg(long)]
pub sink: Option<usize>,
/// Required sink inflow for IntegralFlowHomologousArcs and IntegralFlowWithMultipliers
/// Required total flow R for IntegralFlowHomologousArcs, IntegralFlowWithMultipliers, and PathConstrainedNetworkFlow
#[arg(long)]
pub requirement: Option<u64>,
/// Required number of paths for LengthBoundedDisjointPaths
#[arg(long)]
pub num_paths_required: Option<usize>,
/// Prescribed directed s-t paths as semicolon-separated arc-index sequences (e.g., "0,2,5;1,4,6")
#[arg(long)]
pub paths: Option<String>,
/// Pairwise couplings J_ij for SpinGlass (e.g., 1,-1,1) [default: all 1s]
#[arg(long)]
pub couplings: Option<String>,
Expand Down
179 changes: 177 additions & 2 deletions problemreductions-cli/src/commands/create.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ use problemreductions::models::formula::Quantifier;
use problemreductions::models::graph::{
GeneralizedHex, GraphPartitioning, HamiltonianCircuit, HamiltonianPath,
LengthBoundedDisjointPaths, LongestCircuit, LongestPath, MinimumCutIntoBoundedSets,
MinimumMultiwayCut, MixedChinesePostman, MultipleChoiceBranching, SteinerTree,
SteinerTreeInGraphs, StrongConnectivityAugmentation,
MinimumMultiwayCut, MixedChinesePostman, MultipleChoiceBranching, PathConstrainedNetworkFlow,
SteinerTree, SteinerTreeInGraphs, StrongConnectivityAugmentation,
};
use problemreductions::models::misc::{
AdditionalKey, BinPacking, BoyceCoddNormalFormViolation, CbqRelation, ConjunctiveBooleanQuery,
Expand Down Expand Up @@ -55,6 +55,7 @@ fn all_data_flags_empty(args: &CreateArgs) -> bool {
&& args.sink.is_none()
&& args.requirement.is_none()
&& args.num_paths_required.is_none()
&& args.paths.is_none()
&& args.couplings.is_none()
&& args.fields.is_none()
&& args.clauses.is_none()
Expand All @@ -77,6 +78,7 @@ fn all_data_flags_empty(args: &CreateArgs) -> bool {
&& args.sink_2.is_none()
&& args.requirement_1.is_none()
&& args.requirement_2.is_none()
&& args.requirement.is_none()
&& args.sizes.is_none()
&& args.capacity.is_none()
&& args.sequence.is_none()
Expand Down Expand Up @@ -540,6 +542,9 @@ fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str {
"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"
}
"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"
}
"IsomorphicSpanningTree" => "--graph 0-1,1-2,0-2 --tree 0-1,1-2",
"KthBestSpanningTree" => "--graph 0-1,0-2,1-2 --edge-weights 2,3,1 --k 1 --bound 3",
"LongestCircuit" => {
Expand Down Expand Up @@ -771,6 +776,9 @@ fn help_flag_hint(
("ConsistencyOfDatabaseFrequencyTables", "known_values") => {
"semicolon-separated triples: \"0,0,0;3,0,1;1,2,1\""
}
("PathConstrainedNetworkFlow", "paths") => {
"semicolon-separated arc-index paths: \"0,2,5,8;1,4,7,9\""
}
("ConsecutiveOnesSubmatrix", "matrix") => "semicolon-separated 0/1 rows: \"1,0;0,1\"",
("TimetableDesign", "craftsman_avail") | ("TimetableDesign", "task_avail") => {
"semicolon-separated 0/1 rows: \"1,1,0;0,1,1\""
Expand Down Expand Up @@ -3319,6 +3327,47 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> {
)
}

// PathConstrainedNetworkFlow
"PathConstrainedNetworkFlow" => {
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";
let arcs_str = args.arcs.as_deref().ok_or_else(|| {
anyhow::anyhow!("PathConstrainedNetworkFlow 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 capacities: Vec<u64> = if let Some(ref s) = args.capacities {
util::parse_comma_list(s)?
} else {
vec![1; num_arcs]
};
anyhow::ensure!(
capacities.len() == num_arcs,
"capacities length ({}) must match number of arcs ({num_arcs})",
capacities.len()
);
let source = args.source.ok_or_else(|| {
anyhow::anyhow!("PathConstrainedNetworkFlow requires --source\n\n{usage}")
})?;
let sink = args.sink.ok_or_else(|| {
anyhow::anyhow!("PathConstrainedNetworkFlow requires --sink\n\n{usage}")
})?;
let requirement = args.requirement.ok_or_else(|| {
anyhow::anyhow!("PathConstrainedNetworkFlow requires --requirement\n\n{usage}")
})?;
let paths = parse_prescribed_paths(args, num_arcs, usage)?;
(
ser(PathConstrainedNetworkFlow::new(
graph,
capacities,
source,
sink,
paths,
requirement,
))?,
resolved_variant.clone(),
)
}

// MinimumFeedbackArcSet
"MinimumFeedbackArcSet" => {
let arcs_str = args.arcs.as_deref().ok_or_else(|| {
Expand Down Expand Up @@ -5164,6 +5213,40 @@ fn parse_directed_graph(
Ok((DirectedGraph::new(num_v, arcs), num_arcs))
}

fn parse_prescribed_paths(
args: &CreateArgs,
num_arcs: usize,
usage: &str,
) -> Result<Vec<Vec<usize>>> {
let paths_str = args
.paths
.as_deref()
.ok_or_else(|| anyhow::anyhow!("PathConstrainedNetworkFlow requires --paths\n\n{usage}"))?;

paths_str
.split(';')
.map(|path_str| {
let trimmed = path_str.trim();
anyhow::ensure!(
!trimmed.is_empty(),
"PathConstrainedNetworkFlow paths must be non-empty\n\n{usage}"
);
let path: Vec<usize> = util::parse_comma_list(trimmed)?;
anyhow::ensure!(
!path.is_empty(),
"PathConstrainedNetworkFlow paths must be non-empty\n\n{usage}"
);
for &arc_idx in &path {
anyhow::ensure!(
arc_idx < num_arcs,
"Path arc index {arc_idx} out of bounds for {num_arcs} arcs\n\n{usage}"
);
}
Ok(path)
})
.collect()
}

fn parse_mixed_graph(args: &CreateArgs, usage: &str) -> Result<MixedGraph> {
let (undirected_graph, num_vertices) =
parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?;
Expand Down Expand Up @@ -5865,6 +5948,90 @@ mod tests {
std::fs::remove_file(output_path).unwrap();
}

#[test]
fn test_create_path_constrained_network_flow_outputs_problem_json() {
let cli = Cli::try_parse_from([
"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",
])
.expect("parse create command");

let args = match cli.command {
Commands::Create(args) => args,
_ => panic!("expected create command"),
};

let output_path = temp_output_path("path_constrained_network_flow");
let out = OutputConfig {
output: Some(output_path.clone()),
quiet: true,
json: false,
auto_json: false,
};

create(&args, &out).expect("create PathConstrainedNetworkFlow JSON");

let created: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&output_path).unwrap()).unwrap();
fs::remove_file(output_path).ok();

assert_eq!(created["type"], "PathConstrainedNetworkFlow");
assert_eq!(created["data"]["source"], 0);
assert_eq!(created["data"]["sink"], 7);
assert_eq!(created["data"]["requirement"], 3);
assert_eq!(created["data"]["paths"][0], serde_json::json!([0, 2, 5, 8]));
}

#[test]
fn test_create_path_constrained_network_flow_rejects_invalid_paths() {
let cli = Cli::try_parse_from([
"pred",
"create",
"PathConstrainedNetworkFlow",
"--arcs",
"0>1,1>2,2>3",
"--capacities",
"1,1,1",
"--source",
"0",
"--sink",
"3",
"--paths",
"0,3",
"--requirement",
"1",
])
.expect("parse create command");

let args = match cli.command {
Commands::Create(args) => args,
_ => panic!("expected create command"),
};

let out = OutputConfig {
output: None,
quiet: true,
json: false,
auto_json: false,
};

let err = create(&args, &out).unwrap_err().to_string();
assert!(err.contains("out of bounds") || err.contains("not contiguous"));
}

#[test]
fn test_create_staff_scheduling_reports_invalid_schedule_without_panic() {
let cli = Cli::try_parse_from([
Expand Down Expand Up @@ -5916,6 +6083,13 @@ mod tests {
);
}

#[test]
fn test_example_for_path_constrained_network_flow_mentions_paths_flag() {
let example = example_for("PathConstrainedNetworkFlow", None);
assert!(example.contains("--paths"));
assert!(example.contains("--requirement"));
}

#[test]
fn test_create_timetable_design_outputs_problem_json() {
let cli = Cli::try_parse_from([
Expand Down Expand Up @@ -6233,6 +6407,7 @@ mod tests {
sink: None,
requirement: None,
num_paths_required: None,
paths: None,
couplings: None,
fields: None,
clauses: None,
Expand Down
Loading
Loading