using w4c_workflows.Models.Nodes;
namespace w4c_workflows.Services.Nodes;
///
/// Loop support for the node graph. An edge that closes a cycle is a
/// loop-back edge: it must target a node whose blueprint declares
/// (currently core.splitInBatches),
/// and at run time it re-triggers that node for the next iteration instead of
/// counting toward its readiness.
///
/// Classification is derived from the edge set, not from traversal order: an edge
/// u → v is a loop-back exactly when it lies on a cycle (the target
/// v can reach u) and v is loop-capable. This is
/// order-independent, so the compiler and a rebuilt persisted graph always agree
/// regardless of task declaration order.
///
public static class NodeGraphLinks
{
///
/// Returns the edges with set, or an
/// error when a cycle does not pass through a loop-capable node.
///
public static (List Edges, string? Error) MarkLoopBackEdges(
IReadOnlyList nodes, IReadOnlyList edges)
{
var byId = nodes.ToDictionary(n => n.Id, StringComparer.Ordinal);
var adjacency = new Dictionary>(StringComparer.Ordinal);
foreach (var edge in edges)
{
if (!adjacency.TryGetValue(edge.FromNodeId, out var outgoing))
adjacency[edge.FromNodeId] = outgoing = new List();
outgoing.Add(edge.ToNodeId);
}
// reach[v] = every node reachable from v via one or more edges. Edge u→v
// closes a cycle iff u is reachable from v.
var reach = new Dictionary>(StringComparer.Ordinal);
foreach (var node in nodes)
{
var seen = new HashSet(StringComparer.Ordinal);
var stack = new Stack();
stack.Push(node.Id);
while (stack.Count > 0)
{
var current = stack.Pop();
if (!adjacency.TryGetValue(current, out var next))
continue;
foreach (var target in next)
{
if (seen.Add(target))
stack.Push(target);
}
}
reach[node.Id] = seen;
}
bool ClosesCycle(NodeGraphEdge edge)
=> reach.TryGetValue(edge.ToNodeId, out var fromTarget)
&& fromTarget.Contains(edge.FromNodeId);
var marked = new List(edges.Count);
foreach (var edge in edges)
{
var isLoopBack = ClosesCycle(edge) && byId[edge.ToNodeId].Blueprint.LoopBack;
marked.Add(edge with { IsLoopBack = isLoopBack });
}
// A cycle is only valid when it re-enters a loop-capable node. Group the
// mutually-reachable nodes into SCCs and require a loop node in every
// cyclic component; otherwise the graph would deadlock or spin.
var error = ValidateCyclesHaveLoopNodes(nodes, adjacency, reach, byId);
return (marked, error);
}
private static string? ValidateCyclesHaveLoopNodes(
IReadOnlyList nodes,
IReadOnlyDictionary> adjacency,
IReadOnlyDictionary> reach,
IReadOnlyDictionary byId)
{
var ids = nodes.Select(n => n.Id).ToList();
var assigned = new HashSet(StringComparer.Ordinal);
foreach (var id in ids)
{
if (!assigned.Add(id))
continue;
var members = new List { id };
foreach (var other in ids)
{
if (other == id || assigned.Contains(other))
continue;
if (reach[id].Contains(other) && reach[other].Contains(id))
{
members.Add(other);
assigned.Add(other);
}
}
var isCyclic = members.Count > 1
|| adjacency.TryGetValue(id, out var self) && self.Contains(id);
if (!isCyclic)
continue;
if (members.Any(m => byId[m].Blueprint.LoopBack))
continue;
return $"a cycle involving {string.Join(", ", members)} is not allowed: " +
"none of these nodes is a loop node (e.g. core.splitInBatches)";
}
return null;
}
}