diff --git a/docs/superpowers/plans/2026-08-01-local-g2-split-derivative-scale-recovery.md b/docs/superpowers/plans/2026-08-01-local-g2-split-derivative-scale-recovery.md index bbcba76..2c00a5c 100644 --- a/docs/superpowers/plans/2026-08-01-local-g2-split-derivative-scale-recovery.md +++ b/docs/superpowers/plans/2026-08-01-local-g2-split-derivative-scale-recovery.md @@ -105,6 +105,9 @@ In `verify_path_smoothing_local_g2_candidates.ps1`, replace `$acceptedSoftCandid ```powershell $acceptedSplitCandidates = @() $maximumAnchorError = 0.0 +$maximumConnectionPositionError = 0.0 +$maximumTangentDirectionError = 0.0 +$maximumCurvatureError = 0.0 foreach ($realCandidate in $realCandidates) { $evaluation = (Get-InternalMethod $evaluatorType 'Evaluate').Invoke($realEvaluator, @( $preparedPath, $preparedPath, $region, $realCandidate, $singleTurnRequest, $options, @@ -116,6 +119,15 @@ foreach ($realCandidate in $realCandidates) { foreach ($diagnostic in (Get-InternalProperty $realCandidate 'InternalScaleDiagnostics')) { $anchorError = [double](Get-InternalProperty $diagnostic 'AnchorPositionErrorMeters') $maximumAnchorError = [Math]::Max($maximumAnchorError, $anchorError) + $maximumConnectionPositionError = [Math]::Max( + $maximumConnectionPositionError, + [double](Get-InternalProperty $diagnostic 'ConnectionPositionErrorMeters')) + $maximumTangentDirectionError = [Math]::Max( + $maximumTangentDirectionError, + [double](Get-InternalProperty $diagnostic 'TangentDirectionErrorRadians')) + $maximumCurvatureError = [Math]::Max( + $maximumCurvatureError, + [double](Get-InternalProperty $diagnostic 'CurvatureErrorPerMeter')) $incoming = [double](Get-InternalProperty $diagnostic 'IncomingDerivativeScale') $outgoing = [double](Get-InternalProperty $diagnostic 'OutgoingDerivativeScale') if ([Math]::Abs($incoming - $outgoing) -gt 1e-10) { $hasDifferentScale = $true } @@ -128,23 +140,16 @@ Assert-Equal 0 $duplicateFailures.Count ` 'SingleTurn candidates must not fail raw-window analysis on coincident boundary points.' Assert-True ($maximumAnchorError -le 1e-9) ` 'Every internal primitive boundary must remain at its original coordinate.' +Assert-True ($maximumConnectionPositionError -le 1e-9) ` + 'Every real split-scale connection must be position continuous.' +Assert-True ($maximumTangentDirectionError -le 1e-8) ` + 'Every real split-scale connection must preserve unit tangent direction.' +Assert-True ($maximumCurvatureError -le 1e-8) ` + 'Every real split-scale connection must preserve geometric curvature.' Assert-True ($acceptedSplitCandidates.Count -gt 0) ` 'SingleTurn must accept a zero-coordinate-offset candidate with different incoming/outgoing scales.' ``` -Add the analytical connection assertion: - -```powershell -$connectionMethod = $hooksType.GetMethod( - 'ExecuteSplitScaleConnection', [Reflection.BindingFlags]'Public,Static') -$connection = $connectionMethod.Invoke($null, @()) -Assert-Near 0.0 $connection.PositionErrorMeters 1e-9 'Split-scale connection position must be continuous.' -Assert-Near 0.0 $connection.TangentDirectionErrorRadians 1e-8 'Split-scale connection unit tangent must be continuous.' -Assert-Near 0.0 $connection.CurvatureErrorPerMeter 1e-8 'Split-scale connection geometric curvature must be continuous.' -Assert-True ([Math]::Abs($connection.IncomingDerivativeScale - $connection.OutgoingDerivativeScale) -gt 1e-10) ` - 'The connection test must use genuinely different parameter-speed scales.' -``` - - [ ] **Step 2: Run the permanent test against the old shared builder and confirm RED** Run: @@ -153,7 +158,7 @@ Run: powershell -ExecutionPolicy Bypass -File ClumsyPilot/tests/verify_path_smoothing_local_g2_candidates.ps1 ``` -Expected: FAIL because `InternalScaleDiagnostics` and `ExecuteSplitScaleConnection` do not exist, or because no accepted zero-offset split-scale candidate exists. A pass at this point means the test did not exercise the new contract; correct the test before continuing. +Expected: FAIL because the real candidate diagnostics do not expose the fixed-anchor/split-scale G2 measurements, or because no accepted zero-offset split-scale candidate exists. A pass at this point means the test did not exercise the new contract; correct the test before continuing. - [ ] **Step 3: Create an isolated feasibility copy and capture the `bd08a9b` baseline** @@ -206,23 +211,35 @@ internal sealed class LocalG2InternalScaleDiagnostic double arcLengthMeters, double incomingDerivativeScale, double outgoingDerivativeScale, - double anchorPositionErrorMeters) + double anchorPositionErrorMeters, + double connectionPositionErrorMeters, + double tangentDirectionErrorRadians, + double curvatureErrorPerMeter) { if (!NumericGuard.IsFinite(arcLengthMeters) || !NumericGuard.IsPositiveFinite(incomingDerivativeScale) || !NumericGuard.IsPositiveFinite(outgoingDerivativeScale) || - !NumericGuard.IsFinite(anchorPositionErrorMeters) || anchorPositionErrorMeters < 0d) + !NumericGuard.IsFinite(anchorPositionErrorMeters) || anchorPositionErrorMeters < 0d || + !NumericGuard.IsFinite(connectionPositionErrorMeters) || connectionPositionErrorMeters < 0d || + !NumericGuard.IsFinite(tangentDirectionErrorRadians) || tangentDirectionErrorRadians < 0d || + !NumericGuard.IsFinite(curvatureErrorPerMeter) || curvatureErrorPerMeter < 0d) throw new ArgumentOutOfRangeException(nameof(arcLengthMeters)); ArcLengthMeters = arcLengthMeters; IncomingDerivativeScale = incomingDerivativeScale; OutgoingDerivativeScale = outgoingDerivativeScale; AnchorPositionErrorMeters = anchorPositionErrorMeters; + ConnectionPositionErrorMeters = connectionPositionErrorMeters; + TangentDirectionErrorRadians = tangentDirectionErrorRadians; + CurvatureErrorPerMeter = curvatureErrorPerMeter; } internal double ArcLengthMeters { get; } internal double IncomingDerivativeScale { get; } internal double OutgoingDerivativeScale { get; } internal double AnchorPositionErrorMeters { get; } + internal double ConnectionPositionErrorMeters { get; } + internal double TangentDirectionErrorRadians { get; } + internal double CurvatureErrorPerMeter { get; } } ``` @@ -374,7 +391,7 @@ private static bool TryGetDerivatives( } ``` -Do not alter transition X/Y construction. Build one `LocalG2InternalScaleDiagnostic` for every internal node with the actual incoming/outgoing scales and `AnchorPositionErrorMeters = 0d`. +Do not alter transition X/Y construction. Step 7 builds one `LocalG2InternalScaleDiagnostic` for every real internal connection from the actual incoming/outgoing curve endpoint derivatives. - [ ] **Step 6: Add deterministic representative scheduling and lazy Tier 2 construction in the disposable copy** @@ -679,49 +696,110 @@ internal sealed class LocalG2CandidateBuildSession Change `TryBuildCandidate` to receive `LocalG2DerivativeScaleProfile profile` and `int candidateTier`, call `TryAssignDerivativeScales(nodes, profile)`, and pass the profile, tier, and diagnostics into `LocalG2CandidateGeometry`. -- [ ] **Step 7: Add the analytical split-scale TestHook in the disposable copy** +- [ ] **Step 7: Measure real split-scale connections without adding a test-only production method** -Add this public snapshot next to the existing `CandidateTestSnapshot`: +Construct all adjacent curves first, measure each real internal connection, then sample the same curve objects. Add: ```csharp -public sealed class SplitScaleConnectionTestSnapshot +private static bool TryMeasureConnection( + QuinticHermiteCurve2D incomingCurve, + QuinticHermiteCurve2D outgoingCurve, + BoundaryNode node, + out LocalG2InternalScaleDiagnostic diagnostic) { - internal SplitScaleConnectionTestSnapshot( - double positionErrorMeters, - double tangentDirectionErrorRadians, - double curvatureErrorPerMeter, - double incomingDerivativeScale, - double outgoingDerivativeScale) - { - PositionErrorMeters = positionErrorMeters; - TangentDirectionErrorRadians = tangentDirectionErrorRadians; - CurvatureErrorPerMeter = curvatureErrorPerMeter; - IncomingDerivativeScale = incomingDerivativeScale; - OutgoingDerivativeScale = outgoingDerivativeScale; - } - public double PositionErrorMeters { get; } - public double TangentDirectionErrorRadians { get; } - public double CurvatureErrorPerMeter { get; } - public double IncomingDerivativeScale { get; } - public double OutgoingDerivativeScale { get; } + diagnostic = null; + incomingCurve.Evaluate(1d, + out double lx, out double ly, + out double ldx, out double ldy, + out double lddx, out double lddy); + outgoingCurve.Evaluate(0d, + out double rx, out double ry, + out double rdx, out double rdy, + out double rddx, out double rddy); + double leftNorm = Math.Sqrt(ldx * ldx + ldy * ldy); + double rightNorm = Math.Sqrt(rdx * rdx + rdy * rdy); + if (!NumericGuard.IsPositiveFinite(leftNorm) || + !NumericGuard.IsPositiveFinite(rightNorm)) + return false; + + double leftAnchorError = Distance(lx, ly, node.X, node.Y); + double rightAnchorError = Distance(rx, ry, node.X, node.Y); + double anchorError = Math.Max(leftAnchorError, rightAnchorError); + double connectionPositionError = Distance(lx, ly, rx, ry); + double cosine = (ldx * rdx + ldy * rdy) / (leftNorm * rightNorm); + double tangentError = Math.Acos(Math.Max(-1d, Math.Min(1d, cosine))); + double leftCurvature = (ldx * lddy - ldy * lddx) / + (leftNorm * leftNorm * leftNorm); + double rightCurvature = (rdx * rddy - rdy * rddx) / + (rightNorm * rightNorm * rightNorm); + double curvatureError = Math.Abs(leftCurvature - rightCurvature); + if (!NumericGuard.IsFinite(anchorError) || + !NumericGuard.IsFinite(connectionPositionError) || + !NumericGuard.IsFinite(tangentError) || + !NumericGuard.IsFinite(curvatureError)) + return false; + + diagnostic = new LocalG2InternalScaleDiagnostic( + node.ArcLengthMeters, + node.IncomingDerivativeScale, + node.OutgoingDerivativeScale, + anchorError, + connectionPositionError, + tangentError, + curvatureError); + return true; +} + +private static double Distance(double x0, double y0, double x1, double y1) +{ + double dx = x1 - x0; + double dy = y1 - y0; + return Math.Sqrt(dx * dx + dy * dy); } ``` -`ExecuteSplitScaleConnection` builds straight zero-curvature nodes at `(0,0)`, `(1,0)`, `(2,0)`, with the internal node's incoming scale `0.75` and outgoing scale `1.25`. Construct both curves through `TryCreateCurve`, evaluate the left curve at `u=1` and right curve at `u=0`, then compute: +Replace the single create-and-sample loop with this exact ordering: ```csharp -positionError = Math.Sqrt((lx - rx) * (lx - rx) + (ly - ry) * (ly - ry)); -tangentError = Math.Acos(Math.Max(-1d, Math.Min(1d, - (ldx * rdx + ldy * rdy) / - (Math.Sqrt(ldx * ldx + ldy * ldy) * Math.Sqrt(rdx * rdx + rdy * rdy))))); -leftCurvature = (ldx * lddy - ldy * lddx) / - Math.Pow(ldx * ldx + ldy * ldy, 1.5d); -rightCurvature = (rdx * rddy - rdy * rddx) / - Math.Pow(rdx * rdx + rdy * rdy, 1.5d); -curvatureError = Math.Abs(leftCurvature - rightCurvature); +var curves = new List(nodes.Count - 1); +for (int nodeIndex = 1; nodeIndex < nodes.Count; nodeIndex++) +{ + cancellationToken.ThrowIfCancellationRequested(); + if (!TryCreateCurve(nodes[nodeIndex - 1], nodes[nodeIndex], directionSign, + out QuinticHermiteCurve2D curve)) + return false; + curves.Add(curve); +} + +var scaleDiagnostics = new List(); +for (int nodeIndex = 1; nodeIndex < nodes.Count - 1; nodeIndex++) +{ + if (!TryMeasureConnection( + curves[nodeIndex - 1], + curves[nodeIndex], + nodes[nodeIndex], + out LocalG2InternalScaleDiagnostic diagnostic)) + return false; + scaleDiagnostics.Add(diagnostic); +} + +var sampled = new List(); +for (int curveIndex = 0; curveIndex < curves.Count; curveIndex++) +{ + cancellationToken.ThrowIfCancellationRequested(); + if (!TryAppendCurveSamples( + curves[curveIndex], + nodes[curveIndex], + nodes[curveIndex + 1], + segment, + outputSpacingMeters, + sampled, + cancellationToken)) + return false; +} ``` -Return the snapshot with the actual `0.75` and `1.25` scales. +Pass `scaleDiagnostics` into the candidate geometry. These measurements are internal feasibility diagnostics required by the design; they are not exposed as a new callable test API. - [ ] **Step 8: Run the isolated feasibility gate**