# 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<LocalG2CandidateGeometry> BuildPrimary(
    CancellationToken cancellationToken);

internal IReadOnlyList<LocalG2CandidateGeometry> BuildFallback(
    CancellationToken cancellationToken);

internal int AttemptCount { get; }
internal int SuccessfulCandidateCount { get; }
internal IReadOnlyList<LocalG2CandidateAttemptDiagnostic> 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
$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,
        [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)
        $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 }
    }
    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 ($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.'
```

- [ ] **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 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**

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,
        double connectionPositionErrorMeters,
        double tangentDirectionErrorRadians,
        double curvatureErrorPerMeter)
    {
        if (!NumericGuard.IsFinite(arcLengthMeters) ||
            !NumericGuard.IsPositiveFinite(incomingDerivativeScale) ||
            !NumericGuard.IsPositiveFinite(outgoingDerivativeScale) ||
            !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; }
}
```

Append these optional parameters to the `LocalG2CandidateGeometry` constructor so existing evaluator TestHooks remain source-compatible:

```csharp
LocalG2DerivativeScaleProfile scaleProfile = LocalG2DerivativeScaleProfile.P0,
int candidateTier = 1,
IReadOnlyList<LocalG2InternalScaleDiagnostic> 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<LocalG2InternalScaleDiagnostic>();
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<LocalG2InternalScaleDiagnostic>(diagnosticCopy);

internal LocalG2DerivativeScaleProfile ScaleProfile { get; }
internal int CandidateTier { get; }
internal IReadOnlyList<LocalG2InternalScaleDiagnostic> 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<BoundaryNode> 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. 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**

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<LocalG2CandidateGeometry> Build(
    PreparedDirectionSegment originalSegment,
    LocalG2SmoothingRegion region,
    double outputSpacingMeters,
    LocalG2OptionsSnapshot options,
    CancellationToken cancellationToken)
{
    LocalG2CandidateBuildSession session = BeginBuild(
        originalSegment, region, outputSpacingMeters, options);
    var all = new List<LocalG2CandidateGeometry>();
    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<CandidateAttempt> CreateAttempts(
    LocalG2SmoothingRegion region,
    IReadOnlyList<LocalG2WindowVariant> windows,
    out int primaryAttemptCount)
{
    var attempts = new List<CandidateAttempt>(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<LocalG2WindowVariant> SelectRepresentativeWindows(
    IReadOnlyList<LocalG2WindowVariant> variants)
{
    var selected = new List<LocalG2WindowVariant>(4);
    if (variants == null || variants.Count == 0)
        return new ReadOnlyCollection<LocalG2WindowVariant>(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<LocalG2WindowVariant>(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<LocalG2WindowVariant> 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<CandidateAttempt> _attempts;
    private readonly int _primaryAttemptCount;
    private readonly int _attemptLimit;
    private readonly List<LocalG2CandidateAttemptDiagnostic> _attemptDiagnostics =
        new List<LocalG2CandidateAttemptDiagnostic>();
    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<CandidateAttempt>();
            _primaryAttemptCount = 0;
            _attemptLimit = 0;
            return;
        }
        IReadOnlyList<LocalG2WindowVariant> 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<LocalG2CandidateAttemptDiagnostic> AttemptDiagnostics =>
        new ReadOnlyCollection<LocalG2CandidateAttemptDiagnostic>(_attemptDiagnostics);

    internal IReadOnlyList<LocalG2CandidateGeometry> BuildPrimary(
        CancellationToken cancellationToken)
    {
        if (_primaryBuilt)
            throw new InvalidOperationException("Local G2 primary candidates were already built.");
        _primaryBuilt = true;
        return BuildUntil(_primaryAttemptCount, cancellationToken);
    }

    internal IReadOnlyList<LocalG2CandidateGeometry> 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<LocalG2CandidateGeometry> BuildUntil(
        int endExclusive,
        CancellationToken cancellationToken)
    {
        var candidates = new List<LocalG2CandidateGeometry>();
        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: Measure real split-scale connections without adding a test-only production method**

Construct all adjacent curves first, measure each real internal connection, then sample the same curve objects. Add:

```csharp
private static bool TryMeasureConnection(
    QuinticHermiteCurve2D incomingCurve,
    QuinticHermiteCurve2D outgoingCurve,
    BoundaryNode node,
    out LocalG2InternalScaleDiagnostic diagnostic)
{
    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);
}
```

Replace the single create-and-sample loop with this exact ordering:

```csharp
var curves = new List<QuinticHermiteCurve2D>(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<LocalG2InternalScaleDiagnostic>();
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<SmoothingPoint2D>();
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;
}
```

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**

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<WindowKey>
{
    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<WindowKey, RawWindowEntry>`, 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<SmoothingPoint2D> points,
        PathGeometryAnalysis analysis,
        double minimumCurvature,
        double maximumCurvature,
        string failureReason)
    {
        Points = points;
        Analysis = analysis;
        MinimumCurvature = minimumCurvature;
        MaximumCurvature = maximumCurvature;
        FailureReason = failureReason ?? string.Empty;
    }
    internal IReadOnlyList<SmoothingPoint2D> 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<LocalG2CandidateGeometry>();
var evaluations = new List<LocalG2CandidateEvaluation>();
IReadOnlyList<LocalG2CandidateGeometry> primary =
    buildSession.BuildPrimary(cancellationToken);
candidates.AddRange(primary);
EvaluateAll(primary, evaluationSession, evaluations, cancellationToken);
LocalG2CandidateEvaluation best =
    LocalG2CandidateEvaluator.SelectBest(evaluations);

if (!best.Accepted)
{
    IReadOnlyList<LocalG2CandidateGeometry> fallback =
        buildSession.BuildFallback(cancellationToken);
    candidates.AddRange(fallback);
    EvaluateAll(fallback, evaluationSession, evaluations, cancellationToken);
    best = LocalG2CandidateEvaluator.SelectBest(evaluations);
}
```

Add:

```csharp
private static void EvaluateAll(
    IReadOnlyList<LocalG2CandidateGeometry> candidates,
    LocalG2CandidateEvaluator.EvaluationSession session,
    List<LocalG2CandidateEvaluation> 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<LocalG2CandidateGeometry> 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<PathSmoothingRegionReport> 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.
