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
58 changes: 40 additions & 18 deletions Sources/CodexBar/SessionEquivalentForecast.swift
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,6 @@ struct SessionEquivalentForecast: Equatable, Sendable {
== self.sessionWindowMinutes,
weeklyWindow.windowMinutes.map({ PlanUtilizationSeriesName.weekly.canonicalWindowMinutes($0) })
== self.weeklyWindowMinutes,
let sessionResetsAt = sessionWindow.resetsAt,
let weeklyResetsAt = weeklyWindow.resetsAt,
weeklyWindow.usedPercent.isFinite,
(0...100).contains(weeklyWindow.usedPercent),
Expand All @@ -64,12 +63,18 @@ struct SessionEquivalentForecast: Equatable, Sendable {

let sessionSeconds = TimeInterval(Self.sessionWindowMinutes * 60)
let weeklySeconds = TimeInterval(Self.weeklyWindowMinutes * 60)
let sessionRemaining = sessionResetsAt.timeIntervalSince(now)
if let sessionResetsAt = sessionWindow.resetsAt {
let sessionRemaining = sessionResetsAt.timeIntervalSince(now)
guard sessionRemaining.isFinite,
sessionRemaining > 0,
sessionRemaining <= sessionSeconds + Self.resetTolerance
else {
return nil
}
}

let weeklyRemaining = weeklyResetsAt.timeIntervalSince(now)
guard sessionRemaining.isFinite,
sessionRemaining > 0,
sessionRemaining <= sessionSeconds + Self.resetTolerance,
weeklyRemaining.isFinite,
guard weeklyRemaining.isFinite,
weeklyRemaining > 0,
weeklyRemaining <= weeklySeconds + Self.resetTolerance
else {
Expand Down Expand Up @@ -167,7 +172,7 @@ enum SessionEquivalentBurnEstimator {

static func estimate(
histories: [PlanUtilizationSeriesHistory],
currentSessionResetsAt: Date,
currentSessionResetsAt: Date?,
now: Date,
sampleLimit: Int = Self.defaultSampleLimit) -> SessionEquivalentBurnEstimate?
{
Expand All @@ -188,15 +193,20 @@ enum SessionEquivalentBurnEstimator {

let sessionDuration = TimeInterval(SessionEquivalentForecast.sessionWindowMinutes * 60)
let weeklyDuration = TimeInterval(SessionEquivalentForecast.weeklyWindowMinutes * 60)
let currentSessionRemaining = currentSessionResetsAt.timeIntervalSince(now)
guard currentSessionRemaining.isFinite,
currentSessionRemaining > 0,
currentSessionRemaining <= sessionDuration + Self.resetEquivalenceTolerance,
Self.isChronologicallyOrdered(sessionHistory.entries),
guard Self.isChronologicallyOrdered(sessionHistory.entries),
Self.isChronologicallyOrdered(weeklyHistory.entries)
else {
return nil
}
if let currentSessionResetsAt {
let currentSessionRemaining = currentSessionResetsAt.timeIntervalSince(now)
guard currentSessionRemaining.isFinite,
currentSessionRemaining > 0,
currentSessionRemaining <= sessionDuration + Self.resetEquivalenceTolerance
else {
return nil
}
}

var groups: [SessionGroup] = []
groups.reserveCapacity(sessionHistory.entries.count)
Expand Down Expand Up @@ -226,7 +236,10 @@ enum SessionEquivalentBurnEstimator {
}

let completedActiveGroups = groups.reversed().compactMap { group -> SessionGroup? in
guard group.resetsAt < currentSessionResetsAt.addingTimeInterval(-Self.resetEquivalenceTolerance),
let precedesCurrentSession = currentSessionResetsAt.map {
group.resetsAt < $0.addingTimeInterval(-Self.resetEquivalenceTolerance)
} ?? true

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include time in idle burn cache keys

When currentSessionResetsAt is nil, this branch makes the eligible history groups depend on now via the later group.resetsAt <= now check, but UsageStore still caches the estimator result under the same nil-reset key across menu openings. If the first idle computation happens before a recorded partial session's reset, it can cache nil or an older median; after that reset passes with no history revision, the now-completed group is still skipped because the stale cached estimate is reused.

Useful? React with 👍 / 👎.

guard precedesCurrentSession,
group.resetsAt <= now,
group.maximumUsedPercent > 0
else {
Expand Down Expand Up @@ -399,10 +412,13 @@ enum SessionEquivalentBurnEstimator {
}

private struct SessionEquivalentBurnCacheKey: Equatable {
static let idleTimeBucketSeconds: TimeInterval = 60

let historyRevision: Int
let historySelectionIdentity: String
let currentSessionResetsAt: Date
let currentSessionResetsAt: Date?
let weeklyWindowID: String?
let idleTimeBucket: Int64?
}

struct SessionEquivalentBurnCacheEntry {
Expand All @@ -422,12 +438,14 @@ extension UsageStore {
now: Date = .init()) -> SessionEquivalentForecast?
{
guard sessionWindow.windowMinutes.map({ PlanUtilizationSeriesName.session.canonicalWindowMinutes($0) })
== SessionEquivalentForecast.sessionWindowMinutes,
let currentSessionResetsAt = sessionWindow.resetsAt,
currentSessionResetsAt.timeIntervalSinceReferenceDate.isFinite
== SessionEquivalentForecast.sessionWindowMinutes
else {
return nil
}
let currentSessionResetsAt = sessionWindow.resetsAt
guard currentSessionResetsAt?.timeIntervalSinceReferenceDate.isFinite ?? true else {
return nil
}

let selection = historySelection ?? self.planUtilizationHistorySelection(for: provider)
guard self.sessionEquivalentHistoryIdentityMatches(
Expand All @@ -441,7 +459,11 @@ extension UsageStore {
historyRevision: self.planUtilizationHistoryRevision,
historySelectionIdentity: selection.cacheIdentity,
currentSessionResetsAt: currentSessionResetsAt,
weeklyWindowID: weeklyWindowID)
weeklyWindowID: weeklyWindowID,
idleTimeBucket: currentSessionResetsAt == nil
? Int64(floor(now.timeIntervalSinceReferenceDate /
SessionEquivalentBurnCacheKey.idleTimeBucketSeconds))
: nil)
let burnEstimate: SessionEquivalentBurnEstimate?
if let cached = self.sessionEquivalentBurnCache[provider], cached.key == cacheKey {
burnEstimate = cached.estimate
Expand Down
67 changes: 66 additions & 1 deletion Tests/CodexBarTests/SessionEquivalentForecastTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -440,7 +440,7 @@ struct SessionEquivalentForecastTests {
planSeries(name: .session, windowMinutes: 300, entries: sessionEntries),
planSeries(name: .weekly, windowMinutes: 10080, entries: weeklyEntries),
],
currentSessionResetsAt: fixture.currentSessionReset,
currentSessionResetsAt: nil,
now: now) == nil)
}

Expand Down Expand Up @@ -562,6 +562,71 @@ struct SessionEquivalentForecastTests {
windowID: "antigravity-quota-summary-3p-weekly"))
}

@MainActor
@Test
func `usage store preserves learned forecast while current session is idle`() throws {
let store = UsageStorePlanUtilizationTests.makeStore()
let fixture = Self.historyFixture(burns: [4, 8, 6, 10])
store.planUtilizationHistory[.claude] = PlanUtilizationHistoryBuckets(unscoped: fixture.histories)
let now = fixture.currentSessionReset.addingTimeInterval(-3600)
let session = RateWindow(
usedPercent: 0,
windowMinutes: 300,
resetsAt: nil,
resetDescription: nil)
let weekly = RateWindow(
usedPercent: 60,
windowMinutes: 10080,
resetsAt: now.addingTimeInterval(2 * 24 * 3600),
resetDescription: nil)

let forecast = try #require(store.sessionEquivalentForecast(
provider: .claude,
sessionWindow: session,
weeklyWindow: weekly,
now: now))

#expect(forecast.sampleCount == 4)
#expect(forecast.estimatedWindowsToExhaustWeekly > 0)
}

@MainActor
@Test
func `idle forecast cache refreshes after a historical session completes`() throws {
let store = UsageStorePlanUtilizationTests.makeStore()
let fixture = Self.historyFixture(burns: [5, 5, 5])
store.planUtilizationHistory[.claude] = PlanUtilizationHistoryBuckets(unscoped: fixture.histories)
store.planUtilizationHistoryRevision = 1
let thirdReset = fixture.currentSessionReset.addingTimeInterval(-5 * 3600)
let beforeReset = thirdReset.addingTimeInterval(-61)
let afterReset = thirdReset.addingTimeInterval(61)
let session = RateWindow(
usedPercent: 0,
windowMinutes: 300,
resetsAt: nil,
resetDescription: nil)
let weekly = RateWindow(
usedPercent: 15,
windowMinutes: 10080,
resetsAt: afterReset.addingTimeInterval(2 * 24 * 3600),
resetDescription: nil)

#expect(store.sessionEquivalentForecast(
provider: .claude,
sessionWindow: session,
weeklyWindow: weekly,
now: beforeReset) == nil)
#expect(store._sessionEquivalentHistoryScanCountForTesting == 1)

let forecast = try #require(store.sessionEquivalentForecast(
provider: .claude,
sessionWindow: session,
weeklyWindow: weekly,
now: afterReset))
#expect(forecast.sampleCount == 3)
#expect(store._sessionEquivalentHistoryScanCountForTesting == 2)
}

@MainActor
@Test
func `usage store memoizes the history scan until revision changes`() {
Expand Down
Loading