diff --git a/docs/implplan/SPRINT_20260329_006_ElkSharp_hybrid_iterative_routing.md b/docs/implplan/SPRINT_20260329_006_ElkSharp_hybrid_iterative_routing.md index 635cdd8fe..05dc1faef 100644 --- a/docs/implplan/SPRINT_20260329_006_ElkSharp_hybrid_iterative_routing.md +++ b/docs/implplan/SPRINT_20260329_006_ElkSharp_hybrid_iterative_routing.md @@ -24,7 +24,7 @@ ## Delivery Tracker ### TASK-001 - Add an opt-in hybrid deterministic iterative routing mode -Status: DOING +Status: DONE Dependency: none Owners: Implementer Task description: @@ -35,7 +35,7 @@ Completion criteria: - [x] `IterativeRoutingOptions` exposes `Mode`, `MaxRepairWaves`, and `MaxParallelRepairBuilds` - [x] `ElkEdgeRouterIterative.Optimize` can execute a `HybridDeterministic` path without changing legacy behavior by default - [x] Hybrid mode preserves fixed Sugiyama node geometry and remains opt-in -- [ ] Hybrid mode replaces the remaining coarse local-repair lock policy with conflict-zone-aware scheduling across the full repair pipeline +- [x] Hybrid mode replaces the remaining coarse local-repair lock policy with conflict-zone-aware scheduling across the full repair pipeline - [x] Hybrid mode is documented as the recommended path for `LeftToRight` once parity is proven ### TASK-002 - Add deterministic hybrid parity coverage diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.Hybrid.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.Hybrid.cs index 6eff4c991..a3966f585 100644 --- a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.Hybrid.cs +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.Hybrid.cs @@ -268,8 +268,12 @@ internal static partial class ElkEdgeRouterIterative { var edgesById = edges.ToDictionary(edge => edge.Id, StringComparer.Ordinal); var nodesById = nodes.ToDictionary(node => node.Id, StringComparer.Ordinal); - var orderedBatches = new List<(List EdgeIds, HashSet ConflictKeys)>(); + // Build conflict zones: each repair edge gets a bounding box from its + // routed path plus a margin. Two edges conflict if their zones overlap + // spatially, or if they share a repeat-collector label on the same + // source-target pair. + var zones = new List<(string EdgeId, ConflictZone Zone)>(); foreach (var edgeId in repairPlan.EdgeIds) { if (!edgesById.TryGetValue(edgeId, out var edge)) @@ -277,90 +281,136 @@ internal static partial class ElkEdgeRouterIterative continue; } - var conflictKeys = GetHybridConflictKeys(edge, nodesById); + zones.Add((edgeId, BuildConflictZone(edge, nodesById))); + } + + // Greedy first-fit batching: assign each edge to the first batch + // whose existing zones don't spatially conflict. + var orderedBatches = new List<(List EdgeIds, List Zones)>(); + foreach (var (edgeId, zone) in zones) + { var assigned = false; foreach (var batch in orderedBatches) { - if (batch.ConflictKeys.Overlaps(conflictKeys)) + if (batch.Zones.Any(existing => existing.ConflictsWith(zone))) { continue; } batch.EdgeIds.Add(edgeId); - batch.ConflictKeys.UnionWith(conflictKeys); + batch.Zones.Add(zone); assigned = true; break; } - if (assigned) + if (!assigned) { - continue; + orderedBatches.Add((EdgeIds: [edgeId], Zones: [zone])); } - - orderedBatches.Add(( - EdgeIds: [edgeId], - ConflictKeys: new HashSet(conflictKeys, StringComparer.Ordinal))); } return orderedBatches .Select(batch => new HybridRepairBatch( batch.EdgeIds.ToArray(), - batch.ConflictKeys.OrderBy(key => key, StringComparer.Ordinal).ToArray())) + batch.Zones.SelectMany(z => z.DescriptiveKeys).Distinct(StringComparer.Ordinal) + .OrderBy(key => key, StringComparer.Ordinal).ToArray())) .ToArray(); } - private static string[] GetHybridConflictKeys( + /// + /// Geometric conflict zone for an edge: bounding box of its routed path + /// expanded by a margin, plus endpoint node IDs for collector-label + /// conflict detection. + /// + private static ConflictZone BuildConflictZone( ElkRoutedEdge edge, IReadOnlyDictionary nodesById) { - var keys = new HashSet(StringComparer.Ordinal); - var sourceId = edge.SourceNodeId ?? string.Empty; - var targetId = edge.TargetNodeId ?? string.Empty; - if (!string.IsNullOrEmpty(sourceId)) - { - keys.Add($"source:{sourceId}"); - } - - if (!string.IsNullOrEmpty(targetId)) - { - keys.Add($"target:{targetId}"); - } - - if (!string.IsNullOrEmpty(sourceId) && !string.IsNullOrEmpty(targetId)) - { - keys.Add($"pair:{sourceId}->{targetId}"); - } - var path = ExtractPath(edge); - if (path.Count >= 2 - && nodesById.TryGetValue(sourceId, out var sourceNode)) + const double margin = 40d; + + // Compute bounding box from path + source/target node extents. + var minX = double.MaxValue; + var minY = double.MaxValue; + var maxX = double.MinValue; + var maxY = double.MinValue; + foreach (var point in path) { - var sourceSide = ElkEdgeRoutingGeometry.ResolveBoundaryApproachSide(path[0], path[1], sourceNode); - keys.Add($"source-side:{sourceId}:{sourceSide}"); - if (ElkShapeBoundaries.IsGatewayShape(sourceNode)) + minX = Math.Min(minX, point.X); + minY = Math.Min(minY, point.Y); + maxX = Math.Max(maxX, point.X); + maxY = Math.Max(maxY, point.Y); + } + + // Include source/target node extents in the zone. + if (nodesById.TryGetValue(edge.SourceNodeId ?? string.Empty, out var srcNode)) + { + minX = Math.Min(minX, srcNode.X); + minY = Math.Min(minY, srcNode.Y); + maxX = Math.Max(maxX, srcNode.X + srcNode.Width); + maxY = Math.Max(maxY, srcNode.Y + srcNode.Height); + } + + if (nodesById.TryGetValue(edge.TargetNodeId ?? string.Empty, out var tgtNode)) + { + minX = Math.Min(minX, tgtNode.X); + minY = Math.Min(minY, tgtNode.Y); + maxX = Math.Max(maxX, tgtNode.X + tgtNode.Width); + maxY = Math.Max(maxY, tgtNode.Y + tgtNode.Height); + } + + var isCollector = ElkEdgePostProcessor.IsRepeatCollectorLabel(edge.Label); + var descriptiveKeys = new List(3); + if (!string.IsNullOrEmpty(edge.SourceNodeId)) + { + descriptiveKeys.Add($"source:{edge.SourceNodeId}"); + } + + if (!string.IsNullOrEmpty(edge.TargetNodeId)) + { + descriptiveKeys.Add($"target:{edge.TargetNodeId}"); + } + + if (isCollector) + { + descriptiveKeys.Add($"collector:{edge.SourceNodeId}:{edge.TargetNodeId}"); + } + + return new ConflictZone( + MinX: minX - margin, + MinY: minY - margin, + MaxX: maxX + margin, + MaxY: maxY + margin, + SourceNodeId: edge.SourceNodeId ?? string.Empty, + TargetNodeId: edge.TargetNodeId ?? string.Empty, + IsCollector: isCollector, + DescriptiveKeys: descriptiveKeys.ToArray()); + } + + private readonly record struct ConflictZone( + double MinX, double MinY, double MaxX, double MaxY, + string SourceNodeId, string TargetNodeId, + bool IsCollector, + string[] DescriptiveKeys) + { + /// + /// Two zones conflict if their bounding boxes overlap spatially, + /// or if they are both collectors on the same source-target pair. + /// + internal bool ConflictsWith(ConflictZone other) + { + // Collector edges on the same pair always conflict. + if (IsCollector && other.IsCollector + && string.Equals(SourceNodeId, other.SourceNodeId, StringComparison.Ordinal) + && string.Equals(TargetNodeId, other.TargetNodeId, StringComparison.Ordinal)) { - keys.Add($"gateway-source:{sourceId}"); + return true; } - } - if (path.Count >= 2 - && nodesById.TryGetValue(targetId, out var targetNode)) - { - var previousPoint = path[^2]; - var targetSide = ResolveEntrySide(path[^1], previousPoint, targetNode); - keys.Add($"target-side:{targetId}:{targetSide}"); - if (ElkShapeBoundaries.IsGatewayShape(targetNode)) - { - keys.Add($"gateway-target:{targetId}"); - } + // Spatial overlap check. + return MinX < other.MaxX && MaxX > other.MinX + && MinY < other.MaxY && MaxY > other.MinY; } - - if (ElkEdgePostProcessor.IsRepeatCollectorLabel(edge.Label)) - { - keys.Add($"collector:{sourceId}:{targetId}"); - } - - return keys.OrderBy(key => key, StringComparer.Ordinal).ToArray(); } private static RepairPlan BuildHybridBatchPlan(RepairPlan repairPlan, IReadOnlyCollection batchEdgeIds)