Pitwall — Calculation & Math Alignment

A goal-by-goal audit of every quantitative calculation in Pitwall Local — the clean-room Swift menu-bar app that paces AI-coding subscriptions (Claude, Codex, Gemini) across session and weekly windows. Each entry states what the calculation is meant to produce, the formula as implemented, where it is invoked in the data path, and a verification verdict. Generated 2026-06-23 by reading the source and running the test suites.

Core math verdict
Sound
All core pacing formulas verified correct & defensively clamped.
PitwallCore tests
98 / 98
swift test on the pacing/parsing package — 0 failures (exit 0).
Full app baseline
504 / 504
Per apps/pitwall-local/CLAUDE.md XCTest baseline.
Findings
1 review · 5 minor
No core-math errors. See Findings. One reported "bug" was disproven.

The data path — raw quota → number on screen

Every figure Pitwall shows is a chain of four stages. Calculations are grouped below by the stage they belong to.

1 · ParseProvider APIs/CLIs → normalized utilizationPercent, resetsAt, window duration.
2 · Core pacePacingCalculator: pace ratio, label, projected max, daily budget, today's usage.
3 · Daily sessionsDailySessionPaceCalculator + view-model: per-slice targets, rebalance, weekly-finish trajectory.
4 · PresentSignals→colors, "% left", forecast text, menu-bar string.

Stage 2 — Core pacing (packages/pitwall-core/…/PacingCalculator.swift)

The provider-agnostic heart of the app. Pure functions, no I/O. Inputs are a utilization percentage plus the [windowStart, resetAt] bracket and now.

Pace ratio & label — evaluatePace ✓ verified

Invoked by ClaudeUsageDomainMapper, CodexProviderAdapter.paceEvaluation, GeminiProviderAdapter for both the weekly and session windows.

Goal: answer "are you spending this window faster or slower than a straight, even burn?" — and turn that into an actionable label.

elapsedFraction         = (now − windowStart) / (resetAt − windowStart)
expectedUtilizationPct  = elapsedFraction × 100      // even-burn reference line
paceRatio               = utilizationPercent / expectedUtilizationPct

Suppression gates (return .notEnoughWindow instead of a noisy early/late read): weekly window ignores the first 6 h and last 1 h; session window ignores the first 15 min and last 5 min.

Label buckets on paceRatio (utilization ≥ 100 short-circuits to capped):

paceRatioLabelActionMeaning
< 0.50underusingpushFar below the even-burn line — headroom to spare.
0.50 – 0.85behindPacepushBelow pace.
0.85 – 1.15onPacepushTracking the even-burn line.
1.15 – 1.50aheadOfPaceconserveSpending faster than even.
1.50 – 2.00warningconserveWell ahead — likely to cap early.
> 2.00criticalwaitBurning roughly 2×+ the even rate.

Verified: ranges are contiguous with no gaps/overlaps; boundaries inclusive on the lower (calmer) side. Matches PacingCalculatorTests exactly (e.g. day 4 of 7 at 60% → ratio 1.05 → onPace; 101% → capped). Gate edges pinned by tests at ±1 s.

Projected weekly maximum — projectedWeeklyMax ✓ verified

Set into PacingState.projectedWeeklyMaxPercent by the Claude mapper & Codex adapter, but only when the label is not capped/notEnoughWindow. Consumed by the forecast text.

Goal: "if you keep the current average pace, what % of the weekly limit will you reach by reset?"

projectedWeeklyMaxPercent = paceRatio × 100
                          = utilizationPercent / elapsedFraction   // linear extrapolation

Verified: ratio 1.0 → 100%, 1.5 → 150%, 0.72 → 72% (tests). It is a straight-line projection of the window-average rate — intentionally not an instantaneous burn rate. Sensitive early in a window, which the 6 h gate mitigates.

Daily budget — dailyBudget ✓ verified

Invoked by the Claude mapper, Codex/Gemini adapters, and the view-model's normalizedDailyBudget. Feeds the daily ring's fallback target and the "X% left today" copy.

Goal: spread the remaining weekly allowance evenly across the days left, so you finish exactly at the reset.

remainingUtilizationPercent = max(0, 100 − weeklyUtilizationPercent)
daysRemaining               = max( (resetAt − now) / 86 400 ,  1/24 )   // floored at 1 hour
dailyBudgetPercent          = remainingUtilizationPercent / daysRemaining

Verified: 70% used with 36 h left → 30% / 1.5 d = 20%/day (test). The 1/24 floor prevents divide-by-zero and the blow-up when resetAt ≈ now or is already past (a past reset → "burn it now" signal, finite).

Today's usage baseline — todayUsage / snapshotsInCurrentWeeklyWindow ✓ verified

Computed inside dailyBudget; surfaced as TodayUsage on the pacing state and in tooltips.

Goal: estimate "how much of the weekly counter did today consume" by diffing the live weekly % against the best baseline snapshot.

Verified: six dedicated tests in DailyBudgetTests cover the pre-midnight pick, the 2 h grace, rejection of a late same-day snapshot, and the mid-day-reset case (pre-reset 96% snapshot correctly ignored).

Stage 3 — Daily-session pacing (DailySessionPaceCalculator.swift + ProviderUsageRowsViewModel.swift)

Splits "today" into the provider's natural reset windows (or 5 even segments) and assigns each slice a usage target, then rebalances around what has actually been observed. This is where the recent weekly-finish trajectory and reset-scoped work lives.

Window tiling & per-slice target — slices / evenDivisionSlices ✓ verified

Driven by dailySessionSegments & siblings in the view-model (segment count 5 for the even path).

Goal: lay the provider's repeating window onto the local day [dayStart, dayEnd] and give each visible slice a fair share of the day's target, proportional to how much of the slice falls inside today.

targetPercent(slice) = dailyTargetPercent × (displayEnd − displayStart) / dayDuration
observedActual(slice) = max(0, endWeekly − startWeekly)   // diff of bracketing history snapshots

Each slice is classified past / current / future × observed / unobserved, with an availability reason (waiting for first usage, offline, before Pitwall started, …) so the UI can explain gaps honestly.

Verified: windows tile backward from the current reset to cover the whole day; target weights sum to the day's target; the max(0, …) on the observed diff guards a snapshot ordering glitch. window(containing:) correctly snaps any instant into its [start, end) bracket.

Rebalance after observed usage — rebalanceTargets ✓ verified

Applied when policy is rebalanceAfterObservedUsage (see next entry).

Goal: once part of the day is spent, re-spread only the remaining day-budget across the slices you can still influence (current + future), so the targets you're shown reflect what's actually left.

remainingPaceBudget = dailyTargetPercent
                      − Σ observedActuals(past+current observed)
                      − Σ reservedTargets(past unobserved)        // keep their fair share parked
target(adjustable i) = remainingPaceBudget × duration(i) / Σ duration(adjustable)

Reset-scoped exclusion: slices whose displayEnd ≤ activeWeeklyResetAt are dropped from both sums, so usage and targets from before a mid-day weekly reset don't count against the post-reset day budget.

Verified: activeWeeklyResetAt is supplied by dailyPaceResetBoundary, which only ever returns a reset that is in the past and inside today (explicit, from the reset field, or inferred from a drop in the weekly counter previous > next + 0.1). So the comparison is semantically correct. If the day is overspent, remainingPaceBudget goes negative and downstream slices correctly turn red.

Rebalance policy — dailySessionRebalancePolicy ✓ verified

Goal: decide whether to keep the original fair per-slice targets or rebalance. If the week is at or under its straight-line trajectory you've earned slack, so keep fair targets; otherwise rebalance toward catch-up.

expectedWeeklyByNow = weeklyFinishTrajectoryPercent(at: now)
policy = weeklyUtilization ≤ expectedWeeklyByNow ? preserveFairTargets : rebalanceAfterObservedUsage

Verified: equality favours the calmer preserveFairTargets; Claude/Codex only.

Weekly-finish trajectory target — weeklyFinishDailyTarget / weeklyFinishTrajectoryPercent ✓ verified

The primary daily target fed into the slices (falls back to dailyBudget.dailyBudgetPercent when weekly reset/utilization is unknown). New in commit 5955e76.

Goal: instead of "spread the remainder evenly," anchor to a fixed straight line that hits exactly 100% at the weekly reset, and hand today the segment of that line still owed — adjusted for where you actually started the day.

trajectory(at t)   = clamp((t − weeklyStart) / (weeklyReset − weeklyStart), 0…1) × 100
targetAtDayEnd     = trajectory( min(nextMidnight, weeklyReset) )

// when weekly% and today's usage are both known:
dayStartUtil       = max(0, weeklyUtilization − todayUsage)
dailyTarget        = max(0, targetAtDayEnd − dayStartUtil)

// otherwise (no live today figure):
dailyTarget        = max(0, targetAtDayEnd − trajectory(dayStart))

Verified well-formed: because dayStartUtil + dailyTarget = targetAtDayEnd ≤ 100, hitting the target lands end-of-day weekly exactly on the finish line and never projects past 100%. On the final partial day, dayEnd clamps to weeklyReset so the target becomes "all remaining." Differs from dailyBudgetPercent by design (fixed finish line vs. flat-remainder) — this is the intended product behaviour, not a discrepancy.

In-day reset boundary — dailyPaceResetBoundary ✓ verified

Goal: find a weekly reset that happened within today's visible strip so the UI can draw a marker and the rebalancer can scope to the post-reset window.

Verified: all branches require the boundary to be ≤ now and within today, which is exactly the precondition the rebalancer's ≤ activeWeeklyResetAt exclusion relies on.

Codex midnight-crossing target — sessionAwareDailyTarget ℹ context

Codex-only helper used by two of the session-segment builders.

Goal: when a Codex session window straddles local midnight, count "today" as a partial extra day so the daily target isn't overstated.

inclusiveDaysRemaining = max(1, floor(daysRemaining) + (crossesMidnight ? 0.8 : 1))
target                 = remainingUtilizationPercent / inclusiveDaysRemaining

Note: the 0.8 partial-day weight is a deliberate heuristic, not a derived constant. Arithmetic is sound (floored at 1 day → no divide-by-zero). Only engages for Codex with remaining budget > 0.

Stage 4 — Presentation math

Weekly-limit forecast — WeeklyLimitForecastText.capTime ✓ verified ⚠ see F2

Invoked by ProviderCardViewModel & ProviderUsageRowsViewModel to produce the "On pace to hit your weekly limit in 2h 10m, around Thursday 4:15 PM" string.

Goal: if the projected max exceeds 100%, estimate when the weekly limit is hit, assuming the window-average pace continues.

projectedFutureUse = projectedWeeklyMaxPercent − weeklyUtilization
burnRatePerSecond  = projectedFutureUse / remainingWindowSeconds
secondsToCap       = (100 − weeklyUtilization) / burnRatePerSecond
capTime            = now + min(secondsToCap, remainingWindowSeconds)   // only when projected > 100

Verified internally consistent: substituting the definitions gives secondsToCap = remainingWindow × (100 − util)/(projected − util), which is always < remainingWindow when projected > 100 — the min() is a safety clamp, and all guards check finiteness/positivity. See finding F2: the alternate branch that would read a pre-computed projectedCapTime is never exercised in production because that field is never populated.

Per-segment color signal — dailySegmentSignal (ratio scale) ✓ verified

Colors each daily-pace chip. This is the scale proposed in alignment/daily-pace-color-scale-alignment.html.

Goal: color a slice by how its actual usage compares to its (rebalanced) target as a ratio, so small targets aren't washed out.

ratio = actual / expectedSignalColor
< 0.50wayBehindyellow
0.50 – <1.00behindPacegreen
1.00 – 1.05onTrackpurple
>1.05 – 1.25warningorange
> 1.25criticalred

Edge guards: expected < 0 → critical (overdrawn); |expected| < 0.05 (≈ zero target) → critical if any usage else onTrack; this keeps the color agreeing with what the "0%" rounding will display.

Verified: buckets are complete & non-overlapping on [0, ∞); the five signalColor RGB values match the palette hexes exactly (purple #9459fa, green #299e4f, yellow #e6b31f, orange #f57a1f, red #e0292e). Worked examples in the alignment doc reproduce.

Absolute-delta signal — paceSignal & raw-utilization fallback — sessionSignal/weeklySignal ⚠ see F3 / F4

Goal: the older band used for the aggregate Daily roll-up and for Session/Weekly rows when no pace target exists.

// paceSignal — absolute percentage-point delta
delta = actual − expected → >12 red · >6 orange · [−6, +6] purple · <−6 green

// sessionSignal — no target, color by raw utilization
≥100 limitHit · ≥80 red · ≥60 orange · [1, 60) purple · <1 unknown

Verified contiguous, but see findings F3 (two copies of paceSignal differ in their near-zero handling) and F4 (the aggregate Daily dot uses this absolute band while the chips use the ratio scale — they can disagree for small targets). The 60/80 session cutoffs are heuristics, not doc-governed.

"% left" & percent formatting ✓ verified ⚠ see F5

Goal: turn a utilization % into remaining headroom and format it cleanly.

headroom = 100 − utilizationPercent
formatPercent(v): show "N%" when within 0.05 of an integer, else one decimal "N.N%"

Verified for normal 0–100 inputs. Codex/Gemini clamp utilization upstream so headroom can't go negative; the Claude path does not (finding F5). Three slightly different "small but non-zero" labels exist (<1% at a 0.5 cutoff, <0.1% at a 0.1 cutoff) — cosmetic.

Stage 1 — Input normalization (parsers & usage clients)

These mostly pass provider numbers through; the arithmetic that exists is unit/clamp handling.

CalculationGoalFormulaVerdict
CodexUsageClient
remaining/used %
Headroom + used %, both clamped. remainingPercent = max(0, min(100, 100 − usedPercent)) gold-standard clamp; pinned by tests.
GeminiQuotaBucket.usedPercent Invert "remaining fraction" 0–1 into used %. max(0, min(100, (1 − remainingFraction) × 100)) clamped; sign correct.
Codex window duration → seconds Window length for the pace bracket. windowStart = resetAt − windowDurationMinutes × 60 minutes→seconds reconcile.
Token-expiry epoch (Claude/Gemini) Auto-detect seconds vs. milliseconds. v > 1e10 ? v/1000 : v (+60 s early-refresh margin) safe threshold (~year 2286).
ClaudeUsageParser utilization Per-window utilization %. Provider utilization passed through verbatim. ⚠ F5 no [0,100] clamp / no fraction guard.
Reset-time parsing (Codex epoch / Gemini ISO) Parse resetsAt. Codex assumes seconds; Gemini ISO lacks fractional-seconds. ⚠ F6 inconsistent vs. other providers.
GeminiProviderAdapter weekly window Pace bracket for the primary bucket. windowStart = resetAt − 24 h, fed to weekly evaluator. ⚠ F1 review

Findings

No errors were found in the core pacing math (Stage 2) or the daily-session math (Stage 3) — both are correct and defensively clamped. The items below are peripheral: one behavioural mismatch worth a product decision, a few consistency/robustness notes, and one reported "bug" that turned out to be correct.

F1 · Gemini uses the weekly pace evaluator over a hard-coded 24 h window review

GeminiProviderAdapter labels the primary bucket weeklyPercent and calls evaluateWeeklyPace with windowStart = resetAt − 24h. The weekly evaluator suppresses the first 6 h and last 1 h — a quarter of a 24 h window — and elapsedFraction is computed against the assumed 24 h. Gemini buckets carry no windowDurationMinutes, so the 24 h is a guess. If the real Gemini quota cadence isn't daily, the pace ratio and projection are miscalibrated and the blackout is coarse.

Recommendation: confirm what the Gemini primary bucket actually represents; either feed a true window duration or use the session evaluator (15 min/5 min gates) for a daily-scale window. Not a crash — guards prevent divide-by-zero — but the pace verdict's accuracy depends on the 24 h assumption holding.

F2 · currentBurnRatePercentPerHour & projectedCapTime are never populated in production

Both PacingState fields are only ever assigned nil by the Claude mapper and the Codex/Gemini adapters (non-nil values appear only in tests). Consequences: the local HTTP API's burnRatePercentPerHour is always null; the persisted pacing record's burnRatePerHour/projectedCapTime are always nil; and the first branch of WeeklyLimitForecastText.capTime (read a stored cap time) is dead — the forecast always recomputes the cap time from the pace ratio instead. The recomputation is correct, so there's no wrong number on screen; these are unwired fields, not a math error. Decide: wire a real burn-rate computation or remove the dormant fields/columns.

F3 · Two copies of paceSignal diverge near zero

The view-model copy treats actual < 0.05 → behindPace and special-cases negative / near-zero expected; the MenuBarStatusFormatter copy only checks actual == 0 and expected > 0. A sub-0.05% value that displays as "0%" can therefore read green in the popover row but purple in the rich menu-bar token. The rounding-vs-color fix recorded in alignment/investigate-daily-row-provider-pace-color.html landed in one copy but not the other. Cosmetic; worth unifying the two functions.

F4 · Aggregate Daily dot uses the absolute band, chips use the ratio scale

Per-segment chips use dailySegmentSignal (ratio) — the doc-approved scale that exists precisely because the absolute ±6/±12-point band is too wide for small daily targets. But the single Daily roll-up dot/emoji (currentDailySegmentSignal, dailySignal, and the menu-bar daily token) still uses the absolute paceSignal. For a small target, the roll-up can read purple while a chip reads orange/red. Also note the magic actual ≥ 40% switch in currentDailySegmentSignal that abruptly changes the color model. Likely an intentional "keep the summary lenient" choice — worth confirming it is.

F5 · Claude utilization is not clamped / not fraction-guarded

Codex and Gemini clamp utilization into [0,100] and Gemini guards the fraction-vs-percent ambiguity; the Claude parser passes the API's utilization through verbatim. If Claude ever returned a value > 100 (a capped state) or a 0–1 fraction, 100 − utilization could render a negative "% left." Low severity — depends on the API contract — but inconsistent with the other two providers. Add a max(0, …) on the "% left" display and/or clamp at the parser.

F6 · Inconsistent reset-time parsing across providers

Codex resetsAt assumes epoch seconds with no ms/s guard (Claude and Gemini token paths have one); Gemini's reset ISO8601DateFormatter omits .withFractionalSeconds, so a fractional-second timestamp parses to nil and silently drops pace/budget for that refresh. Both latent; harmless under today's payloads.

✓ Disproven · Codex ?? 0 > 0 is not a precedence bug

An automated pass flagged primary?.usedPercent ?? 0 > 0 as parsing to primary?.usedPercent ?? (0 > 0). That is wrong: in Swift, NilCoalescingPrecedence is higher than ComparisonPrecedence, so it groups as (primary?.usedPercent ?? 0) > 0 — exactly the intent — and the alternative wouldn't type-check (Double? ?? Bool). hasUsage/hasNoUsage are correct. Parentheses would aid readability but nothing is broken. Recorded here so the false positive isn't "fixed" into a regression.

Verification method

Files audited (relative to repo root): packages/pitwall-core/Sources/PitwallCore/{PacingCalculator,ClaudeUsageDomainMapper,ClaudeUsageParser,ProviderModels}.swift; apps/pitwall-local/Sources/PitwallAppSupport/{DailySessionPaceCalculator,WeeklyLimitForecastText,ProviderState+SessionUtilization,ProviderUsageRowsViewModel,MenuBarStatusFormatter,CodexUsageClient,GeminiUsageClient}.swift; …/Adapters/{CodexProviderAdapter,GeminiProviderAdapter}.swift. No source files were modified by this audit.