Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions Sources/StitchEngine/Actions/GraphCalculate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Node.ID>) {
public func calculate(from nodeIds: Set<Node.ID>,
willResetCyclePreferences: Bool = false) {
let graphState = self

var visitedNodes = Set<Node.ID>()
Expand All @@ -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 {
Expand Down
7 changes: 7 additions & 0 deletions Sources/StitchEngine/Actions/TopologicalDataRefresh.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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)
}
}
19 changes: 19 additions & 0 deletions Sources/StitchEngine/Data/GraphCalculatable.swift
Original file line number Diff line number Diff line change
Expand Up @@ -79,4 +79,23 @@ extension GraphCalculatable {
public func immediatelyUpstreamNodes(for node: Node.ID) -> Set<Node.ID> {
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
}
61 changes: 50 additions & 11 deletions Sources/StitchEngine/GraphQueueLogic.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,16 @@ extension GraphTopologicalData {
}

mutating func getNextNodeToCalculate(for nodeIds: Set<Node.ID>,
visitedNodes: Set<Node.ID>) -> Node.ID? {
visitedNodes: Set<Node.ID>,
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
}

Expand All @@ -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<Node.ID>,
visitedNodes: Set<Node.ID>) -> Node.ID? {
visitedNodes: Set<Node.ID>,
willResetCyclePreferences: Bool) -> Node.ID? {
if queuedNodeIds.isEmpty {
//#if DEV_DEBUG
// log("TopologicalData.findNextNodeInGraphCalc: none")
Expand All @@ -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)")
Expand Down Expand Up @@ -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<Node.ID>,
visitedNodes: Set<Node.ID>) -> Node.ID? {
visitedNodes: Set<Node.ID>,
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
Expand All @@ -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)")
Expand All @@ -132,4 +144,31 @@ extension GraphTopologicalData {
return cycleNode
}

mutating func getCandidateRootCycleNode(candidates: Set<Node.ID>,
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
}
}
7 changes: 7 additions & 0 deletions Sources/StitchEngine/TopologicalData.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,13 @@ public struct GraphTopologicalData<Node: NodeCalculatable> {
// 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()
Expand Down