Replace coarse lock-key batching with conflict-zone-aware scheduling

Replace string-based conflict keys (source:{nodeId}, target:{nodeId}) with
geometric bounding-box overlap detection. Edges now conflict only when their
routed path bounding boxes overlap spatially (with 40px margin) or share a
repeat-collector label on the same source-target pair.

This enables true spatial parallelism: edges using different sides of the
same node can now be repaired in parallel instead of being serialized.

Sprint 006 TASK-001 final criterion met. All 4 tasks DONE.

Tests verified: StraightExit 2/2, HybridDeterministicMode 3/3,
DocumentProcessingWorkflow artifact 1/1 (all 44+ assertions pass).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
master
2026-04-01 14:29:51 +03:00
parent 8a28e25d05
commit b6513528be
2 changed files with 106 additions and 56 deletions

View File

@@ -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<string> EdgeIds, HashSet<string> 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<string> EdgeIds, List<ConflictZone> 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<string>(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(
/// <summary>
/// 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.
/// </summary>
private static ConflictZone BuildConflictZone(
ElkRoutedEdge edge,
IReadOnlyDictionary<string, ElkPositionedNode> nodesById)
{
var keys = new HashSet<string>(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<string>(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)
{
/// <summary>
/// Two zones conflict if their bounding boxes overlap spatially,
/// or if they are both collectors on the same source-target pair.
/// </summary>
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<string> batchEdgeIds)