diff --git a/mac/Sources/CodeBurnMenubar/Data/QuotaPace.swift b/mac/Sources/CodeBurnMenubar/Data/QuotaPace.swift new file mode 100644 index 00000000..01ff031f --- /dev/null +++ b/mac/Sources/CodeBurnMenubar/Data/QuotaPace.swift @@ -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 + ) + } +} diff --git a/mac/Sources/CodeBurnMenubar/Views/HeatmapSection.swift b/mac/Sources/CodeBurnMenubar/Views/HeatmapSection.swift index c9a2b915..c28c5dab 100644 --- a/mac/Sources/CodeBurnMenubar/Views/HeatmapSection.swift +++ b/mac/Sources/CodeBurnMenubar/Views/HeatmapSection.swift @@ -2076,7 +2076,7 @@ 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 { @@ -2084,7 +2084,7 @@ private struct CodexPlanInsight: View { 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 @@ -2095,7 +2095,7 @@ 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 { @@ -2103,7 +2103,7 @@ private struct CodexPlanInsight: View { label: "\(limit.name) · \(s.windowLabel)", percent: s.usedPercent, resetsAt: s.resetsAt, - projection: nil + projection: pace(for: s) ) } } @@ -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 } @@ -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 { @@ -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 { diff --git a/mac/Tests/CodeBurnMenubarTests/QuotaPaceTests.swift b/mac/Tests/CodeBurnMenubarTests/QuotaPaceTests.swift new file mode 100644 index 00000000..e71cef1e --- /dev/null +++ b/mac/Tests/CodeBurnMenubarTests/QuotaPaceTests.swift @@ -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) + } +}