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.
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.
utilizationPercent, resetsAt, window duration.PacingCalculator: pace ratio, label, projected max, daily budget, today's usage.DailySessionPaceCalculator + view-model: per-slice targets, rebalance, weekly-finish trajectory.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
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):
| paceRatio | Label | Action | Meaning |
|---|---|---|---|
< 0.50 | underusing | push | Far below the even-burn line — headroom to spare. |
0.50 – 0.85 | behindPace | push | Below pace. |
0.85 – 1.15 | onPace | push | Tracking the even-burn line. |
1.15 – 1.50 | aheadOfPace | conserve | Spending faster than even. |
1.50 – 2.00 | warning | conserve | Well ahead — likely to cap early. |
> 2.00 | critical | wait | Burning roughly 2×+ the even rate. |
Projected weekly maximum — projectedWeeklyMax ✓ verified
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
Daily budget — dailyBudget ✓ verified
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
Today's usage baseline — todayUsage / snapshotsInCurrentWeeklyWindow ✓ verified
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.
- Exact — last snapshot recorded before local midnight:
delta = max(0, weeklyNow − baseline). - Estimated — no pre-midnight snapshot, but one exists within a 2 h grace after midnight (and ≤ now): diff against it.
- Reset-aware — snapshots taken before a weekly reset (detected as any snapshot whose weekly % is higher than the current %) are dropped, so a counter that restarted mid-day doesn't poison the baseline.
- Unknown — no usable baseline (e.g. only a snapshot from after the 2 h grace) → no delta shown.
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
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.
Rebalance after observed usage — rebalanceTargets ✓ verified
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.
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
Weekly-finish trajectory target — weeklyFinishDailyTarget / weeklyFinishTrajectoryPercent ✓ verified
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))
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.
- Exact — a recorded
weeklyResetAtinside(visibleStart, visibleEnd), ≤ now, bracketed by snapshots. - Inferred — a consecutive pair of observations where the weekly % drops (
previous > next + 0.1) inside the strip; the drop time is the reset.
Codex midnight-crossing target — sessionAwareDailyTarget ℹ context
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
Stage 4 — Presentation math
Weekly-limit forecast — WeeklyLimitForecastText.capTime ✓ verified ⚠ see F2
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
Per-segment color signal — dailySegmentSignal (ratio scale) ✓ verified
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 / expected | Signal | Color |
|---|---|---|
< 0.50 | wayBehind | yellow |
0.50 – <1.00 | behindPace | green |
1.00 – 1.05 | onTrack | purple |
>1.05 – 1.25 | warning | orange |
> 1.25 | critical | red |
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.
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
"% 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%"
Stage 1 — Input normalization (parsers & usage clients)
These mostly pass provider numbers through; the arithmetic that exists is unit/clamp handling.
| Calculation | Goal | Formula | Verdict |
|---|---|---|---|
CodexUsageClientremaining/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.
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
- Read every calculation source end-to-end:
PacingCalculator,DailySessionPaceCalculator,ClaudeUsageDomainMapper, the Codex/Gemini adapters,WeeklyLimitForecastText, the signal/format helpers inProviderUsageRowsViewModel&MenuBarStatusFormatter, and the usage clients/parsers. - Re-derived each formula by hand and checked it against the worked examples baked into the test suites.
- Ran
swift test --package-path packages/pitwall-core→ 98 tests, 0 failures (exit 0). Full app baseline is 504/504 perCLAUDE.md. - Mapped every calculation to its call sites via source search, so each entry's "where it is invoked" reflects the real data path.
- Independently re-verified the two most surprising automated findings against the source before recording them — which is how F-disproven was caught.
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.