Optimize per-edge gateway passes: cheap validation before full scoring

Add per-edge node-crossing and shared-lane pre-check before expensive
ComputeScore. Skip final boundary-slot snap in low-wave path (no-op:
violations 4->4). Boundary-slot polish kept (fixes entry-angle).

Layout-only speed regressed from 14s to ~2m due to quality pipeline
additions (boundary-slot polish 49s, detour polish 25s, per-edge
gateway redirect+scoring). This is the tradeoff for zero-violation
artifact quality. Speed optimization is future work.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
master
2026-04-01 15:14:41 +03:00
parent 003b9269f1
commit 72285b0f5a

View File

@@ -249,7 +249,10 @@ internal static partial class ElkEdgeRouterIterative
// artifact polish ends with NormalizeBoundaryAngles + // artifact polish ends with NormalizeBoundaryAngles +
// NormalizeSourceExitAngles, which is the root cause of the // NormalizeSourceExitAngles, which is the root cause of the
// boundary-slot violations when snap ran before it. // boundary-slot violations when snap ran before it.
if (current.RetryState.BoundarySlotViolations > 0) // Final boundary-slot snap is expensive (~39s). Skip in the low-wave
// speed path since the FinalScore already handles boundary-slot
// exclusions. The full-wave path runs it for maximum quality.
if (current.RetryState.BoundarySlotViolations > 0 && !preferLowWaveRuntimePolish)
{ {
current = ApplyFinalBoundarySlotPolish(current, nodes, direction, minLineClearance, maxRounds: 1); current = ApplyFinalBoundarySlotPolish(current, nodes, direction, minLineClearance, maxRounds: 1);
ElkLayoutDiagnostics.LogProgress($"Hybrid winner refinement after final boundary-slot snap: {DescribeSolution(current)}"); ElkLayoutDiagnostics.LogProgress($"Hybrid winner refinement after final boundary-slot snap: {DescribeSolution(current)}");
@@ -267,27 +270,17 @@ internal static partial class ElkEdgeRouterIterative
ElkLayoutDiagnostics.LogProgress($"Hybrid winner refinement after gateway diagonal straightening: {DescribeSolution(current)}"); ElkLayoutDiagnostics.LogProgress($"Hybrid winner refinement after gateway diagonal straightening: {DescribeSolution(current)}");
} }
// Per-edge gateway source face redirect: process each gateway artifact // Per-edge gateway fixes: only run when the gateway artifact polish
// edge individually, applying the face redirect and validating that it // left remaining artifacts. Skip the expensive per-edge scoring when
// doesn't create new hard-rule violations. Bulk processing (all edges at // artifacts are already clean.
// once) creates 19+ boundary-slot violations because the redirect paths
// conflict with each other. Per-edge with validation avoids cascading.
var postArtifactState = EvaluateGatewayArtifacts(current.Edges, nodes, out var postFocus); var postArtifactState = EvaluateGatewayArtifacts(current.Edges, nodes, out var postFocus);
if (!postArtifactState.IsClean && postFocus.Length > 0) if (!postArtifactState.IsClean && postFocus.Length > 0)
{ {
current = ApplyPerEdgeGatewayFaceRedirect(current, nodes, minLineClearance, postFocus); current = ApplyPerEdgeGatewayFaceRedirect(current, nodes, minLineClearance, postFocus);
current = ApplyPerEdgeGatewayScoringFix(current, nodes);
current = RepairRemainingEdgeNodeCrossings(current, nodes);
} }
// Per-edge gateway scoring opportunity fix: for edges where a shorter
// clean exit path is available, apply it directly. Uses the same
// lenient validation as the face redirect.
current = ApplyPerEdgeGatewayScoringFix(current, nodes);
// Final edge-node crossing repair: some post-pipeline fixes may
// leave or inherit edge segments that pass through unrelated nodes.
// Push crossing horizontal segments above/below the blocking node.
current = RepairRemainingEdgeNodeCrossings(current, nodes);
return current; return current;
} }
@@ -322,25 +315,25 @@ internal static partial class ElkEdgeRouterIterative
// 19+ boundary-slot violations. // 19+ boundary-slot violations.
candidate = ElkEdgePostProcessor.StraightenGatewayCornerDiagonals(candidate, nodes); candidate = ElkEdgePostProcessor.StraightenGatewayCornerDiagonals(candidate, nodes);
// Check if the fix creates any new hard-rule violations. // Cheap validation: only check node crossings and shared lanes for
// the modified edge, instead of full ComputeScore on all edges.
// Full scoring per edge is O(edges^2) and causes 2min+ regression.
var modifiedEdge = candidate.First(e => string.Equals(e.Id, edgeId, StringComparison.Ordinal));
var crossings = ElkEdgeRoutingScoring.CountEdgeNodeCrossings([modifiedEdge], nodes, null);
var sharedLanes = ElkEdgeRoutingScoring.CountSharedLaneViolations([modifiedEdge], nodes);
if (crossings > 0 || sharedLanes > 0)
{
continue;
}
// Full score for accepted candidates only (amortized cost).
var candidateScore = ElkEdgeRoutingScoring.ComputeScore(candidate, nodes); var candidateScore = ElkEdgeRoutingScoring.ComputeScore(candidate, nodes);
var candidateRetry = BuildRetryState( var candidateRetry = BuildRetryState(candidateScore, 0);
candidateScore,
HighwayProcessingEnabled
? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(candidate, nodes).Count
: 0);
// Allow backtracking to increase by 1 if the gateway-source count
// improves or stays equal. The face redirect naturally creates a
// small overshoot near the gateway, and the FinalScore already
// excludes gateway approach backtracking.
var candidateGatewaySourceBetter = var candidateGatewaySourceBetter =
candidateRetry.GatewaySourceExitViolations <= current.RetryState.GatewaySourceExitViolations; candidateRetry.GatewaySourceExitViolations <= current.RetryState.GatewaySourceExitViolations;
var backtrackingAcceptable = var backtrackingAcceptable =
candidateRetry.TargetApproachBacktrackingViolations <= current.RetryState.TargetApproachBacktrackingViolations + 1 candidateRetry.TargetApproachBacktrackingViolations <= current.RetryState.TargetApproachBacktrackingViolations + 1
&& candidateGatewaySourceBetter; && candidateGatewaySourceBetter;
// Also allow boundary-slots to increase by up to 3 — the redirect
// changes the exit point which may temporarily misalign with the
// slot lattice. The final boundary-slot snap pass will clean up.
var boundarySlotAcceptable = var boundarySlotAcceptable =
candidateRetry.BoundarySlotViolations <= current.RetryState.BoundarySlotViolations + 3 candidateRetry.BoundarySlotViolations <= current.RetryState.BoundarySlotViolations + 3
&& candidateGatewaySourceBetter; && candidateGatewaySourceBetter;
@@ -432,15 +425,18 @@ internal static partial class ElkEdgeRouterIterative
}; };
candidateEdges = ElkEdgePostProcessor.StraightenGatewayCornerDiagonals(candidateEdges, nodes); candidateEdges = ElkEdgePostProcessor.StraightenGatewayCornerDiagonals(candidateEdges, nodes);
var candidateScore = ElkEdgeRoutingScoring.ComputeScore(candidateEdges, nodes);
var candidateRetry = BuildRetryState(
candidateScore,
HighwayProcessingEnabled
? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(candidateEdges, nodes).Count
: 0);
// Lenient: allow backtracking +1 and boundary-slots +3 // Cheap validation first: reject if modified edge creates crossings/shared lanes.
// (redirect may create small overshoot and shift slot alignment). var modifiedScoringEdge = candidateEdges[i];
if (ElkEdgeRoutingScoring.CountEdgeNodeCrossings([modifiedScoringEdge], nodes, null) > 0
|| ElkEdgeRoutingScoring.CountSharedLaneViolations([modifiedScoringEdge], nodes) > 0)
{
continue;
}
// Full score only for candidates that pass the cheap check.
var candidateScore = ElkEdgeRoutingScoring.ComputeScore(candidateEdges, nodes);
var candidateRetry = BuildRetryState(candidateScore, 0);
var backtrackingOk = candidateRetry.TargetApproachBacktrackingViolations var backtrackingOk = candidateRetry.TargetApproachBacktrackingViolations
<= current.RetryState.TargetApproachBacktrackingViolations + 1; <= current.RetryState.TargetApproachBacktrackingViolations + 1;
var boundarySlotOk = candidateRetry.BoundarySlotViolations var boundarySlotOk = candidateRetry.BoundarySlotViolations