# Local G2 Minimum Curvature Excursion Feasibility 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:** In two disposable roots, determine whether a hard-anchor, arc-length curvature-Bézier Local G2 candidate can pass every existing `SingleTurn` safety and quality gate except the strict raw-curvature-range early return, and report the smallest excursion found within the fixed search budget.

**Architecture:** A disposable `probe-core` Git repository owns the analytic curvature curve, the bounded two-variable pose-closure solver, the temporary candidate builder, deterministic diagnostics, and the shared probe script. Task 4 clones the same frozen core commit into a strict root whose evaluator is byte-identical to the shared baseline and a measurement root whose evaluator changes only the raw-range early return plus non-behavioral diagnostics; candidate SHA-256 records bind the two results atomically. No probe source is merged back: the shared branch receives only this plan and the final evidence report.

**Tech Stack:** C# 10, .NET Standard 2.0, PowerShell 5.1 reflection tests, `System.Security.Cryptography.SHA256`, existing Local G2 preprocessing/evaluation/validation components, local disposable Git repositories.

## Global Constraints

- This plan implements only the isolated feasibility probe authorized by `docs/superpowers/specs/2026-08-01-local-g2-minimum-curvature-excursion-feasibility-design.md`; it does not authorize production candidate/evaluator/configuration changes.
- Path start, path end, gear switches, outer window endpoints, and every internal primitive boundary remain hard position anchors.
- Internal primitive boundaries retain the original vehicle heading and the existing distance-weighted shared vehicle curvature.
- Work in travel geometry: `geometricCurvature = directionSign * vehicleCurvature`, with forward `directionSign=+1` and reverse `directionSign=-1`.
- Per interval, `kappa(u)` is cubic Bézier; `c2 = 4*deltaHeading/L - c0 - c1 - c3`; the only solver variables are `[L,c1]`.
- All `c0,c1,c2,c3` must be finite and within `[-Kvehicle,+Kvehicle]`; analyzer-discrete vehicle curvature must still pass the unchanged vehicle maximum-curvature gate.
- Before exact-anchor output overwrite, Euclidean position closure is `<=1e-9 m`; analytic heading error is `<=1e-8 rad`; endpoint/seam curvature error is `<=1e-8 1/m`.
- Candidate deviation remains `<=0.10 m`; full-body collision, map boundary, clearance, 20 percent peak-gradient improvement, and 2 percent variation-cost tolerance remain unchanged.
- The strict root evaluator is byte-identical to the recorded shared baseline. The measurement root may only record-and-continue at the raw-range early return and add diagnostics that do not reorder or change any other gate.
- Use the ordered states `PROBE_INVALID`, `BUDGET_RED`, `STRICT_GREEN`, `NO_PHYSICALLY_ADMISSIBLE_CLOSED_CANDIDATE_FOUND_WITHIN_BUDGET`, `QUALITY_RED`, and `FEASIBLE_WITH_EXCURSION`; exactly one must result.
- Search limits are 12 windows, 36 window/seed combinations, 8 intervals per combination, 96 total solver invocations including failures, 16 outer LM iterations, 4 damping trials per iteration, and integration depth 10.
- Performance protocol is 5 warm-ups plus 30 measured runs. Solver-phase median/worst must be `<=10/25 ms`; measurement-root region median/worst must be `<=25/50 ms`.
- Identical measured runs must have identical candidate keys, candidate SHA-256 values, evaluator results, state, and diagnostic selection.
- Use TDD in the disposable repository. Every implementation task commits there and receives an independent task review before the next task.
- Preserve the shared dirty worktree. On the shared branch, stage only this plan or the final report named in Task 5.
- Never read, modify, copy, delete, or commit `ClumsyPilot/ParkrobTrajplanner/auto_avoidance`; verify it is absent from every disposable root before building.
- A cancellation is `PROBE_INVALID`, never a geometry RED. Do not use wall-clock time as a solver stop condition.

---

## Execution workspace contract

Task 1 creates one disposable Git repository under the system temp directory and records its absolute path and baseline hashes in `.superpowers/sdd/local-g2-excursion-task-1-report.md`. The controller passes that exact path to Tasks 2 and 3. Task 4 clones the frozen Task 3 core commit twice and records strict/measurement paths in `.superpowers/sdd/local-g2-excursion-task-4-report.md`.

All `Create` and `Modify` paths in Tasks 1–4 are relative to the disposable root named in the task report, except `.superpowers/sdd/*`, which always resolves against the original shared repository. Those report files match the existing `.superpowers/sdd/.gitignore`, are never written inside strict/measurement clones, and must never be staged; the surrounding `.superpowers/` directory may remain unrelated untracked user state.

## File Structure

- `ClumsyPilot/ParkrobTrajplanner/PathSmoothing/LocalG2/ArcLengthCurvatureBezierCurve2D.cs`
  - Owns cubic curvature coefficients, analytic heading, adaptive 8/16 Gauss–Legendre coordinate integration, exact analytic curvature range/gradient diagnostics, and curve sampling.
- `ClumsyPilot/ParkrobTrajplanner/PathSmoothing/LocalG2/LocalG2PoseClosureSolver.cs`
  - Owns the joint physical-curvature domain, three seeds, finite differences, projected two-variable LM, fixed failure exits, and per-interval diagnostics.
- `ClumsyPilot/ParkrobTrajplanner/PathSmoothing/LocalG2/LocalG2ExcursionProbeDiagnostics.cs`
  - Owns immutable interval/attempt/candidate diagnostics used only by the disposable probe.
- `ClumsyPilot/ParkrobTrajplanner/PathSmoothing/LocalG2/LocalG2CandidateGeometry.cs`
  - Temporarily attaches one `LocalG2ExcursionCandidateDiagnostic` without changing existing geometry semantics.
- `ClumsyPilot/ParkrobTrajplanner/PathSmoothing/LocalG2/LocalG2CandidateBuilder.cs`
  - Temporarily replaces the Hermite candidate family with bounded curvature-domain interval solving and enforces all attempt limits.
- `ClumsyPilot/ParkrobTrajplanner/PathSmoothing/LocalG2/LocalG2PreSmoothingPipeline.cs`
  - Passes the already-computed vehicle maximum curvature into the temporary builder; no evaluator gate changes.
- `ClumsyPilot/ParkrobTrajplanner/PathSmoothing/LocalG2/LocalG2CandidateEvaluator.cs`
  - Unchanged in core/strict roots. Measurement-root-only commit records the raw range and continues, while preserving every later gate.
- `ClumsyPilot/tests/verify_path_smoothing_local_g2_curvature_probe_curve.ps1`
  - Verifies curve math, integration, analytic extrema, finite inputs, and fixed integration boundaries.
- `ClumsyPilot/tests/verify_path_smoothing_local_g2_curvature_probe_solver.ps1`
  - Verifies analytic heading elimination, joint domain, exact LM constants/exits, cancellation, limits, and deterministic solutions.
- `ClumsyPilot/tests/verify_path_smoothing_local_g2_excursion_probe.ps1`
  - Runs the real fixture, emits stable candidate keys/digests and gate/audit JSON, runs 5+30 timing, and applies the exhaustive state machine.
- `docs/superpowers/reports/2026-08-01-local-g2-minimum-curvature-excursion-feasibility-report.md`
  - The only probe output copied to and committed on the shared branch.

---

### Task 1: Create the disposable core and implement the analytic curvature curve

**Files:**

- Create in disposable core: `.gitignore`
- Create in disposable core: `.gitattributes`
- Create in disposable core: `ClumsyPilot/ParkrobTrajplanner/PathSmoothing/LocalG2/ArcLengthCurvatureBezierCurve2D.cs`
- Create in disposable core: `ClumsyPilot/tests/verify_path_smoothing_local_g2_curvature_probe_curve.ps1`
- Record without committing: `.superpowers/sdd/local-g2-excursion-task-1-report.md`

**Interfaces:**

- Produces:

```csharp
internal sealed class ArcLengthCurvatureBezierCurve2D
{
    internal static bool TryCreate(
        double startX, double startY, double startTravelHeadingRadians,
        double lengthMeters, double c0, double c1, double c2, double c3,
        out ArcLengthCurvatureBezierCurve2D curve, out string reason);

    internal double LengthMeters { get; }
    internal double StartCurvaturePerMeter { get; }
    internal double EndCurvaturePerMeter { get; }
    internal double EvaluateCurvature(double u);
    internal double EvaluateCurvatureDerivativePerSquareMeter(double u);
    internal double EvaluateTravelHeading(double u);
    internal bool TryEvaluate(double u, CancellationToken cancellationToken,
        out CurvatureCurveSample sample, out CurvatureIntegrationDiagnostic diagnostic,
        out string reason);
    internal bool TryGetAnalyticExtrema(out double minimumCurvature,
        out double maximumCurvature, out double maximumAbsoluteGradient);

    public sealed class CurvatureCurveTestSnapshot
    {
        public double EndX { get; }
        public double EndY { get; }
        public double EndHeading { get; }
        public double MinimumCurvature { get; }
        public double MaximumCurvature { get; }
        public double MaximumEndpointCurvature { get; }
        public bool AllValuesFinite { get; }
        public bool AnalyticMatchesDenseReference { get; }
        public bool GradientMatchesDenseReference { get; }
        public bool EqualToleranceAccepted { get; }
        public bool DepthTenFailureIsStable { get; }
        public bool CancellationPropagates { get; }
        public bool InvalidInputsRejected { get; }
    }

    public static class TestHooks
    {
        public static CurvatureCurveTestSnapshot Execute(string scenario);
    }
}

internal readonly struct CurvatureCurveSample
{
    internal CurvatureCurveSample(double parameter, double x, double y,
        double travelHeadingRadians, double curvaturePerMeter);
    internal double Parameter { get; }
    internal double X { get; }
    internal double Y { get; }
    internal double TravelHeadingRadians { get; }
    internal double CurvaturePerMeter { get; }
}

internal readonly struct CurvatureIntegrationDiagnostic
{
    internal CurvatureIntegrationDiagnostic(int evaluationCount, int maximumDepth);
    internal int EvaluationCount { get; }
    internal int MaximumDepth { get; }
}

```

- Consumers: Task 2 uses curve endpoint integration as the position residual; Task 3 uses `TryEvaluate` for candidate sampling.

- [ ] **Step 1: Create and fingerprint the disposable core before changing code**

Run from the shared repository root:

```powershell
$sourceProject = (Resolve-Path 'ClumsyPilot').Path
$excludedAuto = [IO.Path]::GetFullPath((Join-Path $sourceProject 'ParkrobTrajplanner\auto_avoidance'))
$probeRoot = Join-Path ([IO.Path]::GetTempPath()) ('local-g2-excursion-' + [Guid]::NewGuid().ToString('N'))
$coreRoot = Join-Path $probeRoot 'probe-core'
$coreProject = Join-Path $coreRoot 'ClumsyPilot'
$probeEvidenceRoot = Join-Path $probeRoot 'evidence'
New-Item -ItemType Directory -Path $coreProject -Force | Out-Null
New-Item -ItemType Directory -Path $probeEvidenceRoot -Force | Out-Null
robocopy $sourceProject $coreProject /E /XD $excludedAuto bin obj .task8-sweep | Out-Null
if ($LASTEXITCODE -gt 7) { throw "robocopy failed with exit code $LASTEXITCODE" }
if (Test-Path (Join-Path $coreProject 'ParkrobTrajplanner\auto_avoidance')) {
    throw 'auto_avoidance was copied into the disposable core.'
}
```

Use `apply_patch` in `$coreRoot` to create `.gitignore` with exactly:

```gitignore
**/bin/
**/obj/
```

Create `.gitattributes` with exactly:

```gitattributes
* -text
```

This disposable-only policy preserves every copied file's raw bytes and prevents the machine-level `core.autocrlf` setting from changing LF/CRLF bytes between the core and later clones. It does not modify the shared repository's attributes.

Then initialize and commit the snapshot:

```powershell
git -C $coreRoot init
git -C $coreRoot config core.autocrlf false
if ((git -C $coreRoot config --get core.autocrlf).Trim() -ne 'false') {
    throw 'Disposable core must pin core.autocrlf=false.'
}
git -C $coreRoot add -- .gitattributes .gitignore ClumsyPilot
git -C $coreRoot commit -m 'chore: capture Local G2 excursion probe baseline'
```

Record in the task report:

```text
shared HEAD
shared dirty-worktree status
absolute probeRoot/coreRoot
core baseline commit
SHA-256 of shared and core LocalG2CandidateEvaluator.cs
SHA-256 of path-smoothing-fixtures.json
sorted path + SHA-256 manifest for ClumsyPilot.csproj,
every .cs file below ParkrobTrajplanner/PathSmoothing,
and ParkrobTrajplanner/PathSmoothing/Test/Fixtures/path-smoothing-fixtures.json
confirmation that auto_avoidance is absent
```

Build the manifest by enumerating only those explicit paths; never enumerate `ParkrobTrajplanner/auto_avoidance`. Map each shared relative path to the copied core path and require identical file counts, paths, and hashes before continuing. The two evaluator hashes must match before continuing. Store the machine-readable manifest under `$probeRoot/evidence/shared-baseline-manifest.json` and its SHA-256 in the Task 1 scratch report.

- [ ] **Step 2: Write the failing curve test**

Create `verify_path_smoothing_local_g2_curvature_probe_curve.ps1`. It must load the disposable `ClumsyPilot.dll`, reflect `ArcLengthCurvatureBezierCurve2D.TestHooks.Execute(string)`, and assert these exact scenarios:

```powershell
$straight = Invoke-Scenario 'Straight'
Assert-Near 2.0 $straight.EndX 1e-11 'Straight X endpoint must integrate exactly.'
Assert-Near 0.0 $straight.EndY 1e-11 'Straight Y endpoint must remain zero.'
Assert-Near 0.0 $straight.EndHeading 1e-12 'Straight heading must remain zero.'

$circle = Invoke-Scenario 'ConstantCurvature'
Assert-Near ([Math]::Sin(0.4) / 0.4) $circle.EndX 1e-11 'Circle X must match.'
Assert-Near ((1.0 - [Math]::Cos(0.4)) / 0.4) $circle.EndY 1e-11 'Circle Y must match.'
Assert-Near 0.4 $circle.EndHeading 1e-12 'Circle heading integral must match.'

$counter = Invoke-Scenario 'ZeroHeadingCounterCurvature'
Assert-Near 0.0 $counter.EndHeading 1e-12 'Bezier heading elimination identity must hold.'
Assert-True ($counter.MinimumCurvature -lt 0.0) 'Counter-curvature scenario must go negative.'
Assert-Near 0.4 $counter.MaximumCurvature 1e-12 'Counter-curvature endpoint must remain 0.4 /m.'

$overshoot = Invoke-Scenario 'PositiveInteriorOvershoot'
Assert-True ($overshoot.MaximumCurvature -gt $overshoot.MaximumEndpointCurvature) `
    'A non-monotone control polygon must expose positive interior overshoot.'
Assert-True $overshoot.AllValuesFinite 'Positive-overshoot diagnostics must remain finite.'

$extrema = Invoke-Scenario 'AnalyticExtrema'
Assert-True $extrema.AnalyticMatchesDenseReference 'Analytic extrema must match a dense reference.'
Assert-True $extrema.GradientMatchesDenseReference 'Analytic gradient peak must match a dense reference.'

$limits = Invoke-Scenario 'IntegrationLimits'
Assert-True $limits.EqualToleranceAccepted 'Error equal to 1e-12 m must be accepted.'
Assert-True $limits.DepthTenFailureIsStable 'Depth 10 above tolerance must fail closed.'
Assert-True $limits.CancellationPropagates 'Cancellation must propagate.'
Assert-True $limits.InvalidInputsRejected 'Non-finite/illegal inputs must be rejected.'
```

- [ ] **Step 3: Run RED**

```powershell
dotnet build ClumsyPilot/ClumsyPilot.csproj --configuration Debug
powershell -ExecutionPolicy Bypass -File ClumsyPilot/tests/verify_path_smoothing_local_g2_curvature_probe_curve.ps1
```

Expected: build or reflection FAIL because `ArcLengthCurvatureBezierCurve2D` does not exist. A pass means the seam is not exercising the new component.

- [ ] **Step 4: Implement the cubic curvature and analytic heading**

Use the polynomial coefficients exactly:

```csharp
_a0 = c0;
_a1 = 3d * (c1 - c0);
_a2 = 3d * (c0 - 2d * c1 + c2);
_a3 = -c0 + 3d * c1 - 3d * c2 + c3;

internal double EvaluateCurvature(double u) =>
    ((_a3 * u + _a2) * u + _a1) * u + _a0;

internal double EvaluateTravelHeading(double u) => _startHeading + _length *
    (_a0 * u + _a1 * u * u / 2d + _a2 * u * u * u / 3d +
     _a3 * u * u * u * u / 4d);

internal double EvaluateCurvatureDerivativePerSquareMeter(double u) =>
    (_a1 + 2d * _a2 * u + 3d * _a3 * u * u) / _length;
```

`TryGetAnalyticExtrema` evaluates curvature at `u=0`, `u=1`, and every finite derivative root in `[0,1]`. It evaluates absolute gradient at `u=0`, `u=1`, and the quadratic-gradient vertex `u=-_a2/(3*_a3)` when finite and in range.

- [ ] **Step 5: Implement fixed adaptive 8/16 Gauss–Legendre integration**

Use these positive nodes/weights and mirror them around zero:

```csharp
private static readonly double[] Nodes8 =
{ 0.1834346424956498d, 0.5255324099163290d, 0.7966664774136267d, 0.9602898564975363d };
private static readonly double[] Weights8 =
{ 0.3626837833783620d, 0.3137066458778873d, 0.2223810344533745d, 0.1012285362903763d };
private static readonly double[] Nodes16 =
{ 0.09501250983763744d, 0.2816035507792589d, 0.4580167776572274d,
  0.6178762444026438d, 0.7554044083550030d, 0.8656312023878318d,
  0.9445750230732326d, 0.9894009349916499d };
private static readonly double[] Weights16 =
{ 0.1894506104550685d, 0.1826034150449236d, 0.1691565193950025d,
  0.1495959888165767d, 0.1246289712555339d, 0.0951585116824928d,
  0.06225352393864789d, 0.02715245941175409d };
```

For `cos(theta(u))` and `sin(theta(u))`, compute the 8- and 16-point normalized vector integrals over the current `u` subinterval. Accept when `L * EuclideanNorm(vector16-vector8) <= 1e-12 m`; otherwise bisect while `depth<10`; at `depth==10` return `IntegrationDepthLimit`. Multiply the accepted normalized integral by `L` exactly once when producing coordinates.

- [ ] **Step 6: Run GREEN twice and commit in the disposable core**

```powershell
dotnet build ClumsyPilot/ClumsyPilot.csproj --configuration Debug
powershell -ExecutionPolicy Bypass -File ClumsyPilot/tests/verify_path_smoothing_local_g2_curvature_probe_curve.ps1
powershell -ExecutionPolicy Bypass -File ClumsyPilot/tests/verify_path_smoothing_local_g2_curvature_probe_curve.ps1
git add -- ClumsyPilot/ParkrobTrajplanner/PathSmoothing/LocalG2/ArcLengthCurvatureBezierCurve2D.cs ClumsyPilot/tests/verify_path_smoothing_local_g2_curvature_probe_curve.ps1
git commit -m 'feat: add curvature-domain Local G2 probe curve'
```

Expected: build exit 0 and both script runs print `Local G2 curvature probe curve checks passed.` Record commands, exact output, baseline/head commits, and concerns in the Task 1 report.

---

### Task 2: Implement the deterministic two-variable closure solver

**Files:**

- Create in disposable core: `ClumsyPilot/ParkrobTrajplanner/PathSmoothing/LocalG2/LocalG2PoseClosureSolver.cs`
- Create in disposable core: `ClumsyPilot/tests/verify_path_smoothing_local_g2_curvature_probe_solver.ps1`
- Record without committing: `.superpowers/sdd/local-g2-excursion-task-2-report.md`

**Interfaces:**

```csharp
internal readonly struct LocalG2HardBoundaryState
{
    internal LocalG2HardBoundaryState(double x, double y, double travelUnwrappedHeadingRadians,
        double geometricCurvaturePerMeter, double referenceArcLengthMeters);
    internal double X { get; }
    internal double Y { get; }
    internal double TravelUnwrappedHeadingRadians { get; }
    internal double GeometricCurvaturePerMeter { get; }
    internal double ReferenceArcLengthMeters { get; }
}

internal enum LocalG2ClosureFailure
{
    None, InvalidInput, EmptyJointCurvatureDomain, IntegrationDepthLimit,
    JacobianBoundaryDegenerate, SingularJacobian, NoDescentStep,
    StalledAtBoundary, IterationLimit
}

internal sealed class LocalG2PoseClosureSolution
{
    internal ArcLengthCurvatureBezierCurve2D Curve { get; }
    internal int SeedIndex { get; }
    internal int IterationCount { get; }
    internal int DampingTrialCount { get; }
    internal double LengthMeters { get; }
    internal double C0 { get; }
    internal double C1 { get; }
    internal double C2 { get; }
    internal double C3 { get; }
    internal double PreOverwritePositionErrorMeters { get; }
    internal double HeadingErrorRadians { get; }
}

internal sealed class LocalG2PoseClosureDiagnostic
{
    internal int SeedIndex { get; }
    internal int OuterIterationCount { get; }
    internal int DampingTrialCount { get; }
    internal int IntegrationEvaluationCount { get; }
    internal int MaximumIntegrationDepth { get; }
    internal double? LastLengthMeters { get; }
    internal double? LastC1 { get; }
    internal double? LastC2 { get; }
    internal LocalG2ClosureFailure Failure { get; }
    internal string Reason { get; }
}

internal sealed class LocalG2PoseClosureSolver
{
    internal bool TrySolve(LocalG2HardBoundaryState start, LocalG2HardBoundaryState end,
        double maximumDeviationMeters, double maximumVehicleCurvaturePerMeter,
        int seedIndex, CancellationToken cancellationToken,
        out LocalG2PoseClosureSolution solution,
        out LocalG2PoseClosureDiagnostic diagnostic,
        out LocalG2ClosureFailure failure, out string reason);

    public sealed class PoseClosureTestSnapshot
    {
        public bool Accepted { get; }
        public double PositionError { get; }
        public double HeadingError { get; }
        public double C1 { get; }
        public double C2 { get; }
        public bool ExactBounds { get; }
        public bool EmptyDomainRejected { get; }
        public bool C2NeverClamped { get; }
        public bool LengthBoundsExact { get; }
        public bool ChordAboveMaximumRejected { get; }
        public bool DifferenceRulesExact { get; }
        public bool ProjectionOrderExact { get; }
        public bool FourTrialLimitExact { get; }
        public bool DampingScheduleExact { get; }
        public bool PivotThresholdExact { get; }
        public bool EqualStepStalls { get; }
        public bool AboveStepContinues { get; }
        public bool IterationLimitExact { get; }
        public bool FinalTrialClosureAccepted { get; }
        public bool AllFailureExitsStable { get; }
        public IReadOnlyList<string> FailureExitNames { get; }
        public bool AllSeedsOrdered { get; }
        public double SeedLowerBound { get; }
        public double SeedUpperBound { get; }
        public double Seed0C1 { get; }
        public double Seed1C1 { get; }
        public double Seed2C1 { get; }
        public bool RepeatedSolutionsEqual { get; }
        public bool CancellationPropagates { get; }
    }

    public static class TestHooks
    {
        public static PoseClosureTestSnapshot Execute(string scenario);
    }
}
```

- Consumes Task 1 curve.
- Produces one interval solution; Task 3 owns window/seed scheduling and total invocation budgets.
- Every non-cancelled `TrySolve` call returns a non-null immutable diagnostic, including `InvalidInput` and all other failures. Fields that were never evaluated remain nullable; cancellation throws and does not fabricate a diagnostic.

- [ ] **Step 1: Write solver RED tests**

The reflection script invokes `LocalG2PoseClosureSolver.TestHooks.Execute` and checks:

```powershell
$known = Invoke-Scenario 'KnownCurveRecovery'
Assert-True $known.Accepted 'A known cubic-curvature endpoint must be recovered.'
Assert-True ($known.PositionError -le 1e-9) 'Known curve position must close.'
Assert-True ($known.HeadingError -le 1e-8) 'Known curve heading must close.'

$counter = Invoke-Scenario 'SingleTurnLeftCounterCurvature'
Assert-True $counter.Accepted 'The zero-heading left interval must close within physical bounds.'
Assert-True ($counter.C1 -lt 0.0 -or $counter.C2 -lt 0.0) 'Closure must expose counter-curvature.'

$domain = Invoke-Scenario 'JointDomain'
Assert-True $domain.ExactBounds 'c1 bounds must equal max(-K,S-K)..min(K,S+K).'
Assert-True $domain.EmptyDomainRejected 'An empty joint domain must be rejected.'
Assert-True $domain.C2NeverClamped 'c2 must be derived, never independently clamped.'

$length = Invoke-Scenario 'LengthBounds'
Assert-True $length.LengthBoundsExact 'Length must be projected to [chord,rawSpan+2*deviation].'
Assert-True $length.ChordAboveMaximumRejected 'An empty length interval must reject InvalidInput.'

$algorithm = Invoke-Scenario 'AlgorithmConstants'
Assert-True $algorithm.DifferenceRulesExact 'Central/one-sided finite differences must match the design.'
Assert-True $algorithm.ProjectionOrderExact 'Projection order must match the design.'
Assert-True $algorithm.FourTrialLimitExact 'Each outer iteration must permit at most four damping trials.'
Assert-True $algorithm.DampingScheduleExact 'Lambda must start at 1e-3 and change by factors of ten.'
Assert-True $algorithm.PivotThresholdExact 'The 1e-14 pivot threshold must be enforced.'
Assert-True $algorithm.EqualStepStalls 'normalizedStep exactly 1e-12 must stall when not closed.'
Assert-True $algorithm.AboveStepContinues 'normalizedStep above 1e-12 must not take the stall exit.'
Assert-True $algorithm.IterationLimitExact 'The outer iteration cap must be 16.'
Assert-True $algorithm.FinalTrialClosureAccepted `
    'A closing fourth trial in outer iteration 16 must succeed, not return IterationLimit.'
Assert-True $algorithm.AllFailureExitsStable `
    'Every failure enum must be triggered by a deterministic fixture and retain its exact reason.'
Assert-Sequence @('InvalidInput','EmptyJointCurvatureDomain','IntegrationDepthLimit', `
    'JacobianBoundaryDegenerate','SingularJacobian','NoDescentStep', `
    'StalledAtBoundary','IterationLimit') $algorithm.FailureExitNames `
    'Every non-cancellation solver failure exit must be covered exactly once.'

$stable = Invoke-Scenario 'DeterminismAndCancellation'
Assert-True $stable.AllSeedsOrdered 'Seeds must be mid, lower-quarter, upper-quarter.'
Assert-Near (($stable.SeedLowerBound + $stable.SeedUpperBound) / 2.0) `
    $stable.Seed0C1 1e-15 'Seed 0 must be the exact midpoint.'
Assert-Near (($stable.SeedLowerBound + $stable.Seed0C1) / 2.0) `
    $stable.Seed1C1 1e-15 'Seed 1 must be the exact lower quarter.'
Assert-Near (($stable.Seed0C1 + $stable.SeedUpperBound) / 2.0) `
    $stable.Seed2C1 1e-15 'Seed 2 must be the exact upper quarter.'
Assert-True $stable.RepeatedSolutionsEqual 'Repeated solution fields must match exactly.'
Assert-True $stable.CancellationPropagates 'Cancellation must throw OperationCanceledException.'

```

- [ ] **Step 2: Run RED**

```powershell
dotnet build ClumsyPilot/ClumsyPilot.csproj --configuration Debug
powershell -ExecutionPolicy Bypass -File ClumsyPilot/tests/verify_path_smoothing_local_g2_curvature_probe_solver.ps1
```

Expected: reflection FAIL because the solver type does not exist.

- [ ] **Step 3: Implement joint domain and analytic heading elimination**

Use exactly:

```csharp
double chord = Distance(start.X, start.Y, end.X, end.Y);
double rawSpan = end.ReferenceArcLengthMeters - start.ReferenceArcLengthMeters;
double maximumLength = rawSpan + 2d * maximumDeviationMeters;
double deltaHeading = end.TravelUnwrappedHeadingRadians -
    start.TravelUnwrappedHeadingRadians;

double SumForLength(double length) =>
    4d * deltaHeading / length - start.GeometricCurvaturePerMeter -
    end.GeometricCurvaturePerMeter;

void GetJointBounds(double length, out double low, out double high)
{
    double sum = SumForLength(length);
    low = Math.Max(-maximumVehicleCurvaturePerMeter,
        sum - maximumVehicleCurvaturePerMeter);
    high = Math.Min(maximumVehicleCurvaturePerMeter,
        sum + maximumVehicleCurvaturePerMeter);
}
```

After projecting `L`, recompute bounds, project `c1`, then derive `c2=SumForLength(L)-c1`. Never clamp `c2`.

- [ ] **Step 4: Implement the exact finite-difference and LM contract**

Bind these constants in one place:

```csharp
private const int MaximumOuterIterations = 16;
private const int MaximumDampingTrials = 4;
private const double InitialDamping = 1e-3d;
private const double MinimumDamping = 1e-12d;
private const double MaximumDamping = 1e12d;
private const double PivotTolerance = 1e-14d;
private const double StalledStepTolerance = 1e-12d;
private const double PositionToleranceMeters = 1e-9d;
private const double HeadingToleranceRadians = 1e-8d;
```

Use:

```csharp
double positionScale = Math.Max(rawSpan, 1e-3d);
double hL = Math.Max(1e-7d, 1e-6d * positionScale);
double hC = Math.Max(1e-8d, 1e-6d * maximumVehicleCurvaturePerMeter);
double stepL = Math.Abs(trialL - currentL) / positionScale;
double stepC = Math.Abs(trialC1 - currentC1) /
    Math.Max(maximumVehicleCurvaturePerMeter, 1e-8d);
double normalizedStep = Math.Max(stepL, stepC);
```

`J` is the Jacobian of `[dx/positionScale,dy/positionScale]`. Prefer central differences; use the actual projected variable delta in the denominator; fall back to one-sided when only one side is legal. Solve `(J^T J + lambda * diag(max(diag(J^T J),1))) * delta = -J^T rNormalized` as a damped `2x2` normal equation with partial pivoting. Accept only a finite strict norm decrease. Four failed trials return `NoDescentStep`; do not hide trials inside the outer-iteration counter.

Implement `TrySolve` with this complete control flow:

1. Check cancellation; validate all inputs, `seedIndex in [0,2]`, `rawSpan>0`, `maximumDeviation>=0`, and `Kvehicle>0`. Compute `minimumLength=chord`, `maximumLength=rawSpan+2*maximumDeviation`; return `InvalidInput` when the interval is empty or non-finite.
2. Project `rawSpan` to the length interval, compute the joint `c1` bounds, and return `EmptyJointCurvatureDomain` when empty. Initialize `c1` as `mid`, `(low+mid)/2`, or `(mid+high)/2` for seed 0, 1, or 2. Derive `c2`; evaluate the curve and normalized residual. Propagate cancellation and map only integration depth exhaustion to `IntegrationDepthLimit`.
3. At the start of each of 16 outer iterations, check cancellation and all success conditions: Euclidean position `<=1e-9`, analytic heading `<=1e-8`, endpoint curvatures `<=1e-8`, finite values, and all four controls inside the physical domain. Return the immutable solution immediately only when every condition passes.
4. For `L` then `c1`, form projected plus/minus perturbations in that order. Evaluate both distinct legal sides for a central column; otherwise evaluate the one distinct legal side against the current residual. Divide by the actual projected physical-variable delta. If neither side is distinct/legal, return `JacobianBoundaryDegenerate`.
5. Carry `lambda` across outer iterations, initially `1e-3`. For damping trials 0 through 3: check cancellation; form the exact damped normal equation; solve it with partial pivoting; return `SingularJacobian` when either selected absolute pivot is `<1e-14`; project the proposed `L`, recompute/project the joint `c1` domain, derive `c2`, and evaluate the trial.
6. Immediately after every trial evaluation and before testing step size or descent, run the complete hard-success predicate from item 3 and return the immutable solution if it passes. Otherwise, if projected `normalizedStep<=1e-12`, return `StalledAtBoundary`. Accept only a finite strict residual-norm decrease, set `lambda=max(1e-12,lambda/10)`, store the trial, and begin the next outer iteration. On rejection set `lambda=min(1e12,lambda*10)` and try again. Four rejected trials return `NoDescentStep`. This post-trial success check also applies to the fourth damping trial of the 16th outer iteration; a closed final trial must never fall through to `IterationLimit`.
7. After the 16th unsuccessful outer iteration return `IterationLimit`. Never catch `OperationCanceledException`, randomize seeds, use wall-clock termination, or reuse damping/history across calls.

- [ ] **Step 5: Run GREEN twice, run Task 1 regression, and commit**

```powershell
dotnet build ClumsyPilot/ClumsyPilot.csproj --configuration Debug
powershell -ExecutionPolicy Bypass -File ClumsyPilot/tests/verify_path_smoothing_local_g2_curvature_probe_solver.ps1
powershell -ExecutionPolicy Bypass -File ClumsyPilot/tests/verify_path_smoothing_local_g2_curvature_probe_solver.ps1
powershell -ExecutionPolicy Bypass -File ClumsyPilot/tests/verify_path_smoothing_local_g2_curvature_probe_curve.ps1
git add -- ClumsyPilot/ParkrobTrajplanner/PathSmoothing/LocalG2/LocalG2PoseClosureSolver.cs ClumsyPilot/tests/verify_path_smoothing_local_g2_curvature_probe_solver.ps1
git commit -m 'feat: add bounded Local G2 pose closure probe'
```

Expected: all scripts exit 0 with their named pass messages. Record the exact solver status counts from synthetic cases.

---

### Task 3: Integrate bounded candidates and emit strict candidate audit data

**Files:**

- Create in disposable core: `ClumsyPilot/ParkrobTrajplanner/PathSmoothing/LocalG2/LocalG2ExcursionProbeDiagnostics.cs`
- Modify in disposable core: `ClumsyPilot/ParkrobTrajplanner/PathSmoothing/LocalG2/LocalG2CandidateGeometry.cs`
- Modify in disposable core: `ClumsyPilot/ParkrobTrajplanner/PathSmoothing/LocalG2/LocalG2CandidateBuilder.cs`
- Modify in disposable core: `ClumsyPilot/ParkrobTrajplanner/PathSmoothing/LocalG2/LocalG2PreSmoothingPipeline.cs`
- Create in disposable core: `ClumsyPilot/tests/verify_path_smoothing_local_g2_excursion_probe.ps1`
- Record without committing: `.superpowers/sdd/local-g2-excursion-task-3-report.md`

**Interfaces:**

```csharp
internal sealed class LocalG2ExcursionIntervalDiagnostic
{
    internal int IntervalIndex { get; }
    internal bool Accepted { get; }
    internal ArcLengthCurvatureBezierCurve2D Curve { get; }
    internal LocalG2ClosureFailure Failure { get; }
    internal string Reason { get; }
    internal double? LengthMeters { get; }
    internal double? GeometricC0 { get; }
    internal double? GeometricC1 { get; }
    internal double? GeometricC2 { get; }
    internal double? GeometricC3 { get; }
    internal double? VehicleC0 { get; }
    internal double? VehicleC1 { get; }
    internal double? VehicleC2 { get; }
    internal double? VehicleC3 { get; }
    internal int OuterIterationCount { get; }
    internal int DampingTrialCount { get; }
    internal int IntegrationEvaluationCount { get; }
    internal int MaximumIntegrationDepth { get; }
    internal long SolverElapsedStopwatchTicks { get; }
    internal long StopwatchFrequency { get; }
    internal double? PreOverwritePositionErrorMeters { get; }
    internal double? AnalyticHeadingErrorRadians { get; }
    internal double? AnalyticMinimumCurvaturePerMeter { get; }
    internal double? AnalyticMaximumCurvaturePerMeter { get; }
    internal double? AnalyticMaximumGradientPerSquareMeter { get; }
}

internal sealed class LocalG2ExcursionCandidateDiagnostic
{
    internal int WindowIndex { get; }
    internal int SeedIndex { get; }
    internal int CombinationAttemptIndex { get; }
    internal int SolverInvocationCount { get; }
    internal int DirectionSign { get; }
    internal IReadOnlyList<LocalG2HardBoundaryState> HardNodes { get; }
    internal IReadOnlyList<LocalG2ExcursionIntervalDiagnostic> Intervals { get; }
    internal double MaximumPreOverwritePositionErrorMeters { get; }
    internal double MaximumPostOverwritePositionErrorMeters { get; }
    internal double MaximumTangentSeamErrorRadians { get; }
    internal double MaximumCurvatureSeamErrorPerMeter { get; }
    internal double MinimumAdjacentPointDistanceMeters { get; }
}

internal sealed class LocalG2ExcursionCandidateLedgerEntry
{
    internal int WindowIndex { get; }
    internal int SeedIndex { get; }
    internal int CombinationAttemptIndex { get; }
    internal LocalG2CandidateGeometry Candidate { get; }
    internal bool IsCanonical { get; }
    internal int CanonicalCandidateIndex { get; }
    internal IReadOnlyList<double> EquivalenceParameters { get; }
    internal double MaximumEquivalenceCoordinateDifferenceMeters { get; }
}

internal enum LocalG2ExcursionAttemptOutcome
{
    CandidateConstructed,
    NumericallyDeduplicated,
    HardNodeInvalid,
    IntervalSolveFailed,
    CandidateAssemblyFailed,
    CombinationBudgetExhausted,
    SolverInvocationBudgetExhausted,
    CandidateLimitReached,
    ProbeInvalid
}

internal enum LocalG2ExcursionSearchStop
{
    None,
    CombinationBudgetExhausted,
    SolverInvocationBudgetExhausted,
    CandidateLimitReached,
    ProbeInvalid
}

internal sealed class LocalG2ExcursionAttemptDiagnostic
{
    internal int WindowIndex { get; }
    internal int SeedIndex { get; }
    internal int CombinationAttemptIndex { get; }
    internal int IntervalIndex { get; }
    internal int EquivalentCandidateIndex { get; }
    internal LocalG2ExcursionAttemptOutcome Outcome { get; }
    internal LocalG2ClosureFailure ClosureFailure { get; }
    internal string Reason { get; }
    internal IReadOnlyList<LocalG2ExcursionIntervalDiagnostic> Intervals { get; }
}

internal sealed class LocalG2ExcursionProbeRunDiagnostic
{
    internal int WindowCount { get; }
    internal int CombinationAttemptCount { get; }
    internal int FailedCombinationCount { get; }
    internal int SolverInvocationCount { get; }
    internal int CandidateCount { get; }
    internal int MaximumIntervalsPerCombination { get; }
    internal LocalG2ExcursionSearchStop SearchStop { get; }
    internal int SearchStopWindowIndex { get; }
    internal int SearchStopSeedIndex { get; }
    internal bool ProbeInvalid { get; }
    internal string ProbeInvalidReason { get; }
    internal bool HasCandidateGeometryMetrics { get; }
    internal double? MaximumPreOverwritePositionErrorMeters { get; }
    internal double? MaximumPostOverwritePositionErrorMeters { get; }
    internal double? MaximumTangentSeamErrorRadians { get; }
    internal double? MaximumCurvatureSeamErrorPerMeter { get; }
    internal double? MinimumAdjacentPointDistanceMeters { get; }
    internal IReadOnlyList<LocalG2ExcursionAttemptDiagnostic> Attempts { get; }
    internal IReadOnlyList<LocalG2ExcursionCandidateLedgerEntry> CandidateLedger { get; }
}

internal sealed class LocalG2CandidateBuilder
{
    private static readonly double[] EquivalenceParameters =
        { 0d, 0.25d, 0.5d, 0.75d, 1d };
    internal LocalG2ExcursionProbeRunDiagnostic LastProbeDiagnostic { get; }

    public sealed class DirectionRoundTripSnapshot
    {
        public double ForwardVehicleCurvature { get; }
        public double ForwardGeometricCurvature { get; }
        public double ReverseVehicleCurvature { get; }
        public double ReverseGeometricCurvature { get; }
        public bool ForwardHeadingRoundTrips { get; }
        public bool ReverseHeadingRoundTrips { get; }
    }

    public sealed class EquivalenceToleranceSnapshot
    {
        public bool ExactThresholdsEquivalent { get; }
        public bool AboveAnyThresholdDistinct { get; }
        public bool HardNodeMismatchDistinct { get; }
        public bool IntervalCountMismatchDistinct { get; }
        public bool FixedEquivalenceGridExact { get; }
        public bool AliasRetainsIndependentDigestInput { get; }
    }

    public static class TestHooks
    {
        public static DirectionRoundTripSnapshot ExecuteDirectionRoundTrip();
        public static EquivalenceToleranceSnapshot ExecuteEquivalenceTolerance();
    }
}

internal IReadOnlyList<LocalG2CandidateGeometry> Build(
    PreparedDirectionSegment originalSegment,
    LocalG2SmoothingRegion region,
    double outputSpacingMeters,
    double maximumVehicleCurvaturePerMeter,
    LocalG2OptionsSnapshot options,
    CancellationToken cancellationToken);

private bool TryCreateHardBoundaryStates(
    PreparedDirectionSegment originalSegment,
    LocalG2SmoothingRegion region,
    LocalG2WindowVariant window,
    double maximumVehicleCurvaturePerMeter,
    out IReadOnlyList<LocalG2HardBoundaryState> hardNodes,
    out string reason);

private bool TryCreateCandidateFromSolutions(
    PreparedDirectionSegment originalSegment,
    LocalG2SmoothingRegion region,
    LocalG2WindowVariant window,
    int windowIndex,
    int seedIndex,
    int combinationAttemptIndex,
    int candidateIndex,
    IReadOnlyList<LocalG2HardBoundaryState> hardNodes,
    IReadOnlyList<LocalG2PoseClosureSolution> solutions,
    IReadOnlyList<LocalG2ExcursionIntervalDiagnostic> intervalDiagnostics,
    double outputSpacingMeters,
    int solverInvocationCount,
    out LocalG2CandidateGeometry candidate,
    out LocalG2ExcursionCandidateDiagnostic diagnostic,
    out string reason);

private void RecordAttemptFailure(int windowIndex, int seedIndex,
    int combinationAttemptIndex, LocalG2ClosureFailure failure, string reason);
private void RecordIntervalFailure(int windowIndex, int seedIndex,
    int combinationAttemptIndex, int intervalIndex,
    LocalG2ClosureFailure failure, string reason,
    IReadOnlyList<LocalG2ExcursionIntervalDiagnostic> intervalDiagnostics);
private void RecordCandidateFailure(int windowIndex, int seedIndex,
    int combinationAttemptIndex, LocalG2ExcursionCandidateDiagnostic diagnostic,
    string reason);
private void MarkProbeInvalid(int windowIndex, int seedIndex, string reason);
private void MarkSearchStopped(int windowIndex, int seedIndex,
    LocalG2ExcursionSearchStop stop, string reason);
private bool TryAddIfNumericallyNew(List<LocalG2CandidateGeometry> candidates,
    LocalG2CandidateGeometry candidate,
    LocalG2ExcursionCandidateDiagnostic diagnostic,
    CancellationToken cancellationToken, out string reason);
private static bool TryAreNumericallyEquivalent(
    LocalG2ExcursionCandidateDiagnostic left,
    LocalG2ExcursionCandidateDiagnostic right,
    CancellationToken cancellationToken, out bool equivalent,
    out double maximumCoordinateDifferenceMeters, out string reason);
private static LocalG2ExcursionIntervalDiagnostic CreateIntervalDiagnostic(
    int intervalIndex, int directionSign,
    LocalG2HardBoundaryState start, LocalG2HardBoundaryState end,
    LocalG2PoseClosureSolution solution,
    LocalG2PoseClosureDiagnostic solverDiagnostic,
    LocalG2ClosureFailure failure, string reason,
    long solverElapsedStopwatchTicks, long stopwatchFrequency);
private static int DirectionSign(TravelDirection direction);
```

The displayed builder `TestHooks` methods are added to the existing nested `TestHooks` class; do not create a second class, replace the existing `Execute(string): CandidateTestSnapshot`, or overload by return type.

- The `LocalG2CandidateGeometry` constructor adds one final optional parameter:

```csharp
LocalG2ExcursionCandidateDiagnostic excursionDiagnostic = null
```

- The probe script command-line contract is:

```powershell
param(
    [ValidateSet('Strict','Measurement','FinalGate')]
    [string]$Mode = 'Strict',
    [string]$StrictRoot,
    [string]$MeasurementRoot,
    [string]$EvidenceRoot,
    [string]$RunLabel = 'single',
    [string]$CoreCommit,
    [switch]$Batch
)
```

`Strict` and non-batch `Measurement` use the assembly under the current root and emit one run at the path returned by `Join-Path $EvidenceRoot ($RunLabel + '.json')`; when `EvidenceRoot` is omitted for a one-root developer run, create a unique directory under `[IO.Path]::GetTempPath()` and print its full path. `Strict` may omit `CoreCommit`, in which case both identity commits are the current root `HEAD`; when supplied it must equal `HEAD`. `Measurement` requires `CoreCommit`, records its current `HEAD` as `RootCommit`, and requires every committed path in `CoreCommit..HEAD` to be exactly the evaluator path. `Measurement -Batch` loads the assembly and fixture once, performs five warm-up runs followed immediately by 30 measured runs serially in that same process, and emits one batch JSON containing both arrays. `Batch` is invalid with `Strict` or `FinalGate`. `FinalGate` requires both absolute root paths and an explicit `EvidenceRoot`, derives the expected core commit from strict-root `HEAD`, launches one strict child and one measurement-batch child with that exact `CoreCommit`, pairs their evidence, writes timing/state/report artifacts, and exits nonzero only for `PROBE_INVALID` or a broken test contract; valid RED/STRICT_GREEN/FEASIBLE states are evidence outcomes and exit zero.

- [ ] **Step 1: Write the strict-root candidate/audit RED seam**

The new script must use the real `single-turn` fixture and existing preprocessor/detector/window planner. Its `Invoke-ProbeHook` helper maps `DirectionRoundTrip` to `LocalG2CandidateBuilder.TestHooks.ExecuteDirectionRoundTrip()` and `EquivalenceTolerance` to `ExecuteEquivalenceTolerance()`, and throws for any other name or absent snapshot. Before evaluation it asserts exact attempt limits and hard geometry diagnostics:

```powershell
$runDiagnostic = Get-InternalProperty $builder 'LastProbeDiagnostic'
$attempts = @(Get-InternalProperty $runDiagnostic 'Attempts')
$candidateLedger = @(Get-InternalProperty $runDiagnostic 'CandidateLedger')
$allIntervals = @($attempts | ForEach-Object {
    @(Get-InternalProperty $_ 'Intervals')
})
$hasCandidateGeometryMetrics = [bool](Get-InternalProperty $runDiagnostic 'HasCandidateGeometryMetrics')
$probe = [pscustomobject]@{
    WindowCount = [int](Get-InternalProperty $runDiagnostic 'WindowCount')
    CombinationAttemptCount = [int](Get-InternalProperty $runDiagnostic 'CombinationAttemptCount')
    SolverInvocationCount = [int](Get-InternalProperty $runDiagnostic 'SolverInvocationCount')
    MaximumIntervalsPerCombination = [int](Get-InternalProperty $runDiagnostic 'MaximumIntervalsPerCombination')
    CandidateCount = [int](Get-InternalProperty $runDiagnostic 'CandidateCount')
    CandidateLedgerCount = $candidateLedger.Count
    CanonicalLedgerCount = @($candidateLedger | Where-Object {
        [bool](Get-InternalProperty $_ 'IsCanonical')
    }).Count
    DeduplicatedLedgerCount = @($candidateLedger | Where-Object {
        -not [bool](Get-InternalProperty $_ 'IsCanonical')
    }).Count
    ProbeInvalid = [bool](Get-InternalProperty $runDiagnostic 'ProbeInvalid')
    SearchStop = (Get-InternalProperty $runDiagnostic 'SearchStop').ToString()
    HasCandidateGeometryMetrics = $hasCandidateGeometryMetrics
    MaximumPreOverwritePositionError = $(if ($hasCandidateGeometryMetrics) { [double](Get-InternalProperty $runDiagnostic 'MaximumPreOverwritePositionErrorMeters') } else { $null })
    MaximumPostOverwritePositionError = $(if ($hasCandidateGeometryMetrics) { [double](Get-InternalProperty $runDiagnostic 'MaximumPostOverwritePositionErrorMeters') } else { $null })
    MaximumTangentError = $(if ($hasCandidateGeometryMetrics) { [double](Get-InternalProperty $runDiagnostic 'MaximumTangentSeamErrorRadians') } else { $null })
    MaximumCurvatureError = $(if ($hasCandidateGeometryMetrics) { [double](Get-InternalProperty $runDiagnostic 'MaximumCurvatureSeamErrorPerMeter') } else { $null })
    MinimumAdjacentDistance = $(if ($hasCandidateGeometryMetrics) { [double](Get-InternalProperty $runDiagnostic 'MinimumAdjacentPointDistanceMeters') } else { $null })
    AllAttemptsCategorized = Test-AllAttemptsCategorized $attempts `
        ([int](Get-InternalProperty $runDiagnostic 'CombinationAttemptCount'))
    IntervalDiagnosticCount = $allIntervals.Count
    AllSolverTicksNonnegative = -not @($allIntervals | Where-Object {
        [long](Get-InternalProperty $_ 'SolverElapsedStopwatchTicks') -lt 0
    }).Count
    AllStopwatchFrequenciesPositive = -not @($allIntervals | Where-Object {
        [long](Get-InternalProperty $_ 'StopwatchFrequency') -le 0
    }).Count
    StrictEvaluatorSourceMatchesBaseline = Test-StrictEvaluatorHash
    CandidateLedgerAuditValid =
        Test-CandidateDigestDeduplication $candidateLedger $attempts
}

Assert-True ($probe.WindowCount -le 12) 'Window attempts must be capped at 12.'
Assert-True ($probe.CombinationAttemptCount -le 36) 'Combinations must be capped at 36.'
Assert-True ($probe.SolverInvocationCount -le 96) 'Failed solver calls must count toward 96.'
Assert-True ($probe.IntervalDiagnosticCount -eq $probe.SolverInvocationCount) `
    'Every completed solver invocation must emit one timing diagnostic.'
Assert-True $probe.AllSolverTicksNonnegative 'Solver elapsed ticks must be nonnegative.'
Assert-True $probe.AllStopwatchFrequenciesPositive 'Stopwatch frequencies must be positive.'
if ($probe.MaximumIntervalsPerCombination -gt 8) {
    Assert-True $probe.ProbeInvalid 'More than 8 intervals must produce PROBE_INVALID.'
    Assert-True ($probe.SearchStop -eq 'ProbeInvalid') 'Invalid interval count must stop all scheduling.'
} else {
    Assert-True ($probe.MaximumIntervalsPerCombination -le 8) 'A scheduled combination must contain at most 8 intervals.'
}
Assert-True ($probe.CandidateCount -le 12) 'Constructed candidates must be capped at 12.'
Assert-True ($probe.CandidateCount -eq $probe.CanonicalLedgerCount) `
    'CandidateCount must count canonical ledger entries only.'
Assert-True ($probe.CandidateLedgerCount -eq
    ($probe.CanonicalLedgerCount + $probe.DeduplicatedLedgerCount)) `
    'Every ledger record must be canonical or a deduplicated alias.'
Assert-True $probe.AllAttemptsCategorized 'Every failed, exhausted, invalid, constructed, or deduplicated attempt must be retained.'
if ($probe.CandidateCount -gt 0) {
    Assert-True ($probe.MaximumPreOverwritePositionError -le 1e-9) 'Pre-overwrite closure must pass.'
    Assert-True ($probe.MaximumPostOverwritePositionError -le 1e-9) 'Post-overwrite anchor must pass.'
    Assert-True ($probe.MaximumTangentError -le 1e-8) 'Travel tangent seams must be G2.'
    Assert-True ($probe.MaximumCurvatureError -le 1e-8) 'Curvature seams must be G2.'
    Assert-True ($probe.MinimumAdjacentDistance -gt 1e-10) 'Candidate points must not repeat.'
} else {
    Assert-True (-not $probe.HasCandidateGeometryMetrics) `
        'No-candidate runs must not synthesize hard-geometry metrics.'
}
Assert-True $probe.StrictEvaluatorSourceMatchesBaseline 'Strict evaluator must remain unchanged.'
Assert-True $probe.CandidateLedgerAuditValid `
    'Candidate ledger keys, digests, canonical links, and deduplication evidence must be valid.'

$direction = Invoke-ProbeHook 'DirectionRoundTrip'
Assert-Near $direction.ForwardVehicleCurvature $direction.ForwardGeometricCurvature 1e-12 `
    'Forward geometric curvature must preserve the vehicle sign.'
Assert-Near (-$direction.ReverseVehicleCurvature) $direction.ReverseGeometricCurvature 1e-12 `
    'Reverse geometric curvature must negate the vehicle sign.'
Assert-True $direction.ForwardHeadingRoundTrips 'Forward travel/vehicle heading conversion must round-trip.'
Assert-True $direction.ReverseHeadingRoundTrips 'Reverse travel/vehicle heading conversion must round-trip.'

$equivalence = Invoke-ProbeHook 'EquivalenceTolerance'
Assert-True $equivalence.ExactThresholdsEquivalent `
    'Equal 1e-9 L/c1/c2 and coordinate differences must deduplicate.'
Assert-True $equivalence.AboveAnyThresholdDistinct `
    'A difference above any 1e-9 threshold must remain distinct.'
Assert-True $equivalence.HardNodeMismatchDistinct `
    'Different hard-node sequences must remain distinct.'
Assert-True $equivalence.IntervalCountMismatchDistinct `
    'Different interval counts must remain distinct.'
Assert-True $equivalence.FixedEquivalenceGridExact `
    'The common grid must be exactly 0, 0.25, 0.5, 0.75, 1 per interval.'
Assert-True $equivalence.AliasRetainsIndependentDigestInput `
    'A deduplicated key must retain its own hard nodes and candidate points.'
```

`Test-AllAttemptsCategorized` requires exactly one terminal attempt record for every started combination index `1..CombinationAttemptCount`; permitted terminal outcomes are constructed, deduplicated, hard-node invalid, interval failure, candidate assembly failure, started-combination budget exhaustion, or probe invalid. `Test-StrictEvaluatorHash` compares the current evaluator SHA-256 with the Task 1 manifest. `Test-CandidateDigestDeduplication($candidateLedger,$attempts)` recomputes a digest from every entry's own retained candidate, requires one ledger entry for every `CandidateConstructed` or `NumericallyDeduplicated` terminal attempt and none for failed attempts, requires stable keys to be unique and canonical indices to be unique/dense, and requires each duplicate attempt and alias to name the same preserved canonical index. Equal content SHA-256 values on two canonical records do not themselves deduplicate or invalidate them; only the fixed continuous-curve equivalence contract may deduplicate. Each alias must keep a distinct stable key, its own digest input/digest, the exact fixed grid and measured proof, and a resolvable canonical evaluation source; the script separately constructs evaluator-call, eligibility, state-count, performance-count, and selection input arrays and asserts no alias stable key occurs in any of them.

The script must emit one invariant JSON object per attempt/candidate, including stable key, digest, interval values, failures, strict result, actual strict stop gate, raw/candidate ranges when evaluated, and `not evaluated` for gates after strict early return.

- [ ] **Step 2: Run RED**

```powershell
dotnet build ClumsyPilot/ClumsyPilot.csproj --configuration Debug
powershell -ExecutionPolicy Bypass -File ClumsyPilot/tests/verify_path_smoothing_local_g2_excursion_probe.ps1 -Mode Strict
```

Expected: FAIL because the builder signature, diagnostics, and curvature-domain scheduling do not exist.

- [ ] **Step 3: Add immutable probe diagnostics**

Diagnostics validate all finite/nonnegative fields in constructors and copy input lists into `ReadOnlyCollection<T>`. Store both geometric and recovered vehicle `c0..c3`, `L`, seed, iteration/damping/integration counts, failure enum, pre/post errors, analytic extrema, and seam errors. `EquivalentCandidateIndex` is the dense emitted index only for `NumericallyDeduplicated` and is `-1` for every other outcome. Do not add public production contracts; these files exist only in the disposable repository.

- [ ] **Step 4: Replace candidate scheduling in the disposable builder**

Add `using System.Diagnostics;` and use this exact outer scheduling shape:

```csharp
int maximumWindows = Math.Min(region.WindowVariants.Count, 12);
int combinationAttempts = 0;
int solverInvocations = 0;
bool stopScheduling = false;
for (int windowIndex = 0; windowIndex < maximumWindows && !stopScheduling; windowIndex++)
{
    LocalG2WindowVariant window = region.WindowVariants[windowIndex];
    for (int seedIndex = 0; seedIndex < 3; seedIndex++)
    {
        cancellationToken.ThrowIfCancellationRequested();
        if (candidates.Count >= Math.Min(options.MaximumCandidatesPerRegion, 12))
        {
            MarkSearchStopped(windowIndex, seedIndex,
                LocalG2ExcursionSearchStop.CandidateLimitReached,
                "The constructed-candidate limit was reached.");
            stopScheduling = true;
            break;
        }
        if (combinationAttempts >= 36)
        {
            MarkSearchStopped(windowIndex, seedIndex,
                LocalG2ExcursionSearchStop.CombinationBudgetExhausted,
                "The window/seed combination limit was reached.");
            stopScheduling = true;
            break;
        }
        if (solverInvocations >= 96)
        {
            MarkSearchStopped(windowIndex, seedIndex,
                LocalG2ExcursionSearchStop.SolverInvocationBudgetExhausted,
                "The solver invocation limit was reached.");
            stopScheduling = true;
            break;
        }
        combinationAttempts++;

        if (!TryCreateHardBoundaryStates(originalSegment, region, window,
                maximumVehicleCurvaturePerMeter,
                out IReadOnlyList<LocalG2HardBoundaryState> hardNodes,
                out string hardNodeReason))
        {
            RecordAttemptFailure(windowIndex, seedIndex, combinationAttempts,
                LocalG2ClosureFailure.InvalidInput, hardNodeReason);
            continue;
        }

        int intervalCount = hardNodes.Count - 1;
        if (intervalCount < 1 || intervalCount > 8)
        {
            MarkProbeInvalid(windowIndex, seedIndex,
                "Hard-node interval count must be in [1,8].");
            stopScheduling = true;
            break;
        }
        if (solverInvocations + intervalCount > 96)
        {
            MarkSearchStopped(windowIndex, seedIndex,
                LocalG2ExcursionSearchStop.SolverInvocationBudgetExhausted,
                "The remaining solver budget cannot cover this combination.");
            stopScheduling = true;
            break;
        }

        var solutions = new List<LocalG2PoseClosureSolution>(intervalCount);
        var intervalDiagnostics = new List<LocalG2ExcursionIntervalDiagnostic>(intervalCount);
        bool solved = true;
        for (int intervalIndex = 0; intervalIndex < intervalCount; intervalIndex++)
        {
            solverInvocations++;
            long solverStarted = Stopwatch.GetTimestamp();
            bool intervalAccepted = _poseClosureSolver.TrySolve(
                hardNodes[intervalIndex], hardNodes[intervalIndex + 1],
                options.MaximumDeviationMeters, maximumVehicleCurvaturePerMeter,
                seedIndex, cancellationToken, out LocalG2PoseClosureSolution solution,
                out LocalG2PoseClosureDiagnostic solverDiagnostic,
                out LocalG2ClosureFailure failure, out string solveReason);
            long solverElapsed = Stopwatch.GetTimestamp() - solverStarted;
            LocalG2ExcursionIntervalDiagnostic intervalDiagnostic =
                CreateIntervalDiagnostic(intervalIndex, DirectionSign(originalSegment.Direction),
                    hardNodes[intervalIndex], hardNodes[intervalIndex + 1],
                    solution, solverDiagnostic, failure, solveReason,
                    solverElapsed, Stopwatch.Frequency);
            intervalDiagnostics.Add(intervalDiagnostic);
            if (!intervalAccepted)
            {
                RecordIntervalFailure(windowIndex, seedIndex, combinationAttempts,
                    intervalIndex, failure, solveReason, intervalDiagnostics);
                solved = false;
                break;
            }
            solutions.Add(solution);
        }
        if (!solved) continue;

        if (TryCreateCandidateFromSolutions(originalSegment, region, window,
                windowIndex, seedIndex, combinationAttempts, candidates.Count,
                hardNodes, solutions, intervalDiagnostics,
                outputSpacingMeters, solverInvocations,
                out LocalG2CandidateGeometry candidate,
                out LocalG2ExcursionCandidateDiagnostic diagnostic,
                out string candidateReason))
        {
            if (!TryAddIfNumericallyNew(candidates, candidate, diagnostic,
                    cancellationToken, out string equivalenceReason))
            {
                MarkProbeInvalid(windowIndex, seedIndex, equivalenceReason);
                stopScheduling = true;
                break;
            }
        }
        else
        {
            RecordCandidateFailure(windowIndex, seedIndex, combinationAttempts,
                diagnostic, candidateReason);
        }
    }
}
```

The method publishes `LastProbeDiagnostic` from one `finally`-style exit path after both loops. `RecordAttemptFailure`, `MarkProbeInvalid`, `MarkSearchStopped`, `RecordIntervalFailure`, `RecordCandidateFailure`, and `TryAddIfNumericallyNew` populate that immutable snapshot; none may silently discard a failed attempt. `TryAddIfNumericallyNew` applies the Task 3 equivalence contract below, preserves the first numerical solution, records later equivalent stable keys as deduplicated ledger entries, and keeps emitted `candidateIndex` values dense in combination order.

`DirectionSign` returns `+1` for `TravelDirection.Forward`, `-1` for `TravelDirection.Reverse`, and throws `ArgumentOutOfRangeException` otherwise. Every completed solver invocation appends its interval diagnostic before success/failure branching; a failed combination therefore retains all successful prefix intervals plus the failed interval, including elapsed ticks and exact exit diagnostics. `SolverPhaseElapsedMilliseconds` is the sum of `SolverElapsedStopwatchTicks * 1000.0 / StopwatchFrequency` over all invocation diagnostics, not a second independently rounded measurement.

Implement numerical equivalence here with the immutable common grid `u={0,0.25,0.5,0.75,1}` for every corresponding interval. Require identical hard-node count/content and interval count; for every interval require `|delta L|`, `|delta c1|`, and `|delta c2|` each `<=1e-9`; then evaluate both stored immutable curves at all five identical `u` values and require maximum Euclidean coordinate difference `<=1e-9 m`. Check cancellation before every interval and grid point. Equality at the threshold deduplicates; any field above it remains distinct. A curve-evaluation/integration failure returns `false` with a reason and makes the run `PROBE_INVALID`, not “distinct.” The Task 3 `EquivalenceTolerance` hook asserts the exact grid and boundary behavior.

Every successfully assembled combination is appended to `CandidateLedger` before canonical/deduplicated branching. A canonical entry retains its full candidate and has `IsCanonical=true`, `CanonicalCandidateIndex=candidate.CandidateIndex`, grid `{0,0.25,0.5,0.75,1}`, and maximum equivalence coordinate difference `0`. A deduplicated entry retains its own full candidate points/hard nodes and independent digest input, has `IsCanonical=false`, points to the preserved canonical dense index, and stores the measured maximum grid difference. The builder returns only canonical candidates to the pipeline; ledger aliases do not increment `CandidateCount`.

Keep the current hard-node construction, interpolation, direction-sign handling, distance-weighted shared curvature, reference body-clearance lookup, source marker, and dense candidate indices. Convert each normalized vehicle heading to normalized travel heading. Set the first unwrapped travel heading to that normalized value; for every later node use `currentUnwrapped = previousUnwrapped + AngleMath.ShortestSignedDifference(AngleMath.NormalizeRadians(previousUnwrapped), currentTravelNormalized)`. Pass those unwrapped travel headings to the solver. For each solved interval, sample `count=max(1,ceil(L/outputSpacingMeters))`; emit the shared hard node once; use exact anchors only after pre-overwrite closure passes.

- [ ] **Step 5: Pass the physical curvature limit from the pipeline**

Change the sole production-path call in the disposable `LocalG2PreSmoothingPipeline` to:

```csharp
IReadOnlyList<LocalG2CandidateGeometry> candidates = _builder.Build(
    preparedPath.Segments[region.SegmentIndex], region,
    request.Configuration.OutputSpacingMeters, maximumCurvature,
    options, cancellationToken);
```

Update disposable test-hook/reflection calls with explicit finite positive curvature limits. Do not modify `LocalG2CandidateEvaluator.cs` in the core.

- [ ] **Step 6: Implement the complete audit/final-gate script in the disposable core**

Implement all three command modes, the Task 4 child JSON schema, child-process pairing, timing capture, state resolver/table tests, and report generation now; Task 3 requires only `Strict` to pass, while `Measurement` intentionally remains RED until the isolated evaluator patch. The candidate digest input uses UTF-8 invariant round-trip text, one field per line, in this exact order:

The script owns one `Get-ProbeSourceManifest -Root <absolute-root>` helper. It enumerates the current filesystem only beneath explicit safe paths: root `.gitattributes`, `ClumsyPilot/ClumsyPilot.csproj`, every `.cs` file below `ClumsyPilot/ParkrobTrajplanner/PathSmoothing` except `ClumsyPilot/ParkrobTrajplanner/PathSmoothing/LocalG2/LocalG2CandidateEvaluator.cs`, the fixture `ClumsyPilot/ParkrobTrajplanner/PathSmoothing/Test/Fixtures/path-smoothing-fixtures.json`, and exactly the three probe verifier scripts created by Tasks 1–3. This allows Task 3's pre-commit GREEN run to hash its TDD worktree; the frozen post-commit run and both Task 4 roots additionally require clean Git status, so their manifest bytes are bound to the recorded commits. The helper rejects any path containing a tab/newline or `auto_avoidance`, requires all seven categories to be nonempty, requires `.gitattributes` raw text to be exactly `* -text` plus one LF, normalizes separators to `/`, sorts with `StringComparer.Ordinal`, and hashes each file's raw bytes with lowercase SHA-256. The canonical manifest text is one `relative/path<TAB>lowercase-sha256<LF>` line per entry including a final LF, encoded as UTF-8 without BOM; `ProbeSourceManifestSha256` is the lowercase SHA-256 of those exact bytes. The evaluator is intentionally excluded because its strict/measurement hashes and complete diff are audited separately. Every single/batch child recomputes this manifest from its own root; when `EvidenceRoot` is supplied it also writes the canonical text to `<RunLabel>-probe-source-manifest.txt` and records that full path beside the hash.

Use this exact canonicalization core inside the helper:

```powershell
$paths = [string[]]@($sourcePaths | ForEach-Object { $_.Replace('\','/') })
[Array]::Sort($paths, [StringComparer]::Ordinal)
$entries = foreach ($relativePath in $paths) {
    if ($relativePath.IndexOf("`t", [StringComparison]::Ordinal) -ge 0 -or
        $relativePath.IndexOf("`n", [StringComparison]::Ordinal) -ge 0 -or
        $relativePath.IndexOf('auto_avoidance', [StringComparison]::Ordinal) -ge 0) {
        throw "Illegal probe-manifest path: $relativePath"
    }
    $absolutePath = Join-Path $Root ($relativePath.Replace('/', [IO.Path]::DirectorySeparatorChar))
    $fileSha = (Get-FileHash -Algorithm SHA256 -LiteralPath $absolutePath).Hash.ToLowerInvariant()
    $relativePath + "`t" + $fileSha
}
$canonicalText = [string]::Join("`n", [string[]]$entries) + "`n"
$utf8NoBom = New-Object Text.UTF8Encoding($false)
$manifestBytes = $utf8NoBom.GetBytes($canonicalText)
$sha256 = [Security.Cryptography.SHA256]::Create()
try { $manifestSha256 = -join ($sha256.ComputeHash($manifestBytes) | ForEach-Object { $_.ToString('x2') }) }
finally { $sha256.Dispose() }
```

`$sourcePaths` is built from the exact six explicit files plus a recursive `*.cs` enumeration rooted only at `ClumsyPilot/ParkrobTrajplanner/PathSmoothing`; it never enumerates the `ParkrobTrajplanner` parent. Convert every absolute result back to a root-relative path and apply the exact evaluator exclusion before canonicalization. A missing file, duplicate normalized path, empty manifest, category-count failure, EOL-policy mismatch, `core.autocrlf` other than `false`, or final-evidence dirty Git status aborts the run as `PROBE_INVALID` evidence.

```powershell
function Get-CandidateLedgerDigest {
param($ledgerEntry, $preparedSegment)
$candidate = Get-InternalProperty $ledgerEntry 'Candidate'
$culture = [Globalization.CultureInfo]::InvariantCulture
$builder = [Text.StringBuilder]::new()
[void]$builder.AppendLine(([int]$preparedSegment.Direction).ToString($culture))
[void]$builder.AppendLine(([int](Get-InternalProperty $candidate 'SegmentIndex')).ToString($culture))
[void]$builder.AppendLine(([double](Get-InternalProperty $candidate 'StartArcLengthMeters')).ToString('R', $culture))
[void]$builder.AppendLine(([double](Get-InternalProperty $candidate 'EndArcLengthMeters')).ToString('R', $culture))
$diagnostic = Get-InternalProperty $candidate 'ExcursionDiagnostic'
$directionSign = [int](Get-InternalProperty $diagnostic 'DirectionSign')
if ($directionSign -ne 1 -and $directionSign -ne -1) { throw 'Invalid direction sign.' }
$hardNodes = @(Get-InternalProperty $diagnostic 'HardNodes')
[void]$builder.AppendLine($hardNodes.Count.ToString($culture))
foreach ($node in $hardNodes) {
    [void]$builder.AppendLine(([double](Get-InternalProperty $node 'X')).ToString('R', $culture))
    [void]$builder.AppendLine(([double](Get-InternalProperty $node 'Y')).ToString('R', $culture))
    [void]$builder.AppendLine(([double](Get-InternalProperty $node 'ReferenceArcLengthMeters')).ToString('R', $culture))
    [void]$builder.AppendLine(([double](Get-InternalProperty $node 'TravelUnwrappedHeadingRadians')).ToString('R', $culture))
    $geometricCurvature = [double](Get-InternalProperty $node 'GeometricCurvaturePerMeter')
    [void]$builder.AppendLine($geometricCurvature.ToString('R', $culture))
    [void]$builder.AppendLine(($directionSign * $geometricCurvature).ToString('R', $culture))
}
$regionPoints = @(Get-InternalProperty $candidate 'RegionPoints')
[void]$builder.AppendLine($regionPoints.Count.ToString($culture))
foreach ($point in $regionPoints) {
    [void]$builder.AppendLine(([double](Get-InternalProperty $point 'X')).ToString('R', $culture))
    [void]$builder.AppendLine(([double](Get-InternalProperty $point 'Y')).ToString('R', $culture))
    [void]$builder.AppendLine(([double](Get-InternalProperty $point 'ArcLength')).ToString('R', $culture))
    [void]$builder.AppendLine(([double](Get-InternalProperty $point 'Heading')).ToString('R', $culture))
    [void]$builder.AppendLine(([double](Get-InternalProperty $point 'UnwrappedHeading')).ToString('R', $culture))
    [void]$builder.AppendLine(([int](Get-InternalProperty $point 'Source')).ToString($culture))
}
$bytes = [Text.Encoding]::UTF8.GetBytes($builder.ToString())
$hash = [Security.Cryptography.SHA256]::Create().ComputeHash($bytes)
$digest = -join ($hash | ForEach-Object { $_.ToString('x2') })
return $digest
}
```

Iterate `LastProbeDiagnostic.CandidateLedger`, not only the builder's returned canonical list, call `Get-CandidateLedgerDigest $ledgerEntry $preparedSegment`, and compute this digest independently from every ledger entry's own retained candidate. The stable key is `single-turn/s<segmentIndex>/r<regionIndex>/w<windowIndex>/seed<seedIndex>` with every index zero-based. The atomic record also stores each hard node's recovered vehicle curvature (`directionSign * geometricCurvature`) and fails closed if either geometric or recovered value is non-finite.

Canonical records use `RecordKind=Canonical`, run the evaluator directly, and may enter state counts/diagnostic selection. Deduplicated records use `RecordKind=DeduplicatedAlias`, retain their independent stable key/content digest in both roots, store the exact equivalence grid/proof and `EvaluationSourceStableKey` of the canonical record, and reuse that canonical evaluator result only as annotated evidence. Aliases are never counted in `CandidateCount`, `StrictQualifiedCount`, `PhysicalCandidateCount`, `ExcursionQualifiedRecords`, performance evaluator-call counts, or minimum-excursion selection. Missing canonical references, alias cycles, cross-root alias digest/proof mismatch, or a canonical source that was not directly evaluated makes the probe invalid.

Every child atomic record therefore contains `StableKey`, `CandidateSha256`, `RecordKind`, `CanonicalCandidateIndex`, `EvaluationSourceStableKey`, `EquivalenceParameters`, `MaximumEquivalenceCoordinateDifferenceMeters`, `EvaluatorExecutedDirectly`, evaluator result/stop-gate fields, and the interval/candidate diagnostics. For canonical records, `EvaluationSourceStableKey=StableKey`, `EvaluatorExecutedDirectly=true`, and the equivalence difference is `0`. For aliases, `EvaluationSourceStableKey` resolves to a canonical record, `EvaluatorExecutedDirectly=false`, and evaluator result/stop-gate fields are copied from that source only after the reference audit succeeds.

- [ ] **Step 7: Run strict GREEN twice and commit the frozen core**

```powershell
dotnet build ClumsyPilot/ClumsyPilot.csproj --configuration Debug
powershell -ExecutionPolicy Bypass -File ClumsyPilot/tests/verify_path_smoothing_local_g2_curvature_probe_curve.ps1
powershell -ExecutionPolicy Bypass -File ClumsyPilot/tests/verify_path_smoothing_local_g2_curvature_probe_solver.ps1
powershell -ExecutionPolicy Bypass -File ClumsyPilot/tests/verify_path_smoothing_local_g2_excursion_probe.ps1 -Mode Strict
powershell -ExecutionPolicy Bypass -File ClumsyPilot/tests/verify_path_smoothing_local_g2_excursion_probe.ps1 -Mode Strict
git diff --check
git add -- ClumsyPilot/ParkrobTrajplanner/PathSmoothing/LocalG2/LocalG2ExcursionProbeDiagnostics.cs ClumsyPilot/ParkrobTrajplanner/PathSmoothing/LocalG2/LocalG2CandidateGeometry.cs ClumsyPilot/ParkrobTrajplanner/PathSmoothing/LocalG2/LocalG2CandidateBuilder.cs ClumsyPilot/ParkrobTrajplanner/PathSmoothing/LocalG2/LocalG2PreSmoothingPipeline.cs ClumsyPilot/tests/verify_path_smoothing_local_g2_excursion_probe.ps1
git commit -m 'feat: integrate bounded Local G2 excursion candidates'
$coreCommit = (git rev-parse HEAD).Trim()
powershell -ExecutionPolicy Bypass -File ClumsyPilot/tests/verify_path_smoothing_local_g2_excursion_probe.ps1 `
    -Mode Strict -EvidenceRoot $probeEvidenceRoot -RunLabel frozen-core
$frozenCoreJsonPath = Join-Path $probeEvidenceRoot 'frozen-core.json'
$frozenCoreManifestPath = Join-Path $probeEvidenceRoot 'frozen-core-probe-source-manifest.txt'
if (!(Test-Path -LiteralPath $frozenCoreJsonPath) -or !(Test-Path -LiteralPath $frozenCoreManifestPath)) {
    throw 'Frozen core evidence is incomplete.'
}
$frozenCoreJson = Get-Content -Raw -Encoding UTF8 -LiteralPath $frozenCoreJsonPath | ConvertFrom-Json
$manifestFileSha = (Get-FileHash -Algorithm SHA256 -LiteralPath $frozenCoreManifestPath).Hash.ToLowerInvariant()
if ($frozenCoreJson.Identity.CoreCommit -ne $coreCommit -or
    $frozenCoreJson.Identity.RootCommit -ne $coreCommit -or
    $frozenCoreJson.Identity.ProbeSourceManifestSha256 -ne $manifestFileSha) {
    throw 'Frozen core identity or probe-source manifest hash mismatch.'
}
```

Expected: all structural assertions pass. This task does not require a particular feasibility outcome; strict candidate results are evidence for Task 4. Record the frozen core commit, evaluator hash, canonical probe-source manifest path/hash, and frozen-core JSON path. Do not edit the core after review.

---

### Task 4: Build strict/measurement roots and execute the exhaustive feasibility gate

**Files:**

- Modify only in measurement clone: `ClumsyPilot/ParkrobTrajplanner/PathSmoothing/LocalG2/LocalG2CandidateEvaluator.cs`
- Use unchanged in both clones: `ClumsyPilot/tests/verify_path_smoothing_local_g2_excursion_probe.ps1`
- Generate without committing: `.superpowers/sdd/local-g2-minimum-curvature-excursion-feasibility-report.md`
- Record without committing: `.superpowers/sdd/local-g2-excursion-task-4-report.md`

**Interfaces:**

- Consumes the frozen Task 3 core commit.
- Produces two roots with a shared parent commit, one measurement-only evaluator commit, per-candidate `CandidateAuditRecord` JSON, 5+30 timing data, one exhaustive state, and a generated Markdown report.

The measurement evaluator adds only private trace storage plus this disposable reflection snapshot:

```csharp
public sealed class MeasurementTraceSnapshot
{
    public int CandidateIndex { get; }
    public double? RawMinimumCurvature { get; }
    public double? RawMaximumCurvature { get; }
    public double? CandidateMinimumCurvature { get; }
    public double? CandidateMaximumCurvature { get; }
    public double? Eminus { get; }
    public double? Eplus { get; }
    public bool? RawRangeExceeded { get; }
    public double? VehicleMaximumCurvatureLimitPerMeter { get; }
    public double? CandidateMaximumAbsoluteVehicleCurvaturePerMeter { get; }
    public double? MaximumDeviationMeters { get; }
    public double? MaximumDeviationLimitMeters { get; }
    public bool? CollisionAndMapBoundaryPassed { get; }
    public double? MinimumBodyClearanceMeters { get; }
    public double? MinimumClearanceReserveMeters { get; }
    public double? RawPeakCurvatureDerivativePerSquareMeter { get; }
    public double? CandidatePeakCurvatureDerivativePerSquareMeter { get; }
    public double? MinimumPeakGradientImprovementRatio { get; }
    public double? RawCurvatureVariationCost { get; }
    public double? CandidateCurvatureVariationCost { get; }
    public double? MaximumVariationCostRegressionRatio { get; }
    public double? AbsolutePathLengthChangeMeters { get; }
    public IReadOnlyList<string> ExecutedGates { get; }
    public IReadOnlyList<string> PassedGates { get; }
    public string StopGate { get; }
    public string Result { get; }
    public string Reason { get; }
}

private static class LocalG2ExcursionMeasurementTrace
{
    internal static void Begin(int candidateIndex);
    internal static void RecordGate(string gate, bool passed);
    internal static void RecordRawRange(int candidateIndex,
        double rawMinimumCurvature, double rawMaximumCurvature,
        double candidateMinimumCurvature, double candidateMaximumCurvature,
        bool rawRangeExceeded);
    internal static void RecordVehicleCurvature(double? vehicleLimit,
        double? candidateMaximumAbsoluteVehicleCurvature, bool passed);
    internal static void RecordDeviation(double? maximumDeviation,
        double maximumDeviationLimit, bool passed);
    internal static void RecordCollisionAndMapBoundary(bool passed,
        double? minimumBodyClearance, string reason);
    internal static void RecordClearance(double? minimumBodyClearance,
        double minimumClearanceReserve, bool passed);
    internal static void RecordPeakGradient(double rawPeak, double candidatePeak,
        double minimumImprovementRatio, bool passed);
    internal static void RecordVariationCost(double rawCost, double candidateCost,
        double maximumRegressionRatio, bool passed);
    internal static void RecordRawFullPathAnalysis(
        double? absolutePathLengthChangeMeters, bool passed);
    internal static void Complete(string stopGate,
        PathSmoothingRegionFailureReason result, bool accepted, string reason);
    internal static MeasurementTraceSnapshot Capture();
}

// Add this method to the existing public static TestHooks class.
public static MeasurementTraceSnapshot CaptureLastMeasurementTrace();
```

`Begin` replaces the previous candidate trace at the first line of `Evaluate`. Store the trace in a `[ThreadStatic]` field because the probe is sequential. Use these gate names in this order: `InputAnalysis`, `VehicleCurvature`, `RawCurvatureRange`, `Deviation`, `FullPathAnalysis`, `CollisionAndMapBoundary`, `Clearance`, `PeakGradientImprovement`, `VariationCost`, `RawFullPathAnalysis`, `Accepted`. Each metric-specific recorder atomically stores its operands and appends its named gate to `ExecutedGates`; it appends the gate to `PassedGates` only when `passed=true`. `RecordRawRange` similarly always appends `RawCurvatureRange` to executed and appends it to passed only when `rawRangeExceeded=false`; when exceeded, the measurement clone records a failed raw-range gate and continues. Nullable recorder operands are `null` only when the underlying computation was unavailable or non-finite; never serialize a non-finite double or substitute zero. On validator failure, `RecordCollisionAndMapBoundary(false,null,reason)` records the exact existing reason and leaves clearance `null`; on validator success it records the returned finite clearance, then `RecordClearance` records the reserve and pass/fail result. `RecordPeakGradient` and `RecordVariationCost` preserve both raw/candidate operands and configured ratios even on their rejection branches; the script derives improvement/regression percentages and leaves a ratio `null` when its denominator is zero. `RecordRawFullPathAnalysis(null,false)` is used when raw full-path analysis fails; on success it records the absolute path-length change. Use plain `RecordGate` only for `InputAnalysis`, `FullPathAnalysis`, and `Accepted`. Call the appropriate recorder immediately before every rejection and after each success, and call `Complete` before every return. `Capture` returns an immutable copy, and `CaptureLastMeasurementTrace` only forwards that copy. The probe script must call `Evaluate` for one candidate, immediately capture its trace before evaluating the next candidate, and store the copy in that candidate's atomic record. It then separately invokes the full pipeline once per run to record the probe `Complete` status; it must not infer per-candidate traces from the last full-pipeline evaluation. `Eminus=max(0,rawMinimum-candidateMinimum)` and `Eplus=max(0,candidateMaximum-rawMaximum)`.

`Complete` sets `Result` to the exact string `Accepted` when `accepted=true`; otherwise it sets `Result=result.ToString()`. This mapping is invariant across processes and is part of the determinism signature.
Nullable range/excursion fields remain `null` until `RecordRawRange`; JSON preserves `null`, and the Markdown renderer writes it as `not evaluated`, never `0`.

- [ ] **Step 1: Clone the frozen core twice and prove source identity**

```powershell
$strictRoot = Join-Path $probeRoot 'strict-root'
$measureRoot = Join-Path $probeRoot 'measurement-root'
$evidenceRoot = Join-Path $probeRoot 'evidence'
New-Item -ItemType Directory -Path $evidenceRoot -Force | Out-Null
git -c core.autocrlf=false clone $coreRoot $strictRoot
git -c core.autocrlf=false clone $coreRoot $measureRoot
git -C $strictRoot config core.autocrlf false
git -C $measureRoot config core.autocrlf false
$coreCommit = (git -C $coreRoot rev-parse HEAD).Trim()
if ((git -C $strictRoot rev-parse HEAD).Trim() -ne $coreCommit) { throw 'Strict clone drift.' }
if ((git -C $measureRoot rev-parse HEAD).Trim() -ne $coreCommit) { throw 'Measurement clone drift.' }
if (Test-Path (Join-Path $strictRoot 'ClumsyPilot\ParkrobTrajplanner\auto_avoidance')) { throw 'Strict root contains excluded source.' }
if (Test-Path (Join-Path $measureRoot 'ClumsyPilot\ParkrobTrajplanner\auto_avoidance')) { throw 'Measurement root contains excluded source.' }
if ((Get-Content -Raw -Encoding UTF8 -LiteralPath (Join-Path $strictRoot '.gitattributes')) -ne "* -text`n") { throw 'Strict root EOL policy drift.' }
if ((Get-Content -Raw -Encoding UTF8 -LiteralPath (Join-Path $measureRoot '.gitattributes')) -ne "* -text`n") { throw 'Measurement root EOL policy drift.' }
if ((git -C $strictRoot config --get core.autocrlf).Trim() -ne 'false') { throw 'Strict root autocrlf drift.' }
if ((git -C $measureRoot config --get core.autocrlf).Trim() -ne 'false') { throw 'Measurement root autocrlf drift.' }
```

Hash shared snapshot, core, strict, and measurement evaluators before the measurement patch. All four hashes must match.

Load `$probeEvidenceRoot/frozen-core.json` and `$probeEvidenceRoot/frozen-core-probe-source-manifest.txt` from Task 3. Require the JSON core/root commit to equal `$coreCommit`, require the canonical file's raw-byte SHA-256 to equal `Identity.ProbeSourceManifestSha256`, and retain that value as `$expectedProbeSourceManifestSha256`. Later strict and measurement children must each recompute exactly this value from their own clean filesystem root; because the evaluator is excluded, the measurement evaluator commit does not change it.

- [ ] **Step 2: Write the measurement RED assertion before changing the evaluator**

Run the unchanged probe in measurement mode:

```powershell
dotnet build ClumsyPilot/ClumsyPilot.csproj --configuration Debug
powershell -ExecutionPolicy Bypass -File ClumsyPilot/tests/verify_path_smoothing_local_g2_excursion_probe.ps1 `
    -Mode Measurement -CoreCommit $coreCommit
```

Expected: FAIL with exactly `Measurement evaluator hook is unavailable`. It must not silently treat the strict early return as measurement success.

- [ ] **Step 3: Add the only permitted measurement behavior**

In measurement-root evaluator, preserve the existing vehicle gate, compute `rawRangeExceeded`, record it, and reject only when not in the measurement-only clone. Because this source exists only in that clone, bind it as:

```csharp
private const bool MeasureRawCurvatureExcursion = true;
```

Replace only the existing raw-range return with:

```csharp
bool rawRangeExceeded = ExceedsRawCurvatureRange(
    candidateAnalysis.Path, rawMinimumCurvature, rawMaximumCurvature);
GetCurvatureRange(candidateAnalysis.Path,
    out double candidateMinimumCurvature, out double candidateMaximumCurvature);
LocalG2ExcursionMeasurementTrace.RecordRawRange(
    candidateIndex, rawMinimumCurvature, rawMaximumCurvature,
    candidateMinimumCurvature, candidateMaximumCurvature, rawRangeExceeded);
if (rawRangeExceeded && !MeasureRawCurvatureExcursion)
    return Rejected(candidateIndex, PathSmoothingRegionFailureReason.CurvatureOvershoot,
        "局部 G2 候选超出原始区域曲率范围。");
```

Add the exact trace calls above immediately after each metric becomes available, after each later gate is actually passed, and at each later failure return. In particular, do not read quality metrics from `Rejected(...)`, because that helper replaces unavailable values with zeros: copy the live evaluator locals into the metric recorders before returning. Trace state is reset for every candidate and is only exposed through the existing nested reflection `TestHooks`; it must not change return values, ordering, analysis inputs, validator calls, or selection.

- [ ] **Step 4: Run measurement tests and commit only the evaluator in the measurement clone**

```powershell
dotnet build ClumsyPilot/ClumsyPilot.csproj --configuration Debug
powershell -ExecutionPolicy Bypass -File ClumsyPilot/tests/verify_path_smoothing_local_g2_excursion_probe.ps1 `
    -Mode Measurement -CoreCommit $coreCommit
git diff --check
git add -- ClumsyPilot/ParkrobTrajplanner/PathSmoothing/LocalG2/LocalG2CandidateEvaluator.cs
git commit -m 'test: measure Local G2 raw curvature excursion'
```

Expected: the script reaches every later gate for candidates that pass earlier gates and emits execution flags. The commit must contain exactly one file.

The measurement-mode script must also exercise the existing deterministic evaluator scenarios by calling `LocalG2CandidateEvaluator.TestHooks.Execute(scenario)` and immediately calling `CaptureLastMeasurementTrace()` after each call. Assert: `TooFar` records finite deviation and its `0.10 m` limit before rejecting `Deviation`; `LowClearance` records finite checked clearance and the `0.02 m` reserve before rejecting `Clearance`; `NoOp` records raw/candidate peak-gradient operands plus the `0.20` ratio before rejecting `PeakGradientImprovement`; `Oscillating` records raw/candidate variation costs plus the `0.02` ratio before rejecting `VariationCost`; `Improved` records every metric field, all named gates, and a finite absolute path-length change before `Accepted`; and `Overshoot` records `RawCurvatureRange` as executed-but-not-passed while proving at least the next `Deviation` gate executed. For each early rejection, all later metric fields remain `null` and render `not evaluated`, not zero.

- [ ] **Step 5: Produce atomic strict/measurement candidate records**

Run strict and measurement roots sequentially and compare by stable key. For every key require identical candidate SHA-256; pair strict result, measurement result, stop gate, execution flags, raw/candidate ranges, `Eminus/Eplus`, improvement, variation, deviation, clearance, and elapsed metrics in one record.

The strict side qualifies an excursion pair only when its result is exactly `CurvatureOvershoot`. `CurvatureExceeded`, `CandidateGenerationFailed`, any earlier result, digest mismatch, or missing gate flag cannot support `FEASIBLE_WITH_EXCURSION`.

Derive `StrictStopGate` without modifying the strict evaluator: `Accepted -> Accepted`, `CurvatureExceeded -> VehicleCurvature`, `CurvatureOvershoot -> RawCurvatureRange`, `DeviationExceeded -> Deviation`, `Collision -> CollisionAndMapBoundary`, `InsufficientClearance -> Clearance`, `InsufficientImprovement -> PeakGradientImprovement`, and `VariationCostRegression -> VariationCost`. For `CandidateGenerationFailed`, use the paired measurement trace's `InputAnalysis`, `VehicleCurvature`, `FullPathAnalysis`, or `RawFullPathAnalysis` stop gate only when the trace either stopped before raw-range evaluation or recorded `RawRangeExceeded=false`; if it recorded an exceeded raw range, the unchanged strict result should have been `CurvatureOvershoot`, so the pair is invalid. No reason-string guessing is permitted.

Every single-run `Strict` or non-batch `Measurement` JSON uses schema version 1 and contains these exact top-level fields:

```text
SchemaVersion
Mode
RunLabel
Environment { MachineName, CpuName, LogicalProcessorCount, TotalMemoryBytes, OsDescription,
              PowerShellVersion, DotNetSdkVersion, BuildConfiguration, TargetFramework }
Identity { SharedBaselineCommit, CoreCommit, RootCommit, FixtureSha256,
           EvaluatorSha256, ProbeSourceManifestSha256 }
ProbeRunDiagnostic
CandidateRecords
PipelineStatus
PipelineSelectedStableKey
Timings { IntervalSolverElapsedMilliseconds[], SolverPhaseElapsedMilliseconds,
          CandidateEvaluationElapsedMilliseconds[], CandidateEvaluationPhaseElapsedMilliseconds,
          RegionElapsedMilliseconds, FixtureElapsedMilliseconds }
```

A `Measurement -Batch` JSON has exact top-level fields `SchemaVersion`, `Mode`, `RunLabel`, `Environment`, `Identity`, `WarmupRuns`, and `MeasuredRuns`. `WarmupRuns` has exactly five single-run payloads and `MeasuredRuns` exactly 30, both in execution order; nested payloads omit duplicate `Environment`/`Identity` but otherwise use the same `ProbeRunDiagnostic`, `CandidateRecords`, `PipelineStatus`, `PipelineSelectedStableKey`, and `Timings` fields.

Before timing, compare the batch environment with the recorded baseline in the shared scratch file `.superpowers/sdd/local-g2-split-scale-feasibility-report.md`: machine `DESKTOP-428CCNK`, CPU `11th Gen Intel Core i5-1135G7 @ 2.40 GHz`, 8 logical processors, 16,863,318,016 RAM bytes, Windows 10 Pro `10.0.19045`, PowerShell `5.1.19041.7548`, .NET SDK `10.0.302`, and `Debug/netstandard2.0`. A mismatch sets `InvalidEvidence=true`; record both observed and expected metadata rather than silently changing the performance baseline.

`IntervalSolverElapsedMilliseconds` records every successful or failed invocation in order. `SolverPhaseElapsedMilliseconds` covers all closure calls in the region. Candidate evaluation timings record every evaluator call and their sum. `RegionElapsedMilliseconds` covers candidate construction, all candidate evaluations, selection, and region result. `FixtureElapsedMilliseconds` covers the full `SingleTurn` probe run after assembly/fixture setup. All timing fields use `Stopwatch.GetTimestamp`, are finite and nonnegative, and exclude process launch/build. FinalGate additionally reports strict-child, measurement-child, pairing, and two-root total diagnostic wall time separately; these diagnostic totals never enter the four performance gates.

For every paired atomic record compute:

```text
denominator = max(rawMaximum - rawMinimum,
                  AbsoluteCurvatureJumpFloorPerMeter)
Rminus = Eminus / denominator
Rplus  = Eplus / denominator
analyticVehicleCurvatureUtilization = max(abs(c0),abs(c1),abs(c2),abs(c3)) / Kvehicle
discreteVehicleCurvatureUtilization = candidateMaximumAbsoluteVehicleCurvature / Kvehicle
```

The record contains `HardPhysicalQualified`, which is true only when every interval passes hard closure/seam/nondegeneracy checks, all four continuous controls pass the physical curvature range, and the unchanged evaluator actually passes its discrete vehicle-curvature gate. Missing or non-finite operands make the record invalid, never zero.

`New-PairedObservation -StrictAudit <json> -MeasurementRun <json>` performs this deterministic join: validate schema/identity/source hashes, including requiring both `ProbeSourceManifestSha256` values to equal `$expectedProbeSourceManifestSha256` and requiring each written canonical manifest file to be raw-byte identical to the frozen-core manifest; require the same ordered stable-key set; for each key require equal candidate digest, `RecordKind`, canonical index, evaluation-source key, fixed equivalence grid, and equivalence proof before copying both child records into one `CandidateAuditRecord`; compute excursion/utilization fields for canonical records only; verify every required executed-gate flag; derive `HardPhysicalQualified`; retain both roots' pipeline statuses and selected keys; sort atomic records by stable key; then populate `InvalidEvidence`, `StrictQualifiedCount`, `PhysicalCandidateCount`, and `ExcursionQualifiedRecords` from canonical records only. It returns a new object and never mutates either child JSON. It also proves each alias points to one directly evaluated canonical record, rejects self-reference/cycles, and proves aliases never enter evaluator-call/performance/eligibility/selection counts. A missing/duplicate key, digest or alias-proof mismatch, probe-manifest mismatch, non-finite required number, missing flag, or `Complete` pipeline selection that names no canonical atomic record sets `InvalidEvidence=true` and records the exact reason list. Strict and measurement selected keys may differ because measurement can admit additional raw-range-excursion candidates; both keys remain determinism evidence but do not filter otherwise accepted per-candidate canonical records.

- [ ] **Step 6: Execute the fixed 5+30 performance and determinism protocol**

```powershell
function Invoke-ProbeChild {
    param(
        [Parameter(Mandatory=$true)][string]$Root,
        [Parameter(Mandatory=$true)][ValidateSet('Strict','Measurement')][string]$ChildMode,
        [Parameter(Mandatory=$true)][string]$ChildRunLabel,
        [Parameter(Mandatory=$true)][string]$CoreCommit,
        [switch]$ChildBatch
    )
    $scriptPath = Join-Path $Root 'ClumsyPilot\tests\verify_path_smoothing_local_g2_excursion_probe.ps1'
    $jsonPath = Join-Path $EvidenceRoot ($ChildRunLabel + '.json')
    $childArgs = @('-ExecutionPolicy','Bypass','-File',$scriptPath,'-Mode',$ChildMode,
        '-EvidenceRoot',$EvidenceRoot,'-RunLabel',$ChildRunLabel,'-CoreCommit',$CoreCommit)
    if ($ChildBatch) { $childArgs += '-Batch' }
    & powershell @childArgs
    if ($LASTEXITCODE -ne 0) { throw "$ChildMode child failed for $ChildRunLabel." }
    if (!(Test-Path -LiteralPath $jsonPath)) { throw "Missing child JSON: $jsonPath" }
    return Get-Content -Raw -Encoding UTF8 -LiteralPath $jsonPath | ConvertFrom-Json
}

$strictAudit = Invoke-ProbeChild -Root $StrictRoot -ChildMode Strict `
    -ChildRunLabel 'strict-audit' -CoreCommit $coreCommit
$measurementBatch = Invoke-ProbeChild -Root $MeasurementRoot -ChildMode Measurement `
    -ChildRunLabel 'measurement-batch' -CoreCommit $coreCommit -ChildBatch
if (@($measurementBatch.WarmupRuns).Count -ne 5) { throw 'Measurement batch must contain 5 warm-ups.' }
if (@($measurementBatch.MeasuredRuns).Count -ne 30) { throw 'Measurement batch must contain 30 measured runs.' }
$warmups = @($measurementBatch.WarmupRuns | ForEach-Object {
    New-PairedObservation -StrictAudit $strictAudit -MeasurementRun $_
})
$runs = @($measurementBatch.MeasuredRuns | ForEach-Object {
    New-PairedObservation -StrictAudit $strictAudit -MeasurementRun $_
})
$measurementAudit = $runs[0]
```

The measurement batch child loads its assembly and fixture exactly once, then runs 5+30 serial iterations with the same immutable `SingleTurn` request and `CancellationToken.None` in that process; each iteration constructs fresh builder/evaluator/pipeline instances so only JIT/runtime warming persists. The strict child is a separate process so same-name assemblies never share a loader. `New-PairedObservation` creates atomic records, marks `InvalidEvidence` on identity/key/digest mismatch, and determines `HardPhysicalQualified`. When evidence is valid it runs the Step 7 underlying-state function with the fixed strict audit plus that measurement run; when invalid it sets paired state to `PROBE_INVALID` and leaves selection `not evaluated`. It returns `PairedState`, `UnderlyingStateWithoutPerformanceGate`, and `SelectedStableKey` beside the measurement timings. Thus a Measurement child never invents a final state by itself.

Build each measured determinism signature by sorting paired atomic records by stable key and hashing invariant JSON containing only key, candidate SHA-256, `RecordKind`, canonical index, evaluation-source key, equivalence grid/proof, direct-evaluation flag, strict/measurement results, stop gates, execution flags, interval diagnostics, `PairedState`, `UnderlyingStateWithoutPerformanceGate`, and `SelectedStableKey`—never elapsed values or file paths. Assert all 30 signatures match and retain all five warm-up records plus all 30 measured records in evidence. Sort the 30 `SolverPhaseElapsedMilliseconds` values and the 30 `RegionElapsedMilliseconds` values separately; for each, use the mean of elements 14 and 15 (zero-based) as the median and `Measure-Object -Maximum` as worst. Apply all four gates: solver median/worst `<=10/25 ms`, region median/worst `<=25/50 ms`. Also report every per-invocation, evaluator-phase, fixture, and two-root diagnostic timing without applying additional pass/fail thresholds.

- [ ] **Step 7: Apply the frozen, exhaustively tested ordered state machine**

Create one observation object after pairing. `InvalidEvidence` is true for any design section 11.1 condition: unknown commits/root/fixture, source-diff violation, strict evaluator mismatch, key/digest mismatch, missing or non-finite required data, missing gate flags/diffs/repeat evidence, infrastructure failure, cancellation, or unequal 30-run signatures. `PerformanceExceeded` is true when any of the four fixed median/worst limits fails. `StrictQualifiedCount` counts atomic records whose hard/physical gates pass, whose strict evaluator and measurement evaluator both accept all unchanged gates, and whose strict and measurement full pipelines both report probe `Complete`; per-candidate direct acceptance is the qualification, while each pipeline's selected key is retained only as audit/determinism evidence. `PhysicalCandidateCount` counts `HardPhysicalQualified` records. `ExcursionQualifiedRecords` contains all hard/physical records with strict result exactly `CurvatureOvershoot`, measurement result `Accepted`, every later gate executed and passed, measurement pipeline status `Complete`, valid source identity, and `Eminus>0` or `Eplus>0`; do not discard an accepted candidate merely because the pipeline selected another accepted candidate.

The unchanged Task 3 script already contains and table-tests these exact functions; execute them against the paired evidence:

```powershell
function Resolve-UnderlyingStateWithoutPerformanceGate {
    param($Observation)
    if ($Observation.StrictQualifiedCount -gt 0) { return 'STRICT_GREEN' }
    if ($Observation.PhysicalCandidateCount -eq 0) {
        return 'NO_PHYSICALLY_ADMISSIBLE_CLOSED_CANDIDATE_FOUND_WITHIN_BUDGET'
    }
    if (@($Observation.ExcursionQualifiedRecords).Count -eq 0) { return 'QUALITY_RED' }
    return 'FEASIBLE_WITH_EXCURSION'
}

function Resolve-FinalState {
    param($Observation)
    if ($Observation.InvalidEvidence) { return 'PROBE_INVALID' }
    $underlying = Resolve-UnderlyingStateWithoutPerformanceGate $Observation
    if ($Observation.PerformanceExceeded) { return 'BUDGET_RED' }
    return $underlying
}
```

Before using real observations, table-test: each of the six states alone; `InvalidEvidence+PerformanceExceeded -> PROBE_INVALID`; performance failure plus a strict candidate `-> BUDGET_RED`; strict-qualified plus excursion-qualified `-> STRICT_GREEN`; physical zero `-> NO_PHYSICALLY_ADMISSIBLE_CLOSED_CANDIDATE_FOUND_WITHIN_BUDGET`; physical positive plus zero excursion `-> QUALITY_RED`; digest mismatch `-> PROBE_INVALID`. Enumerate every logically legal combination of the four booleans/count predicates, assert the return belongs to the six-state set, and assert exactly one string is returned. Any failure exits nonzero as a broken test contract.

Always record `UnderlyingStateWithoutPerformanceGate`; it is especially mandatory when final state is `BUDGET_RED`. For `FEASIBLE_WITH_EXCURSION`, select only `ExcursionQualifiedRecords` by `(max(Eminus,Eplus), Eminus+Eplus, maximumDeviation, candidateIndex)`. For `STRICT_GREEN`, report that no raw-range relaxation is needed. For the budget/no-candidate/quality states, retain every categorized failure count. Do not turn `not evaluated` into zero.

The generated report must contain every field required by spec sections 12–15 plus full paths to:

```text
strict-vs-shared evaluator hash proof
measurement evaluator commit and diff
strict/measurement candidate audit JSON
30-run timing JSON
state decision trace
```

- [ ] **Step 8: Independently verify root diffs and rerun the final gate**

```powershell
git -C $strictRoot diff --quiet $coreCommit HEAD
if ($LASTEXITCODE -ne 0) { throw 'Strict root has uncommitted drift.' }
$strictStatus = @(git -C $strictRoot status --porcelain)
if ($strictStatus.Count -ne 0) { throw ('Strict root working tree drift: ' + ($strictStatus -join ',')) }
$measureFiles = @(git -C $measureRoot diff --name-only $coreCommit HEAD)
if ($measureFiles.Count -ne 1 -or $measureFiles[0] -ne 'ClumsyPilot/ParkrobTrajplanner/PathSmoothing/LocalG2/LocalG2CandidateEvaluator.cs') {
    throw ('Unexpected measurement changes: ' + ($measureFiles -join ','))
}
$measureStatus = @(git -C $measureRoot status --porcelain)
if ($measureStatus.Count -ne 0) { throw ('Measurement root working tree drift: ' + ($measureStatus -join ',')) }
$finalGateScript = Join-Path $measureRoot 'ClumsyPilot\tests\verify_path_smoothing_local_g2_excursion_probe.ps1'
powershell -ExecutionPolicy Bypass -File $finalGateScript -Mode FinalGate `
    -StrictRoot $strictRoot -MeasurementRoot $measureRoot -EvidenceRoot $evidenceRoot
```

The controller reruns the same final-gate command independently after the task reviewer approves. Task 4 is complete when evidence is valid and exactly one state is produced; it does not require the state to be positive.

---

### Task 5: Publish only the verified feasibility report on the shared branch

**Files:**

- Create on shared branch: `docs/superpowers/reports/2026-08-01-local-g2-minimum-curvature-excursion-feasibility-report.md`
- Record without committing: `.superpowers/sdd/local-g2-excursion-task-5-report.md`

**Interfaces:**

- Consumes the Task 4 generated report and controller rerun evidence.
- Produces one immutable shared report; no probe `.cs`, `.ps1`, configuration, fixture, or evaluator file is copied back.

- [ ] **Step 1: Verify the shared branch has no staged files and no probe source drift**

```powershell
git diff --cached --quiet
if ($LASTEXITCODE -ne 0) { throw 'Shared index is not empty.' }
git diff --no-ext-diff a4e116a -- ClumsyPilot/ParkrobTrajplanner/PathSmoothing/LocalG2/LocalG2CandidateBuilder.cs ClumsyPilot/ParkrobTrajplanner/PathSmoothing/LocalG2/LocalG2CandidateEvaluator.cs
```

The diff is recorded as context and is not compared with `a4e116a`, because the shared tree already contained user changes at Task 1. The authoritative preservation check is the Task 1 machine-readable manifest: regenerate it from the same explicit `ClumsyPilot.csproj`, `ParkrobTrajplanner/PathSmoothing/**/*.cs`, and fixture paths, then require identical path count, sorted paths, per-file SHA-256 values, and manifest SHA-256. This includes the currently untracked `LocalG2PreSmoothingPipeline.cs`.

- [ ] **Step 2: Add the generated report with `apply_patch`**

Use the exact Task 4 generated Markdown as the new report. It must name the final state, core/strict/measurement commits and paths, all source hashes/diffs, candidate atomic records, timing samples, decision trace, and the matching authorization boundary:

```text
PROBE_INVALID -> repair and rerun; no geometry conclusion
BUDGET_RED -> performance-only optimization and rerun
STRICT_GREEN -> next design keeps the strict raw-range gate
NO_PHYSICALLY_ADMISSIBLE_CLOSED_CANDIDATE_FOUND_WITHIN_BUDGET or QUALITY_RED -> no evaluator change
FEASIBLE_WITH_EXCURSION -> report minimum found excursion; request a new production-envelope decision
```

- [ ] **Step 3: Verify and commit only the report**

```powershell
git add -- docs/superpowers/reports/2026-08-01-local-g2-minimum-curvature-excursion-feasibility-report.md
git diff --cached --check
$staged = @(git diff --cached --name-only)
if ($staged.Count -ne 1 -or $staged[0] -ne 'docs/superpowers/reports/2026-08-01-local-g2-minimum-curvature-excursion-feasibility-report.md') {
    throw ('Unexpected staged files: ' + ($staged -join ','))
}
git commit -m 'docs: report Local G2 curvature excursion feasibility'
```

- [ ] **Step 4: Controller final verification**

Freshly run:

```powershell
git show --check --stat HEAD
git diff --cached --quiet
git status --short -- docs/superpowers/reports/2026-08-01-local-g2-minimum-curvature-excursion-feasibility-report.md
```

Then compare the committed report SHA-256 with the Task 4 generated report SHA-256. Only after equality and the independent final-gate rerun may the controller state the probe result to the user.

---

## Stop Rules

- Stop immediately with `PROBE_INVALID` on source identity drift, unauthorized evaluator differences, digest mismatch, missing evidence, nondeterminism, cancellation, or test infrastructure failure.
- `BUDGET_RED`, `NO_PHYSICALLY_ADMISSIBLE_CLOSED_CANDIDATE_FOUND_WITHIN_BUDGET`, and `QUALITY_RED` are valid terminal evidence outcomes, not permission to expand search or relax gates.
- `STRICT_GREEN` authorizes only a new production design that keeps the strict raw-range evaluator unchanged.
- `FEASIBLE_WITH_EXCURSION` authorizes only a user decision and a new production-envelope design; it does not authorize copying measurement behavior or probe source into production.
- Do not execute any old split-scale plan task after this plan begins.

## Subagent-Driven Review Gates

- Record the disposable core baseline commit before Task 1 implementation and use it as the review-package base for Task 1.
- For Tasks 2 and 3, use the previous approved disposable-core commit as the review-package base; never use `HEAD~1` when a task creates multiple commits.
- Task 4 review receives the core commit, measurement commit, strict/measurement evaluator diff, candidate-audit JSON, timing JSON, state trace, and generated report.
- Resolve every task reviewer Critical/Important before continuing. A `⚠️ Cannot verify` item must be resolved by the controller before marking the task complete.
- After Task 5, perform a broad final review of the design commit, plan commit, final report, and all disposable evidence paths. The final reviewer must confirm that no probe source reached the shared branch.
