Skip to content

Commit 8f00256

Browse files
Merge pull request #226 from refactorfirst/use-ClassRelationshipDTO-in-package-cycles
Use class relationship dto in package cycles. Feedback arc selections are consistent across repeated runs
2 parents dabe805 + c6b8174 commit 8f00256

10 files changed

Lines changed: 1496 additions & 537 deletions

File tree

‎.refactorfirst/refactor-first.json‎

Lines changed: 683 additions & 485 deletions
Large diffs are not rendered by default.

‎graph-algorithms/src/main/java/org/hjug/feedback/arc/pageRank/PageRankFAS.java‎

Lines changed: 88 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
import java.util.*;
44
import java.util.concurrent.ConcurrentHashMap;
5-
import java.util.concurrent.ConcurrentMap;
65
import lombok.extern.slf4j.Slf4j;
76
import org.hjug.feedback.SuperTypeToken;
87
import org.jgrapht.Graph;
@@ -98,11 +97,49 @@ private E processStronglyConnectedComponent(Graph<V, E> graph, Set<V> scc) {
9897
// Run PageRank on line digraph
9998
Map<LineVertex<V, E>, Double> pageRankScores = computePageRank(lineDigraph);
10099

101-
// Find the edge (line vertex) with highest PageRank score
102-
return pageRankScores.entrySet().parallelStream()
103-
.max(Map.Entry.comparingByValue())
104-
.map(entry -> entry.getKey().getOriginalEdge())
105-
.orElse(null);
100+
// Deterministically select the edge (line vertex) with the highest PageRank score
101+
return selectHighestScoredEdge(pageRankScores);
102+
}
103+
104+
/**
105+
* Canonical, run-stable total order over line vertices: by the string form of the source
106+
* vertex, then of the target vertex. Because the analyzed graphs never contain parallel
107+
* edges, {@code (source, target)} uniquely identifies an edge, making this a total order
108+
* over line vertices. Every ordering-sensitive step of the algorithm (PageRank accumulation,
109+
* max-score selection, line-digraph DFS start) follows this order so that repeated runs on
110+
* the same input graph — e.g. by the htmlReport and the jsonReport — produce identical
111+
* feedback arc sets.
112+
*/
113+
private Comparator<LineVertex<V, E>> lineVertexOrder() {
114+
return Comparator.comparing((LineVertex<V, E> lineVertex) -> String.valueOf(lineVertex.getSource()))
115+
.thenComparing(lineVertex -> String.valueOf(lineVertex.getTarget()));
116+
}
117+
118+
/**
119+
* Deterministically selects the line vertex carrying the highest PageRank score. Ties —
120+
* which are common in symmetric cycles where every candidate edge scores identically — are
121+
* broken by the canonical line-vertex order so the selection cannot vary between runs.
122+
*
123+
* @param pageRankScores PageRank score per line vertex
124+
* @return the original edge of the highest-scored line vertex, or {@code null} when empty
125+
*/
126+
private E selectHighestScoredEdge(Map<LineVertex<V, E>, Double> pageRankScores) {
127+
LineVertex<V, E> best = null;
128+
double bestScore = Double.NEGATIVE_INFINITY;
129+
for (Map.Entry<LineVertex<V, E>, Double> entry : pageRankScores.entrySet()) {
130+
double score = entry.getValue();
131+
boolean tiedWinner = score == bestScore && best != null && isCanonicallyBefore(entry.getKey(), best);
132+
if (best == null || score > bestScore || tiedWinner) {
133+
best = entry.getKey();
134+
bestScore = score;
135+
}
136+
}
137+
return best != null ? best.getOriginalEdge() : null;
138+
}
139+
140+
/** Returns whether the candidate precedes the incumbent in canonical source/target order. */
141+
private boolean isCanonicallyBefore(LineVertex<V, E> candidate, LineVertex<V, E> incumbent) {
142+
return lineVertexOrder().compare(candidate, incumbent) < 0;
106143
}
107144

108145
/**
@@ -138,10 +175,13 @@ private void createLineDigraphEdges(
138175
Graph<V, E> graph, LineDigraph<V, E> lineDigraph, Map<E, LineVertex<V, E>> edgeToLineVertex) {
139176
Set<V> visited = ConcurrentHashMap.newKeySet();
140177

141-
// Start DFS from a random vertex if graph is not empty
178+
// Start DFS from the canonically smallest vertex so the traversal — and therefore the
179+
// resulting line-digraph edge set — is identical on every run
142180
if (!graph.vertexSet().isEmpty()) {
143-
V startVertex = graph.vertexSet().iterator().next();
144-
createLineDigraphEdgesDFS(graph, lineDigraph, edgeToLineVertex, startVertex, null, visited);
181+
graph.vertexSet().stream()
182+
.min(Comparator.comparing(String::valueOf))
183+
.ifPresent(startVertex -> createLineDigraphEdgesDFS(
184+
graph, lineDigraph, edgeToLineVertex, startVertex, null, visited));
145185
}
146186
}
147187

@@ -158,8 +198,11 @@ private void createLineDigraphEdgesDFS(
158198
Set<V> visited) {
159199
visited.add(vertex);
160200

161-
// Get outgoing edges from current vertex
162-
Set<E> outgoingEdges = graph.outgoingEdgesOf(vertex);
201+
// Iterate outgoing edges in canonical (target-vertex) order rather than the graph's
202+
// internal set order, so the traversal — and therefore the line-digraph edge set and
203+
// the FAS selection — cannot vary with the order edges were added to the graph
204+
List<E> outgoingEdges = new ArrayList<>(graph.outgoingEdgesOf(vertex));
205+
outgoingEdges.sort(Comparator.comparing(edge -> String.valueOf(graph.getEdgeTarget(edge))));
163206

164207
for (E edge : outgoingEdges) {
165208
V target = graph.getEdgeTarget(edge);
@@ -191,6 +234,10 @@ private void createLineDigraphEdgesDFS(
191234

192235
/**
193236
* Compute PageRank scores on the line digraph (Algorithm 4 implementation)
237+
* Accumulation is sequential and iterates vertices and their outgoing neighbors in the
238+
* canonical line-vertex order, keeping the floating-point sums bit-identical across runs —
239+
* a parallel merge would introduce run-dependent rounding and, through it, different
240+
* feedback arc selections in different report executions.
194241
* @param lineDigraph The line digraph
195242
* @return Map of line vertices to their PageRank scores
196243
*/
@@ -200,58 +247,56 @@ private Map<LineVertex<V, E>, Double> computePageRank(LineDigraph<V, E> lineDigr
200247

201248
if (numVertices == 0) return new HashMap<>();
202249

250+
// Canonical iteration order for vertices and adjacency keeps every run identical
251+
List<LineVertex<V, E>> orderedVertices = new ArrayList<>(vertices);
252+
orderedVertices.sort(lineVertexOrder());
253+
Map<LineVertex<V, E>, List<LineVertex<V, E>>> orderedOutgoing = new HashMap<>();
254+
for (LineVertex<V, E> v : orderedVertices) {
255+
List<LineVertex<V, E>> outgoing = new ArrayList<>(lineDigraph.getOutgoingNeighbors(v));
256+
outgoing.sort(lineVertexOrder());
257+
orderedOutgoing.put(v, outgoing);
258+
}
259+
203260
// Initialize PageRank scores
204-
Map<LineVertex<V, E>, Double> currentScores =
205-
new ConcurrentHashMap<>(Math.max(16, (int) (numVertices / 0.75f) + 1));
261+
Map<LineVertex<V, E>, Double> currentScores = new HashMap<>(Math.max(16, (int) (numVertices / 0.75f) + 1));
206262

207263
final double initialScore = 1.0 / numVertices;
208-
// No lambdas here, so nothing captures a non-final variable
209-
for (LineVertex<V, E> v : vertices) {
264+
for (LineVertex<V, E> v : orderedVertices) {
210265
currentScores.put(v, initialScore);
211266
}
212267

213268
// Run PageRank iterations
214269
for (int iteration = 0; iteration < pageRankIterations; iteration++) {
215270
// Fresh map each iteration; pre-seed zeros so all vertices exist in the map
216-
ConcurrentMap<LineVertex<V, E>, Double> newScores = new ConcurrentHashMap<>(currentScores.size());
271+
Map<LineVertex<V, E>, Double> newScores = new HashMap<>(currentScores.size());
217272

218-
for (LineVertex<V, E> v : vertices) {
273+
for (LineVertex<V, E> v : orderedVertices) {
219274
newScores.put(v, 0.0);
220275
}
221276

222-
// Do one iteration in parallel; lambdas only see method parameters (effectively final)
223-
applyOneIteration(vertices, lineDigraph, currentScores, newScores);
277+
// One sequential iteration in canonical order; lambdas only see method parameters
278+
for (LineVertex<V, E> v : orderedVertices) {
279+
double score = currentScores.get(v);
280+
List<LineVertex<V, E>> outgoing = orderedOutgoing.get(v);
281+
282+
if (outgoing.isEmpty()) {
283+
// Sink: keep score on itself
284+
newScores.merge(v, score, Double::sum);
285+
} else {
286+
double scorePerEdge = score / outgoing.size();
287+
for (LineVertex<V, E> target : outgoing) {
288+
newScores.merge(target, scorePerEdge, Double::sum);
289+
}
290+
}
291+
}
224292

225-
// Swap for next iteration (this reassigns local variables, not captured by lambdas)
293+
// Swap for next iteration
226294
currentScores = newScores;
227295
}
228296

229297
return currentScores;
230298
}
231299

232-
private void applyOneIteration(
233-
Set<LineVertex<V, E>> vertices,
234-
LineDigraph<V, E> lineDigraph,
235-
Map<LineVertex<V, E>, Double> currentScores,
236-
ConcurrentMap<LineVertex<V, E>, Double> newScores) {
237-
238-
vertices.parallelStream().forEach(vertex -> {
239-
double score = currentScores.get(vertex);
240-
Set<LineVertex<V, E>> outgoing = lineDigraph.getOutgoingNeighbors(vertex);
241-
242-
if (outgoing.isEmpty()) {
243-
// Sink: keep score on itself
244-
newScores.merge(vertex, score, Double::sum);
245-
} else {
246-
double scorePerEdge = score / outgoing.size();
247-
// Inner loop kept sequential: nested parallel often hurts more than it helps
248-
for (LineVertex<V, E> target : outgoing) {
249-
newScores.merge(target, scorePerEdge, Double::sum);
250-
}
251-
}
252-
});
253-
}
254-
255300
/**
256301
* Find strongly connected components using Kosaraju's algorithm
257302
*/
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
package org.hjug.feedback.arc.pageRank;
2+
3+
import static org.junit.jupiter.api.Assertions.assertEquals;
4+
import static org.junit.jupiter.api.Assertions.assertTrue;
5+
6+
import java.util.HashSet;
7+
import java.util.Set;
8+
import java.util.TreeSet;
9+
import org.hjug.feedback.SuperTypeToken;
10+
import org.jgrapht.Graph;
11+
import org.jgrapht.alg.cycle.CycleDetector;
12+
import org.jgrapht.graph.DefaultDirectedGraph;
13+
import org.jgrapht.graph.DefaultEdge;
14+
import org.junit.jupiter.api.DisplayName;
15+
import org.junit.jupiter.api.Test;
16+
17+
/**
18+
* Regression tests pinning the run-to-run determinism of {@link PageRankFAS#computeFeedbackArcSet()}.
19+
*
20+
* <p>Both the htmlReport and the jsonReport invoke the same cycle-removal pipeline on the same
21+
* package graph, so a non-deterministic feedback arc selection makes the two reports disagree on
22+
* which package relationships (and, transitively, which class relationships) to remove. This
23+
* suite reproduces the exact package-SCC shape that caused the htmlReport / jsonReport
24+
* discrepancy between the {@code org.hjug.feedback} and
25+
* {@code org.hjug.feedback.vertex.kernelized} packages.</p>
26+
*/
27+
class PageRankFASDeterminismTest {
28+
29+
private static final String FEEDBACK = "org.hjug.feedback";
30+
private static final String PAGE_RANK = "org.hjug.feedback.arc.pageRank";
31+
private static final String KERNELIZED = "org.hjug.feedback.vertex.kernelized";
32+
33+
/** Builds the package-SCC shape observed in the RefactorFirst report output. */
34+
private Graph<String, DefaultEdge> createFeedbackPackageScc() {
35+
Graph<String, DefaultEdge> graph = new DefaultDirectedGraph<>(DefaultEdge.class);
36+
graph.addVertex(FEEDBACK);
37+
graph.addVertex(PAGE_RANK);
38+
graph.addVertex(KERNELIZED);
39+
graph.addEdge(FEEDBACK, PAGE_RANK);
40+
graph.addEdge(PAGE_RANK, FEEDBACK);
41+
graph.addEdge(FEEDBACK, KERNELIZED);
42+
graph.addEdge(KERNELIZED, FEEDBACK);
43+
return graph;
44+
}
45+
46+
/** Copies the graph topology with fresh edges so each run can remove edges independently. */
47+
private Graph<String, DefaultEdge> copyGraph(Graph<String, DefaultEdge> original) {
48+
Graph<String, DefaultEdge> copy = new DefaultDirectedGraph<>(DefaultEdge.class);
49+
original.vertexSet().forEach(copy::addVertex);
50+
original.edgeSet()
51+
.forEach(edge ->
52+
copy.addEdge(original.getEdgeSource(edge), original.getEdgeTarget(edge), new DefaultEdge()));
53+
return copy;
54+
}
55+
56+
/** Converts edges to sorted source/target keys for comparison across graph copies. */
57+
private Set<String> edgeKeys(Graph<String, DefaultEdge> graph, Set<DefaultEdge> edges) {
58+
Set<String> keys = new TreeSet<>();
59+
for (DefaultEdge edge : edges) {
60+
keys.add(graph.getEdgeSource(edge) + " -> " + graph.getEdgeTarget(edge));
61+
}
62+
return keys;
63+
}
64+
65+
/** Verifies repeated runs select the same edges and break both cycles in the package SCC. */
66+
@DisplayName("computeFeedbackArcSet selects the same edges on every run for the feedback package SCC")
67+
@Test
68+
void computeFeedbackArcSet_isDeterministicAcrossRuns() {
69+
// Given: the package SCC from the RefactorFirst report whose tied PageRank scores made
70+
// htmlReport and jsonReport pick different package edges to remove
71+
Graph<String, DefaultEdge> template = createFeedbackPackageScc();
72+
73+
// When: the same algorithm runs many times on fresh copies of the same graph
74+
Set<String> firstRun = null;
75+
for (int i = 0; i < 300; i++) {
76+
Graph<String, DefaultEdge> graph = copyGraph(template);
77+
PageRankFAS<String, DefaultEdge> pageRankFAS = new PageRankFAS<>(graph, new SuperTypeToken<>() {});
78+
Set<String> run = edgeKeys(graph, pageRankFAS.computeFeedbackArcSet());
79+
if (firstRun == null) {
80+
firstRun = run;
81+
} else {
82+
assertEquals(
83+
firstRun,
84+
run,
85+
"computeFeedbackArcSet must select identical edges across runs; run " + i + " differed");
86+
}
87+
}
88+
89+
// Then: the selection breaks both 2-cycles, so the graph is acyclic after removal
90+
Graph<String, DefaultEdge> graph = copyGraph(template);
91+
PageRankFAS<String, DefaultEdge> pageRankFAS = new PageRankFAS<>(graph, new SuperTypeToken<>() {});
92+
Set<DefaultEdge> feedbackArcSet = pageRankFAS.computeFeedbackArcSet();
93+
assertEquals(2, feedbackArcSet.size(), "Both 2-cycles require exactly one removed edge each");
94+
feedbackArcSet.forEach(graph::removeEdge);
95+
assertTrue(
96+
!new CycleDetector<>(graph).detectCycles(),
97+
"Removing the feedback arc set must leave the graph acyclic");
98+
}
99+
100+
/** Verifies repeatable feedback arc selection and cycle removal in a larger symmetric graph. */
101+
@DisplayName("computeFeedbackArcSet is deterministic for a graph with parallel tie candidates")
102+
@Test
103+
void computeFeedbackArcSet_isDeterministicForLargerTiedGraph() {
104+
// Given: a larger symmetric SCC where many edges tie on PageRank score
105+
Graph<String, DefaultEdge> template = new DefaultDirectedGraph<>(DefaultEdge.class);
106+
for (int i = 0; i < 6; i++) {
107+
template.addVertex("P" + i);
108+
}
109+
for (int i = 0; i < 6; i++) {
110+
template.addEdge("P" + i, "P" + (i + 1) % 6);
111+
template.addEdge("P" + i, "P" + (i + 2) % 6);
112+
}
113+
114+
// When: the algorithm runs repeatedly on fresh copies
115+
Set<String> firstRun = null;
116+
for (int i = 0; i < 100; i++) {
117+
Graph<String, DefaultEdge> graph = copyGraph(template);
118+
PageRankFAS<String, DefaultEdge> pageRankFAS = new PageRankFAS<>(graph, new SuperTypeToken<>() {});
119+
Set<String> run = edgeKeys(graph, pageRankFAS.computeFeedbackArcSet());
120+
if (firstRun == null) {
121+
firstRun = run;
122+
} else {
123+
assertEquals(
124+
firstRun,
125+
run,
126+
"computeFeedbackArcSet must select identical edges across runs; run " + i + " differed");
127+
}
128+
}
129+
130+
// Then: the selection is a valid feedback arc set
131+
Graph<String, DefaultEdge> graph = copyGraph(template);
132+
PageRankFAS<String, DefaultEdge> pageRankFAS = new PageRankFAS<>(graph, new SuperTypeToken<>() {});
133+
Set<DefaultEdge> feedbackArcSet = pageRankFAS.computeFeedbackArcSet();
134+
Set<DefaultEdge> removed = new HashSet<>(feedbackArcSet);
135+
removed.forEach(graph::removeEdge);
136+
assertTrue(
137+
!new CycleDetector<>(graph).detectCycles(),
138+
"Removing the feedback arc set must leave the graph acyclic");
139+
}
140+
141+
/** Verifies edge insertion order does not change feedback arc selection for overlapping cycles. */
142+
@DisplayName("computeFeedbackArcSet is independent of the order edges were added to the graph")
143+
@Test
144+
void computeFeedbackArcSet_isDeterministicAcrossEdgeInsertionOrders() {
145+
// Given: one logical SCC expressing multiple overlapping cycles
146+
String[][] logicalEdges = {
147+
{"A", "B"},
148+
{"B", "C"},
149+
{"C", "A"}, // triangle 1
150+
{"A", "D"},
151+
{"D", "E"},
152+
{"E", "A"}, // triangle 2
153+
{"B", "E"},
154+
{"D", "B"}, // cross edges
155+
};
156+
157+
// And: several different edge insertion orders of the same logical edges
158+
int[][] permutations = {
159+
{0, 1, 2, 3, 4, 5, 6, 7}, // as declared
160+
{7, 6, 5, 4, 3, 2, 1, 0}, // reversed
161+
{3, 0, 6, 2, 5, 1, 7, 4}, // interleaved
162+
{5, 2, 7, 1, 4, 6, 0, 3}, // another interleave
163+
};
164+
165+
// When: the feedback arc set is computed for each insertion order
166+
Set<String> firstOrder = null;
167+
for (int[] permutation : permutations) {
168+
Graph<String, DefaultEdge> graph = new DefaultDirectedGraph<>(DefaultEdge.class);
169+
for (int edgeIndex : permutation) {
170+
String[] logicalEdge = logicalEdges[edgeIndex];
171+
graph.addVertex(logicalEdge[0]);
172+
graph.addVertex(logicalEdge[1]);
173+
graph.addEdge(logicalEdge[0], logicalEdge[1]);
174+
}
175+
PageRankFAS<String, DefaultEdge> pageRankFAS = new PageRankFAS<>(graph, new SuperTypeToken<>() {});
176+
Set<String> fas = edgeKeys(graph, pageRankFAS.computeFeedbackArcSet());
177+
178+
// Then: every insertion order selects the identical feedback arc set
179+
if (firstOrder == null) {
180+
firstOrder = fas;
181+
} else {
182+
assertEquals(
183+
firstOrder,
184+
fas,
185+
"computeFeedbackArcSet must not depend on the order edges were added to the graph");
186+
}
187+
}
188+
}
189+
}

0 commit comments

Comments
 (0)