Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions mac/Sources/CodeBurnMenubar/Data/QuotaPace.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import Foundation

/// Pure pace math for a quota window (#726 phase 1): whole-window linear
/// extrapolation plus the noise guards that keep it honest. Deliberately no
/// recent-burst weighting or smoothing — with only (used%, resetsAt, window
/// length) to go on, the average pace over the elapsed window is the only
/// rate we can defend.
enum QuotaPace {
struct Result: Equatable {
/// used% − expected%; positive means ahead of pace (in deficit).
let deltaPercent: Double
/// Linear projection of used% at the reset boundary.
let projectedPercent: Double
let willOverflow: Bool
/// When the window hits 100% at the current pace. Nil when there is
/// no overflow, or when the window is short enough that a linear ETA
/// would cry wolf (one heavy burst on a 5h window reads as "runs out
/// in 40min", then recovers). Deficit/reserve still shows there.
let hitsLimitAt: Date?
}

/// Windows at or under this length get deficit/reserve only, no ETA.
static let etaSuppressionMaxSeconds: TimeInterval = 6 * 3600
/// Show nothing until this fraction of the window has elapsed: projecting
/// a whole week off the first few minutes is noise, not signal.
static let minimumElapsedFraction = 0.03

static func evaluate(
usedPercent: Double,
resetsAt: Date?,
windowSeconds: Int,
now: Date = Date()
) -> Result? {
guard let resetsAt, windowSeconds > 0 else { return nil }
let duration = TimeInterval(windowSeconds)
let remaining = resetsAt.timeIntervalSince(now)
// Reset in the past, or further out than one full window: clock or
// data skew. Say nothing rather than something wrong.
guard remaining > 0, remaining <= duration else { return nil }
let elapsed = duration - remaining
let elapsedFraction = elapsed / duration
guard elapsedFraction >= minimumElapsedFraction else { return nil }
let used = min(max(usedPercent, 0), 100)
// Exhausted window: the bar already says everything.
guard used < 100 else { return nil }

let delta = used - elapsedFraction * 100
let projected = used / elapsedFraction
var hitsLimitAt: Date?
if projected > 100, duration > etaSuppressionMaxSeconds {
let percentPerSecond = used / elapsed
if percentPerSecond > 0 {
hitsLimitAt = now.addingTimeInterval((100 - used) / percentPerSecond)
}
}
return Result(
deltaPercent: delta,
projectedPercent: projected,
willOverflow: projected > 100,
hitsLimitAt: hitsLimitAt
)
}
}
48 changes: 44 additions & 4 deletions mac/Sources/CodeBurnMenubar/Views/HeatmapSection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2076,15 +2076,15 @@ private struct CodexPlanInsight: View {
label: "\(primary.windowLabel) window",
percent: primary.usedPercent,
resetsAt: primary.resetsAt,
projection: nil
projection: pace(for: primary)
)
}
if let secondary = usage.secondary {
UtilizationRow(
label: "\(secondary.windowLabel) window",
percent: secondary.usedPercent,
resetsAt: secondary.resetsAt,
projection: nil
projection: pace(for: secondary)
)
}
// Surface non-zero per-model rate limits (Codex Spark, etc.) so
Expand All @@ -2095,15 +2095,15 @@ private struct CodexPlanInsight: View {
label: "\(limit.name) · \(p.windowLabel)",
percent: p.usedPercent,
resetsAt: p.resetsAt,
projection: nil
projection: pace(for: p)
)
}
if let s = limit.secondary, s.usedPercent > 0 {
UtilizationRow(
label: "\(limit.name) · \(s.windowLabel)",
percent: s.usedPercent,
resetsAt: s.resetsAt,
projection: nil
projection: pace(for: s)
)
}
}
Expand All @@ -2126,6 +2126,25 @@ private struct CodexPlanInsight: View {
.padding(.bottom, 8)
}

/// Codex has no snapshot history yet, so this is the linear phase-1 pace
/// (#726): deficit/reserve everywhere, projection and ETA only where the
/// window is long enough for a whole-window rate to be defensible.
private func pace(for window: CodexUsage.Window) -> WindowProjection? {
guard let result = QuotaPace.evaluate(
usedPercent: window.usedPercent,
resetsAt: window.resetsAt,
windowSeconds: window.limitWindowSeconds
) else { return nil }
return WindowProjection(
percent: result.projectedPercent,
willOverflow: result.willOverflow,
hitsLimitAt: result.hitsLimitAt,
source: .linear,
deltaPercent: result.deltaPercent,
compact: TimeInterval(window.limitWindowSeconds) <= QuotaPace.etaSuppressionMaxSeconds
)
}

private func resetCreditsLabel(_ resets: CodexUsage.ResetCredits) -> String {
let count = "\(resets.availableCount) available"
guard let next = resets.nextExpiresAt else { return count }
Expand All @@ -2145,6 +2164,12 @@ private struct WindowProjection {
let willOverflow: Bool
let hitsLimitAt: Date?
let source: Source
/// used% − expected%; set by pace-aware callers (#726) to get the
/// deficit/reserve caption. Nil keeps the original caption wording.
var deltaPercent: Double? = nil
/// Stage-only caption: deficit/reserve without projection or ETA. Used on
/// short windows where a linear extrapolation isn't defensible.
var compact: Bool = false
}

private struct UtilizationRow: View {
Expand Down Expand Up @@ -2211,6 +2236,21 @@ private struct ProjectionCaption: View {

private var captionText: String {
let projected = String(format: "%.0f%%", projection.percent)
// Deficit-first wording (#726): pace state up front, projection or
// ETA after it, and neither on windows flagged compact.
if let delta = projection.deltaPercent {
if abs(delta) <= 2 {
return projection.compact ? "On pace" : "On pace: \(projected) at reset"
}
let stage = delta > 0
? String(format: "%.0f%% in deficit", delta)
: String(format: "%.0f%% in reserve", -delta)
if projection.compact { return stage }
if projection.willOverflow, let hit = projection.hitsLimitAt {
return "\(stage) · hits 100% \(relativeReset(hit))"
}
return "\(stage) · \(projected) at reset"
}
switch projection.source {
case .linear:
if projection.willOverflow, let hit = projection.hitsLimitAt {
Expand Down
110 changes: 110 additions & 0 deletions mac/Tests/CodeBurnMenubarTests/QuotaPaceTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import Foundation
import XCTest
@testable import CodeBurnMenubar

final class QuotaPaceTests: XCTestCase {
private let now = Date(timeIntervalSince1970: 1_800_000_000)
private let week = 7 * 24 * 3600
private let fiveHours = 5 * 3600

/// resetsAt such that `fraction` of the window has elapsed at `now`.
private func resets(afterElapsedFraction fraction: Double, windowSeconds: Int) -> Date {
now.addingTimeInterval(TimeInterval(windowSeconds) * (1 - fraction))
}

func testOnPaceMidWindow() {
let r = QuotaPace.evaluate(
usedPercent: 50, resetsAt: resets(afterElapsedFraction: 0.5, windowSeconds: week),
windowSeconds: week, now: now
)
XCTAssertEqual(r?.deltaPercent ?? -1, 0, accuracy: 0.001)
XCTAssertEqual(r?.projectedPercent ?? -1, 100, accuracy: 0.001)
XCTAssertEqual(r?.willOverflow, false)
XCTAssertNil(r?.hitsLimitAt)
}

func testDeficitOverflowWeeklyGetsETABeforeReset() {
let resetsAt = resets(afterElapsedFraction: 0.5, windowSeconds: week)
let r = QuotaPace.evaluate(usedPercent: 80, resetsAt: resetsAt, windowSeconds: week, now: now)
XCTAssertEqual(r?.deltaPercent ?? 0, 30, accuracy: 0.001)
XCTAssertEqual(r?.projectedPercent ?? 0, 160, accuracy: 0.001)
XCTAssertEqual(r?.willOverflow, true)
// At this pace: 80% in 3.5 days → 100% at 4.375 days elapsed.
let expectedHit = now.addingTimeInterval(TimeInterval(week) * (0.5 * 100 / 80 - 0.5))
XCTAssertEqual(
r?.hitsLimitAt?.timeIntervalSince1970 ?? 0,
expectedHit.timeIntervalSince1970,
accuracy: 1
)
XCTAssertLessThan(r!.hitsLimitAt!, resetsAt)
}

func testReserveNoETA() {
let r = QuotaPace.evaluate(
usedPercent: 20, resetsAt: resets(afterElapsedFraction: 0.5, windowSeconds: week),
windowSeconds: week, now: now
)
XCTAssertEqual(r?.deltaPercent ?? 0, -30, accuracy: 0.001)
XCTAssertEqual(r?.projectedPercent ?? 0, 40, accuracy: 0.001)
XCTAssertEqual(r?.willOverflow, false)
XCTAssertNil(r?.hitsLimitAt)
}

func testShortWindowOverflowSuppressesETAButKeepsDeficit() {
let r = QuotaPace.evaluate(
usedPercent: 90, resetsAt: resets(afterElapsedFraction: 0.5, windowSeconds: fiveHours),
windowSeconds: fiveHours, now: now
)
XCTAssertEqual(r?.deltaPercent ?? 0, 40, accuracy: 0.001)
XCTAssertEqual(r?.willOverflow, true)
XCTAssertNil(r?.hitsLimitAt, "5h window must not show a linear run-out ETA")
}

func testEarlyWindowShowsNothing() {
XCTAssertNil(QuotaPace.evaluate(
usedPercent: 5, resetsAt: resets(afterElapsedFraction: 0.02, windowSeconds: week),
windowSeconds: week, now: now
))
}

func testSkewGuards() {
// Reset in the past.
XCTAssertNil(QuotaPace.evaluate(
usedPercent: 50, resetsAt: now.addingTimeInterval(-60), windowSeconds: week, now: now
))
// Reset further out than one full window.
XCTAssertNil(QuotaPace.evaluate(
usedPercent: 50, resetsAt: now.addingTimeInterval(TimeInterval(week) + 3600),
windowSeconds: week, now: now
))
// Missing reset or nonsense window length.
XCTAssertNil(QuotaPace.evaluate(usedPercent: 50, resetsAt: nil, windowSeconds: week, now: now))
XCTAssertNil(QuotaPace.evaluate(
usedPercent: 50, resetsAt: resets(afterElapsedFraction: 0.5, windowSeconds: week),
windowSeconds: 0, now: now
))
}

func testExhaustedWindowShowsNothing() {
XCTAssertNil(QuotaPace.evaluate(
usedPercent: 100, resetsAt: resets(afterElapsedFraction: 0.5, windowSeconds: week),
windowSeconds: week, now: now
))
// Over-100 inputs clamp to exhausted, same silence.
XCTAssertNil(QuotaPace.evaluate(
usedPercent: 130, resetsAt: resets(afterElapsedFraction: 0.5, windowSeconds: week),
windowSeconds: week, now: now
))
}

func testZeroUsageMidWindowIsAllReserve() {
let r = QuotaPace.evaluate(
usedPercent: 0, resetsAt: resets(afterElapsedFraction: 0.5, windowSeconds: week),
windowSeconds: week, now: now
)
XCTAssertEqual(r?.deltaPercent ?? 0, -50, accuracy: 0.001)
XCTAssertEqual(r?.projectedPercent ?? -1, 0, accuracy: 0.001)
XCTAssertEqual(r?.willOverflow, false)
XCTAssertNil(r?.hitsLimitAt)
}
}