diff --git a/Sources/StitchEngine/Actions/GraphCalculate.swift b/Sources/StitchEngine/Actions/GraphCalculate.swift index 2412eb9..2fc9d4b 100644 --- a/Sources/StitchEngine/Actions/GraphCalculate.swift +++ b/Sources/StitchEngine/Actions/GraphCalculate.swift @@ -10,7 +10,8 @@ import Foundation extension GraphCalculatable { @MainActor /// The main graph calculator. Shouldn't be called directly unless you know what you're doing. - public func calculate(from nodeIds: Set) { + public func calculate(from nodeIds: Set, + willResetCyclePreferences: Bool = false) { let graphState = self var visitedNodes = Set() @@ -20,7 +21,8 @@ extension GraphCalculatable { self.topologicalData.nodesForNextGraphStep = .init() while let nodeId = topologicalData.getNextNodeToCalculate(for: queue, - visitedNodes: visitedNodes) { + visitedNodes: visitedNodes, + willResetCyclePreferences: willResetCyclePreferences) { queue.remove(nodeId) guard !visitedNodes.contains(nodeId) else { diff --git a/Sources/StitchEngine/Actions/TopologicalDataRefresh.swift b/Sources/StitchEngine/Actions/TopologicalDataRefresh.swift index b045092..6363c02 100644 --- a/Sources/StitchEngine/Actions/TopologicalDataRefresh.swift +++ b/Sources/StitchEngine/Actions/TopologicalDataRefresh.swift @@ -31,6 +31,9 @@ extension GraphCalculatable { // Saves edges by coordinate self.setConnections(connections) + + // Reset candidate root cycle node data, which gets updated lazily by graph computation + self.topologicalData.allCandidateRootCycleNodes = .init() self.nodesForNextGraphStep = prevIdsToCalculate @@ -48,5 +51,9 @@ extension GraphCalculatable { self.topologicalData._allMustRunNodes = self.topologicalData.nodesToAlwaysRun .union(self.topologicalData.nodesScheduledToRun) .union(self.topologicalData.keyboardNodes) + + let allNodeIds = Set(self.nodes.keys) + self.calculate(from: allNodeIds, + willResetCyclePreferences: true) } } diff --git a/Sources/StitchEngine/Data/GraphCalculatable.swift b/Sources/StitchEngine/Data/GraphCalculatable.swift index 1c4380d..722b4be 100644 --- a/Sources/StitchEngine/Data/GraphCalculatable.swift +++ b/Sources/StitchEngine/Data/GraphCalculatable.swift @@ -79,4 +79,23 @@ extension GraphCalculatable { public func immediatelyUpstreamNodes(for node: Node.ID) -> Set { self.topologicalData.immediatelyUpstreamNodes(for: node) } + + public func getRootCycleState(for nodeId: Node.ID) -> RootCycleState { + if self.topologicalData.preferredRootCycleNodes.contains(nodeId) { + return .selectedCandidate + } + + if self.topologicalData.allCandidateRootCycleNodes.contains(nodeId) { + return .unselectedCandidate + } + + return .none + } +} + +// TODO: move +public enum RootCycleState { + case none + case unselectedCandidate + case selectedCandidate } diff --git a/Sources/StitchEngine/GraphQueueLogic.swift b/Sources/StitchEngine/GraphQueueLogic.swift index a7c52ab..053681f 100644 --- a/Sources/StitchEngine/GraphQueueLogic.swift +++ b/Sources/StitchEngine/GraphQueueLogic.swift @@ -24,14 +24,16 @@ extension GraphTopologicalData { } mutating func getNextNodeToCalculate(for nodeIds: Set, - visitedNodes: Set) -> Node.ID? { + visitedNodes: Set, + willResetCyclePreferences: Bool) -> Node.ID? { if let nextNodeId = self.memoizedQueues[nodeIds] { return nextNodeId } // Cache ordered list for this set guard let nextNodeId = self.findNextNodeInGraphCalc(queuedNodeIds: nodeIds, - visitedNodes: visitedNodes) else { + visitedNodes: visitedNodes, + willResetCyclePreferences: willResetCyclePreferences) else { return nil } @@ -44,7 +46,8 @@ extension GraphTopologicalData { /// from the other nodes. Therefore we can pick any node matching that criteria. /// The only exception is if the remaining nodes contain a cycle at its root. private mutating func findNextNodeInGraphCalc(queuedNodeIds: Set, - visitedNodes: Set) -> Node.ID? { + visitedNodes: Set, + willResetCyclePreferences: Bool) -> Node.ID? { if queuedNodeIds.isEmpty { //#if DEV_DEBUG // log("TopologicalData.findNextNodeInGraphCalc: none") @@ -57,7 +60,8 @@ extension GraphTopologicalData { guard let nextNode = self.findNextNodeInDAG(queuedNodeIds: queuedNodeIds, visitedNodes: visitedNodes) else { let cycleNode = self.findNextNodeInCycle(queuedNodeIds: queuedNodeIds, - visitedNodes: visitedNodes) + visitedNodes: visitedNodes, + willResetCyclePreferences: willResetCyclePreferences) //#if DEV_DEBUG // log("TopologicalData.findNextNodeInGraphCalc: cycle node \(cycleNode)") @@ -93,14 +97,15 @@ extension GraphTopologicalData { /// 2. Tiebreakers resort to leveraging nodes in a cycle containing a direct connection to a node that was already visited /// in a graph. This ensures cycle calculation starts at the "red edge". private mutating func findNextNodeInCycle(queuedNodeIds: Set, - visitedNodes: Set) -> Node.ID? { + visitedNodes: Set, + willResetCyclePreferences: Bool) -> Node.ID? { // There are cycle nodes involved if the above condition isn't hit. We'll prioritize // the cycle node with the fewest matching upstream parents to other nodes in the set. // Doing so guarantees the upstream-most cycle will be computed first. let allNodesInCycles = self.nodeCycles .flatMap { $0 } - let sortedCycleNodes = queuedNodeIds + let sortedCycleNodes = Set(queuedNodeIds .filter { allNodesInCycles.contains($0) } // Sort by fewest matching upstream parents to queue .sorted { lhs, rhs in @@ -112,18 +117,25 @@ extension GraphTopologicalData { return isEqual ? lhs.hashValue < rhs.hashValue : lhsUpstreamParents < rhsUpstreamParents } // Another sorting to guarantee consistent results - .sorted() + .sorted()) // Prioritize nodes in cycle which contain direct upstream parent to visited node // in this cycle (aka the "red edge") - let cycleNode = sortedCycleNodes - .first { + let connectedUpstreamCycleNodes = Set(sortedCycleNodes + .filter { // Look for directly connected upstream node to cycle node let directUpstreamNodes = self.findDirectlyConnectedUpstreamNodes(from: $0) let containsUpstreamVisitedNode = !visitedNodes.intersection(directUpstreamNodes).isEmpty return containsUpstreamVisitedNode - } ?? - sortedCycleNodes.first + }) + + let candidateRootCycleNodes = connectedUpstreamCycleNodes.isEmpty ? sortedCycleNodes : connectedUpstreamCycleNodes + + // Lazily update candidate root cycle nodes, which gets reset with updateTopologicalData fn + self.allCandidateRootCycleNodes = self.allCandidateRootCycleNodes.union(candidateRootCycleNodes) + + let cycleNode = self.getCandidateRootCycleNode(candidates: candidateRootCycleNodes, + isMutating: willResetCyclePreferences) #if DEV_DEBUG // log("TopologicalData.findNextNodeInGraphCalc: cycle node detected at \(cycleNode)") @@ -132,4 +144,31 @@ extension GraphTopologicalData { return cycleNode } + mutating func getCandidateRootCycleNode(candidates: Set, + isMutating: Bool) -> Node.ID? { + let previousMatchingCandidates = self.preferredRootCycleNodes.intersection(candidates) + + // Arbitrarily select first amongst multiple possible options + guard let selection = previousMatchingCandidates.first else { + // No preference made yet + guard let newSelection = candidates.first else { + // No candidates at all + return nil + } + + if isMutating { + self.preferredRootCycleNodes.insert(newSelection) + } + return newSelection + } + + // If multiple matches, remove all but first + if isMutating && previousMatchingCandidates.count > 1 { + var preferencesToRemove = previousMatchingCandidates + preferencesToRemove.remove(selection) + self.preferredRootCycleNodes = self.preferredRootCycleNodes.subtracting(preferencesToRemove) + } + + return selection + } } diff --git a/Sources/StitchEngine/TopologicalData.swift b/Sources/StitchEngine/TopologicalData.swift index 71b2314..1be304a 100644 --- a/Sources/StitchEngine/TopologicalData.swift +++ b/Sources/StitchEngine/TopologicalData.swift @@ -34,6 +34,13 @@ public struct GraphTopologicalData { // Tracks which nodes are in cycles var nodeCycles = NodeCycles() + // Tracks preferred node starting points, which persist with documents + var preferredRootCycleNodes = NodeIdSet() + + // Tracks all posible candidates for root cycles, resets whenever + // topological data is updated + var allCandidateRootCycleNodes = NodeIdSet() + var upstreamNodesCache = UpstreamNodesCache() var connections = Connections()