Repository navigation
Cut 1.1.0: shortest-path, flow, and connectivity suite completions - #20
Conversation
Bridges and articulation points already fell out of the low-link DFS; returning the actual components completes that connectivity suite. An edge stack popped at articulation boundaries (tracked by stack depth, not edge equality, so equal-valued parallel edges stay distinct) yields biconnected components from the same single pass, and 2-edge-connected components follow by walking the graph with bridges excluded. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Completes the flow suite: Edmonds-Karp and Dinic answer how much flow fits, this answers how cheaply it can be shipped, which also covers assignment problems via unit capacities. Successive shortest paths with vertex potentials reuses the shared residual network (now carrying optional per-arc costs); Bellman-Ford seeds the potentials so negative edge costs work, and each augmentation is a Dijkstra over reduced costs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Floyd-Warshall is O(V cubed) regardless of density, which punishes the sparse graphs most users have. Johnson's reuses what already exists -- Bellman-Ford relaxation for potentials, Dijkstra per source on reduced weights -- and returns the same AllPairsShortestPaths result type, so callers switch algorithms without changing queries. Per-source rows are independent, so the ParallelOptions overload follows the established parallel-analysis pattern with results identical to sequential. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Routing users constantly need the next-best alternatives, not just the optimum. Yen's algorithm ships as a lazy generator like the clique enumerator: paths arrive cheapest-first, Take(k) answers the classic query, and the caller stops paying the repeated-Dijkstra cost the moment they stop enumerating. Spur searches run on a private Dijkstra variant with banned vertices and first hops, leaving the public Dijkstra class untouched. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
M46-M49 complete suites started in earlier releases: biconnected and 2-edge-connected components finish the low-link connectivity family, min-cost max-flow finishes the flow family, and Johnson plus lazy Yen enumeration finish the shortest-path family. All additions are additive per SemVer; the PublicAPI baseline is promoted and Unshipped is empty again. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
e74e1bf to
e1a9f5a
Compare
The CancellationToken overload of the MinimumCostMaximumFlow extension was the only 1.1.0 surface with no test: the cancellation tests called the algorithm class directly, so the one-line forwarder never ran. Exercise both of its contracts — a pre-cancelled token throws, a live token matches the algorithm class — closing the gap Codecov's patch check flagged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
lgamorim
left a comment
There was a problem hiding this comment.
Review: 1.1.0 cut
Ten findings, one of which should block the merge. Everything else is cleanup or coverage and can land as follow-ups if you prefer.
Blocking
Johnson path reconstruction can loop forever — JohnsonAllShortestPaths.cs#L216
Each row stores the first hop of its own Dijkstra tree, but AllPairsShortestPaths.Between reconstructs by hopping across rows (current = _next[current, to]). Rows break ties independently, so on a zero-weight cycle row a can point to b while row b points to a, and Between never terminates.
Reproduced with:
a->b 0, b->a 0, a->p 1, a->f 1, b->q 1, b->f 1, p->t 1, q->t 1 // inserted in this order
new JohnsonAllShortestPaths<...>(e => e.Weight).Compute(graph).Between("a", "t"); // hangsFloyd-Warshall on the same graph returns distance 2. JohnsonTests only compares distances against Floyd-Warshall, never paths, so the suite is green. Fix options: make reconstruction row-local (per-row parent matrix, walk _next[from, …] only), or derive a globally consistent next matrix.
Correctness, lower likelihood
- Parent-edge skip matches by vertex, not tree-edge identity — GraphConnectivityExtensions.cs#L379. Correct for
UndirectedMultigraphbecause both incidence lists share insertion order, butIReadOnlyGraphis public and promises no cross-endpoint ordering. A custom graph backed by per-vertexHashSet<TEdge>would list the tree edge twice and drop its parallel twin, with bridges and articulation points still correct. Skipping onEquals(edge, frame.TreeEdge)once removes the assumption. The existing parallel-edge test uses value-equal edges and asserts counts only, so it would not catch this. MinArcWeightreturnsZerowhen no arc is found — GraphKShortestPathsExtensions.cs#L199.foundis set but never checked. The enumerator is lazy and re-reads the live graph eachMoveNext, and the library documents that mutations show through deferred enumeration, so aRemoveEdgebetween yields silently under-counts the root weight and later distances come out too small and non-monotone. Throwing on!foundmatches howCheckedWeightfails.
Cleanup
- Min-cost flow re-implements
ResidualNetwork.Augmentand the edge-flow loop — MinCostMaximumFlow.cs#L147.ArcCount/Tail/Flow/Originwere exported only to make the copies possible, so the "forward arcs are even" invariant now lives in two files.total += network.Augment(parentArc, sourceIndex, sinkIndex)plus an internal edge-flow enumerator next toBuildResultremoves both. - Cost list kept aligned outside
AddArcPair,_cost!unjustified — ResidualNetwork.cs#L93. Any futurecontinuebetweenAddArcPairand the cost append shifts every later cost by one pair with no exception. Pass the cost intoAddArcPair(Zerowhen no selector) and either drop the!or document the invariant at the suppression site, per coding-standards.md. SpurDijkstracopiesDijkstraShortestPath.RelaxandBuildPath— GraphKShortestPathsExtensions.cs#L207. Differs only by two ban sets and a different negative-weight message. A vertex-filter parameter on the shared core plusGraphTraversalCore.BuildPathwould remove the copy.TwoEdgeConnectedComponentsclones theConnectedComponentsBFS — GraphConnectivityExtensions.cs#L262. Same flood-fill 40 lines apart with one extra skip; a private helper taking an optional edge predicate keeps one implementation.- Bellman-Ford potentials run even when all costs are non-negative — MinCostMaximumFlow.cs#L187. With all costs ≥ 0 the zero vector is already feasible and
hasPotential = all trueis behaviorally identical. On a long-diameter network with unfavorable insertion order this is Θ(V·E) before the first augmentation. One O(E) sign scan skips it for the common case.
Coverage and docs
- New token overloads missing from
CancellableOperations— CancellationTests.cs#L32. The new tests only pass a pre-cancelled token, which the entry-point check satisfies alone, so the per-source and per-augmentation checks are undefended. RegisterJohnsonAllShortestPaths.Compute,MinCostMaximumFlow.FindMinimumCostMaximumFlow, and the facade. AllPairsShortestPathsdoc still says "Floyd-Warshall computation" — FloydWarshallAllShortestPaths.cs#L143. Johnson now returns the same type, and it still lives in the Floyd-Warshall file.
Checked and fine
Unsigned TWeight costs (every compared quantity is provably non-negative), no CancellationToken on EnumerateShortestPaths (matches the documented lazy-enumerator precedent in GraphCliqueExtensions), ResidualNetwork changes are additive for Edmonds-Karp and Dinic, PublicAPI entries match the actual signatures, and the biconnected edge-stack discipline holds on the built-in graph types.
AllPairsShortestPaths.Between walked a next-hop matrix across rows (current = next[current, to]), but Johnson fills each row from its own Dijkstra tree. Rows break ties independently, so on a zero-weight cycle row a could point at b while row b pointed back at a, and Between never terminated. The random tests never used weight 0, so they stayed green. Switch the shared result to a row-local predecessor matrix: Johnson stores its Dijkstra parents directly and Floyd-Warshall uses the textbook predecessor update (pred[i, j] = pred[k, j]). Reconstruction now walks a single row, which is a tree by construction, so tie-breaking differences between rows can no longer form a cycle. Cover the reviewer's reproduction, validate every reconstructed path (edges exist, cheapest-arc sum equals Distance) in the Floyd-Warshall comparisons, and add random graphs dense in zero-weight cycles. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The low-link DFS skipped "the first edge back to the parent" once, which only coincides with the tree edge when both endpoints enumerate their incident edges in the same order. UndirectedMultigraph does, but IReadOnlyGraph promises no cross-endpoint ordering: a custom graph with per-vertex unordered storage could skip a parallel twin instead, stack the tree edge twice, and lose the twin from its component. Bridges and articulation points were unaffected since they only use discovery times. Match the stored tree edge itself, which drops the Parent field from the frame. Covered by a read-only view that reverses one vertex's incidence order over reference-identity edges. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
MinArcWeight tracked whether it found an arc but never checked the flag, returning Zero for a missing one. The enumerator re-reads the live graph on every MoveNext, so removing an edge of an already-yielded path made the root weight silently too small and later distances non-monotone. Surface it as InvalidOperationException, the same way the graphs report mutation during enumeration. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The constructor appended the two cost entries after AddArcPair had already added the arc pair, so any future early-continue between the two calls would shift every later cost by one pair without an error, and the alignment relied on an unexplained null-forgiving operator. Pass the cost into AddArcPair so all per-arc lists grow in one place, and justify the remaining suppression on the accessor. No behaviour changes; the existing flow suites cover it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…flow Min-cost flow re-implemented ResidualNetwork.Augment and the per-edge flow loop, and ArcCount/Tail/Flow/Origin were exported only to make those copies possible, so the "forward arcs are the even ids" invariant lived in two files. Augment now does the bottleneck push, EdgeFlows and TotalCost report the results, and Bellman-Ford walks each vertex's incident arcs instead of raw arc ids, so the four accessors are gone and the arc layout is private to ResidualNetwork again. No behaviour changes; the existing flow suites cover it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
With every edge cost non-negative the zero potential vector already makes all reduced costs non-negative, so the initial Bellman-Ford pass was pure overhead: Θ(V·E) before the first augmentation on a long-diameter network with unfavourable insertion order. One O(E) sign scan skips it for the common case; negative costs still take the full pass and keep the negative-cycle detection. Flow value and total cost are unaffected. Only the tie-break between equal-cost augmenting paths can differ, and the suites assert optimality rather than a specific tie. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…rches SpurDijkstra was a copy of DijkstraShortestPath.Relax plus its own path rebuild, differing only by two ban sets and the wording of the negative-weight message. Move the loop into an internal DijkstraCore with an optional (current, neighbor) arc filter and a caller-supplied requirement string; the public class passes no filter, the spur search expresses its banned vertices and first hops through it and rebuilds the path with GraphTraversalCore.BuildPath. No behaviour changes; the Dijkstra and k-shortest-path suites cover it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
TwoEdgeConnectedComponents repeated the ConnectedComponents BFS forty lines down with one extra skip for bridge endpoints. One private flood fill with an optional (current, other) blocking predicate now serves both, so a future change to the traversal cannot drift between them. No behaviour changes; the connectivity suites cover it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Johnson and min-cost flow only had pre-cancelled-token tests, which the entry check satisfies on its own, so their per-source and per-augmentation checks had no test behind them. Register both (plus the Johnson ParallelOptions overload and the min-cost facade) in CancellableOperations, and cancel from inside the weight/capacity selector, which runs during setup before the inner loops, so the inner checks are the ones that throw. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The result type is now shared by Floyd-Warshall and Johnson, so it no longer belongs inside the Floyd-Warshall file; one top-level type per file is also the repository's coding standard. Pure move, no code changes. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Review response: all ten findings addressedThanks for the thorough pass. Every finding checked out against the code, and the blocking one reproduced exactly as described: your eight-edge graph ran Blocking
Correctness, lower likelihood
Cleanup
Coverage and docs
Verification
|
Follow-up: fixes verifiedAll ten fixes check out against their commits and tests. Suite at a894d51: 921 passing on net8.0 and net10.0, zero warnings,
|
The codecov patch check compares new lines against the project rate, and the only shortfall sat in two files. Each gap was either dead code or a branch the test suite never reached: - SharesPrefix guarded a length its sole caller already checks; remove it. - SpurDijkstra accepted nullable ban sets whose null branches were unreachable because Enumerate always passes both; pass empty sets on the first call and drop the nullability. - CheckedWeight re-validated weights that the spur Dijkstra from the same vertex has always already rejected, so its throw could never run; read the weight directly. - PathComparer's reference, null, and mismatch returns only run on hash collisions; make it internal and test them directly. - The Bellman-Ford skip for vertices unreachable from the source lost its coverage when non-negative networks stopped running Bellman-Ford; add a negative-cost network with such a vertex. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Summary
Minor release 1.1.0: four additive algorithm milestones completing the shortest-path, flow, and connectivity suites. No breaking changes — the public API grows by 23 analyzer-tracked entries, promoted to
PublicAPI.Shipped.txtat the cut.BiconnectedComponents(maximal edge sets sharing a simple cycle; bridges surface as single-edge components) andTwoEdgeConnectedComponents(vertex partition after removing all bridges), riding the existing iterative low-link DFS so all four connectivity views agree by construction. Closes M46: Add biconnected and 2-edge-connected components #11MinCostMaximumFlow(successive shortest augmenting paths with vertex potentials) plus theMinimumCostMaximumFlowentry point; separate capacity/cost selectors, negative costs supported until a negative-cost cycle is reachable,TotalCoston the result. Closes M47: Add minimum-cost maximum flow #12JohnsonAllShortestPaths, the sparse alternative to Floyd-Warshall: one Bellman-Ford pass from an implicit virtual source (no graph mutation) sets potentials, then Dijkstra per source; same result type and negative-weight semantics, plus aParallelOptionsoverload. Closes M48: Add Johnson's all-pairs shortest paths #13EnumerateShortestPaths(Yen's algorithm): a lazy sequence of simple paths in nondecreasing weight, so.Take(k)is the classic query and enumeration cost is caller-controlled. Closes M49: Add lazy k-shortest-path enumeration (Yen) #14Release cut: version 1.1.0,
[1.1.0]CHANGELOG section, both READMEs updated (feature rows, usage snippets, "1.1 is stable", test count).Rebased onto
masterat 1.0.1, so the branch carries the 1.0.1 correctness fixes and packaging.Verification
dotnet test— 905 tests passing on both net8.0 and net10.0, zero warnings underTreatWarningsAsErrorsdotnet pack—Graph1x.1.1.0.nupkg+.snupkgproduced cleanlyPublicAPI.Unshipped.txtempty after promotion🤖 Generated with Claude Code