From ebd92f5ee3bd05fb38eeb4b83ef1b017f9299b8d Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Sat, 21 Mar 2026 23:56:47 +0800 Subject: [PATCH 1/4] Add plan for #288: [Model] LongestPath --- docs/plans/2026-03-21-longest-path.md | 383 ++++++++++++++++++++++++++ 1 file changed, 383 insertions(+) create mode 100644 docs/plans/2026-03-21-longest-path.md diff --git a/docs/plans/2026-03-21-longest-path.md b/docs/plans/2026-03-21-longest-path.md new file mode 100644 index 000000000..cfb24503c --- /dev/null +++ b/docs/plans/2026-03-21-longest-path.md @@ -0,0 +1,383 @@ +# LongestPath Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Implement issue #288 by adding the `LongestPath` model, wiring brute-force and ILP solving support, registering CLI/example-db integration, and documenting the model and new ILP reduction in the paper. + +**Architecture:** Model `LongestPath` as an edge-selection optimization problem, matching the issue's optimization framing from the `fix-issue` comment. A configuration is valid exactly when the selected undirected edges form one simple path from `source_vertex` to `target_vertex`; the metric is the total selected edge length. Because the issue explicitly opts into ILP solving, add a direct `LongestPath -> ILP` reduction with a path-ordering formulation that forbids disconnected cycles and extracts the chosen edge set back into the source configuration. + +**Tech Stack:** Rust workspace, serde/inventory schema registration, registry-backed CLI loading, `BruteForce` + `ILPSolver`, Typst paper, example-db exports. + +--- + +## Issue Packet Summary + +- Issue: `#288 [Model] LongestPath` +- Issue state: open, labeled `Good` +- Existing PRs: none (`action = create-pr`) +- Associated rule already on the board: `#359 [Rule] HAMILTONIAN PATH BETWEEN TWO VERTICES to LONGEST PATH` +- Maintainer guidance from comments: + - Keep `LongestPath` as an optimization problem (`Direction::Maximize`) + - Use the single verified example instance with optimum `20` + - Record ILP support instead of leaving it unspecified + +## Concrete Design Choices + +1. **Problem shape** + - File: `src/models/graph/longest_path.rs` + - Type: `LongestPath` + - Fields: + - `graph: G` + - `edge_lengths: Vec` + - `source_vertex: usize` + - `target_vertex: usize` + - Variants: + - `default opt LongestPath => "num_vertices * 2^num_vertices"` + - `opt LongestPath => "num_vertices * 2^num_vertices"` + +2. **Configuration semantics** + - One binary variable per edge, in graph-edge order + - Valid iff selected edges induce a single undirected simple path from `source_vertex` to `target_vertex` + - `source_vertex == target_vertex` should accept only the empty edge set and evaluate to `Valid(0)` + - Any repeated-edge, branching, disconnected, cyclic, or wrong-endpoint selection is `Invalid` + +3. **Example of record** + - Use the corrected issue instance with 7 vertices, 10 edges, `s = 0`, `t = 6` + - Optimal path edges correspond to `0 -> 1 -> 3 -> 2 -> 4 -> 5 -> 6` + - Optimal objective value: `20` + - Include a suboptimal valid path worth `17` + +4. **ILP solver path** + - File: `src/rules/longestpath_ilp.rs` + - Restrict initial ILP reduction to `LongestPath` + - Use binary directed-arc variables plus visited/order variables so the selected solution is a single simple `s-t` path, not a path plus detached cycles + - Objective: maximize total selected edge length + - Extraction: recover the selected undirected source edges from the ILP solution + +## Batch 1: Implementation + +### Task 1: Write the failing model tests first + +**Files:** +- Create: `src/unit_tests/models/graph/longest_path.rs` + +**Step 1: Add tests that define the required behavior** + +Cover at least these cases: +- `test_longest_path_creation` for constructor/accessors/dimensions +- `test_longest_path_evaluate_valid_and_invalid_configs` +- `test_longest_path_bruteforce_finds_issue_optimum` +- `test_longest_path_serialization` +- `test_longest_path_source_equals_target_only_allows_empty_path` +- `test_longestpath_paper_example` + +Use the corrected issue fixture as the canonical positive instance. + +**Step 2: Run the new test target and confirm RED** + +Run: + +```bash +cargo test longest_path --lib +``` + +Expected: compile failure because `LongestPath` does not exist yet. + +**Step 3: Commit the failing-test checkpoint only if the tree is intentionally staged that way** + +Do not force an early commit if the repo workflow is smoother with model code immediately after the RED check. + +### Task 2: Implement and register the model + +**Files:** +- Create: `src/models/graph/longest_path.rs` +- Modify: `src/models/graph/mod.rs` +- Modify: `src/models/mod.rs` +- Modify: `src/lib.rs` + +**Step 1: Implement `LongestPath`** + +Include: +- `inventory::submit!` schema metadata +- constructor validation: + - `edge_lengths.len() == graph.num_edges()` + - vertex indices in range + - positive lengths for weighted variants +- accessors: + - `graph()` + - `edge_lengths()` + - `set_lengths()` + - `source_vertex()` + - `target_vertex()` + - `num_vertices()` + - `num_edges()` + - `is_weighted()` + - `is_valid_solution()` +- `Problem` impl with `Metric = SolutionSize` +- `OptimizationProblem` impl with `Direction::Maximize` +- internal validation helper that checks the selected edge set forms exactly one simple `s-t` path +- `declare_variants!` +- `canonical_model_example_specs()` +- test link: + - `#[cfg(test)] #[path = "../../unit_tests/models/graph/longest_path.rs"] mod tests;` + +**Step 2: Register the model everywhere it must appear** + +Update: +- `src/models/graph/mod.rs` + - module declaration + - public re-export + - example-db spec chain +- `src/models/mod.rs` + - graph re-export list +- `src/lib.rs` + - `prelude` exports if needed by current conventions + +**Step 3: Re-run the model tests and get GREEN** + +Run: + +```bash +cargo test longest_path --lib +``` + +Expected: the new model tests pass. + +### Task 3: Add ILP rule support with tests first + +**Files:** +- Create: `src/unit_tests/rules/longestpath_ilp.rs` +- Create: `src/rules/longestpath_ilp.rs` +- Modify: `src/rules/mod.rs` + +**Step 1: Add failing rule tests** + +Cover: +- reduction builds an `ILP` with nonempty objective/constraints +- `ILPSolver` solution maps back to a valid `LongestPath` configuration +- reduced optimum matches brute-force optimum on the issue example +- extraction rejects or avoids detached cycles by construction +- path metadata is discoverable from the reduction graph if relevant + +**Step 2: Run the ILP-focused test target and confirm RED** + +Run: + +```bash +cargo test longestpath_ilp --lib --features ilp-solver +``` + +Expected: compile failure because the rule module does not exist yet. + +**Step 3: Implement `LongestPath -> ILP`** + +Model the path with: +- directed-arc binary variables for each undirected edge orientation +- vertex-visited binary variables +- integer order variables (or equivalent MTZ-style progression variables) + +Enforce: +- source has one outgoing selected arc and no incoming selected arc +- target has one incoming selected arc and no outgoing selected arc +- every nonterminal visited vertex has one incoming and one outgoing selected arc +- every unvisited nonterminal vertex has zero selected incident arcs +- each undirected edge is used in at most one direction +- ordering constraints eliminate directed cycles disconnected from the main path + +Objective: +- maximize total selected edge length + +Extraction: +- map whichever arc orientation is selected for each undirected edge back to the edge-indexed source config + +**Step 4: Register and re-run the ILP tests** + +Run: + +```bash +cargo test longestpath_ilp --lib --features ilp-solver +``` + +Expected: rule tests pass. + +### Task 4: Wire CLI creation, aliases, and example-db coverage + +**Files:** +- Modify: `problemreductions-cli/src/problem_name.rs` +- Modify: `problemreductions-cli/src/commands/create.rs` +- Modify: `problemreductions-cli/src/cli.rs` +- Modify: `src/models/graph/mod.rs` + +**Step 1: Add CLI coverage tests first** + +Add tests near the existing `create.rs` test module for: +- successful JSON creation from: + - `pred create LongestPath --graph ... --edge-lengths ... --source-vertex 0 --target-vertex 6` +- missing required `--edge-lengths` +- misuse of `--weights` instead of `--edge-lengths` + +**Step 2: Run the targeted CLI tests and confirm RED** + +Run: + +```bash +cargo test create_longest_path --package problemreductions-cli +``` + +Expected: failures until the CLI path exists. + +**Step 3: Implement CLI integration** + +Update: +- `problemreductions-cli/src/problem_name.rs` + - allow lowercase alias resolution for `longestpath` + - do not invent a short literature acronym +- `problemreductions-cli/src/commands/create.rs` + - add example string + - add create arm parsing `--graph`, `--edge-lengths`, `--source-vertex`, `--target-vertex` + - emit helpful errors for missing fields and wrong flag families +- `problemreductions-cli/src/cli.rs` + - ensure help text lists `LongestPath` + - ensure `all_data_flags_empty()` already covers the flags this model needs; change only if a new flag is introduced + +**Step 4: Re-run the CLI tests** + +Run: + +```bash +cargo test create_longest_path --package problemreductions-cli +``` + +Expected: the new CLI tests pass. + +### Task 5: Run broader implementation verification for Batch 1 + +**Files:** +- No new files; verification only + +**Step 1: Run focused workspace tests** + +Run: + +```bash +cargo test longest_path --workspace --features ilp-solver +``` + +**Step 2: Run the standard repo checks for code touched so far** + +Run: + +```bash +make test +make clippy +``` + +If `make test` is too expensive while iterating, keep using the targeted cargo invocations until the batch is stable, then run the full commands before leaving Batch 1. + +## Batch 2: Paper and Documentation + +### Task 6: Add paper coverage for both the model and the new ILP rule + +**Files:** +- Modify: `docs/paper/reductions.typ` + +**Step 1: Add the display name entry** + +Register: + +```text +"LongestPath": [Longest Path], +``` + +**Step 2: Add `problem-def("LongestPath")`** + +The entry should: +- define the optimization version explicitly +- explain that the decision form is recovered by thresholding the optimum +- cite Garey & Johnson plus the exact-algorithm references already verified in the issue comments +- use the canonical 7-vertex example with highlighted optimal path +- include `pred-commands()` derived from `load-model-example("LongestPath")` + +**Step 3: Add a `reduction-rule("LongestPath", "ILP", ...)` entry** + +Because Batch 1 registers a real reduction edge, the paper must cover it. Document: +- the path-selection variables +- degree / flow / ordering constraints +- why every feasible ILP solution corresponds to a simple `s-t` path +- why the maximized objective equals the path length + +Use exported example data if the existing helper flow supports it; otherwise write the example from the same canonical issue fixture. + +**Step 4: Build the paper** + +Run: + +```bash +make paper +``` + +Expected: no completeness warnings for `LongestPath` or `LongestPath -> ILP`. + +## Final Verification and Cleanup + +### Task 7: Full verification before pushing + +**Step 1: Run the highest-signal verification commands** + +Run: + +```bash +make test +make clippy +make paper +``` + +If runtime permits and coverage impact is uncertain, also run: + +```bash +make coverage +``` + +**Step 2: Inspect the working tree** + +Run: + +```bash +git status --short +``` + +Expected: +- only intended tracked changes remain +- ignored generated exports under `docs/src/reductions/` stay unstaged + +**Step 3: Keep commits coherent** + +Recommended commit sequence: +- `Add plan for #288: [Model] LongestPath` +- `Implement #288: [Model] LongestPath` +- `chore: remove plan file after implementation` + +## Expected File Inventory + +- `src/models/graph/longest_path.rs` +- `src/unit_tests/models/graph/longest_path.rs` +- `src/rules/longestpath_ilp.rs` +- `src/unit_tests/rules/longestpath_ilp.rs` +- `src/models/graph/mod.rs` +- `src/models/mod.rs` +- `src/lib.rs` +- `problemreductions-cli/src/problem_name.rs` +- `problemreductions-cli/src/commands/create.rs` +- `problemreductions-cli/src/cli.rs` +- `docs/paper/reductions.typ` + +## References To Use During Implementation + +- Issue packet for `#288`, including the `fix-issue` comment and the quality-check comment +- `src/models/graph/maximum_independent_set.rs` +- `src/models/graph/shortest_weight_constrained_path.rs` +- `src/models/graph/traveling_salesman.rs` +- `src/rules/travelingsalesman_ilp.rs` +- `src/unit_tests/models/graph/hamiltonian_path.rs` +- `src/unit_tests/rules/maximumclique_ilp.rs` +- `docs/paper/reductions.typ` entries for `HamiltonianPath` and existing `-> ILP` rules From 652604766f49d91c2a8fb844db491867c5df271e Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Sun, 22 Mar 2026 00:20:41 +0800 Subject: [PATCH 2/4] Implement #288: [Model] LongestPath --- docs/paper/reductions.typ | 94 ++++++ problemreductions-cli/src/cli.rs | 1 + problemreductions-cli/src/commands/create.rs | 153 +++++++++- src/lib.rs | 2 +- src/models/graph/longest_path.rs | 303 +++++++++++++++++++ src/models/graph/mod.rs | 4 + src/models/mod.rs | 15 +- src/rules/longestpath_ilp.rs | 196 ++++++++++++ src/rules/mod.rs | 3 + src/unit_tests/models/graph/longest_path.rs | 177 +++++++++++ src/unit_tests/rules/longestpath_ilp.rs | 108 +++++++ 11 files changed, 1046 insertions(+), 10 deletions(-) create mode 100644 src/models/graph/longest_path.rs create mode 100644 src/rules/longestpath_ilp.rs create mode 100644 src/unit_tests/models/graph/longest_path.rs create mode 100644 src/unit_tests/rules/longestpath_ilp.rs diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index 83c5c5f7f..a44a961cb 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -72,6 +72,7 @@ "HamiltonianCircuit": [Hamiltonian Circuit], "BiconnectivityAugmentation": [Biconnectivity Augmentation], "HamiltonianPath": [Hamiltonian Path], + "LongestPath": [Longest Path], "ShortestWeightConstrainedPath": [Shortest Weight-Constrained Path], "UndirectedTwoCommodityIntegralFlow": [Undirected Two-Commodity Integral Flow], "LengthBoundedDisjointPaths": [Length-Bounded Disjoint Paths], @@ -994,6 +995,65 @@ is feasible: each set induces a connected subgraph, the component weights are $2 ] ] } +#{ + let x = load-model-example("LongestPath") + let nv = graph-num-vertices(x.instance) + let edges = x.instance.graph.edges + let lengths = x.instance.edge_lengths + let s = x.instance.source_vertex + let t = x.instance.target_vertex + let path-config = x.optimal_config + let path-order = (0, 1, 3, 2, 4, 5, 6) + let path-edges = edges.enumerate().filter(((idx, _)) => path-config.at(idx) == 1).map(((idx, e)) => e) + [ + #problem-def("LongestPath")[ + Given an undirected graph $G = (V, E)$ with positive edge lengths $l: E -> ZZ^+$ and designated vertices $s, t in V$, find a simple path $P$ from $s$ to $t$ maximizing $sum_(e in P) l(e)$. + ][ + Longest Path is problem ND29 in Garey & Johnson @garey1979. It bridges weighted routing and Hamiltonicity: when every edge has unit length, the optimum reaches $|V| - 1$ exactly when there is a Hamiltonian path from $s$ to $t$. The implementation catalog records the classical subset-DP exact bound $O(|V| dot 2^|V|)$, in the style of Held--Karp dynamic programming @heldkarp1962. For the parameterized $k$-path version, color-coding gives randomized $2^(O(k)) |V|^(O(1))$ algorithms @alon1995. + + Variables: one binary value per edge. A configuration is valid exactly when the selected edges form a single simple $s$-$t$ path; otherwise the metric is `Invalid`. For valid selections, the metric is the total selected edge length. + + *Example.* Consider the graph on #nv vertices with source $s = v_#s$ and target $t = v_#t$. The highlighted path $#path-order.map(v => $v_#v$).join($arrow$)$ uses edges ${#path-edges.map(((u, v)) => $(v_#u, v_#v)$).join(", ")}$, so its total length is $3 + 4 + 1 + 5 + 3 + 4 = 20$. Another valid path, $v_0 arrow v_2 arrow v_4 arrow v_5 arrow v_3 arrow v_1 arrow v_6$, has total length $17$, so the highlighted path is strictly better. + + #pred-commands( + "pred create --example LongestPath -o longest-path.json", + "pred solve longest-path.json", + "pred evaluate longest-path.json --config " + x.optimal_config.map(str).join(","), + ) + + #figure({ + let blue = graph-colors.at(0) + let gray = luma(200) + let verts = ((0, 1.2), (1.2, 2.0), (1.2, 0.4), (2.5, 2.0), (2.5, 0.4), (3.8, 1.2), (5.0, 1.2)) + canvas(length: 1cm, { + import draw: * + for (idx, (u, v)) in edges.enumerate() { + let on-path = path-config.at(idx) == 1 + g-edge(verts.at(u), verts.at(v), stroke: if on-path { 2pt + blue } else { 1pt + gray }) + let mx = (verts.at(u).at(0) + verts.at(v).at(0)) / 2 + let my = (verts.at(u).at(1) + verts.at(v).at(1)) / 2 + let dx = if idx == 0 or idx == 2 { 0 } else if idx == 1 or idx == 4 { -0.18 } else if idx == 5 or idx == 6 { 0.18 } else if idx == 8 { 0 } else { 0.16 } + let dy = if idx == 0 or idx == 2 or idx == 5 or idx == 8 { 0.18 } else if idx == 1 or idx == 4 or idx == 6 { -0.18 } else if idx == 3 { 0 } else { 0.16 } + draw.content( + (mx + dx, my + dy), + text(7pt, fill: luma(80))[#str(int(lengths.at(idx)))] + ) + } + for (k, pos) in verts.enumerate() { + let on-path = path-order.any(v => v == k) + g-node(pos, name: "v" + str(k), + fill: if on-path { blue } else { white }, + label: if on-path { text(fill: white)[$v_#k$] } else { [$v_#k$] }) + } + content((0, 1.55), text(8pt)[$s$]) + content((5.0, 1.55), text(8pt)[$t$]) + }) + }, + caption: [Longest Path instance with edge lengths shown on the edges. The highlighted path from $s = v_0$ to $t = v_6$ has total length 20.], + ) + ] + ] +} #{ let x = load-model-example("UndirectedTwoCommodityIntegralFlow") let satisfying_count = 1 @@ -6548,6 +6608,40 @@ The following reductions to Integer Linear Programming are straightforward formu #let tsp_qubo = load-example("TravelingSalesman", "QUBO") #let tsp_qubo_sol = tsp_qubo.solutions.at(0) +#let lp_ilp = load-example("LongestPath", "ILP") +#let lp_ilp_sol = lp_ilp.solutions.at(0) +#reduction-rule("LongestPath", "ILP", + example: true, + example-caption: [The 3-vertex path $0 arrow 1 arrow 2$ encoded as a 7-variable ILP with optimum 5.], + extra: [ + #pred-commands( + "pred create --example LongestPath -o longest-path.json", + "pred reduce longest-path.json --to " + target-spec(lp_ilp) + " -o bundle.json", + "pred solve bundle.json", + "pred evaluate longest-path.json --config " + lp_ilp_sol.source_config.map(str).join(","), + ) + *Step 1 -- Orient each undirected edge.* The canonical witness has two source edges, so the reduction creates four directed-arc variables. The optimal witness sets $x_(0,1) = 1$ and $x_(1,2) = 1$, leaving the reverse directions at 0.\ + + *Step 2 -- Add order variables.* The target has #lp_ilp.target.instance.num_vars variables and #lp_ilp.target.instance.constraints.len() constraints in total. The order block $bold(o) = (#lp_ilp_sol.target_config.slice(4, 7).map(str).join(", "))$ certifies the increasing path positions $0 < 1 < 2$.\ + + *Step 3 -- Check the objective.* The target witness $bold(z) = (#lp_ilp_sol.target_config.map(str).join(", "))$ selects lengths $2$ and $3$, so the ILP objective is $5$, matching the source optimum. #sym.checkmark + ], +)[ + A simple $s$-$t$ path can be represented as one unit of directed flow from $s$ to $t$ on oriented copies of the undirected edges. Integer order variables then force the selected arcs to move strictly forward, which forbids detached directed cycles. +][ + _Construction._ For graph $G = (V, E)$ with $n = |V|$ and $m = |E|$: + + _Variables:_ For each undirected edge ${u, v} in E$, introduce two binary arc variables $x_(u,v), x_(v,u) in {0, 1}$. Interpretation: $x_(u,v) = 1$ iff the path traverses edge ${u, v}$ from $u$ to $v$. For each vertex $v in V$, add an integer order variable $o_v in {0, dots, n-1}$. Total: $2m + n$ variables. + + _Constraints:_ (1) Flow balance: $sum_(w : {v,w} in E) x_(v,w) - sum_(u : {u,v} in E) x_(u,v) = 1$ at the source, equals $-1$ at the target, and equals $0$ at every other vertex. (2) Degree bounds: every vertex has at most one selected outgoing arc and at most one selected incoming arc. (3) Edge exclusivity: $x_(u,v) + x_(v,u) <= 1$ for each undirected edge. (4) Ordering: for every oriented edge $u -> v$, $o_v - o_u >= 1 - n(1 - x_(u,v))$. (5) Anchor the path at the source with $o_s = 0$. + + _Objective._ Maximize $sum_({u,v} in E) l({u,v}) dot (x_(u,v) + x_(v,u))$. + + _Correctness._ ($arrow.r.double$) Any simple $s$-$t$ path can be oriented from $s$ to $t$, giving exactly one outgoing arc at $s$, one incoming arc at $t$, balanced flow at every internal vertex, and strictly increasing order values along the path. ($arrow.l.double$) Any feasible ILP solution satisfies the flow equations and degree bounds, so the selected arcs form vertex-disjoint directed paths and cycles. The ordering inequalities make every selected arc increase the order value by at least 1, so directed cycles are impossible. The only remaining positive-flow component is therefore a single directed $s$-$t$ path, whose objective is exactly the total selected edge length. + + _Solution extraction._ For each undirected edge ${u, v}$, select it in the source configuration iff either $x_(u,v)$ or $x_(v,u)$ is 1. +] + #reduction-rule("TravelingSalesman", "QUBO", example: true, example-caption: [TSP on $K_3$ with weights $w_(01) = 1$, $w_(02) = 2$, $w_(12) = 3$: the QUBO ground state encodes the optimal tour with cost $1 + 2 + 3 = 6$.], diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index d5856d619..801e8b9a4 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -217,6 +217,7 @@ TIP: Run `pred create ` (no other flags) to see problem-specific help. Flags by problem type: MIS, MVC, MaxClique, MinDomSet --graph, --weights MaxCut, MaxMatching, TSP --graph, --edge-weights + LongestPath --graph, --edge-lengths, --source-vertex, --target-vertex ShortestWeightConstrainedPath --graph, --edge-lengths, --edge-weights, --source-vertex, --target-vertex, --length-bound, --weight-bound MaximalIS --graph, --weights SAT, NAESAT --num-vars, --clauses diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index 7d526a368..abb8147b1 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -13,8 +13,9 @@ use problemreductions::models::algebraic::{ use problemreductions::models::formula::Quantifier; use problemreductions::models::graph::{ GeneralizedHex, GraphPartitioning, HamiltonianCircuit, HamiltonianPath, - LengthBoundedDisjointPaths, MinimumCutIntoBoundedSets, MinimumMultiwayCut, MixedChinesePostman, - MultipleChoiceBranching, SteinerTree, SteinerTreeInGraphs, StrongConnectivityAugmentation, + LengthBoundedDisjointPaths, LongestPath, MinimumCutIntoBoundedSets, MinimumMultiwayCut, + MixedChinesePostman, MultipleChoiceBranching, SteinerTree, SteinerTreeInGraphs, + StrongConnectivityAugmentation, }; use problemreductions::models::misc::{ AdditionalKey, BinPacking, BoyceCoddNormalFormViolation, CbqRelation, ConjunctiveBooleanQuery, @@ -519,6 +520,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", + "LongestPath" => { + "--graph 0-1,0-2,1-3,2-3,2-4,3-5,4-5,4-6,5-6,1-6 --edge-lengths 3,2,4,1,5,2,3,2,4,1 --source-vertex 0 --target-vertex 6" + } "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" }, @@ -1205,6 +1209,39 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { (ser(HamiltonianPath::new(graph))?, resolved_variant.clone()) } + // LongestPath + "LongestPath" => { + let usage = "pred create LongestPath --graph 0-1,0-2,1-3,2-3,2-4,3-5,4-5,4-6,5-6,1-6 --edge-lengths 3,2,4,1,5,2,3,2,4,1 --source-vertex 0 --target-vertex 6"; + let (graph, _) = + parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\nUsage: {usage}"))?; + if args.weights.is_some() { + bail!("LongestPath uses --edge-lengths, not --weights\n\nUsage: {usage}"); + } + let edge_lengths_raw = args.edge_lengths.as_ref().ok_or_else(|| { + anyhow::anyhow!("LongestPath requires --edge-lengths\n\nUsage: {usage}") + })?; + let edge_lengths = + parse_i32_edge_values(Some(edge_lengths_raw), graph.num_edges(), "edge length")?; + ensure_positive_i32_values(&edge_lengths, "edge lengths")?; + let source_vertex = args.source_vertex.ok_or_else(|| { + anyhow::anyhow!("LongestPath requires --source-vertex\n\nUsage: {usage}") + })?; + let target_vertex = args.target_vertex.ok_or_else(|| { + anyhow::anyhow!("LongestPath requires --target-vertex\n\nUsage: {usage}") + })?; + ensure_vertex_in_bounds(source_vertex, graph.num_vertices(), "source_vertex")?; + ensure_vertex_in_bounds(target_vertex, graph.num_vertices(), "target_vertex")?; + ( + ser(LongestPath::new( + graph, + edge_lengths, + source_vertex, + target_vertex, + ))?, + resolved_variant.clone(), + ) + } + // ShortestWeightConstrainedPath "ShortestWeightConstrainedPath" => { let usage = "pred create ShortestWeightConstrainedPath --graph 0-1,0-2,1-3,2-3,2-4,3-5,4-5,1-4 --edge-lengths 2,4,3,1,5,4,2,6 --edge-weights 5,1,2,3,2,3,1,1 --source-vertex 0 --target-vertex 5 --length-bound 10 --weight-bound 8"; @@ -5789,6 +5826,118 @@ mod tests { assert!(err.to_string().contains("GeneralizedHex requires --sink")); } + #[test] + fn test_create_longest_path_serializes_problem_json() { + let output = temp_output_path("longest_path_create"); + let cli = Cli::try_parse_from([ + "pred", + "-o", + output.to_str().unwrap(), + "create", + "LongestPath", + "--graph", + "0-1,0-2,1-3,2-3,2-4,3-5,4-5,4-6,5-6,1-6", + "--edge-lengths", + "3,2,4,1,5,2,3,2,4,1", + "--source-vertex", + "0", + "--target-vertex", + "6", + ]) + .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"], "LongestPath"); + assert_eq!(json["variant"]["graph"], "SimpleGraph"); + assert_eq!(json["variant"]["weight"], "i32"); + assert_eq!(json["data"]["source_vertex"], 0); + assert_eq!(json["data"]["target_vertex"], 6); + assert_eq!( + json["data"]["edge_lengths"], + serde_json::json!([3, 2, 4, 1, 5, 2, 3, 2, 4, 1]) + ); + } + + #[test] + fn test_create_longest_path_requires_edge_lengths() { + let cli = Cli::try_parse_from([ + "pred", + "create", + "LongestPath", + "--graph", + "0-1,1-2", + "--source-vertex", + "0", + "--target-vertex", + "2", + ]) + .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("LongestPath requires --edge-lengths")); + } + + #[test] + fn test_create_longest_path_rejects_weights_flag() { + let cli = Cli::try_parse_from([ + "pred", + "create", + "LongestPath", + "--graph", + "0-1,1-2", + "--weights", + "1,1,1", + "--source-vertex", + "0", + "--target-vertex", + "2", + "--edge-lengths", + "5,7", + ]) + .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("LongestPath uses --edge-lengths, not --weights")); + } + fn empty_args() -> CreateArgs { CreateArgs { problem: Some("BiconnectivityAugmentation".to_string()), diff --git a/src/lib.rs b/src/lib.rs index 38d1ccfb7..311ac00d5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -52,7 +52,7 @@ pub mod prelude { BiconnectivityAugmentation, BoundedComponentSpanningForest, DirectedTwoCommodityIntegralFlow, GeneralizedHex, GraphPartitioning, HamiltonianCircuit, HamiltonianPath, IsomorphicSpanningTree, KClique, KthBestSpanningTree, - LengthBoundedDisjointPaths, MixedChinesePostman, SpinGlass, SteinerTree, + LengthBoundedDisjointPaths, LongestPath, MixedChinesePostman, SpinGlass, SteinerTree, StrongConnectivityAugmentation, SubgraphIsomorphism, }; pub use crate::models::graph::{ diff --git a/src/models/graph/longest_path.rs b/src/models/graph/longest_path.rs new file mode 100644 index 000000000..682de7ed8 --- /dev/null +++ b/src/models/graph/longest_path.rs @@ -0,0 +1,303 @@ +//! Longest Path problem implementation. +//! +//! The Longest Path problem asks for a simple path between two distinguished +//! vertices that maximizes the total edge length. + +use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::topology::{Graph, SimpleGraph}; +use crate::traits::{OptimizationProblem, Problem}; +use crate::types::{Direction, One, SolutionSize, WeightElement}; +use num_traits::Zero; +use serde::{Deserialize, Serialize}; +use std::collections::VecDeque; + +inventory::submit! { + ProblemSchemaEntry { + name: "LongestPath", + display_name: "Longest Path", + aliases: &[], + dimensions: &[ + VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), + VariantDimension::new("weight", "i32", &["i32", "One"]), + ], + module_path: module_path!(), + description: "Find a simple s-t path of maximum total edge length", + fields: &[ + FieldInfo { name: "graph", type_name: "G", description: "The underlying graph G=(V,E)" }, + FieldInfo { name: "edge_lengths", type_name: "Vec", description: "Positive edge lengths l: E -> ZZ_(> 0)" }, + FieldInfo { name: "source_vertex", type_name: "usize", description: "Source vertex s" }, + FieldInfo { name: "target_vertex", type_name: "usize", description: "Target vertex t" }, + ], + } +} + +/// The Longest Path problem. +/// +/// Given a graph `G = (V, E)` with positive edge lengths `l(e)` and +/// distinguished vertices `s` and `t`, find a simple path from `s` to `t` +/// maximizing the total length of its selected edges. +/// +/// # Representation +/// +/// Each edge is assigned a binary variable: +/// - `0`: the edge is not selected +/// - `1`: the edge is selected +/// +/// A valid configuration must select exactly the edges of one simple +/// undirected path from `source_vertex` to `target_vertex`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LongestPath { + graph: G, + edge_lengths: Vec, + source_vertex: usize, + target_vertex: usize, +} + +impl LongestPath { + fn assert_positive_edge_lengths(edge_lengths: &[W]) { + let zero = W::Sum::zero(); + assert!( + edge_lengths + .iter() + .all(|length| length.to_sum() > zero.clone()), + "All edge lengths must be positive (> 0)" + ); + } + + /// Create a new LongestPath instance. + pub fn new(graph: G, edge_lengths: Vec, source_vertex: usize, target_vertex: usize) -> Self { + assert_eq!( + edge_lengths.len(), + graph.num_edges(), + "edge_lengths length must match num_edges" + ); + Self::assert_positive_edge_lengths(&edge_lengths); + assert!( + source_vertex < graph.num_vertices(), + "source_vertex {} out of bounds (graph has {} vertices)", + source_vertex, + graph.num_vertices() + ); + assert!( + target_vertex < graph.num_vertices(), + "target_vertex {} out of bounds (graph has {} vertices)", + target_vertex, + graph.num_vertices() + ); + Self { + graph, + edge_lengths, + source_vertex, + target_vertex, + } + } + + /// Get a reference to the underlying graph. + pub fn graph(&self) -> &G { + &self.graph + } + + /// Get the edge lengths. + pub fn edge_lengths(&self) -> &[W] { + &self.edge_lengths + } + + /// Replace the edge lengths with a new vector. + pub fn set_lengths(&mut self, edge_lengths: Vec) { + assert_eq!( + edge_lengths.len(), + self.graph.num_edges(), + "edge_lengths length must match num_edges" + ); + Self::assert_positive_edge_lengths(&edge_lengths); + self.edge_lengths = edge_lengths; + } + + /// Get the source vertex. + pub fn source_vertex(&self) -> usize { + self.source_vertex + } + + /// Get the target vertex. + pub fn target_vertex(&self) -> usize { + self.target_vertex + } + + /// Check whether this problem uses non-unit edge lengths. + pub fn is_weighted(&self) -> bool { + !W::IS_UNIT + } + + /// Get the number of vertices in the graph. + pub fn num_vertices(&self) -> usize { + self.graph.num_vertices() + } + + /// Get the number of edges in the graph. + pub fn num_edges(&self) -> usize { + self.graph.num_edges() + } + + /// Check if a configuration encodes a valid simple source-target path. + pub fn is_valid_solution(&self, config: &[usize]) -> bool { + is_simple_st_path(&self.graph, self.source_vertex, self.target_vertex, config) + } +} + +impl Problem for LongestPath +where + G: Graph + crate::variant::VariantParam, + W: WeightElement + crate::variant::VariantParam, +{ + const NAME: &'static str = "LongestPath"; + type Metric = SolutionSize; + + fn variant() -> Vec<(&'static str, &'static str)> { + crate::variant_params![G, W] + } + + fn dims(&self) -> Vec { + vec![2; self.graph.num_edges()] + } + + fn evaluate(&self, config: &[usize]) -> SolutionSize { + if !self.is_valid_solution(config) { + return SolutionSize::Invalid; + } + + let mut total = W::Sum::zero(); + for (idx, &selected) in config.iter().enumerate() { + if selected == 1 { + total += self.edge_lengths[idx].to_sum(); + } + } + SolutionSize::Valid(total) + } +} + +impl OptimizationProblem for LongestPath +where + G: Graph + crate::variant::VariantParam, + W: WeightElement + crate::variant::VariantParam, +{ + type Value = W::Sum; + + fn direction(&self) -> Direction { + Direction::Maximize + } +} + +fn is_simple_st_path( + graph: &G, + source_vertex: usize, + target_vertex: usize, + config: &[usize], +) -> bool { + if config.len() != graph.num_edges() || config.iter().any(|&value| value > 1) { + return false; + } + + if source_vertex == target_vertex { + return config.iter().all(|&value| value == 0); + } + + let edges = graph.edges(); + let mut degree = vec![0usize; graph.num_vertices()]; + let mut adjacency = vec![Vec::new(); graph.num_vertices()]; + let mut selected_edge_count = 0usize; + + for (idx, &selected) in config.iter().enumerate() { + if selected == 0 { + continue; + } + let (u, v) = edges[idx]; + degree[u] += 1; + degree[v] += 1; + if degree[u] > 2 || degree[v] > 2 { + return false; + } + adjacency[u].push(v); + adjacency[v].push(u); + selected_edge_count += 1; + } + + if selected_edge_count == 0 { + return false; + } + if degree[source_vertex] != 1 || degree[target_vertex] != 1 { + return false; + } + + let mut selected_vertex_count = 0usize; + for (vertex, &vertex_degree) in degree.iter().enumerate() { + if vertex_degree == 0 { + continue; + } + selected_vertex_count += 1; + if vertex != source_vertex && vertex != target_vertex && vertex_degree != 2 { + return false; + } + } + + if selected_edge_count != selected_vertex_count.saturating_sub(1) { + return false; + } + + let mut visited = vec![false; graph.num_vertices()]; + let mut queue = VecDeque::new(); + visited[source_vertex] = true; + queue.push_back(source_vertex); + + while let Some(vertex) = queue.pop_front() { + for &neighbor in &adjacency[vertex] { + if !visited[neighbor] { + visited[neighbor] = true; + queue.push_back(neighbor); + } + } + } + + visited[target_vertex] + && degree + .iter() + .enumerate() + .all(|(vertex, &vertex_degree)| vertex_degree == 0 || visited[vertex]) +} + +crate::declare_variants! { + default opt LongestPath => "num_vertices * 2^num_vertices", + opt LongestPath => "num_vertices * 2^num_vertices", +} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_model_example_specs() -> Vec { + vec![crate::example_db::specs::ModelExampleSpec { + id: "longest_path_simplegraph_i32", + instance: Box::new(LongestPath::new( + SimpleGraph::new( + 7, + vec![ + (0, 1), + (0, 2), + (1, 3), + (2, 3), + (2, 4), + (3, 5), + (4, 5), + (4, 6), + (5, 6), + (1, 6), + ], + ), + vec![3, 2, 4, 1, 5, 2, 3, 2, 4, 1], + 0, + 6, + )), + optimal_config: vec![1, 0, 1, 1, 1, 0, 1, 0, 1, 0], + optimal_value: serde_json::json!({"Valid": 20}), + }] +} + +#[cfg(test)] +#[path = "../../unit_tests/models/graph/longest_path.rs"] +mod tests; diff --git a/src/models/graph/mod.rs b/src/models/graph/mod.rs index 0c95f91cf..8b9b0bc1b 100644 --- a/src/models/graph/mod.rs +++ b/src/models/graph/mod.rs @@ -22,6 +22,7 @@ //! - [`SpinGlass`]: Ising model Hamiltonian //! - [`MinimumMultiwayCut`]: Minimum weight multiway cut //! - [`HamiltonianPath`]: Hamiltonian path (simple path visiting every vertex) +//! - [`LongestPath`]: Maximum-length simple s-t path //! - [`ShortestWeightConstrainedPath`]: Bicriteria simple s-t path with length and weight bounds //! - [`PartitionIntoPathsOfLength2`]: Partition vertices into triples with at least two edges each //! - [`BicliqueCover`]: Biclique cover on bipartite graphs @@ -59,6 +60,7 @@ pub(crate) mod kclique; pub(crate) mod kcoloring; pub(crate) mod kth_best_spanning_tree; pub(crate) mod length_bounded_disjoint_paths; +pub(crate) mod longest_path; pub(crate) mod max_cut; pub(crate) mod maximal_is; pub(crate) mod maximum_clique; @@ -103,6 +105,7 @@ pub use kclique::KClique; pub use kcoloring::KColoring; pub use kth_best_spanning_tree::KthBestSpanningTree; pub use length_bounded_disjoint_paths::LengthBoundedDisjointPaths; +pub use longest_path::LongestPath; pub use max_cut::MaxCut; pub use maximal_is::MaximalIS; pub use maximum_clique::MaximumClique; @@ -147,6 +150,7 @@ pub(crate) fn canonical_model_example_specs() -> Vec, + num_edges: usize, +} + +impl ReductionLongestPathToILP { + fn arc_var(edge_idx: usize, dir: usize) -> usize { + 2 * edge_idx + dir + } +} + +impl ReductionResult for ReductionLongestPathToILP { + type Source = LongestPath; + type Target = ILP; + + fn target_problem(&self) -> &ILP { + &self.target + } + + fn extract_solution(&self, target_solution: &[usize]) -> Vec { + (0..self.num_edges) + .map(|edge_idx| { + usize::from( + target_solution + .get(Self::arc_var(edge_idx, 0)) + .copied() + .unwrap_or(0) + > 0 + || target_solution + .get(Self::arc_var(edge_idx, 1)) + .copied() + .unwrap_or(0) + > 0, + ) + }) + .collect() + } +} + +#[reduction(overhead = { + num_vars = "2 * num_edges + num_vertices", + num_constraints = "5 * num_edges + 4 * num_vertices + 1", +})] +impl ReduceTo> for LongestPath { + type Result = ReductionLongestPathToILP; + + fn reduce_to(&self) -> Self::Result { + let edges = self.graph().edges(); + let num_vertices = self.num_vertices(); + let num_edges = self.num_edges(); + let num_vars = 2 * num_edges + num_vertices; + let source = self.source_vertex(); + let target = self.target_vertex(); + let big_m = num_vertices as f64; + + let order_var = |vertex: usize| 2 * num_edges + vertex; + + let mut outgoing = vec![Vec::new(); num_vertices]; + let mut incoming = vec![Vec::new(); num_vertices]; + + for (edge_idx, &(u, v)) in edges.iter().enumerate() { + let forward = ReductionLongestPathToILP::arc_var(edge_idx, 0); + let reverse = ReductionLongestPathToILP::arc_var(edge_idx, 1); + outgoing[u].push((forward, 1.0)); + incoming[v].push((forward, 1.0)); + outgoing[v].push((reverse, 1.0)); + incoming[u].push((reverse, 1.0)); + } + + let mut constraints = Vec::new(); + + // Directed arc variables are binary within ILP. + for edge_idx in 0..num_edges { + constraints.push(LinearConstraint::le( + vec![(ReductionLongestPathToILP::arc_var(edge_idx, 0), 1.0)], + 1.0, + )); + constraints.push(LinearConstraint::le( + vec![(ReductionLongestPathToILP::arc_var(edge_idx, 1), 1.0)], + 1.0, + )); + } + + // Order variables stay within [0, |V|-1]. + for vertex in 0..num_vertices { + constraints.push(LinearConstraint::le( + vec![(order_var(vertex), 1.0)], + num_vertices.saturating_sub(1) as f64, + )); + } + + // Flow balance and degree bounds force one simple directed path. + for vertex in 0..num_vertices { + let mut balance_terms = outgoing[vertex].clone(); + for &(var, coef) in &incoming[vertex] { + balance_terms.push((var, -coef)); + } + + let rhs = if source != target { + if vertex == source { + 1.0 + } else if vertex == target { + -1.0 + } else { + 0.0 + } + } else { + 0.0 + }; + constraints.push(LinearConstraint::eq(balance_terms, rhs)); + constraints.push(LinearConstraint::le(outgoing[vertex].clone(), 1.0)); + constraints.push(LinearConstraint::le(incoming[vertex].clone(), 1.0)); + } + + // An undirected edge can be used in at most one direction. + for edge_idx in 0..num_edges { + constraints.push(LinearConstraint::le( + vec![ + (ReductionLongestPathToILP::arc_var(edge_idx, 0), 1.0), + (ReductionLongestPathToILP::arc_var(edge_idx, 1), 1.0), + ], + 1.0, + )); + } + + // If arc u->v is selected then order(v) >= order(u) + 1. + for (edge_idx, &(u, v)) in edges.iter().enumerate() { + constraints.push(LinearConstraint::ge( + vec![ + (order_var(v), 1.0), + (order_var(u), -1.0), + (ReductionLongestPathToILP::arc_var(edge_idx, 0), -big_m), + ], + 1.0 - big_m, + )); + constraints.push(LinearConstraint::ge( + vec![ + (order_var(u), 1.0), + (order_var(v), -1.0), + (ReductionLongestPathToILP::arc_var(edge_idx, 1), -big_m), + ], + 1.0 - big_m, + )); + } + + constraints.push(LinearConstraint::eq(vec![(order_var(source), 1.0)], 0.0)); + + let mut objective = Vec::with_capacity(2 * num_edges); + for (edge_idx, length) in self.edge_lengths().iter().enumerate() { + let coeff = f64::from(*length); + objective.push((ReductionLongestPathToILP::arc_var(edge_idx, 0), coeff)); + objective.push((ReductionLongestPathToILP::arc_var(edge_idx, 1), coeff)); + } + + ReductionLongestPathToILP { + target: ILP::new(num_vars, constraints, objective, ObjectiveSense::Maximize), + num_edges, + } + } +} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_rule_example_specs() -> Vec { + use crate::export::SolutionPair; + + vec![crate::example_db::specs::RuleExampleSpec { + id: "longestpath_to_ilp", + build: || { + crate::example_db::specs::rule_example_with_witness::<_, ILP>( + LongestPath::new(SimpleGraph::new(3, vec![(0, 1), (1, 2)]), vec![2, 3], 0, 2), + SolutionPair { + source_config: vec![1, 1], + target_config: vec![1, 0, 1, 0, 0, 1, 2], + }, + ) + }, + }] +} + +#[cfg(test)] +#[path = "../unit_tests/rules/longestpath_ilp.rs"] +mod tests; diff --git a/src/rules/mod.rs b/src/rules/mod.rs index d7929687b..0fb79e213 100644 --- a/src/rules/mod.rs +++ b/src/rules/mod.rs @@ -68,6 +68,8 @@ pub(crate) mod knapsack_ilp; #[cfg(feature = "ilp-solver")] pub(crate) mod longestcommonsubsequence_ilp; #[cfg(feature = "ilp-solver")] +pub(crate) mod longestpath_ilp; +#[cfg(feature = "ilp-solver")] pub(crate) mod maximumclique_ilp; #[cfg(feature = "ilp-solver")] pub(crate) mod maximummatching_ilp; @@ -136,6 +138,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec LongestPath { + LongestPath::new( + SimpleGraph::new( + 7, + vec![ + (0, 1), + (0, 2), + (1, 3), + (2, 3), + (2, 4), + (3, 5), + (4, 5), + (4, 6), + (5, 6), + (1, 6), + ], + ), + vec![3, 2, 4, 1, 5, 2, 3, 2, 4, 1], + 0, + 6, + ) +} + +fn optimal_config() -> Vec { + vec![1, 0, 1, 1, 1, 0, 1, 0, 1, 0] +} + +fn suboptimal_config() -> Vec { + vec![0, 1, 1, 0, 1, 1, 1, 0, 0, 1] +} + +#[test] +fn test_longest_path_creation() { + let mut problem = issue_problem(); + + assert_eq!(problem.graph().num_vertices(), 7); + assert_eq!(problem.graph().num_edges(), 10); + assert_eq!(problem.num_vertices(), 7); + assert_eq!(problem.num_edges(), 10); + assert_eq!(problem.source_vertex(), 0); + assert_eq!(problem.target_vertex(), 6); + assert_eq!(problem.dims(), vec![2; 10]); + assert_eq!(problem.edge_lengths(), &[3, 2, 4, 1, 5, 2, 3, 2, 4, 1]); + assert!(problem.is_weighted()); + assert_eq!(problem.direction(), Direction::Maximize); + + problem.set_lengths(vec![1; 10]); + assert_eq!(problem.edge_lengths(), &[1; 10]); + + let unweighted = LongestPath::new(SimpleGraph::path(4), vec![One; 3], 0, 3); + assert!(!unweighted.is_weighted()); +} + +#[test] +fn test_longest_path_evaluate_valid_and_invalid_configs() { + let problem = issue_problem(); + + assert_eq!(problem.evaluate(&optimal_config()), SolutionSize::Valid(20)); + assert_eq!( + problem.evaluate(&suboptimal_config()), + SolutionSize::Valid(17) + ); + assert!(problem.is_valid_solution(&optimal_config())); + assert!(problem.is_valid_solution(&suboptimal_config())); + + assert_eq!( + problem.evaluate(&[1, 1, 1, 0, 0, 0, 0, 0, 0, 0]), + SolutionSize::Invalid + ); + assert_eq!( + problem.evaluate(&[1, 0, 1, 0, 1, 0, 0, 0, 0, 1]), + SolutionSize::Invalid + ); + assert_eq!( + problem.evaluate(&[1, 0, 1, 1, 1, 1, 1, 1, 1, 1]), + SolutionSize::Invalid + ); + assert_eq!(problem.evaluate(&[0; 10]), SolutionSize::Invalid); + assert!(!problem.is_valid_solution(&[1, 0, 1])); + assert!(!problem.is_valid_solution(&[1, 0, 1, 0, 1, 0, 1, 0, 1, 2])); +} + +#[test] +fn test_longest_path_bruteforce_finds_issue_optimum() { + let problem = issue_problem(); + let solver = BruteForce::new(); + + let best = solver.find_best(&problem).unwrap(); + assert_eq!(best, optimal_config()); + assert_eq!(problem.evaluate(&best), SolutionSize::Valid(20)); + + let all_best = solver.find_all_best(&problem); + assert_eq!(all_best, vec![optimal_config()]); +} + +#[test] +fn test_longest_path_serialization() { + let problem = issue_problem(); + let json = serde_json::to_value(&problem).unwrap(); + let restored: LongestPath = serde_json::from_value(json).unwrap(); + + assert_eq!(restored.num_vertices(), 7); + assert_eq!(restored.num_edges(), 10); + assert_eq!(restored.source_vertex(), 0); + assert_eq!(restored.target_vertex(), 6); + assert_eq!(restored.edge_lengths(), &[3, 2, 4, 1, 5, 2, 3, 2, 4, 1]); + assert_eq!( + restored.evaluate(&optimal_config()), + SolutionSize::Valid(20) + ); +} + +#[test] +fn test_longest_path_source_equals_target_only_allows_empty_path() { + let problem = LongestPath::new(SimpleGraph::path(3), vec![5, 7], 1, 1); + + assert!(problem.is_valid_solution(&[0, 0])); + assert_eq!(problem.evaluate(&[0, 0]), SolutionSize::Valid(0)); + assert!(!problem.is_valid_solution(&[1, 0])); + assert_eq!(problem.evaluate(&[1, 0]), SolutionSize::Invalid); + + let best = BruteForce::new().find_best(&problem).unwrap(); + assert_eq!(best, vec![0, 0]); +} + +#[test] +fn test_longestpath_paper_example() { + let problem = issue_problem(); + + assert_eq!(problem.evaluate(&optimal_config()), SolutionSize::Valid(20)); + assert_eq!( + problem.evaluate(&suboptimal_config()), + SolutionSize::Valid(17) + ); + assert_eq!( + problem.evaluate(&[1, 1, 1, 0, 0, 0, 0, 0, 0, 0]), + SolutionSize::Invalid + ); +} + +#[test] +fn test_longest_path_problem_name() { + assert_eq!( + as Problem>::NAME, + "LongestPath" + ); +} + +#[test] +#[should_panic(expected = "edge_lengths length must match num_edges")] +fn test_longest_path_rejects_wrong_edge_lengths_len() { + LongestPath::new(SimpleGraph::path(3), vec![1], 0, 2); +} + +#[test] +#[should_panic(expected = "All edge lengths must be positive (> 0)")] +fn test_longest_path_rejects_non_positive_edge_lengths() { + LongestPath::new(SimpleGraph::path(2), vec![0], 0, 1); +} + +#[test] +#[should_panic(expected = "source_vertex 3 out of bounds (graph has 3 vertices)")] +fn test_longest_path_rejects_out_of_bounds_source() { + LongestPath::new(SimpleGraph::path(3), vec![1, 1], 3, 2); +} + +#[test] +#[should_panic(expected = "target_vertex 3 out of bounds (graph has 3 vertices)")] +fn test_longest_path_rejects_out_of_bounds_target() { + LongestPath::new(SimpleGraph::path(3), vec![1, 1], 0, 3); +} diff --git a/src/unit_tests/rules/longestpath_ilp.rs b/src/unit_tests/rules/longestpath_ilp.rs new file mode 100644 index 000000000..6f8411e05 --- /dev/null +++ b/src/unit_tests/rules/longestpath_ilp.rs @@ -0,0 +1,108 @@ +use super::*; +use crate::models::algebraic::{ObjectiveSense, ILP}; +use crate::solvers::{BruteForce, ILPSolver, Solver}; +use crate::topology::SimpleGraph; +use crate::traits::Problem; +use crate::types::SolutionSize; + +fn issue_problem() -> LongestPath { + LongestPath::new( + SimpleGraph::new( + 7, + vec![ + (0, 1), + (0, 2), + (1, 3), + (2, 3), + (2, 4), + (3, 5), + (4, 5), + (4, 6), + (5, 6), + (1, 6), + ], + ), + vec![3, 2, 4, 1, 5, 2, 3, 2, 4, 1], + 0, + 6, + ) +} + +fn simple_path_problem() -> LongestPath { + LongestPath::new(SimpleGraph::new(3, vec![(0, 1), (1, 2)]), vec![2, 3], 0, 2) +} + +#[test] +fn test_reduction_creates_expected_ilp_shape() { + let problem = simple_path_problem(); + let reduction: ReductionLongestPathToILP = ReduceTo::>::reduce_to(&problem); + let ilp = reduction.target_problem(); + + assert_eq!(ilp.num_vars, 7); + assert_eq!(ilp.constraints.len(), 23); + assert_eq!(ilp.sense, ObjectiveSense::Maximize); + + let mut objective = vec![0.0; ilp.num_vars]; + for &(var, coeff) in &ilp.objective { + objective[var] = coeff; + } + + assert_eq!(objective[0], 2.0); + assert_eq!(objective[1], 2.0); + assert_eq!(objective[2], 3.0); + assert_eq!(objective[3], 3.0); + assert_eq!(objective[4], 0.0); +} + +#[test] +fn test_longestpath_to_ilp_closed_loop_on_issue_example() { + let problem = issue_problem(); + let brute_force = BruteForce::new(); + let best = brute_force + .find_best(&problem) + .expect("brute-force optimum"); + let best_value = problem.evaluate(&best); + assert_eq!(best_value, SolutionSize::Valid(20)); + + let reduction: ReductionLongestPathToILP = ReduceTo::>::reduce_to(&problem); + let ilp_solver = ILPSolver::new(); + let ilp_solution = ilp_solver + .solve(reduction.target_problem()) + .expect("ILP should be solvable"); + let extracted = reduction.extract_solution(&ilp_solution); + + assert!(problem.is_valid_solution(&extracted)); + assert_eq!(problem.evaluate(&extracted), best_value); +} + +#[test] +fn test_solution_extraction_from_handcrafted_ilp_assignment() { + let problem = simple_path_problem(); + let reduction: ReductionLongestPathToILP = ReduceTo::>::reduce_to(&problem); + + // x_{0->1}, x_{1->0}, x_{1->2}, x_{2->1}, o_0, o_1, o_2 + let target_solution = vec![1, 0, 1, 0, 0, 1, 2]; + let extracted = reduction.extract_solution(&target_solution); + + assert_eq!(extracted, vec![1, 1]); + assert_eq!(problem.evaluate(&extracted), SolutionSize::Valid(5)); +} + +#[test] +fn test_source_equals_target_uses_empty_path() { + let problem = LongestPath::new( + SimpleGraph::new(3, vec![(0, 1), (1, 2), (0, 2)]), + vec![4, 5, 6], + 1, + 1, + ); + let reduction: ReductionLongestPathToILP = ReduceTo::>::reduce_to(&problem); + let ilp_solver = ILPSolver::new(); + let ilp_solution = ilp_solver + .solve(reduction.target_problem()) + .expect("ILP should solve the trivial empty-path case"); + let extracted = reduction.extract_solution(&ilp_solution); + + assert_eq!(extracted, vec![0, 0, 0]); + assert_eq!(problem.evaluate(&extracted), SolutionSize::Valid(0)); +} From 507163f90318a2b0d8b49ce1bac6afd757324a70 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Sun, 22 Mar 2026 00:20:49 +0800 Subject: [PATCH 3/4] chore: remove plan file after implementation --- docs/plans/2026-03-21-longest-path.md | 383 -------------------------- 1 file changed, 383 deletions(-) delete mode 100644 docs/plans/2026-03-21-longest-path.md diff --git a/docs/plans/2026-03-21-longest-path.md b/docs/plans/2026-03-21-longest-path.md deleted file mode 100644 index cfb24503c..000000000 --- a/docs/plans/2026-03-21-longest-path.md +++ /dev/null @@ -1,383 +0,0 @@ -# LongestPath Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Implement issue #288 by adding the `LongestPath` model, wiring brute-force and ILP solving support, registering CLI/example-db integration, and documenting the model and new ILP reduction in the paper. - -**Architecture:** Model `LongestPath` as an edge-selection optimization problem, matching the issue's optimization framing from the `fix-issue` comment. A configuration is valid exactly when the selected undirected edges form one simple path from `source_vertex` to `target_vertex`; the metric is the total selected edge length. Because the issue explicitly opts into ILP solving, add a direct `LongestPath -> ILP` reduction with a path-ordering formulation that forbids disconnected cycles and extracts the chosen edge set back into the source configuration. - -**Tech Stack:** Rust workspace, serde/inventory schema registration, registry-backed CLI loading, `BruteForce` + `ILPSolver`, Typst paper, example-db exports. - ---- - -## Issue Packet Summary - -- Issue: `#288 [Model] LongestPath` -- Issue state: open, labeled `Good` -- Existing PRs: none (`action = create-pr`) -- Associated rule already on the board: `#359 [Rule] HAMILTONIAN PATH BETWEEN TWO VERTICES to LONGEST PATH` -- Maintainer guidance from comments: - - Keep `LongestPath` as an optimization problem (`Direction::Maximize`) - - Use the single verified example instance with optimum `20` - - Record ILP support instead of leaving it unspecified - -## Concrete Design Choices - -1. **Problem shape** - - File: `src/models/graph/longest_path.rs` - - Type: `LongestPath` - - Fields: - - `graph: G` - - `edge_lengths: Vec` - - `source_vertex: usize` - - `target_vertex: usize` - - Variants: - - `default opt LongestPath => "num_vertices * 2^num_vertices"` - - `opt LongestPath => "num_vertices * 2^num_vertices"` - -2. **Configuration semantics** - - One binary variable per edge, in graph-edge order - - Valid iff selected edges induce a single undirected simple path from `source_vertex` to `target_vertex` - - `source_vertex == target_vertex` should accept only the empty edge set and evaluate to `Valid(0)` - - Any repeated-edge, branching, disconnected, cyclic, or wrong-endpoint selection is `Invalid` - -3. **Example of record** - - Use the corrected issue instance with 7 vertices, 10 edges, `s = 0`, `t = 6` - - Optimal path edges correspond to `0 -> 1 -> 3 -> 2 -> 4 -> 5 -> 6` - - Optimal objective value: `20` - - Include a suboptimal valid path worth `17` - -4. **ILP solver path** - - File: `src/rules/longestpath_ilp.rs` - - Restrict initial ILP reduction to `LongestPath` - - Use binary directed-arc variables plus visited/order variables so the selected solution is a single simple `s-t` path, not a path plus detached cycles - - Objective: maximize total selected edge length - - Extraction: recover the selected undirected source edges from the ILP solution - -## Batch 1: Implementation - -### Task 1: Write the failing model tests first - -**Files:** -- Create: `src/unit_tests/models/graph/longest_path.rs` - -**Step 1: Add tests that define the required behavior** - -Cover at least these cases: -- `test_longest_path_creation` for constructor/accessors/dimensions -- `test_longest_path_evaluate_valid_and_invalid_configs` -- `test_longest_path_bruteforce_finds_issue_optimum` -- `test_longest_path_serialization` -- `test_longest_path_source_equals_target_only_allows_empty_path` -- `test_longestpath_paper_example` - -Use the corrected issue fixture as the canonical positive instance. - -**Step 2: Run the new test target and confirm RED** - -Run: - -```bash -cargo test longest_path --lib -``` - -Expected: compile failure because `LongestPath` does not exist yet. - -**Step 3: Commit the failing-test checkpoint only if the tree is intentionally staged that way** - -Do not force an early commit if the repo workflow is smoother with model code immediately after the RED check. - -### Task 2: Implement and register the model - -**Files:** -- Create: `src/models/graph/longest_path.rs` -- Modify: `src/models/graph/mod.rs` -- Modify: `src/models/mod.rs` -- Modify: `src/lib.rs` - -**Step 1: Implement `LongestPath`** - -Include: -- `inventory::submit!` schema metadata -- constructor validation: - - `edge_lengths.len() == graph.num_edges()` - - vertex indices in range - - positive lengths for weighted variants -- accessors: - - `graph()` - - `edge_lengths()` - - `set_lengths()` - - `source_vertex()` - - `target_vertex()` - - `num_vertices()` - - `num_edges()` - - `is_weighted()` - - `is_valid_solution()` -- `Problem` impl with `Metric = SolutionSize` -- `OptimizationProblem` impl with `Direction::Maximize` -- internal validation helper that checks the selected edge set forms exactly one simple `s-t` path -- `declare_variants!` -- `canonical_model_example_specs()` -- test link: - - `#[cfg(test)] #[path = "../../unit_tests/models/graph/longest_path.rs"] mod tests;` - -**Step 2: Register the model everywhere it must appear** - -Update: -- `src/models/graph/mod.rs` - - module declaration - - public re-export - - example-db spec chain -- `src/models/mod.rs` - - graph re-export list -- `src/lib.rs` - - `prelude` exports if needed by current conventions - -**Step 3: Re-run the model tests and get GREEN** - -Run: - -```bash -cargo test longest_path --lib -``` - -Expected: the new model tests pass. - -### Task 3: Add ILP rule support with tests first - -**Files:** -- Create: `src/unit_tests/rules/longestpath_ilp.rs` -- Create: `src/rules/longestpath_ilp.rs` -- Modify: `src/rules/mod.rs` - -**Step 1: Add failing rule tests** - -Cover: -- reduction builds an `ILP` with nonempty objective/constraints -- `ILPSolver` solution maps back to a valid `LongestPath` configuration -- reduced optimum matches brute-force optimum on the issue example -- extraction rejects or avoids detached cycles by construction -- path metadata is discoverable from the reduction graph if relevant - -**Step 2: Run the ILP-focused test target and confirm RED** - -Run: - -```bash -cargo test longestpath_ilp --lib --features ilp-solver -``` - -Expected: compile failure because the rule module does not exist yet. - -**Step 3: Implement `LongestPath -> ILP`** - -Model the path with: -- directed-arc binary variables for each undirected edge orientation -- vertex-visited binary variables -- integer order variables (or equivalent MTZ-style progression variables) - -Enforce: -- source has one outgoing selected arc and no incoming selected arc -- target has one incoming selected arc and no outgoing selected arc -- every nonterminal visited vertex has one incoming and one outgoing selected arc -- every unvisited nonterminal vertex has zero selected incident arcs -- each undirected edge is used in at most one direction -- ordering constraints eliminate directed cycles disconnected from the main path - -Objective: -- maximize total selected edge length - -Extraction: -- map whichever arc orientation is selected for each undirected edge back to the edge-indexed source config - -**Step 4: Register and re-run the ILP tests** - -Run: - -```bash -cargo test longestpath_ilp --lib --features ilp-solver -``` - -Expected: rule tests pass. - -### Task 4: Wire CLI creation, aliases, and example-db coverage - -**Files:** -- Modify: `problemreductions-cli/src/problem_name.rs` -- Modify: `problemreductions-cli/src/commands/create.rs` -- Modify: `problemreductions-cli/src/cli.rs` -- Modify: `src/models/graph/mod.rs` - -**Step 1: Add CLI coverage tests first** - -Add tests near the existing `create.rs` test module for: -- successful JSON creation from: - - `pred create LongestPath --graph ... --edge-lengths ... --source-vertex 0 --target-vertex 6` -- missing required `--edge-lengths` -- misuse of `--weights` instead of `--edge-lengths` - -**Step 2: Run the targeted CLI tests and confirm RED** - -Run: - -```bash -cargo test create_longest_path --package problemreductions-cli -``` - -Expected: failures until the CLI path exists. - -**Step 3: Implement CLI integration** - -Update: -- `problemreductions-cli/src/problem_name.rs` - - allow lowercase alias resolution for `longestpath` - - do not invent a short literature acronym -- `problemreductions-cli/src/commands/create.rs` - - add example string - - add create arm parsing `--graph`, `--edge-lengths`, `--source-vertex`, `--target-vertex` - - emit helpful errors for missing fields and wrong flag families -- `problemreductions-cli/src/cli.rs` - - ensure help text lists `LongestPath` - - ensure `all_data_flags_empty()` already covers the flags this model needs; change only if a new flag is introduced - -**Step 4: Re-run the CLI tests** - -Run: - -```bash -cargo test create_longest_path --package problemreductions-cli -``` - -Expected: the new CLI tests pass. - -### Task 5: Run broader implementation verification for Batch 1 - -**Files:** -- No new files; verification only - -**Step 1: Run focused workspace tests** - -Run: - -```bash -cargo test longest_path --workspace --features ilp-solver -``` - -**Step 2: Run the standard repo checks for code touched so far** - -Run: - -```bash -make test -make clippy -``` - -If `make test` is too expensive while iterating, keep using the targeted cargo invocations until the batch is stable, then run the full commands before leaving Batch 1. - -## Batch 2: Paper and Documentation - -### Task 6: Add paper coverage for both the model and the new ILP rule - -**Files:** -- Modify: `docs/paper/reductions.typ` - -**Step 1: Add the display name entry** - -Register: - -```text -"LongestPath": [Longest Path], -``` - -**Step 2: Add `problem-def("LongestPath")`** - -The entry should: -- define the optimization version explicitly -- explain that the decision form is recovered by thresholding the optimum -- cite Garey & Johnson plus the exact-algorithm references already verified in the issue comments -- use the canonical 7-vertex example with highlighted optimal path -- include `pred-commands()` derived from `load-model-example("LongestPath")` - -**Step 3: Add a `reduction-rule("LongestPath", "ILP", ...)` entry** - -Because Batch 1 registers a real reduction edge, the paper must cover it. Document: -- the path-selection variables -- degree / flow / ordering constraints -- why every feasible ILP solution corresponds to a simple `s-t` path -- why the maximized objective equals the path length - -Use exported example data if the existing helper flow supports it; otherwise write the example from the same canonical issue fixture. - -**Step 4: Build the paper** - -Run: - -```bash -make paper -``` - -Expected: no completeness warnings for `LongestPath` or `LongestPath -> ILP`. - -## Final Verification and Cleanup - -### Task 7: Full verification before pushing - -**Step 1: Run the highest-signal verification commands** - -Run: - -```bash -make test -make clippy -make paper -``` - -If runtime permits and coverage impact is uncertain, also run: - -```bash -make coverage -``` - -**Step 2: Inspect the working tree** - -Run: - -```bash -git status --short -``` - -Expected: -- only intended tracked changes remain -- ignored generated exports under `docs/src/reductions/` stay unstaged - -**Step 3: Keep commits coherent** - -Recommended commit sequence: -- `Add plan for #288: [Model] LongestPath` -- `Implement #288: [Model] LongestPath` -- `chore: remove plan file after implementation` - -## Expected File Inventory - -- `src/models/graph/longest_path.rs` -- `src/unit_tests/models/graph/longest_path.rs` -- `src/rules/longestpath_ilp.rs` -- `src/unit_tests/rules/longestpath_ilp.rs` -- `src/models/graph/mod.rs` -- `src/models/mod.rs` -- `src/lib.rs` -- `problemreductions-cli/src/problem_name.rs` -- `problemreductions-cli/src/commands/create.rs` -- `problemreductions-cli/src/cli.rs` -- `docs/paper/reductions.typ` - -## References To Use During Implementation - -- Issue packet for `#288`, including the `fix-issue` comment and the quality-check comment -- `src/models/graph/maximum_independent_set.rs` -- `src/models/graph/shortest_weight_constrained_path.rs` -- `src/models/graph/traveling_salesman.rs` -- `src/rules/travelingsalesman_ilp.rs` -- `src/unit_tests/models/graph/hamiltonian_path.rs` -- `src/unit_tests/rules/maximumclique_ilp.rs` -- `docs/paper/reductions.typ` entries for `HamiltonianPath` and existing `-> ILP` rules From a3db448c89f7c43dec2d4e15667ed40ce20b1a96 Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Sun, 22 Mar 2026 03:55:39 +0800 Subject: [PATCH 4/4] Fix formatting after merge --- problemreductions-cli/src/commands/create.rs | 6 +++--- src/models/mod.rs | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index 1487417f3..da36cc42b 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -13,9 +13,9 @@ use problemreductions::models::algebraic::{ use problemreductions::models::formula::Quantifier; use problemreductions::models::graph::{ GeneralizedHex, GraphPartitioning, HamiltonianCircuit, HamiltonianPath, - LengthBoundedDisjointPaths, LongestCircuit, LongestPath, MinimumCutIntoBoundedSets, MinimumMultiwayCut, - MixedChinesePostman, MultipleChoiceBranching, SteinerTree, SteinerTreeInGraphs, - StrongConnectivityAugmentation, + LengthBoundedDisjointPaths, LongestCircuit, LongestPath, MinimumCutIntoBoundedSets, + MinimumMultiwayCut, MixedChinesePostman, MultipleChoiceBranching, SteinerTree, + SteinerTreeInGraphs, StrongConnectivityAugmentation, }; use problemreductions::models::misc::{ AdditionalKey, BinPacking, BoyceCoddNormalFormViolation, CbqRelation, ConjunctiveBooleanQuery, diff --git a/src/models/mod.rs b/src/models/mod.rs index 1b34b1817..687e02324 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -21,9 +21,9 @@ pub use graph::{ AcyclicPartition, BalancedCompleteBipartiteSubgraph, BicliqueCover, BiconnectivityAugmentation, BottleneckTravelingSalesman, BoundedComponentSpanningForest, DirectedTwoCommodityIntegralFlow, GeneralizedHex, GraphPartitioning, HamiltonianCircuit, HamiltonianPath, IsomorphicSpanningTree, - KClique, KColoring, KthBestSpanningTree, LengthBoundedDisjointPaths, LongestCircuit, LongestPath, - MaxCut, MaximalIS, MaximumClique, MaximumIndependentSet, MaximumMatching, MinMaxMulticenter, - MinimumCutIntoBoundedSets, MinimumDominatingSet, MinimumFeedbackArcSet, + KClique, KColoring, KthBestSpanningTree, LengthBoundedDisjointPaths, LongestCircuit, + LongestPath, MaxCut, MaximalIS, MaximumClique, MaximumIndependentSet, MaximumMatching, + MinMaxMulticenter, MinimumCutIntoBoundedSets, MinimumDominatingSet, MinimumFeedbackArcSet, MinimumFeedbackVertexSet, MinimumMultiwayCut, MinimumSumMulticenter, MinimumVertexCover, MixedChinesePostman, MultipleChoiceBranching, MultipleCopyFileAllocation, OptimalLinearArrangement, PartitionIntoPathsOfLength2, PartitionIntoTriangles, RuralPostman,