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 new file mode 100644 index 0000000..bbcba76 --- /dev/null +++ b/docs/superpowers/plans/2026-08-01-local-g2-split-derivative-scale-recovery.md @@ -0,0 +1,1540 @@ +# Local G2 Split-Derivative-Scale Recovery Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the infeasible Local G2 soft-position-anchor family with a bounded hard-position, split incoming/outgoing derivative-scale family, then complete and verify the dedicated Task 8 publication path without relaxing any safety or quality gate. + +**Architecture:** A stateful candidate-build session owns deterministic representative-window/profile scheduling so production constructs Tier 1 first and constructs Tier 2 only after Tier 1 has no accepted candidate. Every primitive-boundary coordinate remains fixed; independent positive incoming/outgoing Hermite derivative scales supply the finite geometric freedom, while a per-region evaluator session caches only raw-window analysis and still runs every candidate-specific geometry, collision, clearance, deviation, and quality check. + +**Tech Stack:** C# 10, .NET Standard 2.0, PowerShell reflection verification, existing quintic Hermite/path analysis/vehicle validation components, Git. + +## Global Constraints + +- `MinimumWindowLengthMeters = 0.20`, `PreferredWindowLengthMeters = 0.50`, and `MaximumWindowLengthMeters = 0.80` are left-plus-right total lengths. +- Path start, path end, gear switches, outer window endpoints, and every internal primitive boundary remain hard position anchors. +- Internal vehicle heading and distance-weighted shared vehicle curvature remain hard boundary values. +- Segment `node[i] -> node[i+1]` uses `node[i].OutgoingDerivativeScale` and `node[i+1].IncomingDerivativeScale`. +- Boundary derivatives remain `r' = lambda * T` and `r'' = lambda^2 * kappa_geometric * N`; geometric G2 requires common position, unit tangent, and geometric curvature, not equal parameter speeds. +- Internal profiles are exactly `P0=(1.00,1.00)`, `P1=(0.75,1.25)`, and `P2=(1.25,0.75)`; outer endpoint factors are always `1.00`. +- A multi-event region applies one profile to every internal node; a window without an internal node emits only P0. +- Representative windows are, in order, planner-first, shortest, longest, and most asymmetric, de-duplicated by start/end arc and tie-broken by planner candidate index. +- Tier 1 is all representative P0 attempts followed by first-window P1 and P2, with at most six attempts. Tier 2 is remaining-window P1 followed by remaining-window P2. +- The configured limit truncates the global attempt order and the hard limit is `12`; failed constructions consume attempt budget, while successful geometries receive dense candidate indices. +- `PathSmoothingRegionReport.CandidateCount` is the number of candidates actually passed to the evaluator, not window count or build-attempt count. +- Maximum vehicle curvature, raw curvature range tolerance `1e-6`, full-body collision, extra clearance, maximum deviation, 20 percent peak-gradient improvement, and 2 percent variation-cost tolerance remain unchanged. +- Do not change Hybrid A*, vehicle parameters, SQP, legacy smoothers, comparison defaults, fixtures, expected status thresholds, or collision/clearance behavior. +- Preserve existing cancellation checks, derivative certification depth `40`, derivative certification interval cap `8192`, and adaptive sampling depth `32`. +- Identical requests must produce identical attempt order, candidate indices, coordinates, status, and region reports. +- Use TDD. The disposable `SingleTurn` feasibility gate must be GREEN before the shared `LocalG2CandidateBuilder.cs` is edited. +- Preserve unrelated dirty worktree files. Stage only the exact files named by each commit step. +- Never edit or delete `ClumsyPilot/ParkrobTrajplanner/auto_avoidance`; when its unavailable assemblies block the normal build, use an isolated copy excluding only that directory. + +--- + +## Completed prerequisites + +- Commit `144a088` already makes evaluator window extraction de-duplicate coincident boundary points. +- Commit `bd08a9b` already makes the window planner cover preferred/minimum/maximum total lengths before asymmetric variants consume the budget. +- The old soft-anchor Task 3 is intentionally abandoned: its `0.05 m` coordinate offsets caused real `SingleTurn` curvature up to approximately `14.86 1/m` against the approximately `0.833333 1/m` vehicle limit. +- This plan supersedes `docs/superpowers/plans/2026-07-31-local-g2-soft-anchor-candidate-recovery.md` from its Task 3 onward. Do not revert or reimplement its completed Tasks 1 and 2. + +## File Structure + +- `ClumsyPilot/ParkrobTrajplanner/PathSmoothing/LocalG2/LocalG2CandidateGeometry.cs` + - Owns internal-only scale-profile, tier, and internal-node scale diagnostics attached to a successfully constructed geometry. +- `ClumsyPilot/ParkrobTrajplanner/PathSmoothing/LocalG2/LocalG2CandidateBuilder.cs` + - Owns representative-window selection, split incoming/outgoing scales, global attempt order, dense candidate indices, and lazy Tier 2 construction. +- `ClumsyPilot/ParkrobTrajplanner/PathSmoothing/LocalG2/LocalG2CandidateEvaluator.cs` + - Owns a request/region-scoped evaluation session and its raw-window analysis cache; candidate safety checks remain here. +- `ClumsyPilot/ParkrobTrajplanner/PathSmoothing/LocalG2/LocalG2PreSmoothingPipeline.cs` + - Owns Tier 1 evaluation, conditional Tier 2 expansion, selected-candidate reporting, global validation, and rollback. +- `ClumsyPilot/ParkrobTrajplanner/PathSmoothing/Facade/PathSmoothingService.cs` + - Dispatches only `LocalG2Quintic` to the dedicated pipeline and preserves all legacy routes. +- `ClumsyPilot/tests/verify_path_smoothing_local_g2_candidates.ps1` + - Proves fixed coordinates, split-scale G2, deterministic scheduling, caps, cache count, forward/reverse behavior, and the real accepted `SingleTurn` tuple. +- `ClumsyPilot/tests/verify_path_smoothing_local_g2_integration.ps1` + - Proves Task 8 statuses, Tier 1 early stop/Tier 2 expansion, safe publication, report order, rollback, and cancellation. +- `ClumsyPilot/tests/measure_path_smoothing_local_g2_performance.ps1` + - Runs the fixed 5-warmup/30-measurement benchmark and emits machine-readable P50/P95 metrics. +- `ClumsyPilot/tests/verify_path_smoothing_service.ps1` + - Proves dedicated Local G2 dispatch and unchanged legacy method routing. + +--- + +### Task 1: Prove and implement the hard-anchor split-scale candidate family + +**Files:** + +- Modify: `ClumsyPilot/ParkrobTrajplanner/PathSmoothing/LocalG2/LocalG2CandidateGeometry.cs` +- Modify: `ClumsyPilot/ParkrobTrajplanner/PathSmoothing/LocalG2/LocalG2CandidateBuilder.cs` +- Modify: `ClumsyPilot/tests/verify_path_smoothing_local_g2_candidates.ps1` +- Record without committing: `.superpowers/sdd/local-g2-split-scale-feasibility-report.md` + +**Interfaces:** + +- Consumes: + +```csharp +internal LocalG2CandidateBuildSession BeginBuild( + PreparedDirectionSegment originalSegment, + LocalG2SmoothingRegion region, + double outputSpacingMeters, + LocalG2OptionsSnapshot options); +``` + +- Produces: + +```csharp +internal IReadOnlyList BuildPrimary( + CancellationToken cancellationToken); + +internal IReadOnlyList BuildFallback( + CancellationToken cancellationToken); + +internal int AttemptCount { get; } +internal int SuccessfulCandidateCount { get; } +internal IReadOnlyList AttemptDiagnostics { get; } +``` + +- `BuildFallback` throws `InvalidOperationException` if called before `BuildPrimary`, and production does not call it after a Tier 1 acceptance. + +- [ ] **Step 1: Replace the obsolete soft-anchor RED assertion** + +In `verify_path_smoothing_local_g2_candidates.ps1`, replace `$acceptedSoftCandidates`, normal-offset inference, and the final non-zero-soft-anchor assertion with these checks: + +```powershell +$acceptedSplitCandidates = @() +$maximumAnchorError = 0.0 +foreach ($realCandidate in $realCandidates) { + $evaluation = (Get-InternalMethod $evaluatorType 'Evaluate').Invoke($realEvaluator, @( + $preparedPath, $preparedPath, $region, $realCandidate, $singleTurnRequest, $options, + [Threading.CancellationToken]::None)) + $failureReason = Get-InternalProperty $evaluation 'FailureReason' + if ($failureReason -eq 2) { $duplicateFailures += (Get-InternalProperty $realCandidate 'CandidateIndex') } + + $hasDifferentScale = $false + foreach ($diagnostic in (Get-InternalProperty $realCandidate 'InternalScaleDiagnostics')) { + $anchorError = [double](Get-InternalProperty $diagnostic 'AnchorPositionErrorMeters') + $maximumAnchorError = [Math]::Max($maximumAnchorError, $anchorError) + $incoming = [double](Get-InternalProperty $diagnostic 'IncomingDerivativeScale') + $outgoing = [double](Get-InternalProperty $diagnostic 'OutgoingDerivativeScale') + if ([Math]::Abs($incoming - $outgoing) -gt 1e-10) { $hasDifferentScale = $true } + } + if ((Get-InternalProperty $evaluation 'Accepted') -and $hasDifferentScale) { + $acceptedSplitCandidates += $realCandidate + } +} +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 ($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: + +```powershell +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. + +- [ ] **Step 3: Create an isolated feasibility copy and capture the `bd08a9b` baseline** + +Use a disposable directory whose resolved path is under the system temp directory: + +```powershell +$probeRoot = Join-Path ([IO.Path]::GetTempPath()) ('local-g2-split-' + [Guid]::NewGuid().ToString('N')) +New-Item -ItemType Directory -Path $probeRoot | Out-Null +$sourceRoot = (Resolve-Path '.').Path +$probeProject = Join-Path $probeRoot 'ParkingRobot' +robocopy $sourceRoot $probeProject /E /XD .git bin obj auto_avoidance .task8-sweep | Out-Null +if ($LASTEXITCODE -gt 7) { throw "robocopy failed with exit code $LASTEXITCODE" } +``` + +Before applying the candidate patch in the disposable copy, run the real `SingleTurn` builder/evaluator path 5 warmups plus 30 measurements. Record the elapsed milliseconds as `baselineSamples`, then compute: + +```powershell +function Get-Percentile([double[]]$Values, [double]$Fraction) { + $ordered = @($Values | Sort-Object) + $index = [Math]::Ceiling($Fraction * $ordered.Count) - 1 + return [double]$ordered[[Math]::Max(0, [Math]::Min($ordered.Count - 1, $index))] +} +$baselineP50 = Get-Percentile $baselineSamples 0.50 +$baselineP95 = Get-Percentile $baselineSamples 0.95 +``` + +The report must identify commit `bd08a9b`, `Debug/netstandard2.0`, the `SingleTurn` fixture/configuration, the machine, 5 warmups, 30 measurements, P50, and P95. If the candidate/evaluator sources differ from `bd08a9b`, overwrite only these three files in the disposable copy from a `git archive bd08a9b` extraction before measuring: + +```text +LocalG2CandidateBuilder.cs +LocalG2CandidateGeometry.cs +LocalG2CandidateEvaluator.cs +``` + +- [ ] **Step 4: Add scale metadata in the disposable copy** + +Add this enum and diagnostic type to `LocalG2CandidateGeometry.cs`: + +```csharp +internal enum LocalG2DerivativeScaleProfile +{ + P0 = 0, + P1 = 1, + P2 = 2, +} + +internal sealed class LocalG2InternalScaleDiagnostic +{ + internal LocalG2InternalScaleDiagnostic( + double arcLengthMeters, + double incomingDerivativeScale, + double outgoingDerivativeScale, + double anchorPositionErrorMeters) + { + if (!NumericGuard.IsFinite(arcLengthMeters) || + !NumericGuard.IsPositiveFinite(incomingDerivativeScale) || + !NumericGuard.IsPositiveFinite(outgoingDerivativeScale) || + !NumericGuard.IsFinite(anchorPositionErrorMeters) || anchorPositionErrorMeters < 0d) + throw new ArgumentOutOfRangeException(nameof(arcLengthMeters)); + ArcLengthMeters = arcLengthMeters; + IncomingDerivativeScale = incomingDerivativeScale; + OutgoingDerivativeScale = outgoingDerivativeScale; + AnchorPositionErrorMeters = anchorPositionErrorMeters; + } + + internal double ArcLengthMeters { get; } + internal double IncomingDerivativeScale { get; } + internal double OutgoingDerivativeScale { get; } + internal double AnchorPositionErrorMeters { get; } +} +``` + +Append these optional parameters to the `LocalG2CandidateGeometry` constructor so existing evaluator TestHooks remain source-compatible: + +```csharp +LocalG2DerivativeScaleProfile scaleProfile = LocalG2DerivativeScaleProfile.P0, +int candidateTier = 1, +IReadOnlyList internalScaleDiagnostics = null +``` + +Validate `candidateTier` is `1` or `2`, copy diagnostics into a `ReadOnlyCollection`, and expose: + +```csharp +if (candidateTier != 1 && candidateTier != 2) + throw new ArgumentOutOfRangeException(nameof(candidateTier)); +ScaleProfile = scaleProfile; +CandidateTier = candidateTier; +var diagnosticCopy = new List(); +if (internalScaleDiagnostics != null) +{ + for (int index = 0; index < internalScaleDiagnostics.Count; index++) + { + if (internalScaleDiagnostics[index] == null) + throw new ArgumentOutOfRangeException(nameof(internalScaleDiagnostics)); + diagnosticCopy.Add(internalScaleDiagnostics[index]); + } +} +InternalScaleDiagnostics = + new ReadOnlyCollection(diagnosticCopy); + +internal LocalG2DerivativeScaleProfile ScaleProfile { get; } +internal int CandidateTier { get; } +internal IReadOnlyList InternalScaleDiagnostics { get; } +``` + +- [ ] **Step 5: Replace the single-scale boundary model in the disposable copy** + +Replace `BoundaryNode.DerivativeScale` and `WithDerivativeScale` with: + +```csharp +internal double IncomingDerivativeScale { get; } +internal double OutgoingDerivativeScale { get; } + +internal BoundaryNode WithDerivativeScales(double incoming, double outgoing) => + new BoundaryNode( + ArcLengthMeters, + X, + Y, + VehicleHeadingRadians, + VehicleCurvaturePerMeter, + incoming, + outgoing); +``` + +The public-shape constructor initializes both scales to `0d`; the private constructor receives both values. Replace `TryAssignDerivativeScales` with: + +```csharp +private static bool TryAssignDerivativeScales( + List nodes, + LocalG2DerivativeScaleProfile profile) +{ + if (nodes == null || nodes.Count < 2) return false; + GetProfileFactors(profile, out double incomingFactor, out double outgoingFactor); + for (int index = 0; index < nodes.Count; index++) + { + bool internalNode = index > 0 && index < nodes.Count - 1; + double incoming = index == 0 + ? 0d + : nodes[index].ArcLengthMeters - nodes[index - 1].ArcLengthMeters; + double outgoing = index == nodes.Count - 1 + ? 0d + : nodes[index + 1].ArcLengthMeters - nodes[index].ArcLengthMeters; + if (internalNode) + { + incoming *= incomingFactor; + outgoing *= outgoingFactor; + } + if ((index > 0 && !NumericGuard.IsPositiveFinite(incoming)) || + (index < nodes.Count - 1 && !NumericGuard.IsPositiveFinite(outgoing))) + return false; + nodes[index] = nodes[index].WithDerivativeScales(incoming, outgoing); + } + return true; +} + +private static void GetProfileFactors( + LocalG2DerivativeScaleProfile profile, + out double incoming, + out double outgoing) +{ + switch (profile) + { + case LocalG2DerivativeScaleProfile.P0: incoming = 1d; outgoing = 1d; return; + case LocalG2DerivativeScaleProfile.P1: incoming = 0.75d; outgoing = 1.25d; return; + case LocalG2DerivativeScaleProfile.P2: incoming = 1.25d; outgoing = 0.75d; return; + default: throw new ArgumentOutOfRangeException(nameof(profile)); + } +} +``` + +Change curve construction to select the side-specific scale: + +```csharp +private static bool TryCreateCurve( + BoundaryNode left, + BoundaryNode right, + double directionSign, + out QuinticHermiteCurve2D curve) +{ + curve = null; + if (!TryGetDerivatives(left, left.OutgoingDerivativeScale, directionSign, + out double ldx, out double ldy, out double lddx, out double lddy) || + !TryGetDerivatives(right, right.IncomingDerivativeScale, directionSign, + out double rdx, out double rdy, out double rddx, out double rddy)) + return false; + return QuinticHermiteCurve2D.TryCreate( + left.X, left.Y, ldx, ldy, lddx, lddy, + right.X, right.Y, rdx, rdy, rddx, rddy, + out curve, out _); +} + +private static bool TryGetDerivatives( + BoundaryNode node, + double derivativeScale, + double directionSign, + out double dx, + out double dy, + out double ddx, + out double ddy) +{ + dx = dy = ddx = ddy = 0d; + if (!NumericGuard.IsPositiveFinite(derivativeScale)) return false; + double travelHeading = directionSign > 0d + ? node.VehicleHeadingRadians + : node.VehicleHeadingRadians - Math.PI; + double geometricCurvature = directionSign * node.VehicleCurvaturePerMeter; + double tx = Math.Cos(travelHeading); + double ty = Math.Sin(travelHeading); + double nx = -ty; + double ny = tx; + dx = derivativeScale * tx; + dy = derivativeScale * ty; + ddx = derivativeScale * derivativeScale * geometricCurvature * nx; + ddy = derivativeScale * derivativeScale * geometricCurvature * ny; + return NumericGuard.IsFinite(dx) && NumericGuard.IsFinite(dy) && + NumericGuard.IsFinite(ddx) && NumericGuard.IsFinite(ddy) && + Math.Sqrt(dx * dx + dy * dy) >= MinimumDerivativeNorm; +} +``` + +Do not alter transition X/Y construction. Build one `LocalG2InternalScaleDiagnostic` for every internal node with the actual incoming/outgoing scales and `AnchorPositionErrorMeters = 0d`. + +- [ ] **Step 6: Add deterministic representative scheduling and lazy Tier 2 construction in the disposable copy** + +Delete `DerivativeScaleMultipliers`. Add `BeginBuild`; keep `Build` only as a test/backward-compatibility wrapper that concatenates `BuildPrimary` and `BuildFallback` from one session: + +```csharp +internal LocalG2CandidateBuildSession BeginBuild( + PreparedDirectionSegment originalSegment, + LocalG2SmoothingRegion region, + double outputSpacingMeters, + LocalG2OptionsSnapshot options) => + new LocalG2CandidateBuildSession( + originalSegment, + region, + outputSpacingMeters, + options); + +internal IReadOnlyList Build( + PreparedDirectionSegment originalSegment, + LocalG2SmoothingRegion region, + double outputSpacingMeters, + LocalG2OptionsSnapshot options, + CancellationToken cancellationToken) +{ + LocalG2CandidateBuildSession session = BeginBuild( + originalSegment, region, outputSpacingMeters, options); + var all = new List(); + all.AddRange(session.BuildPrimary(cancellationToken)); + all.AddRange(session.BuildFallback(cancellationToken)); + return ReadOnly(all); +} +``` + +Use these attempt records and exact global order: + +```csharp +private readonly struct CandidateAttempt +{ + internal CandidateAttempt( + LocalG2WindowVariant window, + LocalG2DerivativeScaleProfile profile, + int tier) + { + Window = window; + Profile = profile; + Tier = tier; + } + internal LocalG2WindowVariant Window { get; } + internal LocalG2DerivativeScaleProfile Profile { get; } + internal int Tier { get; } +} + +internal sealed class LocalG2CandidateAttemptDiagnostic +{ + internal LocalG2CandidateAttemptDiagnostic( + int attemptIndex, + int tier, + LocalG2DerivativeScaleProfile profile, + int plannerWindowIndex, + double startArcLengthMeters, + double endArcLengthMeters, + bool succeeded, + int candidateIndex, + string reason) + { + AttemptIndex = attemptIndex; + Tier = tier; + Profile = profile; + PlannerWindowIndex = plannerWindowIndex; + StartArcLengthMeters = startArcLengthMeters; + EndArcLengthMeters = endArcLengthMeters; + Succeeded = succeeded; + CandidateIndex = candidateIndex; + Reason = reason ?? string.Empty; + } + internal int AttemptIndex { get; } + internal int Tier { get; } + internal LocalG2DerivativeScaleProfile Profile { get; } + internal int PlannerWindowIndex { get; } + internal double StartArcLengthMeters { get; } + internal double EndArcLengthMeters { get; } + internal bool Succeeded { get; } + internal int CandidateIndex { get; } + internal string Reason { get; } +} + +private static List CreateAttempts( + LocalG2SmoothingRegion region, + IReadOnlyList windows, + out int primaryAttemptCount) +{ + var attempts = new List(12); + for (int index = 0; index < windows.Count; index++) + attempts.Add(new CandidateAttempt(windows[index], LocalG2DerivativeScaleProfile.P0, 1)); + if (windows.Count > 0 && HasInternalNode(region, windows[0])) + { + attempts.Add(new CandidateAttempt(windows[0], LocalG2DerivativeScaleProfile.P1, 1)); + attempts.Add(new CandidateAttempt(windows[0], LocalG2DerivativeScaleProfile.P2, 1)); + } + primaryAttemptCount = Math.Min(attempts.Count, 6); + for (int index = 1; index < windows.Count; index++) + if (HasInternalNode(region, windows[index])) + attempts.Add(new CandidateAttempt(windows[index], LocalG2DerivativeScaleProfile.P1, 2)); + for (int index = 1; index < windows.Count; index++) + if (HasInternalNode(region, windows[index])) + attempts.Add(new CandidateAttempt(windows[index], LocalG2DerivativeScaleProfile.P2, 2)); + return attempts; +} + +private static bool HasInternalNode( + LocalG2SmoothingRegion region, + LocalG2WindowVariant window) +{ + for (int index = 0; index < region.Transitions.Count; index++) + { + double arc = region.Transitions[index].LocalArcLengthMeters; + if (arc > window.StartArcLengthMeters + 1e-10d && + arc < window.EndArcLengthMeters - 1e-10d) + return true; + } + return false; +} +``` + +Add the representative selectors exactly as follows: + +```csharp +private static IReadOnlyList SelectRepresentativeWindows( + IReadOnlyList variants) +{ + var selected = new List(4); + if (variants == null || variants.Count == 0) + return new ReadOnlyCollection(selected); + AddDistinctWindow(selected, variants[0]); + LocalG2WindowVariant shortest = variants[0]; + LocalG2WindowVariant longest = variants[0]; + LocalG2WindowVariant asymmetric = variants[0]; + for (int index = 1; index < variants.Count; index++) + { + LocalG2WindowVariant candidate = variants[index]; + if (IsShorter(candidate, shortest)) shortest = candidate; + if (IsLonger(candidate, longest)) longest = candidate; + if (IsMoreAsymmetric(candidate, asymmetric)) asymmetric = candidate; + } + AddDistinctWindow(selected, shortest); + AddDistinctWindow(selected, longest); + AddDistinctWindow(selected, asymmetric); + return new ReadOnlyCollection(selected); +} + +private static bool IsShorter(LocalG2WindowVariant candidate, LocalG2WindowVariant current) +{ + double candidateLength = candidate.EndArcLengthMeters - candidate.StartArcLengthMeters; + double currentLength = current.EndArcLengthMeters - current.StartArcLengthMeters; + return candidateLength < currentLength - 1e-10d || + (Math.Abs(candidateLength - currentLength) <= 1e-10d && + candidate.CandidateIndex < current.CandidateIndex); +} + +private static bool IsLonger(LocalG2WindowVariant candidate, LocalG2WindowVariant current) +{ + double candidateLength = candidate.EndArcLengthMeters - candidate.StartArcLengthMeters; + double currentLength = current.EndArcLengthMeters - current.StartArcLengthMeters; + return candidateLength > currentLength + 1e-10d || + (Math.Abs(candidateLength - currentLength) <= 1e-10d && + candidate.CandidateIndex < current.CandidateIndex); +} + +private static bool IsMoreAsymmetric(LocalG2WindowVariant candidate, LocalG2WindowVariant current) +{ + double candidateValue = Math.Abs( + candidate.LeftWindowLengthMeters - candidate.RightWindowLengthMeters); + double currentValue = Math.Abs( + current.LeftWindowLengthMeters - current.RightWindowLengthMeters); + return candidateValue > currentValue + 1e-10d || + (Math.Abs(candidateValue - currentValue) <= 1e-10d && + candidate.CandidateIndex < current.CandidateIndex); +} + +private static void AddDistinctWindow( + List selected, + LocalG2WindowVariant candidate) +{ + for (int index = 0; index < selected.Count; index++) + { + if (SameArc(selected[index].StartArcLengthMeters, candidate.StartArcLengthMeters) && + SameArc(selected[index].EndArcLengthMeters, candidate.EndArcLengthMeters)) + return; + } + selected.Add(candidate); +} +``` + +Define this nested type inside `LocalG2CandidateBuilder`: + +```csharp +internal sealed class LocalG2CandidateBuildSession +{ + private readonly PreparedDirectionSegment _segment; + private readonly LocalG2SmoothingRegion _region; + private readonly double _outputSpacingMeters; + private readonly List _attempts; + private readonly int _primaryAttemptCount; + private readonly int _attemptLimit; + private readonly List _attemptDiagnostics = + new List(); + private int _nextAttemptIndex; + private int _nextCandidateIndex; + private bool _primaryBuilt; + private bool _fallbackBuilt; + + internal LocalG2CandidateBuildSession( + PreparedDirectionSegment segment, + LocalG2SmoothingRegion region, + double outputSpacingMeters, + LocalG2OptionsSnapshot options) + { + _segment = segment; + _region = region; + _outputSpacingMeters = outputSpacingMeters; + if (!IsValidInput(segment, region, outputSpacingMeters, options)) + { + _attempts = new List(); + _primaryAttemptCount = 0; + _attemptLimit = 0; + return; + } + IReadOnlyList windows = + SelectRepresentativeWindows(region.WindowVariants); + _attempts = CreateAttempts(region, windows, out _primaryAttemptCount); + _attemptLimit = Math.Min(options.MaximumCandidatesPerRegion, 12); + } + + internal int AttemptCount { get; private set; } + internal int SuccessfulCandidateCount { get; private set; } + internal IReadOnlyList AttemptDiagnostics => + new ReadOnlyCollection(_attemptDiagnostics); + + internal IReadOnlyList BuildPrimary( + CancellationToken cancellationToken) + { + if (_primaryBuilt) + throw new InvalidOperationException("Local G2 primary candidates were already built."); + _primaryBuilt = true; + return BuildUntil(_primaryAttemptCount, cancellationToken); + } + + internal IReadOnlyList BuildFallback( + CancellationToken cancellationToken) + { + if (!_primaryBuilt) + throw new InvalidOperationException("BuildPrimary must run before BuildFallback."); + if (_fallbackBuilt) + throw new InvalidOperationException("Local G2 fallback candidates were already built."); + _fallbackBuilt = true; + return BuildUntil(_attempts.Count, cancellationToken); + } + + private IReadOnlyList BuildUntil( + int endExclusive, + CancellationToken cancellationToken) + { + var candidates = new List(); + int boundedEnd = Math.Min(endExclusive, _attemptLimit); + while (_nextAttemptIndex < boundedEnd) + { + cancellationToken.ThrowIfCancellationRequested(); + int attemptIndex = _nextAttemptIndex; + CandidateAttempt attempt = _attempts[_nextAttemptIndex++]; + AttemptCount++; + bool succeeded = TryBuildCandidate( + _nextCandidateIndex, + _segment, + _region, + attempt.Window, + attempt.Profile, + attempt.Tier, + _outputSpacingMeters, + cancellationToken, + out LocalG2CandidateGeometry candidate); + _attemptDiagnostics.Add(new LocalG2CandidateAttemptDiagnostic( + attemptIndex, + attempt.Tier, + attempt.Profile, + attempt.Window.CandidateIndex, + attempt.Window.StartArcLengthMeters, + attempt.Window.EndArcLengthMeters, + succeeded, + succeeded ? candidate.CandidateIndex : -1, + succeeded ? "Built" : "CandidateGenerationFailed")); + if (succeeded) + { + candidates.Add(candidate); + _nextCandidateIndex++; + SuccessfulCandidateCount++; + } + } + return ReadOnly(candidates); + } +} +``` + +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** + +Add this public snapshot next to the existing `CandidateTestSnapshot`: + +```csharp +public sealed class SplitScaleConnectionTestSnapshot +{ + 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; } +} +``` + +`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: + +```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); +``` + +Return the snapshot with the actual `0.75` and `1.25` scales. + +- [ ] **Step 8: Run the isolated feasibility gate** + +Build the disposable copy and run its candidate verification against the real `SingleTurn` fixture. Evaluate all successfully constructed Tier 1 candidates; only if none is accepted, construct and evaluate Tier 2. The gate is GREEN only when all of these are true: + +```text +at least one real candidate is accepted +accepted candidate internal anchor error <= 1e-9 m +at least one accepted candidate has different actual incoming/outgoing scales +connection tangent direction error <= 1e-8 rad +connection geometric curvature error <= 1e-8 1/m +unchanged curvature, raw-range, 20%, 2%, deviation, collision, and clearance gates pass +``` + +Record every attempted tuple as `(tier, representative index, start arc, end arc, profile)`, every successful candidate's internal scales, failure reason or accepted metrics, maximum deviation, minimum clearance, curvature range, peak, variation cost, and elapsed time in `.superpowers/sdd/local-g2-split-scale-feasibility-report.md`. + +If no tuple is accepted, stop the plan with the shared builder and geometry unchanged. Do not alter fixtures, coordinates, shared curvature, vehicle limits, evaluator tolerances, safety gates, or quality gates. + +- [ ] **Step 9: Apply the proven patch to the shared tree and verify GREEN** + +Only after Step 8 is GREEN, apply the exact geometry/builder patch from Steps 4–7 to the shared files. Run: + +```powershell +dotnet build ClumsyPilot/ClumsyPilot.csproj --no-restore +powershell -ExecutionPolicy Bypass -File ClumsyPilot/tests/verify_path_smoothing_local_g2_detection.ps1 +powershell -ExecutionPolicy Bypass -File ClumsyPilot/tests/verify_path_smoothing_local_g2_candidates.ps1 +``` + +If the normal build is blocked only by `auto_avoidance`, repeat the build and scripts in a disposable copy excluding that directory and record both the shared-tree failure and isolated GREEN evidence. Expected: build succeeds in the valid source set and both scripts pass. + +- [ ] **Step 10: Extend schedule/cap/determinism assertions** + +In the candidate script, inspect candidate profile, tier, window start/end, and candidate index. Assert exact global order: + +```text +Tier 1: representative P0 in representative order, first representative P1, first representative P2 +Tier 2: remaining representatives P1, then remaining representatives P2 +``` + +Loop `MaximumCandidatesPerRegion` from `1` through `12`; require `AttemptCount <= limit`, output count `<= limit`, dense successful indices `0..count-1`, and no Tier 2 attempt before all enabled Tier 1 attempts. Run forward, reverse, asymmetric-window, and clustered multi-transition scenarios twice and compare every profile, tier, window, index, point coordinate, heading, arc, and source. + +- [ ] **Step 11: Commit the proven candidate family** + +```powershell +git add -- ` + ClumsyPilot/ParkrobTrajplanner/PathSmoothing/LocalG2/LocalG2CandidateGeometry.cs ` + ClumsyPilot/ParkrobTrajplanner/PathSmoothing/LocalG2/LocalG2CandidateBuilder.cs ` + ClumsyPilot/tests/verify_path_smoothing_local_g2_candidates.ps1 +git diff --cached --check +git diff --cached --name-only +git commit -m "fix: recover Local G2 candidates with split scales" +``` + +Expected staged names: exactly the three files above. Do not commit `.superpowers/sdd` evidence. + +--- + +### Task 2: Cache raw-window analysis within one region evaluation + +**Files:** + +- Modify: `ClumsyPilot/ParkrobTrajplanner/PathSmoothing/LocalG2/LocalG2CandidateEvaluator.cs` +- Modify: `ClumsyPilot/tests/verify_path_smoothing_local_g2_candidates.ps1` + +**Interfaces:** + +- Consumes: candidates from one `LocalG2CandidateBuilder.LocalG2CandidateBuildSession` and one fixed `(rawPath, currentPath, region, request, options)` tuple. +- Produces: + +```csharp +internal EvaluationSession BeginRegionEvaluation( + PreparedPath rawPath, + PreparedPath currentPath, + LocalG2SmoothingRegion region, + PathSmoothingRequest request, + LocalG2OptionsSnapshot options); + +internal sealed class EvaluationSession +{ + internal LocalG2CandidateEvaluation Evaluate( + LocalG2CandidateGeometry candidate, + CancellationToken cancellationToken); + internal int RawWindowAnalysisCount { get; } +} +``` + +- Session lifetime supplies request identity; its cache key is exact `(SegmentIndex, StartArcLengthMeters, EndArcLengthMeters)` and cannot escape one region evaluation. + +- [ ] **Step 1: Add a failing repeated-window cache assertion** + +Add evaluator TestHook scenario `RawWindowCache`. It creates one evaluation session, evaluates three valid geometries sharing the same segment/start/end window, and returns `RawWindowAnalysisCount`. Add to the PowerShell script: + +```powershell +$rawWindowCache = Invoke-EvaluationScenario 'RawWindowCache' +Assert-Equal 1 $rawWindowCache.RawWindowAnalysisCount ` + 'P0/P1/P2 candidates for one window must share one raw-window analysis.' +``` + +Run: + +```powershell +powershell -ExecutionPolicy Bypass -File ClumsyPilot/tests/verify_path_smoothing_local_g2_candidates.ps1 +``` + +Expected: FAIL because `RawWindowAnalysisCount`/`BeginRegionEvaluation` does not exist. + +- [ ] **Step 2: Add the region-scoped session and exact cache key** + +Add: + +```csharp +internal EvaluationSession BeginRegionEvaluation( + PreparedPath rawPath, + PreparedPath currentPath, + LocalG2SmoothingRegion region, + PathSmoothingRequest request, + LocalG2OptionsSnapshot options) => + new EvaluationSession(this, rawPath, currentPath, region, request, options); + +private readonly struct WindowKey : IEquatable +{ + internal WindowKey(int segmentIndex, double start, double end) + { + SegmentIndex = segmentIndex; + Start = start; + End = end; + } + private int SegmentIndex { get; } + private double Start { get; } + private double End { get; } + public bool Equals(WindowKey other) => + SegmentIndex == other.SegmentIndex && Start.Equals(other.Start) && End.Equals(other.End); + public override bool Equals(object value) => value is WindowKey other && Equals(other); + public override int GetHashCode() + { + unchecked + { + int hash = 17; + hash = hash * 31 + SegmentIndex; + hash = hash * 31 + Start.GetHashCode(); + return hash * 31 + End.GetHashCode(); + } + } +} +``` + +The session owns `Dictionary`, all request inputs, and `RawWindowAnalysisCount`. On the first key, it extracts/analyzes the current raw window, verifies finite metrics, computes its curvature range, stores success or the stable failure reason, and increments the count exactly once. Repeated profiles reuse that immutable entry. + +- [ ] **Step 3: Split raw-window preparation from candidate-specific evaluation** + +Define the cached value: + +```csharp +private sealed class RawWindowEntry +{ + internal RawWindowEntry( + IReadOnlyList points, + PathGeometryAnalysis analysis, + double minimumCurvature, + double maximumCurvature, + string failureReason) + { + Points = points; + Analysis = analysis; + MinimumCurvature = minimumCurvature; + MaximumCurvature = maximumCurvature; + FailureReason = failureReason ?? string.Empty; + } + internal IReadOnlyList Points { get; } + internal PathGeometryAnalysis Analysis { get; } + internal double MinimumCurvature { get; } + internal double MaximumCurvature { get; } + internal string FailureReason { get; } + internal bool IsValid => Points != null && Analysis != null && string.IsNullOrEmpty(FailureReason); +} +``` + +Move only raw extraction, raw unified resampling/analysis, finite checks, and raw curvature-range computation into session preparation. Keep these operations inside `EvaluatePrepared` for every candidate: + +```text +candidate unified geometry analysis +vehicle maximum curvature +raw curvature-range comparison +maximum deviation +splice and full-path geometry analysis +full-body collision validation +clearance reserve +20 percent peak improvement +2 percent variation-cost tolerance +accepted path-length change +``` + +Keep the current seven-argument `Evaluate` as a compatibility wrapper: + +```csharp +return BeginRegionEvaluation(rawPath, currentPath, region, request, options) + .Evaluate(candidate, cancellationToken); +``` + +- [ ] **Step 4: Preserve the first concrete rejection when no candidate is accepted** + +Change `SelectBest` so it still compares all accepted candidates by deviation, peak, variation cost, length change, then candidate index, but remembers the first non-null rejection: + +```csharp +LocalG2CandidateEvaluation firstRejected = null; +for (int index = 0; index < evaluations.Count; index++) +{ + LocalG2CandidateEvaluation evaluation = evaluations[index]; + if (evaluation == null) continue; + if (!evaluation.Accepted) + { + if (firstRejected == null) firstRejected = evaluation; + continue; + } + if (best == null || Compare(evaluation, best) < 0) best = evaluation; +} +return best ?? firstRejected ?? + Rejected(-1, PathSmoothingRegionFailureReason.CandidateGenerationFailed, + "没有生成可评价的局部 G2 候选。"); +``` + +- [ ] **Step 5: Verify cache and unchanged gates** + +Run: + +```powershell +dotnet build ClumsyPilot/ClumsyPilot.csproj --no-restore +powershell -ExecutionPolicy Bypass -File ClumsyPilot/tests/verify_path_smoothing_local_g2_candidates.ps1 +powershell -ExecutionPolicy Bypass -File ClumsyPilot/tests/verify_path_smoothing_validation.ps1 +``` + +Expected: all pass; repeated-window count is exactly one, and every existing collision/clearance/curvature/improvement/variation failure classification remains unchanged. + +- [ ] **Step 6: Commit the region-scoped cache** + +```powershell +git add -- ` + ClumsyPilot/ParkrobTrajplanner/PathSmoothing/LocalG2/LocalG2CandidateEvaluator.cs ` + ClumsyPilot/tests/verify_path_smoothing_local_g2_candidates.ps1 +git diff --cached --check +git diff --cached --name-only +git commit -m "perf: cache Local G2 raw window analysis" +``` + +Expected staged names: exactly the two files above. + +--- + +### Task 3: Finish tiered Task 8 publication and service integration + +**Files:** + +- Create from the existing untracked prototype: `ClumsyPilot/ParkrobTrajplanner/PathSmoothing/LocalG2/LocalG2PreSmoothingPipeline.cs` +- Modify: `ClumsyPilot/ParkrobTrajplanner/PathSmoothing/Facade/PathSmoothingService.cs` +- Create from the existing untracked prototype: `ClumsyPilot/tests/verify_path_smoothing_local_g2_integration.ps1` +- Modify: `ClumsyPilot/tests/verify_path_smoothing_service.ps1` + +**Interfaces:** + +- Consumes: `LocalG2CandidateBuilder.LocalG2CandidateBuildSession`, `LocalG2CandidateEvaluator.EvaluationSession`, immutable `RawPathBaseline`, detector report order, and `LocalG2RegionWorkOrder`. +- Produces: + +```csharp +internal PathSmoothingResult Smooth( + PathSmoothingRequest request, + PreparedPath preparedPath, + RawPathBaseline rawBaseline, + CancellationToken cancellationToken); +``` + +- Guarantees: Tier 2 is neither built nor evaluated after a Tier 1 acceptance; valid non-cancelled requests publish a complete verified path; cancellation publishes no partial path. + +- [ ] **Step 1: Run integration/service RED and record the first contractual failure** + +Run: + +```powershell +dotnet build ClumsyPilot/ClumsyPilot.csproj --no-restore +powershell -ExecutionPolicy Bypass -File ClumsyPilot/tests/verify_path_smoothing_local_g2_integration.ps1 +powershell -ExecutionPolicy Bypass -File ClumsyPilot/tests/verify_path_smoothing_service.ps1 +``` + +Expected before the tier/publication corrections: at least one required fixture status, actual candidate count, raw baseline, selected window, rollback, cancellation, or Tier 2 early-stop assertion fails. Record the first failure in `.superpowers/sdd/local-g2-split-scale-task-8-integration-report.md`. + +- [ ] **Step 2: Evaluate Tier 1 and conditionally construct Tier 2** + +Replace the pipeline's eager builder/evaluator loop with: + +```csharp +LocalG2CandidateBuilder.LocalG2CandidateBuildSession buildSession = _builder.BeginBuild( + preparedPath.Segments[region.SegmentIndex], + region, + request.Configuration.OutputSpacingMeters, + options); +LocalG2CandidateEvaluator.EvaluationSession evaluationSession = + _evaluator.BeginRegionEvaluation( + preparedPath, current, region, request, options); + +var candidates = new List(); +var evaluations = new List(); +IReadOnlyList primary = + buildSession.BuildPrimary(cancellationToken); +candidates.AddRange(primary); +EvaluateAll(primary, evaluationSession, evaluations, cancellationToken); +LocalG2CandidateEvaluation best = + LocalG2CandidateEvaluator.SelectBest(evaluations); + +if (!best.Accepted) +{ + IReadOnlyList fallback = + buildSession.BuildFallback(cancellationToken); + candidates.AddRange(fallback); + EvaluateAll(fallback, evaluationSession, evaluations, cancellationToken); + best = LocalG2CandidateEvaluator.SelectBest(evaluations); +} +``` + +Add: + +```csharp +private static void EvaluateAll( + IReadOnlyList candidates, + LocalG2CandidateEvaluator.EvaluationSession session, + List output, + CancellationToken cancellationToken) +{ + for (int index = 0; index < candidates.Count; index++) + { + cancellationToken.ThrowIfCancellationRequested(); + output.Add(session.Evaluate(candidates[index], cancellationToken)); + } +} +``` + +The candidate count passed into every report is `evaluations.Count`. Do not use `region.WindowVariants.Count`, build attempts, or all possible schedule entries. + +- [ ] **Step 3: Bind accepted and rollback reports to the actual selected geometry** + +Resolve the selected geometry by dense candidate index: + +```csharp +private static LocalG2CandidateGeometry FindCandidate( + IReadOnlyList candidates, + int candidateIndex) +{ + for (int index = 0; index < candidates.Count; index++) + if (candidates[index].CandidateIndex == candidateIndex) + return candidates[index]; + return null; +} +``` + +For an accepted evaluation, require `selectedCandidate != null`, store it and `evaluations.Count` in `AcceptedRegion`, and report its exact start/end/left/right window values. For no accepted candidate, use candidate index `-1` only when no evaluation exists; otherwise retain the first concrete rejection and its reason. Rollback reports use the stored selected candidate and actual evaluated count. + +Extend `AcceptedRegion` with: + +```csharp +internal AcceptedRegion( + LocalG2SmoothingRegion region, + PreparedPath before, + LocalG2CandidateEvaluation evaluation, + LocalG2CandidateGeometry candidate, + int evaluatedCandidateCount) +``` + +and read-only `Candidate` and `EvaluatedCandidateCount` properties. + +- [ ] **Step 4: Publish the trusted raw baseline for zero transitions and zero surviving improvements** + +Immediately after detection/planning, if `transitions.Count == 0`, publish the existing `rawBaseline` as `NotNeeded`; do not re-run analysis. After global rollback, if `improvedCount == 0`, publish that same baseline as `Unchanged` with detector-order reports. + +Use: + +```csharp +private static PathSmoothingResult PublishBaseline( + PathSmoothingStatus status, + RawPathBaseline baseline, + Stopwatch stopwatch, + IReadOnlyList reports) => + PathSmoothingResult.PublishLocalG2( + status, + baseline.Path, + baseline.Segments, + new PathSmoothingDiagnostics( + baseline.Metrics, + stopwatch.Elapsed, + 0, + 0d, + string.Empty), + reports); +``` + +- [ ] **Step 5: Preserve work/report order, full validation, and rollback** + +Keep immutable ascending `reportOrder`; process `workRegions` from `LocalG2RegionWorkOrder`. Candidate construction always uses `preparedPath.Segments[region.SegmentIndex]`; evaluation uses immutable `preparedPath` as raw reference and evolving `current` as splice input. + +After regional processing, validate the complete `current` path. On failure, roll accepted regions back in reverse acceptance order and revalidate after each rollback. Mark removed regions `GlobalValidationRollback`. Derive status after rollback only: + +```csharp +if (improvedCount == 0) + status = PathSmoothingStatus.Unchanged; +else if (improvedCount == regions.Count) + status = PathSmoothingStatus.Complete; +else + status = PathSmoothingStatus.PartialImprovement; +``` + +`Complete` and `PartialImprovement` require at least one `Improved` report. If every acceptance rolls back, publish the verified raw baseline. + +- [ ] **Step 6: Keep public service dispatch isolated** + +After common request validation, preparation, and `RawPathBaselineBuilder.TryCreate`, retain: + +```csharp +if (configuration.Method == SmoothingMethod.LocalG2Quintic) + return _localG2Pipeline.Smooth( + request, + preparedPath, + rawBaseline, + cancellationToken); +``` + +Validate `LocalG2QuinticOptions` only for Local G2. Legacy methods continue through `Resolve(configuration.Method)` and `_runner`; `PathSmoothingComparisonRequest.DefaultMethods` remains unchanged. + +- [ ] **Step 7: Complete Task 8 integration assertions** + +Run every fixture twice and require: + +```text +Straight -> NotNeeded +SingleTurn -> Complete +LargeHeadingChange -> Complete +SBend -> Complete or PartialImprovement +RectangleDetour -> Complete or PartialImprovement +MultiObstacleDetour -> Complete or PartialImprovement +ReverseGearSwitch -> Complete, PartialImprovement, or NotNeeded +``` + +Also require: + +```text +Tier 1 accepted -> evaluated CandidateCount <= 6 and no Tier 2 profile appears +Tier 1 rejected -> Tier 2 appears, CandidateCount <= 12 +CandidateCount equals the number actually evaluated +complete segment coverage and feasible diagnostics +configured collision/clearance checks on every published result +deterministic status, coordinates, candidate indices, and reports +strict 0.99 improvement request -> Unchanged +cancellation -> Cancelled with empty path +same-direction two-region processing -> back-to-front work, detector-order reports +global validation failure -> reverse rollback and safe raw fallback when all roll back +``` + +- [ ] **Step 8: Run focused and legacy GREEN checks** + +Run: + +```powershell +dotnet build ClumsyPilot/ClumsyPilot.csproj --no-restore +powershell -ExecutionPolicy Bypass -File ClumsyPilot/tests/verify_path_smoothing_local_g2_integration.ps1 +powershell -ExecutionPolicy Bypass -File ClumsyPilot/tests/verify_path_smoothing_service.ps1 +powershell -ExecutionPolicy Bypass -File ClumsyPilot/tests/verify_path_smoothing_runner.ps1 +powershell -ExecutionPolicy Bypass -File ClumsyPilot/tests/verify_path_smoothing_integration.ps1 +powershell -ExecutionPolicy Bypass -File ClumsyPilot/tests/verify_path_smoothing_local_g2_candidates.ps1 +powershell -ExecutionPolicy Bypass -File ClumsyPilot/tests/verify_path_smoothing_local_g2_detection.ps1 +``` + +Expected: all pass in the valid source set. + +- [ ] **Step 9: Commit Task 8 publication files only** + +```powershell +git add -- ` + ClumsyPilot/ParkrobTrajplanner/PathSmoothing/LocalG2/LocalG2PreSmoothingPipeline.cs ` + ClumsyPilot/ParkrobTrajplanner/PathSmoothing/Facade/PathSmoothingService.cs ` + ClumsyPilot/tests/verify_path_smoothing_local_g2_integration.ps1 ` + ClumsyPilot/tests/verify_path_smoothing_service.ps1 +git diff --cached --check +git diff --cached --name-only +git commit -m "feat: publish tiered Local G2 presmoothing" +``` + +Expected staged names: exactly the four files above. `PathSmoothingService.cs` and its service test already contain provisional dirty hunks; stage only Local G2 Task 8 hunks and inspect the cached diff before committing. + +--- + +### Task 4: Enforce performance and final verification gates + +**Files:** + +- Create: `ClumsyPilot/tests/measure_path_smoothing_local_g2_performance.ps1` +- Verify: all files committed by Tasks 1–3 +- Verify: all `ClumsyPilot/tests/verify_path_smoothing_*.ps1` +- Record without committing: `.superpowers/sdd/local-g2-split-scale-task-8-final-report.md` + +**Interfaces:** + +- Consumes: a baseline assembly built from commit `bd08a9b` sources and the final assembly, the same `SingleTurn` fixture/configuration, and modes `Primary` and `Fallback`; the assembly path identifies baseline versus final code. +- Produces one JSON object per run: + +```json +{ + "Mode": "Primary", + "WarmupCount": 5, + "MeasurementCount": 30, + "P50Milliseconds": 0.0, + "P95Milliseconds": 0.0, + "MaximumAttemptCount": 6, + "MaximumEvaluatedCandidateCount": 6, + "MaximumRawWindowAnalysisCount": 4 +} +``` + +- [ ] **Step 1: Write the fixed benchmark harness** + +The script parameters are: + +```powershell +param( + [Parameter(Mandatory=$true)][string]$AssemblyPath, + [Parameter(Mandatory=$true)][ValidateSet('Primary','Fallback')][string]$Mode +) +``` + +After the parameter block, use this complete harness body: + +```powershell +$ErrorActionPreference = 'Stop' +$newtonsoft = Join-Path $env:USERPROFILE '.nuget\packages\newtonsoft.json\13.0.4\lib\netstandard2.0\Newtonsoft.Json.dll' +if (Test-Path $newtonsoft) { $null = [Reflection.Assembly]::LoadFrom($newtonsoft) } +$assembly = [Reflection.Assembly]::LoadFrom((Resolve-Path $AssemblyPath)) + +function Get-Type([string]$Name) { return $assembly.GetType($Name, $true) } +function Get-Property($Instance, [string]$Name) { + return $Instance.GetType().GetProperty( + $Name, [Reflection.BindingFlags]'Public,NonPublic,Instance').GetValue($Instance) +} +function Get-Method($Type, [string]$Name, [int]$ParameterCount) { + return @($Type.GetMethods([Reflection.BindingFlags]'Public,NonPublic,Instance,Static') | + Where-Object { $_.Name -eq $Name -and $_.GetParameters().Count -eq $ParameterCount })[0] +} +function New-Internal($Type) { + return [Activator]::CreateInstance( + $Type, [Reflection.BindingFlags]'Instance,NonPublic,Public', $null, @(), $null) +} +function Get-Percentile([double[]]$Values, [double]$Fraction) { + $ordered = @($Values | Sort-Object) + $index = [Math]::Ceiling($Fraction * $ordered.Count) - 1 + return [double]$ordered[[Math]::Max(0, [Math]::Min($ordered.Count - 1, $index))] +} + +$root = 'MultiWheelC.TrajectoryPlanning.PathSmoothing.' +$factoryType = Get-Type ($root + 'Test.SmoothingScenarioFactory') +$requestType = Get-Type ($root + 'PathSmoothingRequest') +$configurationType = Get-Type ($root + 'PathSmoothingConfiguration') +$methodType = Get-Type ($root + 'SmoothingMethod') +$preprocessorType = Get-Type ($root + 'Processing.PathSmoothingPreprocessor') +$optionsType = Get-Type ($root + 'LocalG2.LocalG2OptionsSnapshot') +$detectorType = Get-Type ($root + 'LocalG2.CurvatureTransitionDetector') +$plannerType = Get-Type ($root + 'LocalG2.LocalG2WindowPlanner') +$builderType = Get-Type ($root + 'LocalG2.LocalG2CandidateBuilder') +$evaluatorType = Get-Type ($root + 'LocalG2.LocalG2CandidateEvaluator') +$fixturePath = Join-Path $PSScriptRoot '..\ParkrobTrajplanner\PathSmoothing\Test\Fixtures\path-smoothing-fixtures.json' +$fixtures = (Get-Method $factoryType 'CreateFixtureRequests' 1).Invoke( + $null, @((Resolve-Path $fixturePath).Path)) +$baseRequest = $fixtures[1].SmoothingRequest +$configuration = [Activator]::CreateInstance($configurationType) +$configuration.Method = [Enum]::Parse($methodType, 'LocalG2Quintic') +$request = [Activator]::CreateInstance($requestType, @( + $baseRequest.CoarsePath, + $baseRequest.Segments, + $baseRequest.Map, + $baseRequest.Vehicle, + $configuration)) +$preprocessor = [Activator]::CreateInstance($preprocessorType) +$prepareArgs = [object[]]@($request, $null, $null) +if (-not (Get-Method $preprocessorType 'TryPrepare' 3).Invoke($preprocessor, $prepareArgs)) { + throw 'SingleTurn preprocessing failed.' +} +$preparedPath = $prepareArgs[1] +$options = [Activator]::CreateInstance( + $optionsType, + [Reflection.BindingFlags]'Instance,NonPublic,Public', + $null, + @($configuration), + $null) +$detector = New-Internal $detectorType +$detectArgs = [object[]]@( + $request, + [double]$request.Vehicle.MaximumCurvaturePerMeter, + $options, + $null, + $null) +if (-not (Get-Method $detectorType 'TryDetect' 5).Invoke($detector, $detectArgs)) { + throw 'SingleTurn transition detection failed.' +} +$planner = New-Internal $plannerType +$planArgs = [object[]]@($preparedPath, $detectArgs[3], $options, $null, $null) +if (-not (Get-Method $plannerType 'TryPlan' 5).Invoke($planner, $planArgs)) { + throw 'SingleTurn window planning failed.' +} +$region = @($planArgs[3])[0] +$segmentIndex = [int](Get-Property $region 'SegmentIndex') +$preparedSegment = (Get-Property $preparedPath 'Segments')[$segmentIndex] +$beginBuild = @(Get-Method $builderType 'BeginBuild' 4) +$beginEvaluation = @(Get-Method $evaluatorType 'BeginRegionEvaluation' 5) +$isFinalApi = $beginBuild.Count -eq 1 -and $null -ne $beginBuild[0] + +function Invoke-OneMeasurement { + $builder = New-Internal $builderType + $evaluator = New-Internal $evaluatorType + $watch = [Diagnostics.Stopwatch]::StartNew() + $candidateList = [Collections.Generic.List[object]]::new() + $attemptCount = 0 + if ($isFinalApi) { + $buildSession = $beginBuild[0].Invoke($builder, @( + $preparedSegment, $region, [double]$configuration.OutputSpacingMeters, $options)) + $primary = (Get-Method ($buildSession.GetType()) 'BuildPrimary' 1).Invoke( + $buildSession, @([Threading.CancellationToken]::None)) + foreach ($candidate in $primary) { $candidateList.Add($candidate) } + if ($Mode -eq 'Fallback') { + $fallback = (Get-Method ($buildSession.GetType()) 'BuildFallback' 1).Invoke( + $buildSession, @([Threading.CancellationToken]::None)) + foreach ($candidate in $fallback) { $candidateList.Add($candidate) } + } + $attemptCount = [int](Get-Property $buildSession 'AttemptCount') + } + else { + $all = (Get-Method $builderType 'Build' 5).Invoke($builder, @( + $preparedSegment, + $region, + [double]$configuration.OutputSpacingMeters, + $options, + [Threading.CancellationToken]::None)) + $limit = if ($Mode -eq 'Primary') { 6 } else { 12 } + foreach ($candidate in $all) { + if ($candidateList.Count -ge $limit) { break } + $candidateList.Add($candidate) + } + $attemptCount = $candidateList.Count + } + + $rawWindowAnalysisCount = 0 + $evaluationSignature = [Collections.Generic.List[string]]::new() + if ($beginEvaluation.Count -eq 1 -and $null -ne $beginEvaluation[0]) { + $evaluationSession = $beginEvaluation[0].Invoke($evaluator, @( + $preparedPath, $preparedPath, $region, $request, $options)) + $sessionEvaluate = Get-Method ($evaluationSession.GetType()) 'Evaluate' 2 + foreach ($candidate in $candidateList) { + $evaluation = $sessionEvaluate.Invoke($evaluationSession, @( + $candidate, [Threading.CancellationToken]::None)) + $evaluationSignature.Add( + "$(Get-Property $candidate 'CandidateIndex'):$(Get-Property $evaluation 'Accepted'):$(Get-Property $evaluation 'FailureReason')") + } + $rawWindowAnalysisCount = [int](Get-Property $evaluationSession 'RawWindowAnalysisCount') + } + else { + $evaluate = Get-Method $evaluatorType 'Evaluate' 7 + foreach ($candidate in $candidateList) { + $evaluation = $evaluate.Invoke($evaluator, @( + $preparedPath, + $preparedPath, + $region, + $candidate, + $request, + $options, + [Threading.CancellationToken]::None)) + $evaluationSignature.Add( + "$(Get-Property $candidate 'CandidateIndex'):$(Get-Property $evaluation 'Accepted'):$(Get-Property $evaluation 'FailureReason')") + } + $rawWindowAnalysisCount = $candidateList.Count + } + $watch.Stop() + + $windows = @($candidateList | ForEach-Object { + '{0:R}|{1:R}' -f [double](Get-Property $_ 'StartArcLengthMeters'), + [double](Get-Property $_ 'EndArcLengthMeters') + } | Sort-Object -Unique) + return [PSCustomObject]@{ + ElapsedMilliseconds = $watch.Elapsed.TotalMilliseconds + AttemptCount = $attemptCount + EvaluatedCandidateCount = $candidateList.Count + RawWindowAnalysisCount = $rawWindowAnalysisCount + DistinctWindowCount = $windows.Count + Signature = ($evaluationSignature -join ';') + IsFinalApi = $isFinalApi + } +} + +for ($index = 0; $index -lt 5; $index++) { $null = Invoke-OneMeasurement } +$measurements = @() +for ($index = 0; $index -lt 30; $index++) { $measurements += Invoke-OneMeasurement } +$signature = $measurements[0].Signature +if (@($measurements | Where-Object Signature -ne $signature).Count -ne 0) { + throw 'Local G2 benchmark output is nondeterministic.' +} +$maximumAttempts = [int](($measurements | Measure-Object AttemptCount -Maximum).Maximum) +$maximumEvaluated = [int](($measurements | Measure-Object EvaluatedCandidateCount -Maximum).Maximum) +$maximumRaw = [int](($measurements | Measure-Object RawWindowAnalysisCount -Maximum).Maximum) +$maximumWindows = [int](($measurements | Measure-Object DistinctWindowCount -Maximum).Maximum) +if ($Mode -eq 'Primary' -and $maximumAttempts -gt 6) { throw 'Primary attempts exceed six.' } +if ($maximumAttempts -gt 12 -or $maximumEvaluated -gt 12) { throw 'Total candidate work exceeds twelve.' } +if ($maximumWindows -gt 4) { throw 'Representative windows exceed four.' } +if ($isFinalApi -and $maximumRaw -gt 4) { throw 'Final raw-window analyses exceed four.' } +$elapsed = [double[]]@($measurements | ForEach-Object ElapsedMilliseconds) +[PSCustomObject]@{ + Mode = $Mode + WarmupCount = 5 + MeasurementCount = 30 + P50Milliseconds = Get-Percentile $elapsed 0.50 + P95Milliseconds = Get-Percentile $elapsed 0.95 + MaximumAttemptCount = $maximumAttempts + MaximumEvaluatedCandidateCount = $maximumEvaluated + MaximumRawWindowAnalysisCount = $maximumRaw +} | ConvertTo-Json -Depth 3 +``` + +- [ ] **Step 2: Build comparable baseline and final assemblies** + +Create two disposable copies from the same current workspace, excluding `.git`, build outputs, `auto_avoidance`, and `.task8-sweep`. In the baseline copy, extract exactly these `bd08a9b` files over the copy: + +```text +ClumsyPilot/ParkrobTrajplanner/PathSmoothing/LocalG2/LocalG2CandidateBuilder.cs +ClumsyPilot/ParkrobTrajplanner/PathSmoothing/LocalG2/LocalG2CandidateGeometry.cs +ClumsyPilot/ParkrobTrajplanner/PathSmoothing/LocalG2/LocalG2CandidateEvaluator.cs +ClumsyPilot/ParkrobTrajplanner/PathSmoothing/Facade/PathSmoothingService.cs +``` + +The pipeline did not exist at the baseline commit. Resolve the baseline copy's absolute `LocalG2PreSmoothingPipeline.cs` path, assert it starts with the resolved disposable baseline root, and remove only that disposable file before building. Do not remove the shared-tree file. Build both copies with: + +```powershell +dotnet build ClumsyPilot/ClumsyPilot.csproj --no-restore --configuration Debug +``` + +Use the same machine, `Debug/netstandard2.0`, fixture JSON, map, vehicle, and request configuration for both. + +- [ ] **Step 3: Run and compare 5+30 performance measurements** + +Run the benchmark in separate PowerShell processes so the two `ClumsyPilot.dll` versions do not collide in one load context. Capture baseline primary/fallback and final primary/fallback JSON. Assert: + +```powershell +if ($finalPrimary.P95Milliseconds -gt 1.5 * $baselinePrimary.P95Milliseconds) { + throw 'Local G2 primary P95 exceeds 1.5x baseline.' +} +if ($finalFallback.P95Milliseconds -gt 2.0 * $baselineFallback.P95Milliseconds) { + throw 'Local G2 fallback P95 exceeds 2.0x baseline.' +} +``` + +Record all four P50/P95 values, ratios, attempt/evaluation/raw-analysis maxima, machine, commit IDs, build configuration, fixture, warmup count, and measurement count. A wall-clock regression blocks merge but must not become a production timeout. Fix duplicate analysis or scheduling overhead; do not remove safety/quality checks. + +- [ ] **Step 4: Commit the benchmark harness** + +```powershell +git add -- ClumsyPilot/tests/measure_path_smoothing_local_g2_performance.ps1 +git diff --cached --check +git diff --cached --name-only +git commit -m "test: benchmark bounded Local G2 recovery" +``` + +Expected staged name: exactly the benchmark script. + +- [ ] **Step 5: Build the final source and run all PathSmoothing scripts** + +Run: + +```powershell +dotnet build ClumsyPilot/ClumsyPilot.csproj --no-restore +$tests = @( + 'verify_path_smoothing_algorithm_input.ps1', + 'verify_path_smoothing_bezier.ps1', + 'verify_path_smoothing_bspline.ps1', + 'verify_path_smoothing_comparison.ps1', + 'verify_path_smoothing_contracts.ps1', + 'verify_path_smoothing_documentation.ps1', + 'verify_path_smoothing_fixtures.ps1', + 'verify_path_smoothing_geometry.ps1', + 'verify_path_smoothing_integration.ps1', + 'verify_path_smoothing_local_g2_candidates.ps1', + 'verify_path_smoothing_local_g2_curve.ps1', + 'verify_path_smoothing_local_g2_detection.ps1', + 'verify_path_smoothing_local_g2_integration.ps1', + 'verify_path_smoothing_png.ps1', + 'verify_path_smoothing_quintic.ps1', + 'verify_path_smoothing_runner.ps1', + 'verify_path_smoothing_service.ps1', + 'verify_path_smoothing_svg_csv.ps1', + 'verify_path_smoothing_validation.ps1' +) +foreach ($test in $tests) { + & powershell -ExecutionPolicy Bypass -File (Join-Path 'ClumsyPilot/tests' $test) + if ($LASTEXITCODE -ne 0) { throw "$test failed with exit code $LASTEXITCODE" } +} +``` + +Expected: zero build errors and all 19 scripts pass in the valid source set. Record any shared-build limitation plus the isolated-copy evidence if `auto_avoidance` is the sole unrelated blocker. + +- [ ] **Step 6: Audit safety, scope, determinism, and Task 9 boundary** + +Run: + +```powershell +git diff --check +git diff --cached --check +git status --short +git log -8 --oneline +rg -n "CurvatureRangeTolerance|MinimumClearanceReserveMeters|MinimumPeakGradientImprovementRatio|MaximumVariationCostRegressionRatio|MaximumCandidatesPerRegion" ` + ClumsyPilot/ParkrobTrajplanner/PathSmoothing/LocalG2 ` + ClumsyPilot/ParkrobTrajplanner/PathSmoothing/Facade/PathSmoothingService.cs +``` + +Confirm no safety value changed, no staged files remain, unrelated dirty files are untouched, Local G2 is absent from comparison defaults, repeated integration runs match exactly, and Task 9 README/documentation work has not started. + +- [ ] **Step 7: Record final evidence without an empty commit** + +Write `.superpowers/sdd/local-g2-split-scale-task-8-final-report.md` with the accepted `SingleTurn` tuple and scales, zero anchor error, G2 errors, unchanged safety/quality metrics, Tier 1/Tier 2 counts, raw-window cache counts, fixture statuses, rollback/cancellation results, performance P50/P95 ratios, build result, and `19/19` script result. Do not create a verification-only commit. diff --git a/docs/superpowers/specs/2026-08-01-local-g2-split-derivative-scale-recovery-design.md b/docs/superpowers/specs/2026-08-01-local-g2-split-derivative-scale-recovery-design.md index 578c1cf..fc8732c 100644 --- a/docs/superpowers/specs/2026-08-01-local-g2-split-derivative-scale-recovery-design.md +++ b/docs/superpowers/specs/2026-08-01-local-g2-split-derivative-scale-recovery-design.md @@ -1,7 +1,7 @@ # Local G2 固定锚点分离导数尺度恢复设计 **日期:** 2026-08-01 -**状态:** 已完成对话评审,等待书面规格复核 +**状态:** 已完成书面规格复核,批准进入实施 **范围:** 替代失败的软位置锚点候选族,恢复 Task 3 可行性门禁,并在不放宽安全阈值的前提下继续 Task 8 ## 1. 结论