Track Pace Math and Race Track Representation Audit

Alignment review page generated 2026-07-04 for the experimental Pitwall Track Pace popover.

Summary

Audit verdict: The track geometry and car/shadow placement math are internally coherent and covered by focused tests. Actual pace is mapped as usage percent to lap fraction; the shadow car is mapped as ideal target percent to lap fraction; animated speed is driven by the clamped actual-vs-target ratio.

Main concern: Daily scope intentionally uses a whole-day target for the track and a session-aware, sometimes rebalanced target for timing bars. That is defensible, but it needs explicit product acceptance and probably one more integration-style test to guard against future confusion.

Likely edge risk: The track clamps target percent to a lap fraction. When a daily target becomes zero or overdrawn, the shadow sits on the start line or disappears as a meaningful target while the status can still be critical. The daily segment UI has richer overdrawn copy, but the track surface does not show that nuance.

Table of Contents

Scope Data Flow Findings Math Review Race Track Representation Actual Pace and Shadow Car Verification Evidence Matrix Review Gates Compile Responses

Scope

This audit reviewed how Pitwall calculates and renders the new race track representation, with emphasis on:

Primary files reviewed: apps/pitwall-local/Sources/PitwallAppSupport/TrackPaceExperiment.swift, apps/pitwall-local/Sources/PitwallApp/Views/TrackPaceSceneView.swift, apps/pitwall-local/Sources/PitwallAppSupport/ProviderUsageRowsViewModel.swift, and apps/pitwall-local/Sources/PitwallAppSupport/DailySessionPaceCalculator.swift.

Data Flow

  1. TrackPaceExperimentViewModel.update receives app provider state, preferences, history snapshots, and local pacing records.
  2. TrackPaceStandingsBuilder.providerStandings filters data per provider and builds rows through ProviderUsageRowsViewModel.
  3. Each scope maps to a TrackPaceStanding. The car receives row.percent; the shadow receives ProviderUsageRowsViewModel.paceTargetPercent.
  4. TrackPaceLapMotion converts actual and target percents into lap fractions and computes animation speed ratio.
  5. TrackPaceSceneView.Coordinator maps each fraction onto the selected TrackPath and writes SceneKit node position/yaw.

Findings

PassTrack geometry is represented as an arc-length parameterized closed loop.

The circuit model stores dense normalized waypoints for 12 tracks and builds TrackPath(denseLoop:). position(atProgress:) converts fractional lap progress to total arc-length progress with binary search over cumulative lengths, so 50% progress means halfway around the path by distance, not halfway through waypoint index count.

Evidence: TrackCircuit.waypoints and TrackCircuit.path() at lines 103-137; TrackPath.position(atProgress:) at lines 194-217. Tests cover start line, full-lap wrap, half-lap distance, distinct quarter positions, direction, unit box normalization, dense GPS trace use, and aspect preservation.

PassActual car and shadow car positions are mathematically consistent at data refresh.

TrackPaceStanding.lapFraction clamps percent to 0...1. The actual car uses usagePercent / 100; the shadow uses targetPercent / 100 when a target exists. At elapsed zero, TrackPaceLapMotion returns those exact anchors.

Evidence: usageLapFraction, targetLapFraction, and lapFraction at lines 360-371; static and animated progress accessors at lines 420-438. Tests assert 42% usage and 60% target place the car and shadow at the corresponding circuit fractions for every circuit.

PassAnimation speed communicates burn-rate direction without changing the refresh-time truth.

The shadow laps at a fixed 14-second reference duration. The actual car speed uses usagePercent / targetPercent, clamped to 0.25...2.0. A behind-pace car moves slower than the shadow; an over-pace car moves faster. At each data refresh, both cars re-anchor to the real actual and target fractions.

Evidence: TrackPaceLapMotion ratio and progress math at lines 381-438; SceneKit render loop at TrackPaceSceneView.swift lines 193-232. Tests cover drift direction, ratio clamps, wrap across the line, parked capped car behavior, and static re-anchoring after animation.

WatchDaily track target and timing tower targets are intentionally different surfaces.

The daily track uses the whole-day usage/target pair, while the Daily row signal can be burst-aware through current session segments. The code explicitly changes daily track signal to follow the whole-day pair so the label never contradicts the car-shadow gap, then surfaces burst risk through currentPaceRunningHot and timing rows.

This is coherent, but it is easy to misread because the timing tower's segment targets can be session-aware and rebalanced after observed usage. Product copy should make clear that the track is day-level and the tower is session-level.

Evidence: design comment at TrackPaceExperiment.swift lines 9-14; daily builder branch at lines 621-643; dailyTrackSignal at lines 651-671; daily row segment enrichment at ProviderUsageRowsViewModel.swift lines 777-815.

RiskZero or overdrawn daily target weakens shadow-car semantics.

weeklyFinishDailyTarget can return 0 when the day is after or at reset boundary, and segment rebalance can create negative target percent when previous/current observed usage has already consumed more than the daily budget. Segment copy handles negative targets as overdrawn, but TrackPaceStanding.targetLapFraction clamps any negative target to 0. The shadow at the start line no longer means "ideal progress" in a user-obvious way.

This is probably acceptable for an experiment, but it should either be called out in UI copy or tested and deliberately accepted. A possible improvement is to hide or relabel the shadow when the target is <= 0, while keeping the status as critical/limit-hit.

Evidence: weeklyFinishDailyTarget at lines 1061-1103; rebalance remaining budget math at DailySessionPaceCalculator.swift lines 355-394; target fraction clamp at TrackPaceExperiment.swift lines 365-371.

Coverage GapNo provider-level Track Pace test proves daily target parity with paceTargetPercent under history/local pacing inputs.

The focused tests are strong around geometry, animation, and simplified provider state. The narrower missing test is an integration-style case where daily row.percent, paceTargetPercent("Daily"), dailyTrackSignal, and daily segment/tower data are all asserted from the same provider fixture with history snapshots and local pacing records. That would lock down the intended whole-day-versus-session split.

Math Review

CalculationImplementationAudit Read
Daily target weeklyFinishDailyTarget computes a day-end weekly trajectory target minus day-start utilization, falling back to daily budget when weekly reset data is unavailable. Sound for "finish the week evenly" semantics. It intentionally ignores "current session hot" for the track target.
Session-aware segment target sessionAwareDailyPaceSegments uses provider session timing, current-window daily usage, and DailySessionPaceCalculator.slices. Sound for timing bars. It is a different presentation layer from the whole-day track target.
Observed slice actual Past slices use bracketed weekly snapshot deltas and reject reset-crossing or sparse brackets. Conservative. This avoids false precision and is covered by daily calculator tests.
Rebalanced targets Observed actuals and reserved unobserved targets are subtracted from the daily target, then remaining budget is spread across current/future slices by duration. Useful but can go negative when the day is overdrawn. The segment UI handles that; the track shadow does not fully express it.
Pace signal Whole-window track signal uses point bands through paceSignal(forActual:expected:): over +12 critical, over +6 warning, under -6 reserve, otherwise on pace. Matches car-shadow gap thresholds and avoids label/track contradiction.

Race Track Representation

The representation is data-driven rather than hand-drawn per circuit at render time. Each TrackCircuit exposes generated normalized waypoints, race direction, display name, and flag. TrackPath preserves the dense loop as samples and calculates cumulative length once. Rendering uses a ribbon generated from the centerline and adaptive width.

Scene framing is also nontrivial: the camera fit projects every path sample into normalized FOV bounds, binary-searches distance, then pans the target to center the circuit. Tests check every circuit remains visible, centered, and large enough in the assumed view aspect.

Actual Pace and Shadow Car

The current implementation's semantics are:

For daily scope, "actual pace" means day-to-date usage, not current session burn. The current-session burn can make the main row critical while the track stays calm. The code intentionally shows a running-hot hint in that case. This is not a math bug, but it is a product semantics decision.

Verification

Focused test command run from the repo root:

swift test --package-path apps/pitwall-local --filter 'TrackPaceExperimentTests|TrackPaceSceneTests|TrackPaceTimingRowTests|DailySessionPaceCalculatorTests'

Result: passed. The command executed 54 selected tests with 0 failures in the XCTest suites. The selected coverage included 34 TrackPaceExperimentTests, 5 TrackPaceSceneTests, 6 TrackPaceTimingRowTests, and 9 DailySessionPaceCalculatorTests.

Evidence Matrix

ClaimEvidenceInferenceConfidenceDecision Impact
Track position math is correct for percentage progress. TrackPath.position(atProgress:) uses wrapped fraction times total arc length and interpolates between cumulative samples. Percent-to-position is distance-based and stable across circuits with different waypoint density. High Keep representation; no code change needed.
Actual and shadow positions are anchored to real data at refresh. TrackPaceLapMotion.carProgress(at: 0) and shadowProgress(at: 0) equal clamped actual and target fractions; tests assert examples. The visible gap at refresh directly represents actual minus target. High Keep current anchor model.
Daily track semantics are deliberately whole-day, not current-session. Code comments and dailyTrackSignal explicitly select whole-day pair over burst-aware row signal. Behavior is intentional, but user wording needs to prevent misinterpretation. High Approve or request UI copy/test changes.
Overdrawn target edge case is under-expressed by the shadow car. Negative targets are possible in daily segment rebalance; track target fraction clamps to zero. Shadow at start line can fail to communicate "no budget left" or "overdrawn". Medium Consider hiding/relabeling shadow for targetPercent <= 0.

Review Gates

Gate 1: Accept Audit Verdict

Do you accept the audit verdict that the core track geometry and actual/shadow placement math are sound, with product-semantics follow-up around daily targets?

Gate 2: Daily Track Semantics

Should the daily track remain whole-day usage versus whole-day target, with current-session risk shown only through the hot hint and timing tower?

Gate 3: Follow-Up Work

Which follow-up should be treated as approved scope if this audit moves into implementation?

Compile Responses

Use this section to compile gate answers for the producing skill context.

No responses compiled yet.