From 00174e26edf84fae8b60af14c80246ffe8a4c35c Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Sun, 22 Mar 2026 05:12:07 +0800 Subject: [PATCH 1/6] Add plan for #294: [Model] UndirectedFlowLowerBounds --- ...2026-03-22-undirected-flow-lower-bounds.md | 302 ++++++++++++++++++ 1 file changed, 302 insertions(+) create mode 100644 docs/plans/2026-03-22-undirected-flow-lower-bounds.md diff --git a/docs/plans/2026-03-22-undirected-flow-lower-bounds.md b/docs/plans/2026-03-22-undirected-flow-lower-bounds.md new file mode 100644 index 000000000..3ca9ca2ec --- /dev/null +++ b/docs/plans/2026-03-22-undirected-flow-lower-bounds.md @@ -0,0 +1,302 @@ +# UndirectedFlowLowerBounds Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add the `UndirectedFlowLowerBounds` graph model, wire it into the registry/CLI/example-db/paper, and verify the canonical YES/NO instances from issue #294. + +**Architecture:** Represent the problem as an undirected `SimpleGraph` plus per-edge capacities/lower bounds, source/sink terminals, and a required net flow. Use one binary configuration variable per edge to choose its orientation relative to the stored edge order; `evaluate()` then checks whether that orientation admits a feasible directed lower-bound flow with net sink inflow at least `R` by reducing to a feasible circulation instance and solving the resulting max-flow subproblem. Keep rule work out of scope for this PR; related rule issues already exist (`#367` inbound, `#735` outbound). + +**Tech Stack:** Rust workspace (`problemreductions`, `problemreductions-cli`), serde/inventory registry metadata, existing brute-force solver, Typst paper, GitHub pipeline scripts. + +--- + +## Batch 1: Model, Tests, Registry, CLI, Example DB + +### Task 1: Add failing model tests first + +**Files:** +- Create: `src/unit_tests/models/graph/undirected_flow_lower_bounds.rs` +- Reference: `src/unit_tests/models/graph/undirected_two_commodity_integral_flow.rs` +- Reference: `src/models/graph/undirected_two_commodity_integral_flow.rs` + +**Step 1: Write the failing tests** + +Cover these behaviors before production code exists: +- Constructor/accessors/dims shape for the issue YES instance +- `evaluate()` accepts the canonical YES orientation +- `evaluate()` rejects the canonical NO instance +- Serialization round-trip +- Brute-force solver finds a satisfying orientation on the YES instance and none on the NO instance +- Paper example test uses the same canonical YES instance + +Use the issue’s fixed examples: +- YES graph edges: `(0,1) (0,2) (1,3) (2,3) (1,4) (3,5) (4,5)` +- YES lower bounds: `1,1,0,0,1,0,1` +- YES capacities: `2,2,2,2,1,3,2` +- YES source/sink/requirement: `0,5,3` +- YES orientation config: `[0,0,0,0,0,0,0]` +- NO graph edges: `(0,1) (0,2) (1,3) (2,3)` +- NO lower bounds: `2,2,1,1` +- NO capacities: `2,2,1,1` +- NO source/sink/requirement: `0,3,2` + +**Step 2: Run the targeted test to verify RED** + +Run: +```bash +cargo test undirected_flow_lower_bounds --lib +``` + +Expected: +- FAIL to compile because `UndirectedFlowLowerBounds` is not registered yet. + +**Step 3: Commit the red test scaffold** + +```bash +git add src/unit_tests/models/graph/undirected_flow_lower_bounds.rs +git commit -m "test: add UndirectedFlowLowerBounds model coverage" +``` + +### Task 2: Implement the model and binary-orientation feasibility check + +**Files:** +- Create: `src/models/graph/undirected_flow_lower_bounds.rs` +- Modify: `src/models/graph/mod.rs` +- Modify: `src/models/mod.rs` +- Modify: `src/lib.rs` + +**Step 1: Implement the model skeleton** + +Add `inventory::submit!` metadata plus: +- `ProblemSchemaEntry` with fields `graph`, `capacities`, `lower_bounds`, `source`, `sink`, `requirement` +- `ProblemSizeFieldEntry` for `num_vertices`, `num_edges` +- `#[derive(Debug, Clone, Serialize, Deserialize)]` struct using `SimpleGraph` +- Constructor validation: + - capacities/lower-bounds lengths match `graph.num_edges()` + - `source`/`sink` are in range + - `requirement >= 1` + - every `lower_bounds[i] <= capacities[i]` +- Accessors: `graph()`, `capacities()`, `lower_bounds()`, `source()`, `sink()`, `requirement()`, `num_vertices()`, `num_edges()` + +**Step 2: Define the configuration space** + +Implement: +- `dims() -> vec![2; num_edges]` +- `variant() -> crate::variant_params![]` +- `declare_variants! { default sat UndirectedFlowLowerBounds => "2^num_edges", }` + +Interpret each config bit as: +- `0`: orient stored edge `(u,v)` as `u -> v` +- `1`: orient it as `v -> u` + +Document this clearly in the model doc comment and in the paper/example text because it differs from the issue’s earlier “2|E| flow variables” draft. + +**Step 3: Implement the feasibility algorithm** + +Inside the model file: +- Convert the chosen orientation into a directed lower/upper-bound network +- Add a synthetic arc `sink -> source` with lower bound `requirement` and upper bound equal to the sum of edge capacities +- Reduce lower-bound feasibility to ordinary max-flow by: + - replacing each edge capacity with `upper - lower` + - computing vertex imbalances from the lower bounds + - adding super-source/super-sink edges for positive/negative imbalance +- Implement a small local Edmonds-Karp max-flow helper (keep it private to the model file unless later reuse is obvious) +- Return `true` iff the max-flow saturates every edge from the super-source + +Guardrails: +- Use `u128` / checked arithmetic for aggregate capacities and convert safely back to the max-flow scalar type used internally +- Treat wrong-length configs as `false` +- Do not depend on optional ILP features; the model must work in default and non-ILP builds + +**Step 4: Register the model** + +Wire the new type into: +- `src/models/graph/mod.rs` module list, re-export list, and `canonical_model_example_specs()` chain +- `src/models/mod.rs` graph re-export list +- `src/lib.rs` prelude exports + +**Step 5: Run the targeted test to verify GREEN** + +Run: +```bash +cargo test undirected_flow_lower_bounds --lib +``` + +Expected: +- PASS for the new model tests + +**Step 6: Commit** + +```bash +git add src/models/graph/undirected_flow_lower_bounds.rs src/models/graph/mod.rs src/models/mod.rs src/lib.rs src/unit_tests/models/graph/undirected_flow_lower_bounds.rs +git commit -m "feat: add UndirectedFlowLowerBounds model" +``` + +### Task 3: Add CLI discovery and creation support + +**Files:** +- Modify: `problemreductions-cli/src/problem_name.rs` +- Modify: `problemreductions-cli/src/cli.rs` +- Modify: `problemreductions-cli/src/commands/create.rs` + +**Step 1: Extend CLI flags and help text** + +Add: +- `CreateArgs.lower_bounds: Option` with `--lower-bounds` +- help-table line for `UndirectedFlowLowerBounds` +- example command using the issue YES instance +- `all_data_flags_empty()` coverage for `lower_bounds` + +**Step 2: Add alias + parser support** + +Update `resolve_alias()` with the lowercase pass-through mapping: +- `"undirectedflowlowerbounds" => "UndirectedFlowLowerBounds".to_string()` + +Do not invent a short alias. + +**Step 3: Add `pred create` support** + +Add a `create()` arm using: +- `--graph` +- `--capacities` +- `--lower-bounds` +- `--source` +- `--sink` +- `--requirement` + +Implement a small parsing helper mirroring `parse_capacities()` so: +- the list length matches `graph.num_edges()` +- every lower bound parses as `u64` +- constructor-level `lower <= capacity` validation remains the source of truth + +**Step 4: Add focused CLI tests** + +In `problemreductions-cli/src/commands/create.rs` tests, add: +- JSON creation test for the YES instance +- missing `--lower-bounds` usage error test + +In `problemreductions-cli/src/problem_name.rs` tests, add: +- alias resolution pass-through test for `UndirectedFlowLowerBounds` + +**Step 5: Run targeted CLI tests** + +Run: +```bash +cargo test -p problemreductions-cli undirected_flow_lower_bounds +``` + +Expected: +- PASS for the new CLI-specific tests + +**Step 6: Commit** + +```bash +git add problemreductions-cli/src/problem_name.rs problemreductions-cli/src/cli.rs problemreductions-cli/src/commands/create.rs +git commit -m "feat: add CLI support for UndirectedFlowLowerBounds" +``` + +## Batch 2: Paper Entry + +### Task 4: Add canonical example and paper documentation + +**Files:** +- Modify: `src/models/graph/undirected_flow_lower_bounds.rs` +- Modify: `docs/paper/reductions.typ` +- Reference: `docs/paper/reductions.typ` `problem-def("UndirectedTwoCommodityIntegralFlow")` + +**Step 1: Add canonical model example metadata** + +In the model file, add `canonical_model_example_specs()` gated by `#[cfg(feature = "example-db")]` using the issue YES instance: +- instance = the 6-vertex YES graph +- `optimal_config = vec![0, 0, 0, 0, 0, 0, 0]` +- `optimal_value = serde_json::json!(true)` + +Keep the canonical example aligned with the paper example and the unit test. + +**Step 2: Add the paper display name and `problem-def`** + +Update `docs/paper/reductions.typ` with: +- display-name entry for `UndirectedFlowLowerBounds` +- a `problem-def("UndirectedFlowLowerBounds")` section + +The paper entry should explain: +- the formal definition from issue #294 / Garey-Johnson ND37 +- why the implementation uses edge orientations as the configuration space +- the `2^m` brute-force-over-orientations interpretation +- the canonical YES example, including one explicit feasible flow witness derived from the chosen orientation +- `pred-commands()` using `pred create --example UndirectedFlowLowerBounds` + +Do not claim the ILP rule is implemented here; mention it as a natural outbound reduction already tracked separately. + +**Step 3: Run paper verification** + +Run: +```bash +make paper +``` + +Expected: +- PASS with no Typst errors + +**Step 4: Re-run the paper example unit test** + +Run: +```bash +cargo test test_undirected_flow_lower_bounds_paper_example --lib +``` + +Expected: +- PASS + +**Step 5: Commit** + +```bash +git add docs/paper/reductions.typ src/models/graph/undirected_flow_lower_bounds.rs +git commit -m "docs: add UndirectedFlowLowerBounds paper entry" +``` + +## Final Verification + +### Task 5: Run full verification and clean up + +**Files:** +- Verify: workspace root + +**Step 1: Run repository verification** + +Run: +```bash +make test +make clippy +``` + +Expected: +- PASS for the full workspace + +**Step 2: Inspect the tree** + +Run: +```bash +git status --short +``` + +Expected: +- Clean tree, or only ignored/generated docs outputs + +**Step 3: Final commit if needed** + +If verification changed tracked files: +```bash +git add -A +git commit -m "chore: finalize UndirectedFlowLowerBounds verification fixes" +``` + +**Step 4: Push** + +Run: +```bash +git push +``` + +Expected: +- Branch updated and ready for review pipeline From cd6627cc191b95eac8d4088cceef3d8fe0b45f21 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Sun, 22 Mar 2026 05:27:24 +0800 Subject: [PATCH 2/6] Implement #294: [Model] UndirectedFlowLowerBounds --- docs/paper/reductions.typ | 63 +++ docs/paper/references.bib | 10 + problemreductions-cli/src/cli.rs | 8 + problemreductions-cli/src/commands/create.rs | 161 +++++++- problemreductions-cli/src/problem_name.rs | 15 + src/lib.rs | 2 +- src/models/graph/mod.rs | 4 + .../graph/undirected_flow_lower_bounds.rs | 369 ++++++++++++++++++ src/models/mod.rs | 2 +- .../graph/undirected_flow_lower_bounds.rs | 104 +++++ 10 files changed, 721 insertions(+), 17 deletions(-) create mode 100644 src/models/graph/undirected_flow_lower_bounds.rs create mode 100644 src/unit_tests/models/graph/undirected_flow_lower_bounds.rs diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index 9167a0c15..55cabe795 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -74,6 +74,7 @@ "HamiltonianPath": [Hamiltonian Path], "LongestCircuit": [Longest Circuit], "ShortestWeightConstrainedPath": [Shortest Weight-Constrained Path], + "UndirectedFlowLowerBounds": [Undirected Flow with Lower Bounds], "UndirectedTwoCommodityIntegralFlow": [Undirected Two-Commodity Integral Flow], "LengthBoundedDisjointPaths": [Length-Bounded Disjoint Paths], "IsomorphicSpanningTree": [Isomorphic Spanning Tree], @@ -1070,6 +1071,68 @@ is feasible: each set induces a connected subgraph, the component weights are $2 ] ] } +#{ + let x = load-model-example("UndirectedFlowLowerBounds") + let s = x.instance.source + let t = x.instance.sink + let R = x.instance.requirement + let orientation = x.optimal_config + let edges = x.instance.graph.edges + let lower = x.instance.lower_bounds + let caps = x.instance.capacities + let witness = (2, 1, 1, 1, 1, 2, 1) + [ + #problem-def("UndirectedFlowLowerBounds")[ + 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$. + ][ + 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. + + 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. + + *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. + + #pred-commands( + "pred create --example UndirectedFlowLowerBounds -o undirected-flow-lower-bounds.json", + "pred solve undirected-flow-lower-bounds.json", + "pred evaluate undirected-flow-lower-bounds.json --config " + x.optimal_config.map(str).join(","), + ) + + #figure( + canvas(length: 0.9cm, { + import draw: * + let blue = graph-colors.at(0) + let red = rgb("#e15759") + let gray = luma(190) + let verts = ((0, 0), (1.6, 1.2), (1.6, -1.2), (3.4, 0.5), (3.4, -1.5), (5.2, -0.3)) + let labels = ( + [$s = v_0$], + [$v_1$], + [$v_2$], + [$v_3$], + [$v_4$], + [$t = v_5$], + ) + for (u, v) in edges { + g-edge(verts.at(u), verts.at(v), stroke: 1.8pt + blue) + } + for (i, pos) in verts.enumerate() { + let fill = if i == s { blue } else if i == t { red } else { white } + let label = if i == s or i == t { text(fill: white)[#labels.at(i)] } else { labels.at(i) } + g-node(pos, name: "uflb-" + str(i), fill: fill, label: label) + } + content((0.75, 0.7), text(7pt, fill: gray)[$f = 2$]) + content((0.75, -0.7), text(7pt, fill: gray)[$f = 1$]) + content((2.45, 1.05), text(7pt, fill: gray)[$f = 1$]) + content((2.45, -0.25), text(7pt, fill: gray)[$f = 1$]) + content((2.45, -1.45), text(7pt, fill: gray)[$f = 1$]) + content((4.35, 0.35), text(7pt, fill: gray)[$f = 2$]) + content((4.35, -1.1), text(7pt, fill: gray)[$f = 1$]) + }), + 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.], + ) + ] + ] +} #{ let x = load-model-example("UndirectedTwoCommodityIntegralFlow") let satisfying_count = 1 diff --git a/docs/paper/references.bib b/docs/paper/references.bib index f24f1a092..cd9c3c9dc 100644 --- a/docs/paper/references.bib +++ b/docs/paper/references.bib @@ -255,6 +255,16 @@ @article{evenItaiShamir1976 doi = {10.1137/0205048} } +@article{itai1978, + author = {Alon Itai}, + title = {Two-Commodity Flow}, + journal = {Journal of the ACM}, + volume = {25}, + number = {4}, + pages = {596--611}, + year = {1978} +} + @article{abdelWahabKameda1978, author = {H. M. Abdel-Wahab and T. Kameda}, title = {Scheduling to Minimize Maximum Cumulative Cost Subject to Series-Parallel Precedence Constraints}, diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index 02df552d7..0e8278d3c 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -233,6 +233,7 @@ Flags by problem type: HamiltonianCircuit, HC --graph LongestCircuit --graph, --edge-weights, --bound BoundedComponentSpanningForest --graph, --weights, --k, --bound + UndirectedFlowLowerBounds --graph, --capacities, --lower-bounds, --source, --sink, --requirement UndirectedTwoCommodityIntegralFlow --graph, --capacities, --source-1, --sink-1, --source-2, --sink-2, --requirement-1, --requirement-2 IsomorphicSpanningTree --graph, --tree KthBestSpanningTree --graph, --edge-weights, --k, --bound @@ -319,6 +320,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 MultiprocessorScheduling --lengths 4,5,3,2,6 --num-processors 2 --deadline 10 + 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 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\" pred create BiconnectivityAugmentation --graph 0-1,1-2,2-3 --potential-edges 0-2:3,0-3:4,1-3:2 --budget 5 pred create FVS --arcs \"0>1,1>2,2>0\" --weights 1,1,1 @@ -356,12 +358,18 @@ pub struct CreateArgs { /// Edge capacities for multicommodity flow problems (e.g., 1,1,2) #[arg(long)] pub capacities: Option, + /// Edge lower bounds for lower-bounded flow problems (e.g., 1,1,0,0,1,0,1) + #[arg(long)] + pub lower_bounds: Option, /// Source vertex for path-based graph problems and MinimumCutIntoBoundedSets #[arg(long)] pub source: Option, /// Sink vertex for path-based graph problems and MinimumCutIntoBoundedSets #[arg(long)] pub sink: Option, + /// Required net flow R for single-commodity lower-bounded flow problems + #[arg(long)] + pub requirement: Option, /// Required number of paths for LengthBoundedDisjointPaths #[arg(long)] pub num_paths_required: Option, diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index d03de0341..719426697 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -50,8 +50,10 @@ fn all_data_flags_empty(args: &CreateArgs) -> bool { && args.edge_weights.is_none() && args.edge_lengths.is_none() && args.capacities.is_none() + && args.lower_bounds.is_none() && args.source.is_none() && args.sink.is_none() + && args.requirement.is_none() && args.num_paths_required.is_none() && args.couplings.is_none() && args.fields.is_none() @@ -520,6 +522,9 @@ fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str { "--graph 0-1,1-2,2-3,3-4,4-5,5-6,6-7,0-7,1-5,2-6 --weights 2,3,1,2,3,1,2,1 --k 3 --bound 6" } "HamiltonianPath" => "--graph 0-1,1-2,2-3", + "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" + } "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" }, @@ -1324,6 +1329,37 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { ) } + // UndirectedFlowLowerBounds (graph + capacities + lower bounds + terminals + requirement) + "UndirectedFlowLowerBounds" => { + 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"; + let (graph, _) = parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; + let capacities = parse_capacities(args, graph.num_edges(), usage)?; + let lower_bounds = parse_lower_bounds(args, graph.num_edges(), usage)?; + let num_vertices = graph.num_vertices(); + let source = args.source.ok_or_else(|| { + anyhow::anyhow!("UndirectedFlowLowerBounds requires --source\n\n{usage}") + })?; + let sink = args.sink.ok_or_else(|| { + anyhow::anyhow!("UndirectedFlowLowerBounds requires --sink\n\n{usage}") + })?; + let requirement = args.requirement.ok_or_else(|| { + anyhow::anyhow!("UndirectedFlowLowerBounds requires --requirement\n\n{usage}") + })?; + validate_vertex_index("source", source, num_vertices, usage)?; + validate_vertex_index("sink", sink, num_vertices, usage)?; + ( + ser(UndirectedFlowLowerBounds::new( + graph, + capacities, + lower_bounds, + source, + sink, + requirement, + ))?, + 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"; @@ -4168,7 +4204,7 @@ fn validate_vertex_index( /// 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}") + anyhow::anyhow!("This problem requires --capacities\n\n{usage}") })?; let capacities: Vec = capacities .split(',') @@ -4187,23 +4223,34 @@ fn parse_capacities(args: &CreateArgs, num_edges: usize, usage: &str) -> Result< 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 `--lower-bounds` as edge lower bounds (u64). +fn parse_lower_bounds(args: &CreateArgs, num_edges: usize, usage: &str) -> Result> { + let lower_bounds = args.lower_bounds.as_deref().ok_or_else(|| { + anyhow::anyhow!("UndirectedFlowLowerBounds requires --lower-bounds\n\n{usage}") + })?; + let lower_bounds: Vec = lower_bounds + .split(',') + .map(|s| { + let trimmed = s.trim(); + trimmed + .parse::() + .with_context(|| format!("Invalid lower bound `{trimmed}`\n\n{usage}")) + }) + .collect::>>()?; + if lower_bounds.len() != num_edges { + bail!( + "Expected {} lower bounds but got {}\n\n{}", + num_edges, + lower_bounds.len(), + usage + ); + } + Ok(lower_bounds) +} + /// Parse `--couplings` as SpinGlass pairwise couplings (i32), defaulting to all 1s. fn parse_couplings(args: &CreateArgs, num_edges: usize) -> Result> { match &args.couplings { @@ -5860,6 +5907,88 @@ mod tests { assert!(err.to_string().contains("GeneralizedHex requires --sink")); } + #[test] + fn test_create_undirected_flow_lower_bounds_serializes_problem_json() { + let output = temp_output_path("undirected_flow_lower_bounds_create"); + let cli = Cli::try_parse_from([ + "pred", + "-o", + output.to_str().unwrap(), + "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", + ]) + .unwrap(); + let out = OutputConfig { + output: cli.output.clone(), + quiet: true, + json: false, + auto_json: false, + }; + let args = match cli.command { + Commands::Create(args) => args, + _ => unreachable!(), + }; + + create(&args, &out).unwrap(); + + let json: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&output).unwrap()).unwrap(); + fs::remove_file(&output).unwrap(); + assert_eq!(json["type"], "UndirectedFlowLowerBounds"); + assert_eq!(json["data"]["source"], 0); + assert_eq!(json["data"]["sink"], 5); + assert_eq!(json["data"]["requirement"], 3); + assert_eq!(json["data"]["lower_bounds"], serde_json::json!([1, 1, 0, 0, 1, 0, 1])); + } + + #[test] + fn test_create_undirected_flow_lower_bounds_requires_lower_bounds() { + let cli = Cli::try_parse_from([ + "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", + "--source", + "0", + "--sink", + "5", + "--requirement", + "3", + ]) + .unwrap(); + let out = OutputConfig { + output: None, + quiet: true, + json: false, + auto_json: false, + }; + let args = match cli.command { + Commands::Create(args) => args, + _ => unreachable!(), + }; + + let err = create(&args, &out).unwrap_err(); + assert!( + err.to_string() + .contains("UndirectedFlowLowerBounds requires --lower-bounds") + ); + } + fn empty_args() -> CreateArgs { CreateArgs { problem: Some("BiconnectivityAugmentation".to_string()), @@ -5871,8 +6000,10 @@ mod tests { edge_weights: None, edge_lengths: None, capacities: None, + lower_bounds: None, source: None, sink: None, + requirement: None, num_paths_required: None, couplings: None, fields: None, diff --git a/problemreductions-cli/src/problem_name.rs b/problemreductions-cli/src/problem_name.rs index cea49403b..92e2265a2 100644 --- a/problemreductions-cli/src/problem_name.rs +++ b/problemreductions-cli/src/problem_name.rs @@ -14,6 +14,9 @@ pub struct ProblemSpec { /// /// Uses the catalog for both aliases and canonical names. pub fn resolve_alias(input: &str) -> String { + if input.eq_ignore_ascii_case("UndirectedFlowLowerBounds") { + return "UndirectedFlowLowerBounds".to_string(); + } if let Some(pt) = problemreductions::registry::find_problem_type_by_alias(input) { return pt.canonical_name.to_string(); } @@ -319,6 +322,18 @@ mod tests { ); } + #[test] + fn test_resolve_alias_pass_through_undirected_flow_lower_bounds() { + assert_eq!( + resolve_alias("UndirectedFlowLowerBounds"), + "UndirectedFlowLowerBounds" + ); + assert_eq!( + resolve_alias("undirectedflowlowerbounds"), + "UndirectedFlowLowerBounds" + ); + } + #[test] fn test_parse_problem_spec_ksat_alias() { let spec = parse_problem_spec("KSAT").unwrap(); diff --git a/src/lib.rs b/src/lib.rs index f9e84dca0..e8ba340f1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -62,7 +62,7 @@ pub mod prelude { MinimumVertexCover, MultipleChoiceBranching, MultipleCopyFileAllocation, OptimalLinearArrangement, PartitionIntoPathsOfLength2, PartitionIntoTriangles, RuralPostman, ShortestWeightConstrainedPath, SteinerTreeInGraphs, TravelingSalesman, - UndirectedTwoCommodityIntegralFlow, + UndirectedFlowLowerBounds, UndirectedTwoCommodityIntegralFlow, }; pub use crate::models::misc::{ AdditionalKey, BinPacking, BoyceCoddNormalFormViolation, CbqRelation, diff --git a/src/models/graph/mod.rs b/src/models/graph/mod.rs index 06627fb70..65bc236bc 100644 --- a/src/models/graph/mod.rs +++ b/src/models/graph/mod.rs @@ -42,6 +42,7 @@ //! - [`SteinerTree`]: Minimum-weight tree spanning all required terminals //! - [`SubgraphIsomorphism`]: Subgraph isomorphism (decision problem) //! - [`DirectedTwoCommodityIntegralFlow`]: Directed two-commodity integral flow (satisfaction) +//! - [`UndirectedFlowLowerBounds`]: Feasible s-t flow in an undirected graph with lower/upper bounds //! - [`UndirectedTwoCommodityIntegralFlow`]: Two-commodity integral flow on undirected graphs //! - [`StrongConnectivityAugmentation`]: Strong connectivity augmentation with weighted candidate arcs @@ -89,6 +90,7 @@ pub(crate) mod steiner_tree_in_graphs; pub(crate) mod strong_connectivity_augmentation; pub(crate) mod subgraph_isomorphism; pub(crate) mod traveling_salesman; +pub(crate) mod undirected_flow_lower_bounds; pub(crate) mod undirected_two_commodity_integral_flow; pub use acyclic_partition::AcyclicPartition; @@ -135,6 +137,7 @@ pub use steiner_tree_in_graphs::SteinerTreeInGraphs; pub use strong_connectivity_augmentation::StrongConnectivityAugmentation; pub use subgraph_isomorphism::SubgraphIsomorphism; pub use traveling_salesman::TravelingSalesman; +pub use undirected_flow_lower_bounds::UndirectedFlowLowerBounds; pub use undirected_two_commodity_integral_flow::UndirectedTwoCommodityIntegralFlow; #[cfg(feature = "example-db")] @@ -177,6 +180,7 @@ pub(crate) fn canonical_model_example_specs() -> Vec v` +//! - `1` means orient it as `v -> u` +//! +//! For a fixed orientation, feasibility reduces to a directed circulation with +//! lower bounds, so the registered exact complexity matches brute-force +//! enumeration over the `2^|E|` edge orientations. + +use crate::registry::{FieldInfo, ProblemSchemaEntry, ProblemSizeFieldEntry}; +use crate::topology::{Graph, SimpleGraph}; +use crate::traits::{Problem, SatisfactionProblem}; +use serde::{Deserialize, Serialize}; +use std::collections::VecDeque; + +inventory::submit! { + ProblemSchemaEntry { + name: "UndirectedFlowLowerBounds", + display_name: "Undirected Flow with Lower Bounds", + aliases: &[], + dimensions: &[], + module_path: module_path!(), + description: "Determine whether an undirected lower-bounded flow of value at least R exists", + fields: &[ + FieldInfo { name: "graph", type_name: "SimpleGraph", description: "Undirected graph G=(V,E)" }, + FieldInfo { name: "capacities", type_name: "Vec", description: "Upper capacities c(e) in graph edge order" }, + FieldInfo { name: "lower_bounds", type_name: "Vec", description: "Lower bounds l(e) in graph edge order" }, + FieldInfo { name: "source", type_name: "usize", description: "Source vertex s" }, + FieldInfo { name: "sink", type_name: "usize", description: "Sink vertex t" }, + FieldInfo { name: "requirement", type_name: "u64", description: "Required net inflow R at sink t" }, + ], + } +} + +inventory::submit! { + ProblemSizeFieldEntry { + name: "UndirectedFlowLowerBounds", + fields: &["num_vertices", "num_edges"], + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UndirectedFlowLowerBounds { + graph: SimpleGraph, + capacities: Vec, + lower_bounds: Vec, + source: usize, + sink: usize, + requirement: u64, +} + +impl UndirectedFlowLowerBounds { + pub fn new( + graph: SimpleGraph, + capacities: Vec, + lower_bounds: Vec, + source: usize, + sink: usize, + requirement: u64, + ) -> Self { + assert_eq!( + capacities.len(), + graph.num_edges(), + "capacities length must match graph num_edges" + ); + assert_eq!( + lower_bounds.len(), + graph.num_edges(), + "lower_bounds length must match graph num_edges" + ); + + let num_vertices = graph.num_vertices(); + assert!( + source < num_vertices, + "source must be less than num_vertices ({num_vertices})" + ); + assert!( + sink < num_vertices, + "sink must be less than num_vertices ({num_vertices})" + ); + assert!(requirement >= 1, "requirement must be at least 1"); + + for (edge_index, (&lower, &upper)) in lower_bounds.iter().zip(&capacities).enumerate() { + assert!( + lower <= upper, + "lower bound at edge {edge_index} must be at most its capacity" + ); + } + + Self { + graph, + capacities, + lower_bounds, + source, + sink, + requirement, + } + } + + pub fn graph(&self) -> &SimpleGraph { + &self.graph + } + + pub fn capacities(&self) -> &[u64] { + &self.capacities + } + + pub fn lower_bounds(&self) -> &[u64] { + &self.lower_bounds + } + + pub fn source(&self) -> usize { + self.source + } + + pub fn sink(&self) -> usize { + self.sink + } + + pub fn requirement(&self) -> u64 { + self.requirement + } + + 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 total_capacity(&self) -> Option { + self.capacities.iter().try_fold(0_u128, |acc, &capacity| { + acc.checked_add(u128::from(capacity)) + }) + } + + fn has_feasible_orientation(&self, config: &[usize]) -> bool { + if config.len() != self.num_edges() { + return false; + } + + let Some(total_capacity) = self.total_capacity() else { + return false; + }; + let requirement = u128::from(self.requirement); + if requirement > total_capacity { + return false; + } + + let node_count = self.num_vertices(); + let super_source = node_count; + let super_sink = node_count + 1; + let mut network = ResidualNetwork::new(node_count + 2); + let mut balances = vec![0_i128; node_count]; + + for (edge_index, ((u, v), &orientation)) in self + .graph + .edges() + .into_iter() + .zip(config.iter()) + .enumerate() + { + let (from, to) = match orientation { + 0 => (u, v), + 1 => (v, u), + _ => return false, + }; + let lower = u128::from(self.lower_bounds[edge_index]); + let upper = u128::from(self.capacities[edge_index]); + if !add_lower_bounded_edge(&mut network, &mut balances, from, to, lower, upper) { + return false; + } + } + + if !add_lower_bounded_edge( + &mut network, + &mut balances, + self.sink, + self.source, + requirement, + total_capacity, + ) { + return false; + } + + let mut demand = 0_u128; + for (vertex, balance) in balances.into_iter().enumerate() { + if balance > 0 { + let needed = u128::try_from(balance).expect("positive i128 balance fits u128"); + demand = match demand.checked_add(needed) { + Some(value) => value, + None => return false, + }; + network.add_edge(super_source, vertex, needed); + } else if balance < 0 { + let needed = u128::try_from(-balance).expect("negative i128 balance fits u128"); + network.add_edge(vertex, super_sink, needed); + } + } + + network.max_flow(super_source, super_sink) == demand + } +} + +impl Problem for UndirectedFlowLowerBounds { + const NAME: &'static str = "UndirectedFlowLowerBounds"; + type Metric = bool; + + fn variant() -> Vec<(&'static str, &'static str)> { + crate::variant_params![] + } + + fn dims(&self) -> Vec { + vec![2; self.num_edges()] + } + + fn evaluate(&self, config: &[usize]) -> bool { + self.has_feasible_orientation(config) + } +} + +impl SatisfactionProblem for UndirectedFlowLowerBounds {} + +crate::declare_variants! { + default sat UndirectedFlowLowerBounds => "2^num_edges", +} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_model_example_specs() -> Vec { + vec![crate::example_db::specs::ModelExampleSpec { + id: "undirected_flow_lower_bounds", + instance: Box::new(UndirectedFlowLowerBounds::new( + SimpleGraph::new( + 6, + vec![(0, 1), (0, 2), (1, 3), (2, 3), (1, 4), (3, 5), (4, 5)], + ), + vec![2, 2, 2, 2, 1, 3, 2], + vec![1, 1, 0, 0, 1, 0, 1], + 0, + 5, + 3, + )), + optimal_config: vec![0, 0, 0, 0, 0, 0, 0], + optimal_value: serde_json::json!(true), + }] +} + +#[derive(Debug, Clone)] +struct ResidualEdge { + to: usize, + rev: usize, + capacity: u128, +} + +#[derive(Debug, Clone)] +struct ResidualNetwork { + adjacency: Vec>, +} + +impl ResidualNetwork { + fn new(num_vertices: usize) -> Self { + Self { + adjacency: vec![Vec::new(); num_vertices], + } + } + + fn add_edge(&mut self, from: usize, to: usize, capacity: u128) { + let reverse_at_to = self.adjacency[to].len(); + let reverse_at_from = self.adjacency[from].len(); + self.adjacency[from].push(ResidualEdge { + to, + rev: reverse_at_to, + capacity, + }); + self.adjacency[to].push(ResidualEdge { + to: from, + rev: reverse_at_from, + capacity: 0, + }); + } + + fn max_flow(&mut self, source: usize, sink: usize) -> u128 { + let mut total_flow = 0_u128; + + loop { + let mut parent: Vec> = vec![None; self.adjacency.len()]; + let mut queue = VecDeque::new(); + queue.push_back(source); + parent[source] = Some((source, usize::MAX)); + + while let Some(vertex) = queue.pop_front() { + if vertex == sink { + break; + } + + for (edge_index, edge) in self.adjacency[vertex].iter().enumerate() { + if edge.capacity == 0 || parent[edge.to].is_some() { + continue; + } + parent[edge.to] = Some((vertex, edge_index)); + queue.push_back(edge.to); + } + } + + if parent[sink].is_none() { + return total_flow; + } + + let mut path_flow = u128::MAX; + let mut vertex = sink; + while vertex != source { + let (prev, edge_index) = parent[vertex].expect("sink is reachable"); + path_flow = path_flow.min(self.adjacency[prev][edge_index].capacity); + vertex = prev; + } + + let mut vertex = sink; + while vertex != source { + let (prev, edge_index) = parent[vertex].expect("sink is reachable"); + let reverse_edge = self.adjacency[prev][edge_index].rev; + self.adjacency[prev][edge_index].capacity -= path_flow; + self.adjacency[vertex][reverse_edge].capacity += path_flow; + vertex = prev; + } + + total_flow += path_flow; + } + } +} + +fn add_lower_bounded_edge( + network: &mut ResidualNetwork, + balances: &mut [i128], + from: usize, + to: usize, + lower: u128, + upper: u128, +) -> bool { + if lower > upper { + return false; + } + + let residual = upper - lower; + if residual > 0 { + network.add_edge(from, to, residual); + } + + let Ok(lower_signed) = i128::try_from(lower) else { + return false; + }; + balances[from] -= lower_signed; + balances[to] += lower_signed; + true +} + +#[cfg(test)] +#[path = "../../unit_tests/models/graph/undirected_flow_lower_bounds.rs"] +mod tests; diff --git a/src/models/mod.rs b/src/models/mod.rs index d15d4f5d6..2ab34cc9c 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -29,7 +29,7 @@ pub use graph::{ OptimalLinearArrangement, PartitionIntoPathsOfLength2, PartitionIntoTriangles, RuralPostman, ShortestWeightConstrainedPath, SpinGlass, SteinerTree, SteinerTreeInGraphs, StrongConnectivityAugmentation, SubgraphIsomorphism, TravelingSalesman, - UndirectedTwoCommodityIntegralFlow, + UndirectedFlowLowerBounds, UndirectedTwoCommodityIntegralFlow, }; pub use misc::PartiallyOrderedKnapsack; pub use misc::{ diff --git a/src/unit_tests/models/graph/undirected_flow_lower_bounds.rs b/src/unit_tests/models/graph/undirected_flow_lower_bounds.rs new file mode 100644 index 000000000..9cb507b54 --- /dev/null +++ b/src/unit_tests/models/graph/undirected_flow_lower_bounds.rs @@ -0,0 +1,104 @@ +use super::*; +use crate::solvers::{BruteForce, Solver}; +use crate::topology::{Graph, SimpleGraph}; +use crate::traits::Problem; + +fn canonical_yes_instance() -> UndirectedFlowLowerBounds { + UndirectedFlowLowerBounds::new( + SimpleGraph::new( + 6, + vec![(0, 1), (0, 2), (1, 3), (2, 3), (1, 4), (3, 5), (4, 5)], + ), + vec![2, 2, 2, 2, 1, 3, 2], + vec![1, 1, 0, 0, 1, 0, 1], + 0, + 5, + 3, + ) +} + +fn canonical_no_instance() -> UndirectedFlowLowerBounds { + UndirectedFlowLowerBounds::new( + SimpleGraph::new(4, vec![(0, 1), (0, 2), (1, 3), (2, 3)]), + vec![2, 2, 1, 1], + vec![2, 2, 1, 1], + 0, + 3, + 2, + ) +} + +fn yes_orientation_config() -> Vec { + vec![0, 0, 0, 0, 0, 0, 0] +} + +#[test] +fn test_undirected_flow_lower_bounds_creation() { + let problem = canonical_yes_instance(); + assert_eq!(problem.graph().num_vertices(), 6); + assert_eq!(problem.graph().num_edges(), 7); + assert_eq!(problem.capacities(), &[2, 2, 2, 2, 1, 3, 2]); + assert_eq!(problem.lower_bounds(), &[1, 1, 0, 0, 1, 0, 1]); + assert_eq!(problem.source(), 0); + assert_eq!(problem.sink(), 5); + assert_eq!(problem.requirement(), 3); + assert_eq!(problem.num_vertices(), 6); + assert_eq!(problem.num_edges(), 7); + assert_eq!(problem.dims(), vec![2; 7]); +} + +#[test] +fn test_undirected_flow_lower_bounds_evaluation_yes() { + let problem = canonical_yes_instance(); + let config = yes_orientation_config(); + assert!(problem.evaluate(&config)); + assert!(problem.is_valid_solution(&config)); +} + +#[test] +fn test_undirected_flow_lower_bounds_evaluation_no() { + let problem = canonical_no_instance(); + assert!(!problem.evaluate(&[0, 0, 0, 0])); + assert!(BruteForce::new().find_satisfying(&problem).is_none()); +} + +#[test] +fn test_undirected_flow_lower_bounds_rejects_wrong_config_length() { + let problem = canonical_yes_instance(); + let mut config = yes_orientation_config(); + config.pop(); + assert!(!problem.evaluate(&config)); +} + +#[test] +fn test_undirected_flow_lower_bounds_serialization() { + let problem = canonical_yes_instance(); + let value = serde_json::to_value(&problem).unwrap(); + let restored: UndirectedFlowLowerBounds = serde_json::from_value(value).unwrap(); + assert_eq!(restored.graph(), problem.graph()); + assert_eq!(restored.capacities(), problem.capacities()); + assert_eq!(restored.lower_bounds(), problem.lower_bounds()); + assert_eq!(restored.source(), problem.source()); + assert_eq!(restored.sink(), problem.sink()); + assert_eq!(restored.requirement(), problem.requirement()); +} + +#[test] +fn test_undirected_flow_lower_bounds_solver_yes() { + let problem = canonical_yes_instance(); + let solution = BruteForce::new() + .find_satisfying(&problem) + .expect("expected a satisfying orientation"); + assert!(problem.evaluate(&solution)); + assert_eq!(solution.len(), problem.num_edges()); +} + +#[test] +fn test_undirected_flow_lower_bounds_paper_example() { + let problem = canonical_yes_instance(); + let config = yes_orientation_config(); + assert!(problem.evaluate(&config)); + + let all = BruteForce::new().find_all_satisfying(&problem); + assert!(all.contains(&config)); +} From f5fb41dcd24bfe998e7c210dec6105799b3e423d Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Sun, 22 Mar 2026 05:27:28 +0800 Subject: [PATCH 3/6] chore: remove plan file after implementation --- ...2026-03-22-undirected-flow-lower-bounds.md | 302 ------------------ 1 file changed, 302 deletions(-) delete mode 100644 docs/plans/2026-03-22-undirected-flow-lower-bounds.md diff --git a/docs/plans/2026-03-22-undirected-flow-lower-bounds.md b/docs/plans/2026-03-22-undirected-flow-lower-bounds.md deleted file mode 100644 index 3ca9ca2ec..000000000 --- a/docs/plans/2026-03-22-undirected-flow-lower-bounds.md +++ /dev/null @@ -1,302 +0,0 @@ -# UndirectedFlowLowerBounds Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Add the `UndirectedFlowLowerBounds` graph model, wire it into the registry/CLI/example-db/paper, and verify the canonical YES/NO instances from issue #294. - -**Architecture:** Represent the problem as an undirected `SimpleGraph` plus per-edge capacities/lower bounds, source/sink terminals, and a required net flow. Use one binary configuration variable per edge to choose its orientation relative to the stored edge order; `evaluate()` then checks whether that orientation admits a feasible directed lower-bound flow with net sink inflow at least `R` by reducing to a feasible circulation instance and solving the resulting max-flow subproblem. Keep rule work out of scope for this PR; related rule issues already exist (`#367` inbound, `#735` outbound). - -**Tech Stack:** Rust workspace (`problemreductions`, `problemreductions-cli`), serde/inventory registry metadata, existing brute-force solver, Typst paper, GitHub pipeline scripts. - ---- - -## Batch 1: Model, Tests, Registry, CLI, Example DB - -### Task 1: Add failing model tests first - -**Files:** -- Create: `src/unit_tests/models/graph/undirected_flow_lower_bounds.rs` -- Reference: `src/unit_tests/models/graph/undirected_two_commodity_integral_flow.rs` -- Reference: `src/models/graph/undirected_two_commodity_integral_flow.rs` - -**Step 1: Write the failing tests** - -Cover these behaviors before production code exists: -- Constructor/accessors/dims shape for the issue YES instance -- `evaluate()` accepts the canonical YES orientation -- `evaluate()` rejects the canonical NO instance -- Serialization round-trip -- Brute-force solver finds a satisfying orientation on the YES instance and none on the NO instance -- Paper example test uses the same canonical YES instance - -Use the issue’s fixed examples: -- YES graph edges: `(0,1) (0,2) (1,3) (2,3) (1,4) (3,5) (4,5)` -- YES lower bounds: `1,1,0,0,1,0,1` -- YES capacities: `2,2,2,2,1,3,2` -- YES source/sink/requirement: `0,5,3` -- YES orientation config: `[0,0,0,0,0,0,0]` -- NO graph edges: `(0,1) (0,2) (1,3) (2,3)` -- NO lower bounds: `2,2,1,1` -- NO capacities: `2,2,1,1` -- NO source/sink/requirement: `0,3,2` - -**Step 2: Run the targeted test to verify RED** - -Run: -```bash -cargo test undirected_flow_lower_bounds --lib -``` - -Expected: -- FAIL to compile because `UndirectedFlowLowerBounds` is not registered yet. - -**Step 3: Commit the red test scaffold** - -```bash -git add src/unit_tests/models/graph/undirected_flow_lower_bounds.rs -git commit -m "test: add UndirectedFlowLowerBounds model coverage" -``` - -### Task 2: Implement the model and binary-orientation feasibility check - -**Files:** -- Create: `src/models/graph/undirected_flow_lower_bounds.rs` -- Modify: `src/models/graph/mod.rs` -- Modify: `src/models/mod.rs` -- Modify: `src/lib.rs` - -**Step 1: Implement the model skeleton** - -Add `inventory::submit!` metadata plus: -- `ProblemSchemaEntry` with fields `graph`, `capacities`, `lower_bounds`, `source`, `sink`, `requirement` -- `ProblemSizeFieldEntry` for `num_vertices`, `num_edges` -- `#[derive(Debug, Clone, Serialize, Deserialize)]` struct using `SimpleGraph` -- Constructor validation: - - capacities/lower-bounds lengths match `graph.num_edges()` - - `source`/`sink` are in range - - `requirement >= 1` - - every `lower_bounds[i] <= capacities[i]` -- Accessors: `graph()`, `capacities()`, `lower_bounds()`, `source()`, `sink()`, `requirement()`, `num_vertices()`, `num_edges()` - -**Step 2: Define the configuration space** - -Implement: -- `dims() -> vec![2; num_edges]` -- `variant() -> crate::variant_params![]` -- `declare_variants! { default sat UndirectedFlowLowerBounds => "2^num_edges", }` - -Interpret each config bit as: -- `0`: orient stored edge `(u,v)` as `u -> v` -- `1`: orient it as `v -> u` - -Document this clearly in the model doc comment and in the paper/example text because it differs from the issue’s earlier “2|E| flow variables” draft. - -**Step 3: Implement the feasibility algorithm** - -Inside the model file: -- Convert the chosen orientation into a directed lower/upper-bound network -- Add a synthetic arc `sink -> source` with lower bound `requirement` and upper bound equal to the sum of edge capacities -- Reduce lower-bound feasibility to ordinary max-flow by: - - replacing each edge capacity with `upper - lower` - - computing vertex imbalances from the lower bounds - - adding super-source/super-sink edges for positive/negative imbalance -- Implement a small local Edmonds-Karp max-flow helper (keep it private to the model file unless later reuse is obvious) -- Return `true` iff the max-flow saturates every edge from the super-source - -Guardrails: -- Use `u128` / checked arithmetic for aggregate capacities and convert safely back to the max-flow scalar type used internally -- Treat wrong-length configs as `false` -- Do not depend on optional ILP features; the model must work in default and non-ILP builds - -**Step 4: Register the model** - -Wire the new type into: -- `src/models/graph/mod.rs` module list, re-export list, and `canonical_model_example_specs()` chain -- `src/models/mod.rs` graph re-export list -- `src/lib.rs` prelude exports - -**Step 5: Run the targeted test to verify GREEN** - -Run: -```bash -cargo test undirected_flow_lower_bounds --lib -``` - -Expected: -- PASS for the new model tests - -**Step 6: Commit** - -```bash -git add src/models/graph/undirected_flow_lower_bounds.rs src/models/graph/mod.rs src/models/mod.rs src/lib.rs src/unit_tests/models/graph/undirected_flow_lower_bounds.rs -git commit -m "feat: add UndirectedFlowLowerBounds model" -``` - -### Task 3: Add CLI discovery and creation support - -**Files:** -- Modify: `problemreductions-cli/src/problem_name.rs` -- Modify: `problemreductions-cli/src/cli.rs` -- Modify: `problemreductions-cli/src/commands/create.rs` - -**Step 1: Extend CLI flags and help text** - -Add: -- `CreateArgs.lower_bounds: Option` with `--lower-bounds` -- help-table line for `UndirectedFlowLowerBounds` -- example command using the issue YES instance -- `all_data_flags_empty()` coverage for `lower_bounds` - -**Step 2: Add alias + parser support** - -Update `resolve_alias()` with the lowercase pass-through mapping: -- `"undirectedflowlowerbounds" => "UndirectedFlowLowerBounds".to_string()` - -Do not invent a short alias. - -**Step 3: Add `pred create` support** - -Add a `create()` arm using: -- `--graph` -- `--capacities` -- `--lower-bounds` -- `--source` -- `--sink` -- `--requirement` - -Implement a small parsing helper mirroring `parse_capacities()` so: -- the list length matches `graph.num_edges()` -- every lower bound parses as `u64` -- constructor-level `lower <= capacity` validation remains the source of truth - -**Step 4: Add focused CLI tests** - -In `problemreductions-cli/src/commands/create.rs` tests, add: -- JSON creation test for the YES instance -- missing `--lower-bounds` usage error test - -In `problemreductions-cli/src/problem_name.rs` tests, add: -- alias resolution pass-through test for `UndirectedFlowLowerBounds` - -**Step 5: Run targeted CLI tests** - -Run: -```bash -cargo test -p problemreductions-cli undirected_flow_lower_bounds -``` - -Expected: -- PASS for the new CLI-specific tests - -**Step 6: Commit** - -```bash -git add problemreductions-cli/src/problem_name.rs problemreductions-cli/src/cli.rs problemreductions-cli/src/commands/create.rs -git commit -m "feat: add CLI support for UndirectedFlowLowerBounds" -``` - -## Batch 2: Paper Entry - -### Task 4: Add canonical example and paper documentation - -**Files:** -- Modify: `src/models/graph/undirected_flow_lower_bounds.rs` -- Modify: `docs/paper/reductions.typ` -- Reference: `docs/paper/reductions.typ` `problem-def("UndirectedTwoCommodityIntegralFlow")` - -**Step 1: Add canonical model example metadata** - -In the model file, add `canonical_model_example_specs()` gated by `#[cfg(feature = "example-db")]` using the issue YES instance: -- instance = the 6-vertex YES graph -- `optimal_config = vec![0, 0, 0, 0, 0, 0, 0]` -- `optimal_value = serde_json::json!(true)` - -Keep the canonical example aligned with the paper example and the unit test. - -**Step 2: Add the paper display name and `problem-def`** - -Update `docs/paper/reductions.typ` with: -- display-name entry for `UndirectedFlowLowerBounds` -- a `problem-def("UndirectedFlowLowerBounds")` section - -The paper entry should explain: -- the formal definition from issue #294 / Garey-Johnson ND37 -- why the implementation uses edge orientations as the configuration space -- the `2^m` brute-force-over-orientations interpretation -- the canonical YES example, including one explicit feasible flow witness derived from the chosen orientation -- `pred-commands()` using `pred create --example UndirectedFlowLowerBounds` - -Do not claim the ILP rule is implemented here; mention it as a natural outbound reduction already tracked separately. - -**Step 3: Run paper verification** - -Run: -```bash -make paper -``` - -Expected: -- PASS with no Typst errors - -**Step 4: Re-run the paper example unit test** - -Run: -```bash -cargo test test_undirected_flow_lower_bounds_paper_example --lib -``` - -Expected: -- PASS - -**Step 5: Commit** - -```bash -git add docs/paper/reductions.typ src/models/graph/undirected_flow_lower_bounds.rs -git commit -m "docs: add UndirectedFlowLowerBounds paper entry" -``` - -## Final Verification - -### Task 5: Run full verification and clean up - -**Files:** -- Verify: workspace root - -**Step 1: Run repository verification** - -Run: -```bash -make test -make clippy -``` - -Expected: -- PASS for the full workspace - -**Step 2: Inspect the tree** - -Run: -```bash -git status --short -``` - -Expected: -- Clean tree, or only ignored/generated docs outputs - -**Step 3: Final commit if needed** - -If verification changed tracked files: -```bash -git add -A -git commit -m "chore: finalize UndirectedFlowLowerBounds verification fixes" -``` - -**Step 4: Push** - -Run: -```bash -git push -``` - -Expected: -- Branch updated and ready for review pipeline From 8a6accf2bd5a926374943edfa4cf8aa7fe5712a2 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Sun, 22 Mar 2026 14:40:41 +0800 Subject: [PATCH 4/6] Merge origin/main and fix formatting Co-Authored-By: Claude Opus 4.6 (1M context) --- problemreductions-cli/src/commands/create.rs | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index 8e893599d..b4041f8fe 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -4467,9 +4467,10 @@ fn validate_vertex_index( /// 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!("This problem requires --capacities\n\n{usage}") - })?; + let capacities = args + .capacities + .as_deref() + .ok_or_else(|| anyhow::anyhow!("This problem requires --capacities\n\n{usage}"))?; let capacities: Vec = capacities .split(',') .map(|s| { @@ -6414,7 +6415,10 @@ mod tests { assert_eq!(json["data"]["source"], 0); assert_eq!(json["data"]["sink"], 5); assert_eq!(json["data"]["requirement"], 3); - assert_eq!(json["data"]["lower_bounds"], serde_json::json!([1, 1, 0, 0, 1, 0, 1])); + assert_eq!( + json["data"]["lower_bounds"], + serde_json::json!([1, 1, 0, 0, 1, 0, 1]) + ); } #[test] @@ -6517,10 +6521,9 @@ mod tests { }; let err = create(&args, &out).unwrap_err(); - assert!( - err.to_string() - .contains("UndirectedFlowLowerBounds requires --lower-bounds") - ); + assert!(err + .to_string() + .contains("UndirectedFlowLowerBounds requires --lower-bounds")); } fn empty_args() -> CreateArgs { From a893b0caaa00a15c985f3d7196efb11976ef2336 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Sun, 22 Mar 2026 15:11:32 +0800 Subject: [PATCH 5/6] 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) --- problemreductions-cli/src/commands/create.rs | 14 ++++++++++++++ src/models/graph/undirected_flow_lower_bounds.rs | 1 + 2 files changed, 15 insertions(+) diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index b4041f8fe..fe62a2bd8 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -1511,6 +1511,20 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { 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)?; + 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 + ); + } + } let num_vertices = graph.num_vertices(); let source_1 = args.source_1.ok_or_else(|| { anyhow::anyhow!("UndirectedTwoCommodityIntegralFlow requires --source-1\n\n{usage}") diff --git a/src/models/graph/undirected_flow_lower_bounds.rs b/src/models/graph/undirected_flow_lower_bounds.rs index 6b39fd3dc..0db1709d9 100644 --- a/src/models/graph/undirected_flow_lower_bounds.rs +++ b/src/models/graph/undirected_flow_lower_bounds.rs @@ -84,6 +84,7 @@ impl UndirectedFlowLowerBounds { sink < num_vertices, "sink must be less than num_vertices ({num_vertices})" ); + assert!(source != sink, "source and sink must be distinct"); assert!(requirement >= 1, "requirement must be at least 1"); for (edge_index, (&lower, &upper)) in lower_bounds.iter().zip(&capacities).enumerate() { From 57c645862f2c0961e7f2742ed7aba465dcfcc66c Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Sun, 22 Mar 2026 15:40:49 +0800 Subject: [PATCH 6/6] Fix duplicate test from merge conflict resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- problemreductions-cli/src/commands/create.rs | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index 68d6334cc..4abc75010 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -6573,21 +6573,17 @@ mod tests { } #[test] - fn test_create_undirected_flow_lower_bounds_requires_lower_bounds() { + fn test_create_longest_path_requires_edge_lengths() { let cli = Cli::try_parse_from([ "pred", "create", - "UndirectedFlowLowerBounds", + "LongestPath", "--graph", - "0-1,0-2,1-3,2-3,1-4,3-5,4-5", - "--capacities", - "2,2,2,2,1,3,2", - "--source", + "0-1,1-2", + "--source-vertex", "0", - "--sink", - "5", - "--requirement", - "3", + "--target-vertex", + "2", ]) .unwrap(); let out = OutputConfig {