mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-22 15:05:16 +00:00
menubar: linear pace on Codex quota windows — deficit/reserve, projection, run-out ETA (#726) (#728)
Co-authored-by: Aditya Vikram Singh <247195684+avs-io@users.noreply.github.com>
This commit is contained in:
parent
b8435073ee
commit
bcef34f081
3 changed files with 217 additions and 4 deletions
63
mac/Sources/CodeBurnMenubar/Data/QuotaPace.swift
Normal file
63
mac/Sources/CodeBurnMenubar/Data/QuotaPace.swift
Normal file
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
110
mac/Tests/CodeBurnMenubarTests/QuotaPaceTests.swift
Normal file
110
mac/Tests/CodeBurnMenubarTests/QuotaPaceTests.swift
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue