22
33import java .util .*;
44import java .util .concurrent .ConcurrentHashMap ;
5- import java .util .concurrent .ConcurrentMap ;
65import lombok .extern .slf4j .Slf4j ;
76import org .hjug .feedback .SuperTypeToken ;
87import 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 */
0 commit comments