Files
ParkingRobot/docs/superpowers/plans/2026-08-01-local-g2-split-derivative-scale-recovery.md
T

69 KiB
Raw Blame History

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:
internal LocalG2CandidateBuildSession BeginBuild(
    PreparedDirectionSegment originalSegment,
    LocalG2SmoothingRegion region,
    double outputSpacingMeters,
    LocalG2OptionsSnapshot options);
  • Produces:
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:

$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 -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:

$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:

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:

LocalG2CandidateBuilder.cs
LocalG2CandidateGeometry.cs
LocalG2CandidateEvaluator.cs
  • Step 4: Add scale metadata in the disposable copy

Add this enum and diagnostic type to LocalG2CandidateGeometry.cs:

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:

LocalG2DerivativeScaleProfile scaleProfile = LocalG2DerivativeScaleProfile.P0,
int candidateTier = 1,
IReadOnlyList<LocalG2InternalScaleDiagnostic> internalScaleDiagnostics = null

Validate candidateTier is 1 or 2, copy diagnostics into a ReadOnlyCollection, and expose:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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 47 to the shared files. Run:

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:

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

$rawWindowCache = Invoke-EvaluationScenario 'RawWindowCache'
Assert-Equal 1 $rawWindowCache.RawWindowAnalysisCount `
    'P0/P1/P2 candidates for one window must share one raw-window analysis.'

Run:

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:

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:

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:

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:

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:

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:

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

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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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
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 13
  • 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:
{
  "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:

param(
    [Parameter(Mandatory=$true)][string]$AssemblyPath,
    [Parameter(Mandatory=$true)][ValidateSet('Primary','Fallback')][string]$Mode
)

After the parameter block, use this complete harness body:

$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:

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:

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:

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

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:

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.