Skip to content

Cut 1.1.0: shortest-path, flow, and connectivity suite completions - #20

Merged
lgamorim merged 17 commits into
masterfrom
feature/graph-library-1.1.0
Oct 6, 2026
Merged

lgamorim merged 17 commits into
masterfrom
feature/graph-library-1.1.0

Conversation

@lgamorim

@lgamorim lgamorim commented Sep 7, 2026

Copy link
Copy Markdown
Owner

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.txt at the cut.

  • M46 — Biconnected and 2-edge-connected components. BiconnectedComponents (maximal edge sets sharing a simple cycle; bridges surface as single-edge components) and TwoEdgeConnectedComponents (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 #11
  • M47 — Minimum-cost maximum flow. MinCostMaximumFlow (successive shortest augmenting paths with vertex potentials) plus the MinimumCostMaximumFlow entry point; separate capacity/cost selectors, negative costs supported until a negative-cost cycle is reachable, TotalCost on the result. Closes M47: Add minimum-cost maximum flow #12
  • M48 — Johnson's all-pairs shortest paths. JohnsonAllShortestPaths, 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 a ParallelOptions overload. Closes M48: Add Johnson's all-pairs shortest paths #13
  • M49 — Lazy k-shortest-path enumeration. EnumerateShortestPaths (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) #14

Release 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 master at 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 under TreatWarningsAsErrors
  • dotnet pack — Graph1x.1.1.0.nupkg + .snupkg produced cleanly
  • PublicAPI.Unshipped.txt empty after promotion

🤖 Generated with Claude Code

lgamorim and others added 4 commits September 7, 2026 13:21
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

codecov Bot commented Sep 7, 2026 •

Copy link
Copy Markdown

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>
@lgamorim
lgamorim force-pushed the feature/graph-library-1.1.0 branch from e74e1bf to e1a9f5a Compare September 7, 2026 12:56
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 lgamorim left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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");  // hangs

Floyd-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 UndirectedMultigraph because both incidence lists share insertion order, but IReadOnlyGraph is public and promises no cross-endpoint ordering. A custom graph backed by per-vertex HashSet<TEdge> would list the tree edge twice and drop its parallel twin, with bridges and articulation points still correct. Skipping on Equals(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.
  • MinArcWeight returns Zero when no arc is found — GraphKShortestPathsExtensions.cs#L199. found is set but never checked. The enumerator is lazy and re-reads the live graph each MoveNext, and the library documents that mutations show through deferred enumeration, so a RemoveEdge between yields silently under-counts the root weight and later distances come out too small and non-monotone. Throwing on !found matches how CheckedWeight fails.

Cleanup

  • Min-cost flow re-implements ResidualNetwork.Augment and the edge-flow loop — MinCostMaximumFlow.cs#L147. ArcCount/Tail/Flow/Origin were 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 to BuildResult removes both.
  • Cost list kept aligned outside AddArcPair, _cost! unjustified — ResidualNetwork.cs#L93. Any future continue between AddArcPair and the cost append shifts every later cost by one pair with no exception. Pass the cost into AddArcPair (Zero when no selector) and either drop the ! or document the invariant at the suppression site, per coding-standards.md.
  • SpurDijkstra copies DijkstraShortestPath.Relax and BuildPath — GraphKShortestPathsExtensions.cs#L207. Differs only by two ban sets and a different negative-weight message. A vertex-filter parameter on the shared core plus GraphTraversalCore.BuildPath would remove the copy.
  • TwoEdgeConnectedComponents clones the ConnectedComponents BFS — 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 true is 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. Register JohnsonAllShortestPaths.Compute, MinCostMaximumFlow.FindMinimumCostMaximumFlow, and the facade.
  • AllPairsShortestPaths doc 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.

lgamorim and others added 10 commits October 1, 2026 20:37
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>
@lgamorim

lgamorim commented Oct 1, 2026

Copy link
Copy Markdown
Owner Author

Review response: all ten findings addressed

Thanks for the thorough pass. Every finding checked out against the code, and the blocking one reproduced exactly as described: your eight-edge graph ran Between("a", "t") until the path list exhausted memory (about 30 s) while Floyd-Warshall answered immediately. Each item landed as its own commit so they can be read, or reverted, independently.

Blocking

  • Johnson path reconstruction can loop forever — fixed in f692e5e. Took the row-local option: AllPairsShortestPaths now holds a predecessor matrix, Johnson stores its Dijkstra parents directly, and Floyd-Warshall uses the textbook pred[i, j] = pred[k, j] update. Between walks a single row, which is a tree by construction, so independent tie-breaking between rows can no longer form a cycle. Your reproduction is now a test, the Floyd-Warshall comparisons validate every reconstructed path against the graph (edges exist, cheapest-arc sum equals Distance), and a new test runs random graphs dense in zero-weight cycles through both algorithms.

Correctness, lower likelihood

  • Parent-edge skip matches by vertex, not tree-edge identity — a5f6b90. Skips on EqualityComparer<TEdge>.Default.Equals(edge, frame.TreeEdge) once; Parent is gone from the frame. Covered by a read-only view that reverses one vertex's incidence order over reference-identity edges, which failed before the change.
  • MinArcWeight returns Zero when no arc is found — aba101b. Throws InvalidOperationException on !found, matching how the graphs report mutation during enumeration; tested by removing an edge of the yielded path between two MoveNext calls.

Cleanup

  • Min-cost flow re-implements Augment and the edge-flow loop — 67298e8. total += network.Augment(...), plus internal EdgeFlows() and TotalCost() next to BuildResult. Bellman-Ford now walks IncidentArcs(tail) instead of raw arc ids, so ArcCount/Tail/Flow/Origin are removed and the even-id layout is private to ResidualNetwork again.
  • Cost list kept aligned outside AddArcPair, _cost! unjustified — 396475e. Cost is passed into AddArcPair (Zero without a selector) and the remaining suppression on Cost(arc) carries a justification comment.
  • SpurDijkstra copies Relax and BuildPath — 3eb1f59. New internal DijkstraCore.Relax with an optional (current, neighbor) skip filter and a caller-supplied requirement string; the public class passes no filter, the spur search expresses both ban sets through it and rebuilds with GraphTraversalCore.BuildPath.
  • TwoEdgeConnectedComponents clones the BFS — a009c97. One private flood fill with an optional blocking predicate serves both methods.
  • Bellman-Ford runs even when all costs are non-negative — b63bd60. ResidualNetwork.HasNegativeCost() sign scan; the zero vector is used when it is clean. Flow value and total cost are unchanged; only the tie-break between equal-cost augmenting paths can differ, and the suites assert optimality rather than a specific tie.

Coverage and docs

  • Token overloads missing from CancellableOperations — b9b7670. Registered Johnson, JohnsonParallel, MinCostFlow and MinCostFlowFacade, and added mid-computation tests that cancel from inside the weight/capacity selector (which runs during setup) so the per-source and per-augmentation checks are the ones that throw.
  • Doc still says "Floyd-Warshall computation" — wording fixed with the blocking commit; a894d51 moves AllPairsShortestPaths into its own file as a pure move.

Verification

dotnet test Graph1x.sln: 921 passing on net8.0 and net10.0 (up from 905), zero warnings under TreatWarningsAsErrors; dotnet format --verify-no-changes clean; no public API changes, so PublicAPI.*.txt is untouched.

@lgamorim

lgamorim commented Oct 6, 2026

Copy link
Copy Markdown
Owner Author

Follow-up: fixes verified

All ten fixes check out against their commits and tests. Suite at a894d51: 921 passing on net8.0 and net10.0, zero warnings, PublicAPI.*.txt untouched. I also stress-ran both all-pairs reconstructions over 150 random graphs (a third with every weight zero): every path exists, is simple, and sums to its reported distance, for Floyd-Warshall and Johnson alike. Nothing blocking remains. Three minor points, none of which need to hold the merge:

  1. The min-cost flow cancellation test guards a different check than its comment says. Cancelling from the capacity selector trips the ThrowIfCancellationRequested() that follows InitialPotentials, not the one inside the augmentation loop, so deleting the in-loop check alone keeps the suite green. There is no deterministic way to cancel between augmentations from outside, which is fine; the comment on MidComputation_MinCostFlowObservesCancellationBetweenAugmentations and the commit message should just describe what the test actually exercises.

  2. Floyd-Warshall may now return a different path among equal-cost ties than 1.0.1 did. Switching from the next-hop matrix to the predecessor update is within contract (any shortest path is valid), but it is a user-visible change to a shipped API and the 1.1.0 section only has ### Added. A one-line ### Changed entry would match how 1.0.1 called out its behavior changes.

  3. codecov/patch is the only red check. The report attributes the gap to GraphKShortestPathsExtensions.cs. Whether to add coverage or accept it is your call, but the PR stays red until one or the other.

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>
@lgamorim
lgamorim merged commit 279dbd9 into master Oct 6, 2026
5 checks passed
@lgamorim
lgamorim deleted the feature/graph-library-1.1.0 branch October 6, 2026 17:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant