| 1 | package edu.ucsb.cs.scaffold.services; | |
| 2 | ||
| 3 | import edu.ucsb.cs.scaffold.entity.Concept; | |
| 4 | import edu.ucsb.cs.scaffold.entity.ConceptEdge; | |
| 5 | import java.util.ArrayDeque; | |
| 6 | import java.util.ArrayList; | |
| 7 | import java.util.Comparator; | |
| 8 | import java.util.Deque; | |
| 9 | import java.util.HashMap; | |
| 10 | import java.util.HashSet; | |
| 11 | import java.util.List; | |
| 12 | import java.util.Map; | |
| 13 | import java.util.Set; | |
| 14 | import java.util.stream.Collectors; | |
| 15 | import org.springframework.stereotype.Service; | |
| 16 | ||
| 17 | /** | |
| 18 | * Graph algorithms for the prerequisite structure of a course's top-level concepts (subconcepts | |
| 19 | * have no position in this graph). Prerequisite edges only ever connect top-level concepts, so | |
| 20 | * every method here operates on that subgraph. | |
| 21 | * | |
| 22 | * <p>{@link #reset} is the analysis run by {@code POST /api/course/scaffold/reset}: it detects | |
| 23 | * cycles (flagging their edges rather than processing them further), removes edges that are | |
| 24 | * redundant given the graph's transitive structure, ranks concepts by longest path from a root, and | |
| 25 | * lays out each level's x,y position. It is a pure function of its inputs — no repository access — | |
| 26 | * so the controller owns loading input and persisting the result. | |
| 27 | */ | |
| 28 | @Service | |
| 29 | public class ConceptGraphService { | |
| 30 | ||
| 31 | // Index i holds the color for level i+1; levels beyond the palette reuse the last color. | |
| 32 | public static final List<String> LEVEL_COLORS = | |
| 33 | List.of("#c99ffe", "#feaef2", "#93ebff", "#fe9a71", "#2bcd9c"); | |
| 34 | ||
| 35 | public static final String CYCLE_EDGE_COLOR = "#FF0000"; | |
| 36 | ||
| 37 | // Initial guesses; revisit if the resulting layout looks too cramped or too sparse. | |
| 38 | public static final int MIN_HORIZONTAL_SEPARATION = 350; | |
| 39 | public static final int VERTICAL_LEVEL_SEPARATION = 300; | |
| 40 | ||
| 41 | /** The color assigned to a top-level concept at the given longest-path level (1-based). */ | |
| 42 | public String colorForLevel(int level) { | |
| 43 |
1
1. colorForLevel : Replaced integer subtraction with addition → KILLED |
int index = Math.min(Math.max(level, 1), LEVEL_COLORS.size()) - 1; |
| 44 |
1
1. colorForLevel : replaced return value with "" for edu/ucsb/cs/scaffold/services/ConceptGraphService::colorForLevel → KILLED |
return LEVEL_COLORS.get(index); |
| 45 | } | |
| 46 | ||
| 47 | /** | |
| 48 | * True if adding an edge sourceId -> targetId would create a cycle, i.e. targetId can already | |
| 49 | * reach sourceId via existingEdges. Used to reject new prerequisite edges at creation time, | |
| 50 | * before a cycle can ever be persisted. | |
| 51 | */ | |
| 52 | public boolean wouldCreateCycle(List<ConceptEdge> existingEdges, Long sourceId, Long targetId) { | |
| 53 | Map<Long, List<Long>> adjacency = buildAdjacency(existingEdges); | |
| 54 | Set<Long> visited = new HashSet<>(); | |
| 55 | Deque<Long> queue = new ArrayDeque<>(); | |
| 56 | visited.add(targetId); | |
| 57 | queue.add(targetId); | |
| 58 |
1
1. wouldCreateCycle : negated conditional → KILLED |
while (!queue.isEmpty()) { |
| 59 | Long current = queue.poll(); | |
| 60 |
1
1. wouldCreateCycle : negated conditional → KILLED |
if (current.equals(sourceId)) { |
| 61 |
1
1. wouldCreateCycle : replaced boolean return with false for edu/ucsb/cs/scaffold/services/ConceptGraphService::wouldCreateCycle → KILLED |
return true; |
| 62 | } | |
| 63 | for (Long next : adjacency.getOrDefault(current, List.of())) { | |
| 64 |
1
1. wouldCreateCycle : negated conditional → KILLED |
if (visited.add(next)) { |
| 65 | queue.add(next); | |
| 66 | } | |
| 67 | } | |
| 68 | } | |
| 69 |
1
1. wouldCreateCycle : replaced boolean return with true for edu/ucsb/cs/scaffold/services/ConceptGraphService::wouldCreateCycle → KILLED |
return false; |
| 70 | } | |
| 71 | ||
| 72 | public record Position(int x, int y) {} | |
| 73 | ||
| 74 | public record ResetResult( | |
| 75 | Set<Long> cycleEdgeIds, | |
| 76 | Set<Long> removedEdgeIds, | |
| 77 | Map<Long, Integer> levelByConceptId, | |
| 78 | Map<Long, Position> positionByConceptId) {} | |
| 79 | ||
| 80 | /** | |
| 81 | * Runs the full scaffold reset analysis: cycle detection, transitive reduction, longest-path | |
| 82 | * leveling, and layout. Does not mutate concepts or edges or read/write any repository; the | |
| 83 | * caller applies {@link ResetResult} to persistent entities. | |
| 84 | * | |
| 85 | * @param topLevelConcepts every top-level concept in the course (used for their id/x, to sort and | |
| 86 | * lay out concepts with no edges at all, and as the node set for the graph algorithms) | |
| 87 | * @param edges every prerequisite edge in the course | |
| 88 | */ | |
| 89 | public ResetResult reset(List<Concept> topLevelConcepts, List<ConceptEdge> edges) { | |
| 90 | Map<Long, Integer> priorXByConceptId = | |
| 91 | topLevelConcepts.stream().collect(Collectors.toMap(Concept::getId, Concept::getX)); | |
| 92 |
1
1. reset : replaced return value with null for edu/ucsb/cs/scaffold/services/ConceptGraphService::reset → KILLED |
return reset(topLevelConcepts, edges, priorXByConceptId); |
| 93 | } | |
| 94 | ||
| 95 | /** | |
| 96 | * Like {@link #reset(List, List)}, but sorts each level by the given prior x values instead of | |
| 97 | * each concept's own x column. Used by the controller to sort by the requesting user's private, | |
| 98 | * unsaved drag positions where they exist, falling back to the concept's persisted x otherwise — | |
| 99 | * see {@code POST /api/course/scaffold/reset}. | |
| 100 | */ | |
| 101 | public ResetResult reset( | |
| 102 | List<Concept> topLevelConcepts, | |
| 103 | List<ConceptEdge> edges, | |
| 104 | Map<Long, Integer> priorXByConceptId) { | |
| 105 | Set<Long> nodeIds = topLevelConcepts.stream().map(Concept::getId).collect(Collectors.toSet()); | |
| 106 | ||
| 107 | Map<Long, Long> sccId = computeStronglyConnectedComponents(buildAdjacency(edges), nodeIds); | |
| 108 | Map<Long, Long> sccSize = | |
| 109 |
1
1. lambda$reset$0 : replaced Long return value with 0L for edu/ucsb/cs/scaffold/services/ConceptGraphService::lambda$reset$0 → KILLED |
sccId.values().stream().collect(Collectors.groupingBy(id -> id, Collectors.counting())); |
| 110 | ||
| 111 | Set<Long> cycleEdgeIds = new HashSet<>(); | |
| 112 | List<ConceptEdge> acyclicEdges = new ArrayList<>(); | |
| 113 | for (ConceptEdge edge : edges) { | |
| 114 | Long sourceId = edge.getSource().getId(); | |
| 115 | Long targetId = edge.getTarget().getId(); | |
| 116 | boolean inCycle = | |
| 117 |
3
1. reset : negated conditional → KILLED 2. reset : changed conditional boundary → KILLED 3. reset : negated conditional → KILLED |
sccId.get(sourceId).equals(sccId.get(targetId)) && sccSize.get(sccId.get(sourceId)) > 1; |
| 118 |
1
1. reset : negated conditional → KILLED |
if (inCycle) { |
| 119 | cycleEdgeIds.add(edge.getId()); | |
| 120 | } else { | |
| 121 | acyclicEdges.add(edge); | |
| 122 | } | |
| 123 | } | |
| 124 | ||
| 125 | Set<Long> removedEdgeIds = computeTransitiveReductionRemovals(nodeIds, acyclicEdges); | |
| 126 | ||
| 127 | // The transitively redundant edges are only reported for deletion, not filtered out | |
| 128 | // before leveling: a removed edge u->v is by definition a shortcut for a longer path | |
| 129 | // u -> ... -> v, whose constraint on v's level always dominates, so including or | |
| 130 | // excluding these edges cannot change any longest-path level. | |
| 131 | Map<Long, Integer> levelByConceptId = computeLongestPathLevels(nodeIds, acyclicEdges); | |
| 132 | Map<Long, Position> positionByConceptId = | |
| 133 | computeLayout(topLevelConcepts, levelByConceptId, priorXByConceptId); | |
| 134 | ||
| 135 |
1
1. reset : replaced return value with null for edu/ucsb/cs/scaffold/services/ConceptGraphService::reset → KILLED |
return new ResetResult(cycleEdgeIds, removedEdgeIds, levelByConceptId, positionByConceptId); |
| 136 | } | |
| 137 | ||
| 138 | /** | |
| 139 | * Lays each level out left to right, sorted by each concept's prior x (then id to break ties), | |
| 140 | * centered horizontally at x=0. Each level sits {@link #VERTICAL_LEVEL_SEPARATION} above the | |
| 141 | * previous one, with level 1 at y=0. | |
| 142 | */ | |
| 143 | private Map<Long, Position> computeLayout( | |
| 144 | List<Concept> topLevelConcepts, | |
| 145 | Map<Long, Integer> levelByConceptId, | |
| 146 | Map<Long, Integer> priorXByConceptId) { | |
| 147 | Map<Integer, List<Concept>> byLevel = | |
| 148 | topLevelConcepts.stream() | |
| 149 |
1
1. lambda$computeLayout$1 : replaced Integer return value with 0 for edu/ucsb/cs/scaffold/services/ConceptGraphService::lambda$computeLayout$1 → KILLED |
.collect(Collectors.groupingBy(c -> levelByConceptId.get(c.getId()))); |
| 150 | ||
| 151 | Map<Long, Position> positions = new HashMap<>(); | |
| 152 | for (Map.Entry<Integer, List<Concept>> entry : byLevel.entrySet()) { | |
| 153 | int level = entry.getKey(); | |
| 154 | List<Concept> sorted = | |
| 155 | entry.getValue().stream() | |
| 156 | .sorted( | |
| 157 |
1
1. lambda$computeLayout$2 : replaced Integer return value with 0 for edu/ucsb/cs/scaffold/services/ConceptGraphService::lambda$computeLayout$2 → KILLED |
Comparator.comparing((Concept c) -> priorXByConceptId.get(c.getId())) |
| 158 | .thenComparing(Concept::getId)) | |
| 159 | .toList(); | |
| 160 | int n = sorted.size(); | |
| 161 |
3
1. computeLayout : Replaced integer subtraction with addition → KILLED 2. computeLayout : removed negation → KILLED 3. computeLayout : Replaced integer multiplication with division → KILLED |
int y = -(level - 1) * VERTICAL_LEVEL_SEPARATION; |
| 162 |
2
1. computeLayout : changed conditional boundary → KILLED 2. computeLayout : negated conditional → KILLED |
for (int i = 0; i < n; i++) { |
| 163 |
4
1. computeLayout : Replaced double multiplication with division → KILLED 2. computeLayout : Replaced integer subtraction with addition → KILLED 3. computeLayout : Replaced double subtraction with addition → KILLED 4. computeLayout : Replaced double division with multiplication → KILLED |
int x = (int) Math.round((i - (n - 1) / 2.0) * MIN_HORIZONTAL_SEPARATION); |
| 164 | positions.put(sorted.get(i).getId(), new Position(x, y)); | |
| 165 | } | |
| 166 | } | |
| 167 |
1
1. computeLayout : replaced return value with Collections.emptyMap for edu/ucsb/cs/scaffold/services/ConceptGraphService::computeLayout → KILLED |
return positions; |
| 168 | } | |
| 169 | ||
| 170 | /** | |
| 171 | * Tarjan's algorithm: maps each node id to an id shared by its strongly connected component (a | |
| 172 | * nontrivial cycle iff more than one node shares it). The component id is the id of the | |
| 173 | * component's root node — any value unique per component would do, and using the root avoids | |
| 174 | * maintaining a separate counter. | |
| 175 | */ | |
| 176 | private Map<Long, Long> computeStronglyConnectedComponents( | |
| 177 | Map<Long, List<Long>> adjacency, Set<Long> nodeIds) { | |
| 178 | TarjanState state = new TarjanState(adjacency); | |
| 179 | for (Long node : nodeIds) { | |
| 180 |
1
1. computeStronglyConnectedComponents : negated conditional → KILLED |
if (!state.index.containsKey(node)) { |
| 181 |
1
1. computeStronglyConnectedComponents : removed call to edu/ucsb/cs/scaffold/services/ConceptGraphService$TarjanState::strongConnect → KILLED |
state.strongConnect(node); |
| 182 | } | |
| 183 | } | |
| 184 |
1
1. computeStronglyConnectedComponents : replaced return value with Collections.emptyMap for edu/ucsb/cs/scaffold/services/ConceptGraphService::computeStronglyConnectedComponents → KILLED |
return state.sccId; |
| 185 | } | |
| 186 | ||
| 187 | private static final class TarjanState { | |
| 188 | private final Map<Long, List<Long>> adjacency; | |
| 189 | private final Map<Long, Integer> index = new HashMap<>(); | |
| 190 | private final Map<Long, Integer> lowlink = new HashMap<>(); | |
| 191 | private final Set<Long> onStack = new HashSet<>(); | |
| 192 | private final Deque<Long> stack = new ArrayDeque<>(); | |
| 193 | private final Map<Long, Long> sccId = new HashMap<>(); | |
| 194 | private int counter = 0; | |
| 195 | ||
| 196 | TarjanState(Map<Long, List<Long>> adjacency) { | |
| 197 | this.adjacency = adjacency; | |
| 198 | } | |
| 199 | ||
| 200 | void strongConnect(Long v) { | |
| 201 | index.put(v, counter); | |
| 202 | lowlink.put(v, counter); | |
| 203 |
1
1. strongConnect : Replaced integer addition with subtraction → KILLED |
counter++; |
| 204 |
1
1. strongConnect : removed call to java/util/Deque::push → KILLED |
stack.push(v); |
| 205 | onStack.add(v); | |
| 206 | ||
| 207 | for (Long w : adjacency.getOrDefault(v, List.of())) { | |
| 208 |
1
1. strongConnect : negated conditional → KILLED |
if (!index.containsKey(w)) { |
| 209 |
1
1. strongConnect : removed call to edu/ucsb/cs/scaffold/services/ConceptGraphService$TarjanState::strongConnect → KILLED |
strongConnect(w); |
| 210 | lowlink.put(v, Math.min(lowlink.get(v), lowlink.get(w))); | |
| 211 |
1
1. strongConnect : negated conditional → KILLED |
} else if (onStack.contains(w)) { |
| 212 | lowlink.put(v, Math.min(lowlink.get(v), index.get(w))); | |
| 213 | } | |
| 214 | } | |
| 215 | ||
| 216 |
1
1. strongConnect : negated conditional → KILLED |
if (lowlink.get(v).equals(index.get(v))) { |
| 217 | Long w; | |
| 218 | do { | |
| 219 | w = stack.pop(); | |
| 220 | onStack.remove(w); | |
| 221 | // v is this component's root: every member gets tagged with its id. | |
| 222 | sccId.put(w, v); | |
| 223 |
1
1. strongConnect : negated conditional → KILLED |
} while (!w.equals(v)); |
| 224 | } | |
| 225 | } | |
| 226 | } | |
| 227 | ||
| 228 | /** | |
| 229 | * An edge u->v is redundant if some other direct successor w of u can also reach v; such an | |
| 230 | * edge is a "shortcut" whose removal does not change reachability. Requires an acyclic edge set. | |
| 231 | */ | |
| 232 | private Set<Long> computeTransitiveReductionRemovals( | |
| 233 | Set<Long> nodeIds, List<ConceptEdge> acyclicEdges) { | |
| 234 | Map<Long, List<Long>> adjacency = buildAdjacency(acyclicEdges); | |
| 235 | Map<Long, Set<Long>> reachable = new HashMap<>(); | |
| 236 | for (Long node : nodeIds) { | |
| 237 | reachable.put(node, bfsReachable(node, adjacency)); | |
| 238 | } | |
| 239 | ||
| 240 | Set<Long> removed = new HashSet<>(); | |
| 241 | for (ConceptEdge edge : acyclicEdges) { | |
| 242 | Long u = edge.getSource().getId(); | |
| 243 | Long v = edge.getTarget().getId(); | |
| 244 | boolean redundant = | |
| 245 | adjacency.getOrDefault(u, List.of()).stream() | |
| 246 |
3
1. lambda$computeTransitiveReductionRemovals$3 : replaced boolean return with true for edu/ucsb/cs/scaffold/services/ConceptGraphService::lambda$computeTransitiveReductionRemovals$3 → KILLED 2. lambda$computeTransitiveReductionRemovals$3 : negated conditional → KILLED 3. lambda$computeTransitiveReductionRemovals$3 : negated conditional → KILLED |
.anyMatch(w -> !w.equals(v) && reachable.getOrDefault(w, Set.of()).contains(v)); |
| 247 |
1
1. computeTransitiveReductionRemovals : negated conditional → KILLED |
if (redundant) { |
| 248 | removed.add(edge.getId()); | |
| 249 | } | |
| 250 | } | |
| 251 |
1
1. computeTransitiveReductionRemovals : replaced return value with Collections.emptySet for edu/ucsb/cs/scaffold/services/ConceptGraphService::computeTransitiveReductionRemovals → KILLED |
return removed; |
| 252 | } | |
| 253 | ||
| 254 | /** | |
| 255 | * Longest path from any root (no incoming edge) to each node; roots are level 1. Requires an | |
| 256 | * acyclic edge set. | |
| 257 | */ | |
| 258 | private Map<Long, Integer> computeLongestPathLevels(Set<Long> nodeIds, List<ConceptEdge> edges) { | |
| 259 | Map<Long, List<Long>> outNeighbors = buildAdjacency(edges); | |
| 260 | Map<Long, Integer> inDegree = new HashMap<>(); | |
| 261 | for (Long node : nodeIds) { | |
| 262 | inDegree.put(node, 0); | |
| 263 | } | |
| 264 | for (ConceptEdge edge : edges) { | |
| 265 | inDegree.merge(edge.getTarget().getId(), 1, Integer::sum); | |
| 266 | } | |
| 267 | ||
| 268 | Map<Long, Integer> level = new HashMap<>(); | |
| 269 | Deque<Long> queue = new ArrayDeque<>(); | |
| 270 | for (Long node : nodeIds) { | |
| 271 | level.put(node, 1); | |
| 272 |
1
1. computeLongestPathLevels : negated conditional → KILLED |
if (inDegree.get(node) == 0) { |
| 273 | queue.add(node); | |
| 274 | } | |
| 275 | } | |
| 276 | ||
| 277 |
1
1. computeLongestPathLevels : negated conditional → TIMED_OUT |
while (!queue.isEmpty()) { |
| 278 | Long u = queue.poll(); | |
| 279 | for (Long v : outNeighbors.getOrDefault(u, List.of())) { | |
| 280 |
1
1. computeLongestPathLevels : Replaced integer addition with subtraction → KILLED |
level.put(v, Math.max(level.get(v), level.get(u) + 1)); |
| 281 |
1
1. computeLongestPathLevels : negated conditional → KILLED |
if (inDegree.merge(v, -1, Integer::sum) == 0) { |
| 282 | queue.add(v); | |
| 283 | } | |
| 284 | } | |
| 285 | } | |
| 286 |
1
1. computeLongestPathLevels : replaced return value with Collections.emptyMap for edu/ucsb/cs/scaffold/services/ConceptGraphService::computeLongestPathLevels → KILLED |
return level; |
| 287 | } | |
| 288 | ||
| 289 | private Set<Long> bfsReachable(Long start, Map<Long, List<Long>> adjacency) { | |
| 290 | Set<Long> visited = new HashSet<>(adjacency.getOrDefault(start, List.of())); | |
| 291 | Deque<Long> queue = new ArrayDeque<>(visited); | |
| 292 |
1
1. bfsReachable : negated conditional → TIMED_OUT |
while (!queue.isEmpty()) { |
| 293 | Long current = queue.poll(); | |
| 294 | for (Long next : adjacency.getOrDefault(current, List.of())) { | |
| 295 |
1
1. bfsReachable : negated conditional → TIMED_OUT |
if (visited.add(next)) { |
| 296 | queue.add(next); | |
| 297 | } | |
| 298 | } | |
| 299 | } | |
| 300 |
1
1. bfsReachable : replaced return value with Collections.emptySet for edu/ucsb/cs/scaffold/services/ConceptGraphService::bfsReachable → KILLED |
return visited; |
| 301 | } | |
| 302 | ||
| 303 | private Map<Long, List<Long>> buildAdjacency(List<ConceptEdge> edges) { | |
| 304 | Map<Long, List<Long>> adjacency = new HashMap<>(); | |
| 305 | for (ConceptEdge edge : edges) { | |
| 306 | adjacency | |
| 307 |
1
1. lambda$buildAdjacency$4 : replaced return value with Collections.emptyList for edu/ucsb/cs/scaffold/services/ConceptGraphService::lambda$buildAdjacency$4 → KILLED |
.computeIfAbsent(edge.getSource().getId(), k -> new ArrayList<>()) |
| 308 | .add(edge.getTarget().getId()); | |
| 309 | } | |
| 310 |
1
1. buildAdjacency : replaced return value with Collections.emptyMap for edu/ucsb/cs/scaffold/services/ConceptGraphService::buildAdjacency → KILLED |
return adjacency; |
| 311 | } | |
| 312 | } | |
Mutations | ||
| 43 |
1.1 |
|
| 44 |
1.1 |
|
| 58 |
1.1 |
|
| 60 |
1.1 |
|
| 61 |
1.1 |
|
| 64 |
1.1 |
|
| 69 |
1.1 |
|
| 92 |
1.1 |
|
| 109 |
1.1 |
|
| 117 |
1.1 2.2 3.3 |
|
| 118 |
1.1 |
|
| 135 |
1.1 |
|
| 149 |
1.1 |
|
| 157 |
1.1 |
|
| 161 |
1.1 2.2 3.3 |
|
| 162 |
1.1 2.2 |
|
| 163 |
1.1 2.2 3.3 4.4 |
|
| 167 |
1.1 |
|
| 180 |
1.1 |
|
| 181 |
1.1 |
|
| 184 |
1.1 |
|
| 203 |
1.1 |
|
| 204 |
1.1 |
|
| 208 |
1.1 |
|
| 209 |
1.1 |
|
| 211 |
1.1 |
|
| 216 |
1.1 |
|
| 223 |
1.1 |
|
| 246 |
1.1 2.2 3.3 |
|
| 247 |
1.1 |
|
| 251 |
1.1 |
|
| 272 |
1.1 |
|
| 277 |
1.1 |
|
| 280 |
1.1 |
|
| 281 |
1.1 |
|
| 286 |
1.1 |
|
| 292 |
1.1 |
|
| 295 |
1.1 |
|
| 300 |
1.1 |
|
| 307 |
1.1 |
|
| 310 |
1.1 |