times, double nowSec)
+ {
+ int i = 0;
+ while (i < times.Count && nowSec - times[i] > WindowSec) i++;
+ if (i > 0) times.RemoveRange(0, i);
+ }
+
+ /// 0.0-1.0 combined score, or null if was never called for this car.
+ public float? GetScore(int carIdx, double sessionTimeSec)
+ {
+ if (carIdx < 0 || carIdx >= ReplayIncidentIndexBuild.CarSlotCount)
+ return null;
+ if (!_hasSample[carIdx])
+ return null;
+
+ float reversalScore = Math.Min(1f, _steerReversalTimes[carIdx].Count / (float)ReversalsForFullSignal);
+
+ float neutralScore = 0f;
+ if (_neutralSinceSec[carIdx] >= 0)
+ {
+ double dwellSec = sessionTimeSec - _neutralSinceSec[carIdx];
+ neutralScore = (float)Math.Min(1.0, Math.Max(0.0, dwellSec) / NeutralDwellForFullSignalSec);
+ }
+
+ float flickerScore = Math.Min(1f, _flickerTimes[carIdx].Count / (float)FlickersForFullSignal);
+
+ return 0.5f * reversalScore + 0.25f * neutralScore + 0.25f * flickerScore;
+ }
+ }
+}
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `dotnet test --filter "FullyQualifiedName~IncidentSpinHeuristicTests"`
+Expected: PASS (8 tests). If `GetScore_RapidSteerReversals_RaisesScore` doesn't clear 0.5, check the
+reversal count produced by the 6-sample alternating pattern (should be 4 reversals -> reversalScore=1.0
+-> total >= 0.5) before adjusting the test's pattern length rather than loosening the assertion.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/SimSteward.Plugin/IncidentSpinHeuristic.cs src/SimSteward.Plugin.Tests/IncidentSpinHeuristicTests.cs
+git commit -m "feat(incidents): add IncidentSpinHeuristic rolling loss-of-control score"
+```
+
+---
+
+### Task 4: `IncidentCauseMapping.ResolveInferred` + `CauseWall`
+
+**Files:**
+- Modify: `src/SimSteward.Plugin/IncidentCauseMapping.cs`
+- Test: `src/SimSteward.Plugin.Tests/IncidentCauseMappingTests.cs`
+
+**Interfaces:**
+- Consumes: nothing new from earlier tasks (takes primitives only — decoupled from `IncidentSample`
+ so it stays a pure, minimal-surface function).
+- Produces: `IncidentCauseMapping.CauseWall = "wall"`, `IncidentCauseMapping.LossOfControlScoreThreshold = 0.6f`,
+ `IncidentCauseMapping.ResolveInferred(string detectionSource, int? incidentPoints, float? lossOfControlScore = null, int? suspectedContactCarIdx = null) : string` —
+ used by Task 5. Existing `Resolve(string, int?)` is untouched.
+
+- [ ] **Step 1: Write the failing tests**
+
+Add to `src/SimSteward.Plugin.Tests/IncidentCauseMappingTests.cs` (inside the existing class):
+
+```csharp
+ // ── ResolveInferred: additive tiers on top of the untouched Resolve() ──────
+ [Fact]
+ public void ResolveInferred_PointsResolved_DelegatesToResolve_IgnoringInferredSignals()
+ {
+ // A resolved points value must win even when a spin score / contact partner also happen to be present.
+ var cause = IncidentCauseMapping.ResolveInferred(
+ ReplayIncidentIndexDetection.SourceTrackSurface, incidentPoints: 4,
+ lossOfControlScore: 0.9f, suspectedContactCarIdx: 7);
+
+ Assert.Equal("contact", cause);
+ }
+
+ [Fact]
+ public void ResolveInferred_NoPoints_HighLossOfControlScore_ReturnsSpin()
+ {
+ var cause = IncidentCauseMapping.ResolveInferred(
+ ReplayIncidentIndexDetection.SourceTrackSurface, incidentPoints: null,
+ lossOfControlScore: 0.85f);
+
+ Assert.Equal("spin", cause);
+ }
+
+ [Fact]
+ public void ResolveInferred_NoPoints_LowLossOfControlScore_DoesNotOverrideSource()
+ {
+ var cause = IncidentCauseMapping.ResolveInferred(
+ ReplayIncidentIndexDetection.SourceTrackSurface, incidentPoints: null,
+ lossOfControlScore: 0.1f);
+
+ Assert.Equal("off-track", cause);
+ }
+
+ [Theory]
+ [InlineData(ReplayIncidentIndexDetection.SourceFastRepair)]
+ [InlineData(ReplayIncidentIndexDetection.SourceRepairFlag)]
+ public void ResolveInferred_DamageEvent_NoNearbyCarFound_ReturnsWall(string source)
+ {
+ var cause = IncidentCauseMapping.ResolveInferred(source, incidentPoints: null, suspectedContactCarIdx: null);
+ Assert.Equal("wall", cause);
+ }
+
+ [Theory]
+ [InlineData(ReplayIncidentIndexDetection.SourceFastRepair)]
+ [InlineData(ReplayIncidentIndexDetection.SourceRepairFlag)]
+ public void ResolveInferred_DamageEvent_NearbyCarFound_ReturnsContact(string source)
+ {
+ var cause = IncidentCauseMapping.ResolveInferred(source, incidentPoints: null, suspectedContactCarIdx: 12);
+ Assert.Equal("contact", cause);
+ }
+
+ [Fact]
+ public void ResolveInferred_NoInferredSignalsAtAll_MatchesPlainResolve()
+ {
+ // Backward-compat guarantee: every existing 2-arg Resolve() call site behaves identically
+ // when routed through ResolveInferred with the new params left at their defaults.
+ foreach (var source in new[] {
+ ReplayIncidentIndexDetection.SourceTrackSurface, ReplayIncidentIndexDetection.SourceFurledFlag,
+ ReplayIncidentIndexDetection.SourceBlackFlag, ReplayIncidentIndexDetection.SourceDisqualify,
+ ReplayIncidentIndexDetection.SourcePlayerIncidentCount, "unrecognized" })
+ {
+ Assert.Equal(IncidentCauseMapping.Resolve(source, null), IncidentCauseMapping.ResolveInferred(source, null));
+ }
+ }
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `dotnet test --filter "FullyQualifiedName~IncidentCauseMappingTests"`
+Expected: FAIL — compile error, `ResolveInferred`/`CauseWall` don't exist yet.
+
+- [ ] **Step 3: Implement**
+
+In `src/SimSteward.Plugin/IncidentCauseMapping.cs`, add the new constant and method (after the
+existing `CauseUnknown` constant and after the existing `Resolve` method respectively):
+
+```csharp
+ public const string CauseUnknown = "unknown";
+ /// Inferred by elimination — a damage event fired but IncidentProximityResolver found no nearby car. No SDK field represents "wall" at any layer; see docs/IRACING-CROSSWALK.md.
+ public const string CauseWall = "wall";
+
+ /// Tuning value — revisit after live-session scorecard validation (docs/INCIDENT-SCORECARD-TEST-PLAN.md).
+ public const float LossOfControlScoreThreshold = 0.6f;
+```
+
+```csharp
+ ///
+ /// Extends with two additive, heuristic-only tiers — used when
+ /// / have
+ /// something to say. A resolved value always wins and
+ /// delegates straight to , unchanged. When no inferred signal is
+ /// present, behaves identically to (backward compatible).
+ ///
+ public static string ResolveInferred(
+ string detectionSource,
+ int? incidentPoints,
+ float? lossOfControlScore = null,
+ int? suspectedContactCarIdx = null)
+ {
+ if (incidentPoints.HasValue)
+ return Resolve(detectionSource, incidentPoints);
+
+ if (lossOfControlScore.HasValue && lossOfControlScore.Value >= LossOfControlScoreThreshold)
+ return CauseSpin;
+
+ switch ((detectionSource ?? "").Trim().ToLowerInvariant())
+ {
+ case ReplayIncidentIndexDetection.SourceFastRepair:
+ case ReplayIncidentIndexDetection.SourceRepairFlag:
+ return suspectedContactCarIdx.HasValue ? CauseContact : CauseWall;
+ default:
+ return Resolve(detectionSource, null);
+ }
+ }
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `dotnet test --filter "FullyQualifiedName~IncidentCauseMappingTests"`
+Expected: PASS — all prior tests plus the new ones (16 total).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/SimSteward.Plugin/IncidentCauseMapping.cs src/SimSteward.Plugin.Tests/IncidentCauseMappingTests.cs
+git commit -m "feat(incidents): add ResolveInferred spin/wall/contact cause tiers"
+```
+
+---
+
+### Task 5: Wire spin/wall/contact-partner into `IncidentSeverityCorrelator`
+
+**Files:**
+- Modify: `src/SimSteward.Plugin/IncidentSeverityCorrelator.cs`
+- Test: `src/SimSteward.Plugin.Tests/IncidentSeverityCorrelatorTests.cs`
+
+**Interfaces:**
+- Consumes: `IncidentSample.LossOfControlScore`/`.SuspectedContactCarIdx`/`.ContactDistanceMeters`
+ (Task 1), `IncidentCauseMapping.ResolveInferred`/`.CauseWall` (Task 4).
+- Produces: `CorrelationResult.Merged` now carries the inferred fields forward through the
+ quick-succession merge window — used by Task 6 to populate the board entry.
+
+- [ ] **Step 1: Write the failing tests**
+
+Add to `src/SimSteward.Plugin.Tests/IncidentSeverityCorrelatorTests.cs` (inside the existing class;
+reuses the file's existing `Sample(...)` private helper — extend it with the 3 new optional params
+first):
+
+```csharp
+ private static IncidentSample Sample(
+ int carIdx, string source, int? points, double sessionTimeSec = 0, bool aggregate = false,
+ float? lossOfControlScore = null, int? suspectedContactCarIdx = null, float? contactDistanceMeters = null)
+ {
+ return new IncidentSample(
+ carIdx,
+ ReplayIncidentIndexDetection.ToSessionTimeMs(sessionTimeSec),
+ source,
+ points,
+ replayFrame: 0,
+ isAggregateDelta: aggregate,
+ lossOfControlScore: lossOfControlScore,
+ suspectedContactCarIdx: suspectedContactCarIdx,
+ contactDistanceMeters: contactDistanceMeters);
+ }
+
+ [Fact]
+ public void Correlate_HighLossOfControlScore_NoPoints_ReportsSpinCause()
+ {
+ var c = new IncidentSeverityCorrelator();
+ var s = Sample(5, ReplayIncidentIndexDetection.SourceTrackSurface, null, 10.0, lossOfControlScore: 0.9f);
+
+ var r = c.Correlate(s, 10.0, isDirtSurface: false);
+
+ Assert.True(r.IsNewIncident);
+ Assert.Equal("spin", r.Cause);
+ Assert.Equal(0.9f, r.Merged.LossOfControlScore);
+ }
+
+ [Fact]
+ public void Correlate_FastRepair_NoNearbyCarNoPoints_ReportsWallCause()
+ {
+ var c = new IncidentSeverityCorrelator();
+ var s = Sample(5, ReplayIncidentIndexDetection.SourceFastRepair, null, 10.0);
+
+ var r = c.Correlate(s, 10.0, isDirtSurface: false);
+
+ Assert.Equal("wall", r.Cause);
+ }
+
+ [Fact]
+ public void Correlate_FastRepair_NearbyCarNoPoints_ReportsContactCauseAndCarriesPartner()
+ {
+ var c = new IncidentSeverityCorrelator();
+ var s = Sample(5, ReplayIncidentIndexDetection.SourceFastRepair, null, 10.0,
+ suspectedContactCarIdx: 12, contactDistanceMeters: 4.2f);
+
+ var r = c.Correlate(s, 10.0, isDirtSurface: false);
+
+ Assert.Equal("contact", r.Cause);
+ Assert.Equal(12, r.Merged.SuspectedContactCarIdx);
+ Assert.Equal(4.2f, r.Merged.ContactDistanceMeters);
+ }
+
+ [Fact]
+ public void Correlate_WallThenPointsArriveLater_PointsOverrideWallCause()
+ {
+ var c = new IncidentSeverityCorrelator();
+
+ var s1 = Sample(5, ReplayIncidentIndexDetection.SourceFastRepair, null, 10.0);
+ var r1 = c.Correlate(s1, 10.0, isDirtSurface: false);
+ Assert.Equal("wall", r1.Cause);
+
+ var s2 = Sample(5, ReplayIncidentIndexDetection.SourcePlayerIncidentCount, 4, 11.0);
+ var r2 = c.Correlate(s2, 11.0, isDirtSurface: false);
+
+ Assert.True(r2.IsEscalation);
+ Assert.Equal("contact", r2.Cause); // resolved points always wins, even over an already-reported "wall"
+ Assert.Equal(4, r2.Merged.IncidentPoints);
+ }
+
+ [Fact]
+ public void Correlate_ContactPartnerCarriesForwardAcrossEscalation()
+ {
+ var c = new IncidentSeverityCorrelator();
+
+ var s1 = Sample(5, ReplayIncidentIndexDetection.SourceFastRepair, null, 10.0,
+ suspectedContactCarIdx: 12, contactDistanceMeters: 3.0f);
+ c.Correlate(s1, 10.0, isDirtSurface: false);
+
+ // A later same-window sample with a lower cause rank (off-track) and no partner info of
+ // its own must not blank out the already-known contact partner.
+ var s2 = Sample(5, ReplayIncidentIndexDetection.SourceTrackSurface, null, 10.5);
+ var r2 = c.Correlate(s2, 10.5, isDirtSurface: false);
+
+ Assert.Equal(12, r2.Merged.SuspectedContactCarIdx);
+ Assert.Equal(3.0f, r2.Merged.ContactDistanceMeters);
+ }
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `dotnet test --filter "FullyQualifiedName~IncidentSeverityCorrelatorTests"`
+Expected: FAIL — `Sample(...)` compile error (new params) and/or wrong cause strings ("unknown"/"contact"
+instead of "spin"/"wall") since the correlator doesn't call `ResolveInferred` yet.
+
+- [ ] **Step 3: Implement**
+
+In `src/SimSteward.Plugin/IncidentSeverityCorrelator.cs`:
+
+1. Add three new pending-state arrays alongside the existing ones:
+
+```csharp
+ private readonly int?[] _pendingBestContactCarIdx = new int?[ReplayIncidentIndexBuild.CarSlotCount];
+ private readonly float?[] _pendingBestContactDistance = new float?[ReplayIncidentIndexBuild.CarSlotCount];
+ private readonly float?[] _pendingBestLossOfControlScore = new float?[ReplayIncidentIndexBuild.CarSlotCount];
+```
+
+2. In `Reset()`, add to the loop body:
+
+```csharp
+ _pendingBestContactCarIdx[i] = null;
+ _pendingBestContactDistance[i] = null;
+ _pendingBestLossOfControlScore[i] = null;
+```
+
+3. Replace the body of `Correlate` with:
+
+```csharp
+ public CorrelationResult Correlate(IncidentSample sample, double sessionTimeSec, bool isDirtSurface, double windowSec = DefaultWindowSec)
+ {
+ int carIdx = sample.CarIdx;
+ bool inRange = carIdx >= 0 && carIdx < ReplayIncidentIndexBuild.CarSlotCount;
+
+ int? cappedPoints = ApplyDirtCap(sample.IncidentPoints, isDirtSurface);
+ string sampleCause = IncidentCauseMapping.ResolveInferred(
+ sample.DetectionSource, cappedPoints, sample.LossOfControlScore, sample.SuspectedContactCarIdx);
+ int sampleCauseRank = CauseSeverityRank(sampleCause);
+
+ double lastSec = inRange ? _pendingLastSampleTimeSec[carIdx] : NoPending;
+ bool hasPending = lastSec >= 0 && (sessionTimeSec - lastSec) <= windowSec;
+
+ int prevBestPoints = hasPending ? _pendingBestPoints[carIdx] : NoPoints;
+ int prevBestCauseRank = hasPending ? _pendingBestCauseRank[carIdx] : 0;
+
+ int newBestPoints = Math.Max(prevBestPoints, cappedPoints ?? NoPoints);
+ int newBestCauseRank = Math.Max(prevBestCauseRank, sampleCauseRank);
+ // Ties prefer the newest sample so the reported source/context stays traceable to what just happened.
+ bool sampleWinsTie = !hasPending || sampleCauseRank >= prevBestCauseRank;
+ string newBestSource = sampleWinsTie ? sample.DetectionSource : _pendingBestSource[carIdx];
+ int? newBestContactCarIdx = sampleWinsTie ? sample.SuspectedContactCarIdx : (inRange ? _pendingBestContactCarIdx[carIdx] : null);
+ float? newBestContactDistance = sampleWinsTie ? sample.ContactDistanceMeters : (inRange ? _pendingBestContactDistance[carIdx] : null);
+ float? newBestLossOfControlScore = sampleWinsTie ? sample.LossOfControlScore : (inRange ? _pendingBestLossOfControlScore[carIdx] : null);
+
+ int? newPoints = newBestPoints == NoPoints ? (int?)null : newBestPoints;
+ string newCause = newPoints.HasValue
+ ? IncidentCauseMapping.Resolve(newBestSource, newPoints) // points override — source irrelevant here
+ : CauseFromRank(newBestCauseRank);
+
+ bool isNew = !hasPending;
+ bool changed = hasPending && (newBestPoints != prevBestPoints || newBestCauseRank != prevBestCauseRank);
+
+ if (inRange)
+ {
+ _pendingLastSampleTimeSec[carIdx] = sessionTimeSec;
+ _pendingBestPoints[carIdx] = newBestPoints;
+ _pendingBestCauseRank[carIdx] = newBestCauseRank;
+ _pendingBestSource[carIdx] = newBestSource;
+ _pendingBestContactCarIdx[carIdx] = newBestContactCarIdx;
+ _pendingBestContactDistance[carIdx] = newBestContactDistance;
+ _pendingBestLossOfControlScore[carIdx] = newBestLossOfControlScore;
+ }
+
+ var merged = new IncidentSample(
+ sample.CarIdx, sample.SessionTimeMs, newBestSource, newPoints, sample.ReplayFrame,
+ sample.Lap, sample.SessionNum, sample.LapDistPct, sample.CarPosition, sample.IsAggregateDelta,
+ lossOfControlScore: newBestLossOfControlScore,
+ suspectedContactCarIdx: newBestContactCarIdx,
+ contactDistanceMeters: newBestContactDistance);
+
+ return new CorrelationResult(isNew, changed, merged, newCause);
+ }
+```
+
+4. Extend `CauseSeverityRank`/`CauseFromRank` with a new top tier for "wall" (kept above "contact" —
+ not because a wall hit is definitionally worse, but because it's the more specific inference of the
+ two once a nearby-car check has already come back empty; same rank-vs-cause conflation the existing
+ scheme already has, tracked as RISK 4 in `docs/REVIEW-incident-points-implementation.md`):
+
+```csharp
+ private static int CauseSeverityRank(string cause)
+ {
+ if (cause == IncidentCauseMapping.CauseOffTrack) return 1;
+ if (cause == IncidentCauseMapping.CauseFlagged) return 2;
+ if (cause == IncidentCauseMapping.CauseSpin) return 3;
+ if (cause == IncidentCauseMapping.CauseContact) return 4;
+ if (cause == IncidentCauseMapping.CauseWall) return 5;
+ return 0; // unknown
+ }
+
+ private static string CauseFromRank(int rank)
+ {
+ switch (rank)
+ {
+ case 1: return IncidentCauseMapping.CauseOffTrack;
+ case 2: return IncidentCauseMapping.CauseFlagged;
+ case 3: return IncidentCauseMapping.CauseSpin;
+ case 4: return IncidentCauseMapping.CauseContact;
+ case 5: return IncidentCauseMapping.CauseWall;
+ default: return IncidentCauseMapping.CauseUnknown;
+ }
+ }
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `dotnet test --filter "FullyQualifiedName~IncidentSeverityCorrelatorTests"`
+Expected: PASS — all pre-existing tests plus the 5 new ones.
+
+- [ ] **Step 5: Run the full test suite to confirm no cross-file regression**
+
+Run: `dotnet test`
+Expected: PASS, 0 failures.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/SimSteward.Plugin/IncidentSeverityCorrelator.cs src/SimSteward.Plugin.Tests/IncidentSeverityCorrelatorTests.cs
+git commit -m "feat(incidents): carry spin/wall/contact-partner through the severity correlator"
+```
+
+---
+
+### Task 6: Live wiring — board entry fields + `SimStewardPlugin.LiveIncidentDetection.cs`
+
+**Files:**
+- Modify: `src/SimSteward.Plugin/PluginState.cs` (`LiveIncidentBoardEntry`, ~line 196-244)
+- Modify: `src/SimSteward.Plugin/SimStewardPlugin.LiveIncidentDetection.cs`
+
+**Interfaces:**
+- Consumes: `IncidentProximityResolver.FindNearestCar` (Task 2), `IncidentSpinHeuristic` (Task 3),
+ `CorrelationResult.Merged.{LossOfControlScore,SuspectedContactCarIdx,ContactDistanceMeters}` (Task 5).
+- Produces: `LiveIncidentBoardEntry.InferredContactCarIdx : int?`, `.InferredContactDistanceMeters : float?` —
+ used by Task 7 (dashboard).
+
+This file is `#if SIMHUB_SDK`-gated and has no direct unit tests (matches existing project convention —
+verified via build + `PluginSmokeTests.cs` + the manual `deploy.ps1` flow per `CLAUDE.md`).
+
+- [ ] **Step 1: Add the two new fields to `LiveIncidentBoardEntry`**
+
+In `src/SimSteward.Plugin/PluginState.cs`, inside `LiveIncidentBoardEntry` (after the existing
+`Player` property):
+
+```csharp
+ [JsonProperty("player")]
+ public bool Player { get; set; }
+
+ /// Best-guess CarIdx of a nearby car at detection time (see IncidentProximityResolver) — null if none found. A proximity coincidence, never a confirmed contact partner.
+ [JsonProperty("inferredContactCarIdx")]
+ public int? InferredContactCarIdx { get; set; }
+
+ /// Distance in meters to , for display/confidence context.
+ [JsonProperty("inferredContactDistanceMeters")]
+ public float? InferredContactDistanceMeters { get; set; }
+```
+
+- [ ] **Step 2: Add per-tick spin heuristic state + a tuning constant**
+
+In `src/SimSteward.Plugin/SimStewardPlugin.LiveIncidentDetection.cs`, alongside the existing
+`_liveIncidentCorrelator` field declaration (~line 12):
+
+```csharp
+ private readonly IncidentSeverityCorrelator _liveIncidentCorrelator = new IncidentSeverityCorrelator();
+ /// Rolling per-car loss-of-control score, updated every tick — see IncidentSpinHeuristic.
+ private readonly IncidentSpinHeuristic _liveSpinHeuristic = new IncidentSpinHeuristic();
+ /// Nearest-car proximity threshold for the "contact" vs "wall" inference tier — tuning value, revisit after live-session scorecard validation.
+ private const float ContactProximityThresholdMeters = 12.0f;
+```
+
+- [ ] **Step 3: Reset the spin heuristic at session boundaries**
+
+In `ProcessLiveIncidentDetectionTick`, inside the `if (needReset)` block, alongside the existing
+`_liveIncidentCorrelator.Reset();` call:
+
+```csharp
+ _liveIncidentCorrelator.Reset();
+ _liveSpinHeuristic.Reset();
+ _livePendingIncidentFingerprintByCar.Clear();
+```
+
+- [ ] **Step 4: Update the spin heuristic every tick**
+
+Still in `ProcessLiveIncidentDetectionTick`, immediately after the existing block of per-tick scratch
+reads (right after `SafeGetIntPerCar("CarIdxTireCompound", _liveRaceScratchCarIdxTireCompound);` and
+before `int playerIncidents = ...`):
+
+```csharp
+ SafeGetIntPerCar("CarIdxTireCompound", _liveRaceScratchCarIdxTireCompound);
+
+ for (int i = 0; i < ReplayIncidentIndexBuild.CarSlotCount; i++)
+ {
+ _liveSpinHeuristic.Update(
+ i,
+ _liveRaceScratchCarIdxSteer[i],
+ _liveRaceScratchCarIdxGear[i],
+ _liveRaceScratchCarIdxTrackSurface[i],
+ sessionTimeSec);
+ }
+
+ int playerIncidents = 0;
+```
+
+- [ ] **Step 5: Enrich each raw sample before correlation**
+
+In `LogLiveIncidentDetectionsLocked`, inside the `foreach (var raw in samples)` loop, immediately
+after the existing `try` block opens and before `var result = _liveIncidentCorrelator.Correlate(raw, sessionTimeSec, _liveRaceIsDirtSession);`:
+
+```csharp
+ try
+ {
+ var (contactCarIdx, contactDistance) = IncidentProximityResolver.FindNearestCar(
+ raw.CarIdx, _liveRaceScratchCarIdxLapDistPct, trackLengthMeters, ContactProximityThresholdMeters);
+ var enriched = new IncidentSample(
+ raw.CarIdx, raw.SessionTimeMs, raw.DetectionSource, raw.IncidentPoints, raw.ReplayFrame,
+ raw.Lap, raw.SessionNum, raw.LapDistPct, raw.CarPosition, raw.IsAggregateDelta,
+ lossOfControlScore: _liveSpinHeuristic.GetScore(raw.CarIdx, sessionTimeSec),
+ suspectedContactCarIdx: contactCarIdx,
+ contactDistanceMeters: contactDistance);
+
+ var result = _liveIncidentCorrelator.Correlate(enriched, sessionTimeSec, _liveRaceIsDirtSession);
+```
+
+(This replaces the original `var result = _liveIncidentCorrelator.Correlate(raw, sessionTimeSec, _liveRaceIsDirtSession);`
+line — `raw` upstream of this point is untouched, only the local `enriched` copy is used from here on.
+`trackLengthMeters` is already computed earlier in this same method, right above the `foreach` loop.)
+
+- [ ] **Step 6: Populate the new board-entry fields**
+
+Still in `LogLiveIncidentDetectionsLocked`, in the `if (result.IsNewIncident)` branch, inside the
+`new LiveIncidentBoardEntry { ... }` initializer, add the two new properties (after `Player = ...`):
+
+```csharp
+ Player = s.CarIdx == playerCarIdx,
+ InferredContactCarIdx = s.SuspectedContactCarIdx,
+ InferredContactDistanceMeters = s.ContactDistanceMeters
+```
+
+And in the `else // IsEscalation` branch, alongside the existing `entry.Cause = result.Cause;` line:
+
+```csharp
+ entry.Cause = result.Cause;
+ entry.InferredContactCarIdx = s.SuspectedContactCarIdx;
+ entry.InferredContactDistanceMeters = s.ContactDistanceMeters;
+```
+
+- [ ] **Step 7: Add `CarLeftRight` as a corroborating log-only field for the player**
+
+In `AddPlayerOnlyIncidentContext`, alongside the existing `player_lat_accel`/`player_long_accel`/
+`player_vert_accel` reads:
+
+```csharp
+ try { fields["player_lat_accel"] = _irsdk.Data.GetFloat("LatAccel"); } catch { }
+ try { fields["player_long_accel"] = _irsdk.Data.GetFloat("LongAccel"); } catch { }
+ try { fields["player_vert_accel"] = _irsdk.Data.GetFloat("VertAccel"); } catch { }
+ try { fields["player_car_left_right"] = _irsdk.Data.GetInt("CarLeftRight"); } catch { }
+```
+
+Also add the two new fields to `BuildLiveIncidentLogFields`'s returned dictionary (alongside the
+existing `["car_tire_compound"]` line), so every detection log line carries them regardless of cause:
+
+```csharp
+ ["car_tire_compound"] = _liveRaceScratchCarIdxTireCompound[s.CarIdx],
+ ["loss_of_control_score"] = s.LossOfControlScore.HasValue ? (object)s.LossOfControlScore.Value : null,
+ ["suspected_contact_car_idx"] = s.SuspectedContactCarIdx.HasValue ? (object)s.SuspectedContactCarIdx.Value : null,
+ ["contact_distance_meters"] = s.ContactDistanceMeters.HasValue ? (object)s.ContactDistanceMeters.Value : null
+```
+
+- [ ] **Step 8: Build and run the full test suite**
+
+Run: `dotnet build` (expect 0 errors) then `dotnet test` (expect all green, same/higher pass count than
+Task 5's end state).
+
+- [ ] **Step 9: Commit**
+
+```bash
+git add src/SimSteward.Plugin/PluginState.cs src/SimSteward.Plugin/SimStewardPlugin.LiveIncidentDetection.cs
+git commit -m "feat(incidents): wire spin/wall/contact-partner inference into the live detection tick"
+```
+
+---
+
+### Task 7: Dashboard — show the inferred contact partner
+
+**Files:**
+- Modify: `src/SimSteward.Dashboard/index.html`
+
+**Interfaces:**
+- Consumes: `entries[].cause` (`"wall"`/`"spin"` now reachable), `entries[].inferredContactCarIdx`,
+ `entries[].inferredContactDistanceMeters` (Task 6) — via the existing `{type:"incidents", entries:[...]}`
+ WS message, no message-shape change needed.
+
+No CSS changes needed — `.cause-tag.wall` and `.cause-tag.spin` are already defined
+(`index.html:237-238`), just never reachable until this plan; verified during design research.
+
+- [ ] **Step 1: Add a shared contact-partner suffix helper**
+
+In `src/SimSteward.Dashboard/index.html`, add this function near `pointsBadgeHtml` (which it
+complements — same "never let an inferred value look confirmed" rule):
+
+```javascript
+/**
+ * Small suffix appended next to a "contact" cause tag when IncidentProximityResolver found a
+ * candidate nearby car — e.g. "car #12 (~4m)". Always parenthetical/inferred, never implies the
+ * SDK confirmed who was involved (see docs/IRACING-CROSSWALK.md — no per-car world position exists).
+ * Returns '' when there's nothing to show (non-contact causes, or no candidate found).
+ */
+function contactPartnerSuffixHtml(i) {
+ if (String(i.cause) !== 'contact') return '';
+ const carIdx = i.inferredContactCarIdx;
+ if (carIdx == null) return '';
+ const dist = typeof i.inferredContactDistanceMeters === 'number' ? ` ~${i.inferredContactDistanceMeters.toFixed(0)}m` : '';
+ return ` likely car #${escapeHtmlForCaptured(carIdx)}${escapeHtmlForCaptured(dist)}`;
+}
+```
+
+- [ ] **Step 2: Add minimal styling for the hint**
+
+Alongside the existing `.cause-tag` CSS rules (`index.html:235-241`):
+
+```css
+.contact-partner-hint { font-size: 0.62rem; color: var(--muted); font-style: italic; }
+```
+
+- [ ] **Step 3: Wire the suffix into both incident renderers**
+
+In `incidentCardHtml` (~`index.html:1584`), change the cause-tag line to:
+
+```javascript
+ ${escapeHtmlForCaptured(cause.replace('-', ' '))}${contactPartnerSuffixHtml(i)}
+```
+
+In `incidentTableRowHtml` (~`index.html:1609`), change the cause-tag `| ` to:
+
+```javascript
+ | ${escapeHtmlForCaptured(cause.replace('-', ' '))}${contactPartnerSuffixHtml(i)} |
+```
+
+- [ ] **Step 4: Manual verification (per CLAUDE.md's UI-change rule)**
+
+Run `deploy.ps1`, open the dashboard in a browser, and confirm on the Incidents tab:
+- A synthetic/live "contact" entry with `inferredContactCarIdx` set renders the "likely car #N ~Xm"
+ hint next to the cause tag.
+- A "wall" or "spin" cause entry renders with its existing (already-defined) yellow tag styling, no
+ console errors.
+- An entry with no `inferredContactCarIdx` renders exactly as it did before this change (no empty
+ hint span, no layout shift).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/SimSteward.Dashboard/index.html
+git commit -m "feat(dashboard): show inferred contact-partner hint on contact-cause incidents"
+```
+
+---
+
+### Task 8: Full build/test/deploy pass
+
+**Files:** none (verification only)
+
+- [ ] **Step 1: Full solution build**
+
+Run: `dotnet build` — expect 0 errors, 0 new warnings.
+
+- [ ] **Step 2: Full test suite**
+
+Run: `dotnet test` — expect all green.
+
+- [ ] **Step 3: PowerShell test scripts**
+
+Run each script under `tests/*.ps1` — expect all green.
+
+- [ ] **Step 4: Deploy**
+
+Run: `deploy.ps1` (kills SimHub, copies DLLs, relaunches SimHub on success).
+
+- [ ] **Step 5: Retry-once-then-stop**
+
+If any of steps 1-4 fail, fix the root cause and retry the whole sequence **once**. If it fails again,
+stop and report — do not retry further (per `CLAUDE.md`).
+
+- [ ] **Step 6: Flag remaining validation as a separate, later step**
+
+Do not claim the spin/wall/contact heuristics are "accurate" from this pass alone — that requires the
+live-session scorecard process (`docs/INCIDENT-SCORECARD-TEST-PLAN.md`), which is out of scope for
+this implementation plan (design-time tuning only, per the spec's Decisions section).
+
+## Out of scope (YAGNI)
+
+- No changes to the replay fast-forward sweep / Replay Index tab (`SimStewardPlugin.ReplayIncidentIndexBuild.cs`,
+ `ReplayIncidentIndexResultsYaml.cs`) — separate detector instance, separate consumer, not touched.
+- No changes to `pickSuggestedCamera` / camera-suggestion logic — a natural follow-on, not bundled here.
+- No threshold/weight retuning beyond the starting constants defined in Tasks 3/6
+ (`LossOfControlScoreThreshold`, `ContactProximityThresholdMeters`, `ReversalsForFullSignal`, etc.) —
+ tuning is a live-session-validation activity, not a code-review activity.
+- No admin-tier / official per-car incident severity changes.
+- No new WS message types — reuses the existing `{type:"incidents", entries:[...]}` shape.
diff --git a/docs/superpowers/specs/2026-07-25-incident-cause-inference-design.md b/docs/superpowers/specs/2026-07-25-incident-cause-inference-design.md
new file mode 100644
index 0000000..10a357e
--- /dev/null
+++ b/docs/superpowers/specs/2026-07-25-incident-cause-inference-design.md
@@ -0,0 +1,158 @@
+# Incident Cause Inference — Spin, Contact-Partner, Wall (design)
+
+**Date:** 2026-07-25
+**Status:** Approved (brainstorm), pending implementation plan
+**Branch:** feat/incident-scoring-accuracy
+
+## Problem
+
+Today the "cause" label on an incident (`off-track` / `spin` / `contact` / `flagged` / `unknown` —
+`IncidentCauseMapping.cs`) is only ever accurate for `off-track` (a direct read of
+`CarIdxTrackSurface`) and for `spin`/`contact` **when a real points value resolves** (1x→off-track,
+2x→spin, 4x→contact — `IncidentCauseMapping.Resolve`). Points only resolve live for the player's own
+car (`PlayerCarMyIncidentCount` delta); for every other car, `spin` is structurally unreachable and
+`contact` collapses to whatever the triggering SDK source was (`repair_flag`/`fast_repair`→contact,
+`furled_flag`/`black_flag`→flagged), with no notion of *which other car* was involved.
+
+Validated during research (this session, cross-checked against `irsdk_defines.h` and the
+`mrbelowski/CrewChiefV4` source directly, not guessed):
+
+- The iRacing SDK has **no per-car world position** for other cars — confirmed both by
+ `docs/IRACING-DATA-AVAILABILITY.md` (no such field in any group) and structurally by CrewChief
+ itself: its generic `Spotter` base class needs real X/Z opponent coordinates, and only
+ `ACSSpotter.cs`/`R3ESpotterv2.cs`/`PCars2Spotterv2.cs` override the method that supplies them.
+ `iRacingSpotter.cs` never does — it just relays iRacing's own native `CarLeftRight` telemetry
+ value, confirmed against `irsdk_defines.h`'s `irsdk_CarLeftRight` enum
+ (`Off/Clear/CarLeft/CarRight/CarLeftRight/2CarsLeft/2CarsRight`). `CarLeftRight` is player-only and
+ carries no car identity.
+- No SDK field or event represents "spin"/"loss of control" (all 32 `SessionFlags` bits enumerated
+ in `docs/IRACING-CROSSWALK.md` Appendix A checked; none apply) or "wall" (surface-material enums
+ only describe ground type: tarmac/grass/gravel/dirt/rumble — never a fixed barrier).
+- `docs/IRACING-CROSSWALK.md` previously miscited CrewChief's `DamageReporting.cs` as doing
+ `YawRate`-based spin detection — corrected during this session after a full-repo GitHub code
+ search returned zero hits for `YawRate` anywhere in CrewChief.
+
+Goal: close these three gaps as far as the available signals honestly allow, using **only Group 2
+fields** (live + replay, every car, no admin) plus the one Group 3 player-only field (`CarLeftRight`)
+that materially helps — without ever letting an inferred label be mistaken for a resolved one.
+
+## Decisions (locked during brainstorm)
+
+- **Attribution bar for contact partner:** show a best-guess `CarIdx`, always visibly labeled as
+ inferred (e.g. "likely contact: car #12 (~4m)") — not a bare boolean, not a silent guess. Same
+ honesty tier as the existing `EstimatedPoints` treatment.
+- **A resolved points value always wins.** All three new signals are corroborating/fallback only —
+ they must never override `IncidentCauseMapping.Resolve`'s existing points-override rule.
+- **No new SDK polling.** Every field used (`CarIdxSteer`, `CarIdxGear`, `CarIdxTrackSurface`,
+ `CarIdxLapDistPct`, `CarLeftRight`) is already read (or trivially added alongside an existing
+ per-car read) in `SimStewardPlugin.LiveIncidentDetection.cs` / the replay sweep — no new tick-rate
+ SDK calls.
+- **Heuristics stay unvalidated-and-labeled-as-such**, same tier as the dirt 4x→2x cap — real
+ accuracy validation happens later via the live-session scorecard process
+ (`docs/INCIDENT-SCORECARD-TEST-PLAN.md`), not asserted from unit tests alone.
+
+## Design
+
+### New fields on `IncidentSample` (`ReplayIncidentIndexDetection.cs`)
+
+All additive and nullable — absence must never break an existing consumer:
+
+```
+SuspectedContactCarIdx : int? // best-guess nearby car, null if none found within threshold
+ContactDistanceMeters : float? // distance to that car, for display/confidence context
+LossOfControlScore : float? // 0.0-1.0 heuristic strength; null if not yet evaluated
+PlayerCarLeftRight : int? // raw irsdk_CarLeftRight value; only ever set when CarIdx == playerCarIdx
+```
+
+### `IncidentProximityResolver.cs` (new, stateless)
+
+```
+static (int? carIdx, float? distanceMeters) FindNearestCar(
+ int subjectCarIdx, float[] carIdxLapDistPct, float trackLengthMeters, float thresholdMeters)
+```
+
+- Converts every other car's `CarIdxLapDistPct` to a 1-D "meters around the lap" distance from the
+ subject car, handling the lap-boundary wraparound (e.g. subject at 0.99, other car at 0.01).
+- Same fundamental technique CrewChief's own `iRacingGameStateMapper.cs` uses for opponent-relative
+ gap math (`DistanceRoundTrack = trackLength × CorrectedLapDistance`) — precedented, not novel, but
+ still a 1-D proxy: it cannot see lateral separation, so it will misfire on wide straights (two cars
+ far apart side-by-side reads as "close") and in tight corners (linear-distance assumption breaks
+ down). This must be stated in the field's XML doc comment, not just this spec.
+- Called **only** when a primary detection already fired (`repair_flag`, `fast_repair`,
+ `track_surface`) — not every tick. Cheap, on-demand.
+
+### `IncidentSpinHeuristic.cs` (new, stateful per car — same shape as `IncidentSeverityCorrelator`)
+
+```
+void Update(int carIdx, float steerRad, int gear, int trackSurface, double sessionTimeSec)
+float? GetScore(int carIdx)
+```
+
+- Runs every tick (cheap array math) for every car, alongside the existing scratch-array reads in
+ `SimStewardPlugin.LiveIncidentDetection.cs` (`CarIdxSteer`, `CarIdxGear`, `CarIdxTrackSurface` are
+ already read there) — needs rolling history, not a single sample, so it cannot be computed
+ on-demand like the proximity resolver.
+- Score combines: steering-angle sign oscillation frequency (catching a slide), dwell time in neutral
+ gear (0) outside expected shifting patterns, and on/off/on track-surface flicker count within a
+ short rolling window (spin-and-recover) as distinct from a single clean off-track exit.
+- Exact thresholds/weights are an implementation-time tuning question, not locked here — flag as
+ "needs live-session tuning" in the plan.
+
+### Wiring (`SimStewardPlugin.LiveIncidentDetection.cs` + replay sweep)
+
+- `IncidentSpinHeuristic.Update` called every tick, right after the existing
+ `CarIdxSteer`/`CarIdxGear`/`CarIdxTrackSurface` scratch reads (lines ~147-158 today) — no new SDK
+ calls.
+- `IncidentProximityResolver.FindNearestCar` called inside `LogLiveIncidentDetectionsLocked`, only
+ for samples that just became a new/escalated board entry — reuses the already-read
+ `_liveRaceScratchCarIdxLapDistPct`.
+- `CarLeftRight` — one new `SafeGetInt("CarLeftRight")` call, gated `if (s.CarIdx == playerCarIdx)`,
+ placed in `AddPlayerOnlyIncidentContext` alongside the existing Speed/RPM/G-force reads.
+
+### Cause resolution hierarchy (`IncidentCauseMapping.cs`)
+
+Extends, does not replace, the existing points-override rule:
+
+1. Resolved points (1/2/4) → authoritative cause — **unchanged**.
+2. No points, `LossOfControlScore` above threshold → cause = `spin`, tagged inferred. (Currently
+ unreachable without points; this makes it reachable.)
+3. No points, no spin score, `SuspectedContactCarIdx` present → cause = `contact`, carries the
+ candidate CarIdx + distance, tagged inferred.
+4. No points, no spin score, no nearby car, but a damage-adjacent event fired
+ (`fast_repair`/`repair_flag`) → cause = `wall` (inferred by elimination — no direct wall signal
+ exists at any SDK layer), tagged inferred.
+5. Otherwise → `unknown`, same as today.
+
+### Dashboard (`index.html`)
+
+Same visual pattern as the existing `pointsBadgeHtml` (`~Nx est` vs. confirmed `Nx` — never let a
+guess look like confirmed data): a new badge renders `contact: car #12 (~4m, inferred)` or
+`spin (inferred)`, visually distinct from a resolved cause tag. No changes to the points badge itself.
+
+### Tests
+
+Matches the existing per-concern test file pattern (`IncidentSeverityCorrelatorTests.cs`,
+`IncidentCauseMappingTests.cs`):
+
+- `IncidentProximityResolverTests.cs` — lap-boundary wraparound (0.99↔0.01), multiple candidates
+ picks the nearest, empty/all-absent field returns null, subject-car-excluded-from-its-own-search.
+- `IncidentSpinHeuristicTests.cs` — synthetic steer-oscillation / neutral-gear-dwell / surface-flicker
+ sequences, deterministic, no live telemetry required.
+- `IncidentCauseMappingTests.cs` — extend with cases for the new hierarchy tiers (2-4 above),
+ confirming points-override still wins when both a resolved value and an inferred signal are present.
+- **Not** covered by unit tests: real-world accuracy of the heuristics themselves — that requires the
+ live-session scorecard process, flagged explicitly so it isn't silently skipped.
+
+## Out of scope (YAGNI)
+
+- No true lateral/spatial position for other cars — confirmed structurally unavailable; not
+ attempted.
+- No camera-suggestion logic changes (`pickSuggestedCamera` in `index.html`) — this design only
+ produces the underlying cause/contact-partner data; wiring it into camera suggestions is a
+ separate follow-on, not bundled here.
+- No admin-tier / official incident-count changes — this is entirely the no-admin Group 2 (+
+ player-only `CarLeftRight`) signal set; Group 1's admin-gated official severity is untouched.
+- No new SDK polling cadence or new WS message types — reuses existing per-tick reads and the
+ existing `incidents` board broadcast.
+- No threshold/weight tuning values locked in this doc — that's implementation+scorecard work, not a
+ design decision.