import CodexBarCoreimport Foundation struct SessionEquivalentBurnEstimate: Equatable, Sendable { let medianWeeklyPercentPerWindow: Double let sampleCount: Int} struct SessionEquivalentForecast: Equatable, Sendable { static let sessionWindowMinutes = 300 static let weeklyWindowMinutes = 10080 static let resetTolerance: TimeInterval = 2 * 60 let estimatedWindowsToExhaustWeekly: Double let windowsUntilReset: Int let availableWindowsUntilReset: Double let sampleCount: Int let weeklyResetsAt: Date let weeklyUsedPercent: Double let weeklyWindowID: String? init( estimatedWindowsToExhaustWeekly: Double, windowsUntilReset: Int, availableWindowsUntilReset: Double? = nil, sampleCount: Int, weeklyResetsAt: Date, weeklyUsedPercent: Double, weeklyWindowID: String? = nil) { self.estimatedWindowsToExhaustWeekly = estimatedWindowsToExhaustWeekly self.windowsUntilReset = windowsUntilReset self.availableWindowsUntilReset = availableWindowsUntilReset ?? Double(windowsUntilReset) self.sampleCount = sampleCount self.weeklyResetsAt = weeklyResetsAt self.weeklyUsedPercent = weeklyUsedPercent self.weeklyWindowID = weeklyWindowID } static func make( sessionWindow: RateWindow, weeklyWindow: RateWindow, burnEstimate: SessionEquivalentBurnEstimate, weeklyWindowID: String? = nil, now: Date, workDays: Int?, calendar: Calendar = .current) -> Self? { guard !sessionWindow.isSyntheticPlaceholder, sessionWindow.windowMinutes.map({ PlanUtilizationSeriesName.session.canonicalWindowMinutes($0) }) == 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), burnEstimate.medianWeeklyPercentPerWindow.isFinite, burnEstimate.medianWeeklyPercentPerWindow > 0, burnEstimate.sampleCount >= SessionEquivalentBurnEstimator.minimumSampleCount else { return nil } let sessionSeconds = TimeInterval(Self.sessionWindowMinutes * 60) let weeklySeconds = TimeInterval(Self.weeklyWindowMinutes * 60) let sessionRemaining = sessionResetsAt.timeIntervalSince(now) let weeklyRemaining = weeklyResetsAt.timeIntervalSince(now) guard sessionRemaining.isFinite, sessionRemaining > 0, sessionRemaining <= sessionSeconds + Self.resetTolerance, weeklyRemaining.isFinite, weeklyRemaining > 0, weeklyRemaining <= weeklySeconds + Self.resetTolerance else { return nil } let remainingWeeklyPercent = (100 - weeklyWindow.usedPercent).clamped(to: 0...100) guard remainingWeeklyPercent > 0 else { return nil } let estimatedWindows = remainingWeeklyPercent / burnEstimate.medianWeeklyPercentPerWindow guard estimatedWindows.isFinite, estimatedWindows >= 0 else { return nil } let remainingSeconds = Self.effectiveRemainingSeconds( from: now, to: weeklyResetsAt, workDays: workDays, calendar: calendar) guard remainingSeconds >= 0 else { return nil } let availableWindowsUntilReset = remainingSeconds / sessionSeconds let windowsUntilReset = Int(floor(availableWindowsUntilReset)) return Self( estimatedWindowsToExhaustWeekly: estimatedWindows, windowsUntilReset: windowsUntilReset, availableWindowsUntilReset: availableWindowsUntilReset, sampleCount: burnEstimate.sampleCount, weeklyResetsAt: weeklyResetsAt, weeklyUsedPercent: weeklyWindow.usedPercent, weeklyWindowID: weeklyWindowID) } func applies(to weeklyWindow: RateWindow, windowID: String?) -> Bool { guard weeklyWindow.windowMinutes.map({ PlanUtilizationSeriesName.weekly.canonicalWindowMinutes($0) }) == Self.weeklyWindowMinutes, let resetsAt = weeklyWindow.resetsAt else { return false } return self.weeklyWindowID == windowID && abs(resetsAt.timeIntervalSince(self.weeklyResetsAt)) < 2 * 60 && abs(weeklyWindow.usedPercent - self.weeklyUsedPercent) < 0.001 } private static func effectiveRemainingSeconds( from now: Date, to resetsAt: Date, workDays: Int?, calendar: Calendar) -> TimeInterval { let wallClockSeconds = max(0, resetsAt.timeIntervalSince(now)) guard let workDays, workDays >= 2, workDays < 7 else { return wallClockSeconds } var workSeconds: TimeInterval = 0 var cursor = now while cursor < resetsAt { guard let nextDay = calendar.date( byAdding: .day, value: 1, to: calendar.startOfDay(for: cursor)), nextDay > cursor else { return wallClockSeconds } let sliceEnd = min(nextDay, resetsAt) if Self.isWorkday(cursor, workDays: workDays, calendar: calendar) { workSeconds += sliceEnd.timeIntervalSince(cursor) } cursor = sliceEnd } return workSeconds } private static func isWorkday(_ date: Date, workDays: Int, calendar: Calendar) -> Bool { let weekday = calendar.component(.weekday, from: date) let isoWeekday = weekday == 1 ? 7 : weekday - 1 return isoWeekday <= workDays }} enum SessionEquivalentBurnEstimator { static let defaultSampleLimit = 7 static let minimumSampleCount = 3 private static let observationAlignmentTolerance: TimeInterval = 0 private static let resetEquivalenceTolerance = SessionEquivalentForecast.resetTolerance private struct SessionGroup { let resetsAt: Date var entries: [PlanUtilizationHistoryEntry] var maximumUsedPercent: Double } private struct BurnObservation { let sessionUsedPercent: Double let weeklyEntry: PlanUtilizationHistoryEntry } static func estimate( histories: [PlanUtilizationSeriesHistory], currentSessionResetsAt: Date, now: Date, sampleLimit: Int = Self.defaultSampleLimit) -> SessionEquivalentBurnEstimate? { guard sampleLimit > 0, let sessionHistory = histories.first(where: { $0.name == .session && $0.name.canonicalWindowMinutes($0.windowMinutes) == SessionEquivalentForecast.sessionWindowMinutes }), let weeklyHistory = histories.first(where: { $0.name == .weekly && $0.name.canonicalWindowMinutes($0.windowMinutes) == SessionEquivalentForecast.weeklyWindowMinutes }) else { return nil } 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), Self.isChronologicallyOrdered(weeklyHistory.entries) else { return nil } var groups: [SessionGroup] = [] groups.reserveCapacity(sessionHistory.entries.count) for entry in sessionHistory.entries { guard entry.usedPercent.isFinite, (0...100).contains(entry.usedPercent), let resetsAt = entry.resetsAt, Self.isPlausibleReset( resetsAt, capturedAt: entry.capturedAt, duration: sessionDuration) else { continue } if let lastIndex = groups.indices.last, abs(groups[lastIndex].resetsAt.timeIntervalSince(resetsAt)) <= Self.resetEquivalenceTolerance { groups[lastIndex].entries.append(entry) groups[lastIndex].maximumUsedPercent = max(groups[lastIndex].maximumUsedPercent, entry.usedPercent) } else { guard groups.last.map({ $0.resetsAt <= resetsAt }) ?? true else { return nil } groups.append(SessionGroup( resetsAt: resetsAt, entries: [entry], maximumUsedPercent: entry.usedPercent)) } } let completedActiveGroups = groups.reversed().compactMap { group -> SessionGroup? in guard group.resetsAt < currentSessionResetsAt.addingTimeInterval(-Self.resetEquivalenceTolerance), group.resetsAt <= now, group.maximumUsedPercent > 0 else { return nil } return group } let weeklyEntries = weeklyHistory.entries.filter { entry in entry.usedPercent.isFinite && (0...100).contains(entry.usedPercent) && entry.resetsAt.map { Self.isPlausibleReset($0, capturedAt: entry.capturedAt, duration: weeklyDuration) } == true } guard !weeklyEntries.isEmpty else { return nil } var burns: [Double] = [] let candidateGroups = completedActiveGroups.prefix(sampleLimit) burns.reserveCapacity(candidateGroups.count) for group in candidateGroups { guard let fullAllowanceBurn = Self.normalizedBurn( for: group, weeklyEntries: weeklyEntries, sessionDuration: sessionDuration) else { continue } burns.append(fullAllowanceBurn) } guard burns.count >= Self.minimumSampleCount else { return nil } burns.sort() let middle = burns.count / 2 let median = burns.count.isMultiple(of: 2) ? (burns[middle - 1] + burns[middle]) / 2 : burns[middle] guard median.isFinite, median > 0 else { return nil } return SessionEquivalentBurnEstimate( medianWeeklyPercentPerWindow: median, sampleCount: burns.count) } private static func normalizedBurn( for group: SessionGroup, weeklyEntries: [PlanUtilizationHistoryEntry], sessionDuration: TimeInterval) -> Double? { guard let firstSessionEntry = group.entries.first, let lastSessionEntry = group.entries.last else { return nil } var observations: [BurnObservation] = [] let windowStart = group.resetsAt.addingTimeInterval(-sessionDuration) if let weeklyStart = Self.nearestEntry( to: windowStart, entries: weeklyEntries, tolerance: Self.resetEquivalenceTolerance, requireNotAfterTarget: true), weeklyStart.capturedAt <= windowStart, weeklyStart.capturedAt < firstSessionEntry.capturedAt { observations.append(BurnObservation(sessionUsedPercent: 0, weeklyEntry: weeklyStart)) } for sessionEntry in group.entries { guard let weeklyEntry = Self.nearestEntry( to: sessionEntry.capturedAt, entries: weeklyEntries, tolerance: Self.observationAlignmentTolerance) else { continue } observations.append(BurnObservation( sessionUsedPercent: sessionEntry.usedPercent, weeklyEntry: weeklyEntry)) } if group.maximumUsedPercent >= 100, let weeklyEnd = Self.nearestEntry( to: group.resetsAt, entries: weeklyEntries, tolerance: Self.resetEquivalenceTolerance, requireNotAfterTarget: true), weeklyEnd.capturedAt <= group.resetsAt, lastSessionEntry.capturedAt < weeklyEnd.capturedAt { observations.append(BurnObservation(sessionUsedPercent: 100, weeklyEntry: weeklyEnd)) } observations.sort { lhs, rhs in if lhs.weeklyEntry.capturedAt != rhs.weeklyEntry.capturedAt { return lhs.weeklyEntry.capturedAt < rhs.weeklyEntry.capturedAt } return lhs.sessionUsedPercent < rhs.sessionUsedPercent } guard let start = observations.first, let end = observations.last, start.weeklyEntry.capturedAt < end.weeklyEntry.capturedAt, let startReset = start.weeklyEntry.resetsAt, let endReset = end.weeklyEntry.resetsAt, abs(startReset.timeIntervalSince(endReset)) <= Self.resetEquivalenceTolerance else { return nil } let sessionConsumption = end.sessionUsedPercent - start.sessionUsedPercent let weeklyBurn = end.weeklyEntry.usedPercent - start.weeklyEntry.usedPercent guard sessionConsumption.isFinite, sessionConsumption > 0, weeklyBurn.isFinite, weeklyBurn > 0 else { return nil } let fullAllowanceBurn = 100 * weeklyBurn / sessionConsumption guard fullAllowanceBurn.isFinite, fullAllowanceBurn > 0 else { return nil } return fullAllowanceBurn } private static func nearestEntry( to target: Date, entries: [PlanUtilizationHistoryEntry], tolerance: TimeInterval, requireNotAfterTarget: Bool = false) -> PlanUtilizationHistoryEntry? { var lower = 0 var upper = entries.count while lower < upper { let middle = (lower + upper) / 2 if entries[middle].capturedAt < target { lower = middle + 1 } else { upper = middle } } var candidates: [PlanUtilizationHistoryEntry] = [] if lower < entries.count { candidates.append(entries[lower]) } if lower > 0 { candidates.append(entries[lower - 1]) } return candidates .filter { !requireNotAfterTarget || $0.capturedAt <= target } .filter { abs($0.capturedAt.timeIntervalSince(target)) <= tolerance } .min { lhs, rhs in abs(lhs.capturedAt.timeIntervalSince(target)) < abs(rhs.capturedAt.timeIntervalSince(target)) } } private static func isChronologicallyOrdered(_ entries: [PlanUtilizationHistoryEntry]) -> Bool { guard entries.allSatisfy(\.capturedAt.timeIntervalSinceReferenceDate.isFinite) else { return false } return zip(entries, entries.dropFirst()).allSatisfy { pair in pair.0.capturedAt <= pair.1.capturedAt } } private static func isPlausibleReset( _ resetsAt: Date, capturedAt: Date, duration: TimeInterval) -> Bool { let remaining = resetsAt.timeIntervalSince(capturedAt) return remaining.isFinite && remaining >= -Self.resetEquivalenceTolerance && remaining <= duration + Self.resetEquivalenceTolerance }} private struct SessionEquivalentBurnCacheKey: Equatable { let historyRevision: Int let historySelectionIdentity: String let currentSessionResetsAt: Date let weeklyWindowID: String?} struct SessionEquivalentBurnCacheEntry { fileprivate let key: SessionEquivalentBurnCacheKey fileprivate let estimate: SessionEquivalentBurnEstimate?} @MainActorextension UsageStore { func sessionEquivalentForecast( provider: UsageProvider, sessionWindow: RateWindow, weeklyWindow: RateWindow, weeklyWindowID: String? = nil, historyIdentity: String? = nil, historySelection: PlanUtilizationHistorySelection? = nil, now: Date = .init()) -> SessionEquivalentForecast? { guard sessionWindow.windowMinutes.map({ PlanUtilizationSeriesName.session.canonicalWindowMinutes($0) }) == SessionEquivalentForecast.sessionWindowMinutes, let currentSessionResetsAt = sessionWindow.resetsAt, currentSessionResetsAt.timeIntervalSinceReferenceDate.isFinite else { return nil } let selection = historySelection ?? self.planUtilizationHistorySelection(for: provider) guard self.sessionEquivalentHistoryIdentityMatches( provider: provider, accountKey: selection.accountKey, historyIdentity: historyIdentity) else { return nil } let cacheKey = SessionEquivalentBurnCacheKey( historyRevision: self.planUtilizationHistoryRevision, historySelectionIdentity: selection.cacheIdentity, currentSessionResetsAt: currentSessionResetsAt, weeklyWindowID: weeklyWindowID) let burnEstimate: SessionEquivalentBurnEstimate? if let cached = self.sessionEquivalentBurnCache[provider], cached.key == cacheKey { burnEstimate = cached.estimate } else { burnEstimate = SessionEquivalentBurnEstimator.estimate( histories: selection.histories, currentSessionResetsAt: currentSessionResetsAt, now: now) self.sessionEquivalentHistoryScanCount &+= 1 self.sessionEquivalentBurnCache[provider] = SessionEquivalentBurnCacheEntry( key: cacheKey, estimate: burnEstimate) } guard let burnEstimate else { return nil } return SessionEquivalentForecast.make( sessionWindow: sessionWindow, weeklyWindow: weeklyWindow, burnEstimate: burnEstimate, weeklyWindowID: weeklyWindowID, now: now, workDays: self.settings.weeklyProgressWorkDays) } #if DEBUG var _sessionEquivalentHistoryScanCountForTesting: Int { self.sessionEquivalentHistoryScanCount } #endif}
import CodexBarCore
import Foundation
struct SessionEquivalentBurnEstimate: Equatable, Sendable {
let medianWeeklyPercentPerWindow: Double
let sampleCount: Int
}
struct SessionEquivalentForecast: Equatable, Sendable {
static let sessionWindowMinutes = 300
static let weeklyWindowMinutes = 10080
static let resetTolerance: TimeInterval = 2 * 60
let estimatedWindowsToExhaustWeekly: Double
let windowsUntilReset: Int
let availableWindowsUntilReset: Double
let sampleCount: Int
let weeklyResetsAt: Date
let weeklyUsedPercent: Double
let weeklyWindowID: String?
init(
estimatedWindowsToExhaustWeekly: Double,
windowsUntilReset: Int,
availableWindowsUntilReset: Double? = nil,
sampleCount: Int,
weeklyResetsAt: Date,
weeklyUsedPercent: Double,
weeklyWindowID: String? = nil)
{
self.estimatedWindowsToExhaustWeekly = estimatedWindowsToExhaustWeekly
self.windowsUntilReset = windowsUntilReset
self.availableWindowsUntilReset = availableWindowsUntilReset ?? Double(windowsUntilReset)
self.sampleCount = sampleCount
self.weeklyResetsAt = weeklyResetsAt
self.weeklyUsedPercent = weeklyUsedPercent
self.weeklyWindowID = weeklyWindowID
}
static func make(
sessionWindow: RateWindow,
weeklyWindow: RateWindow,
burnEstimate: SessionEquivalentBurnEstimate,
weeklyWindowID: String? = nil,
now: Date,
workDays: Int?,
calendar: Calendar = .current) -> Self?
{
guard !sessionWindow.isSyntheticPlaceholder,
sessionWindow.windowMinutes.map({ PlanUtilizationSeriesName.session.canonicalWindowMinutes($0) })
== 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),
burnEstimate.medianWeeklyPercentPerWindow.isFinite,
burnEstimate.medianWeeklyPercentPerWindow > 0,
burnEstimate.sampleCount >= SessionEquivalentBurnEstimator.minimumSampleCount
else {
return nil
}
let sessionSeconds = TimeInterval(Self.sessionWindowMinutes * 60)
let weeklySeconds = TimeInterval(Self.weeklyWindowMinutes * 60)
let sessionRemaining = sessionResetsAt.timeIntervalSince(now)
let weeklyRemaining = weeklyResetsAt.timeIntervalSince(now)
guard sessionRemaining.isFinite,
sessionRemaining > 0,
sessionRemaining <= sessionSeconds + Self.resetTolerance,
weeklyRemaining.isFinite,
weeklyRemaining > 0,
weeklyRemaining <= weeklySeconds + Self.resetTolerance
else {
return nil
}
let remainingWeeklyPercent = (100 - weeklyWindow.usedPercent).clamped(to: 0...100)
guard remainingWeeklyPercent > 0 else { return nil }
let estimatedWindows = remainingWeeklyPercent / burnEstimate.medianWeeklyPercentPerWindow
guard estimatedWindows.isFinite, estimatedWindows >= 0 else { return nil }
let remainingSeconds = Self.effectiveRemainingSeconds(
from: now,
to: weeklyResetsAt,
workDays: workDays,
calendar: calendar)
guard remainingSeconds >= 0 else { return nil }
let availableWindowsUntilReset = remainingSeconds / sessionSeconds
let windowsUntilReset = Int(floor(availableWindowsUntilReset))
return Self(
estimatedWindowsToExhaustWeekly: estimatedWindows,
windowsUntilReset: windowsUntilReset,
availableWindowsUntilReset: availableWindowsUntilReset,
sampleCount: burnEstimate.sampleCount,
weeklyResetsAt: weeklyResetsAt,
weeklyUsedPercent: weeklyWindow.usedPercent,
weeklyWindowID: weeklyWindowID)
}
func applies(to weeklyWindow: RateWindow, windowID: String?) -> Bool {
guard weeklyWindow.windowMinutes.map({ PlanUtilizationSeriesName.weekly.canonicalWindowMinutes($0) })
== Self.weeklyWindowMinutes,
let resetsAt = weeklyWindow.resetsAt
else {
return false
}
return self.weeklyWindowID == windowID
&& abs(resetsAt.timeIntervalSince(self.weeklyResetsAt)) < 2 * 60
&& abs(weeklyWindow.usedPercent - self.weeklyUsedPercent) < 0.001
}
private static func effectiveRemainingSeconds(
from now: Date,
to resetsAt: Date,
workDays: Int?,
calendar: Calendar) -> TimeInterval
{
let wallClockSeconds = max(0, resetsAt.timeIntervalSince(now))
guard let workDays, workDays >= 2, workDays < 7 else { return wallClockSeconds }
var workSeconds: TimeInterval = 0
var cursor = now
while cursor < resetsAt {
guard let nextDay = calendar.date(
byAdding: .day,
value: 1,
to: calendar.startOfDay(for: cursor)),
nextDay > cursor
else {
return wallClockSeconds
}
let sliceEnd = min(nextDay, resetsAt)
if Self.isWorkday(cursor, workDays: workDays, calendar: calendar) {
workSeconds += sliceEnd.timeIntervalSince(cursor)
}
cursor = sliceEnd
}
return workSeconds
}
private static func isWorkday(_ date: Date, workDays: Int, calendar: Calendar) -> Bool {
let weekday = calendar.component(.weekday, from: date)
let isoWeekday = weekday == 1 ? 7 : weekday - 1
return isoWeekday <= workDays
}
}
enum SessionEquivalentBurnEstimator {
static let defaultSampleLimit = 7
static let minimumSampleCount = 3
private static let observationAlignmentTolerance: TimeInterval = 0
private static let resetEquivalenceTolerance = SessionEquivalentForecast.resetTolerance
private struct SessionGroup {
let resetsAt: Date
var entries: [PlanUtilizationHistoryEntry]
var maximumUsedPercent: Double
}
private struct BurnObservation {
let sessionUsedPercent: Double
let weeklyEntry: PlanUtilizationHistoryEntry
}
static func estimate(
histories: [PlanUtilizationSeriesHistory],
currentSessionResetsAt: Date,
now: Date,
sampleLimit: Int = Self.defaultSampleLimit) -> SessionEquivalentBurnEstimate?
{
guard sampleLimit > 0,
let sessionHistory = histories.first(where: {
$0.name == .session
&& $0.name.canonicalWindowMinutes($0.windowMinutes)
== SessionEquivalentForecast.sessionWindowMinutes
}),
let weeklyHistory = histories.first(where: {
$0.name == .weekly
&& $0.name.canonicalWindowMinutes($0.windowMinutes)
== SessionEquivalentForecast.weeklyWindowMinutes
})
else {
return nil
}
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),
Self.isChronologicallyOrdered(weeklyHistory.entries)
else {
return nil
}
var groups: [SessionGroup] = []
groups.reserveCapacity(sessionHistory.entries.count)
for entry in sessionHistory.entries {
guard entry.usedPercent.isFinite,
(0...100).contains(entry.usedPercent),
let resetsAt = entry.resetsAt,
Self.isPlausibleReset(
resetsAt,
capturedAt: entry.capturedAt,
duration: sessionDuration)
else {
continue
}
if let lastIndex = groups.indices.last,
abs(groups[lastIndex].resetsAt.timeIntervalSince(resetsAt)) <= Self.resetEquivalenceTolerance
{
groups[lastIndex].entries.append(entry)
groups[lastIndex].maximumUsedPercent = max(groups[lastIndex].maximumUsedPercent, entry.usedPercent)
} else {
guard groups.last.map({ $0.resetsAt <= resetsAt }) ?? true else { return nil }
groups.append(SessionGroup(
resetsAt: resetsAt,
entries: [entry],
maximumUsedPercent: entry.usedPercent))
}
}
let completedActiveGroups = groups.reversed().compactMap { group -> SessionGroup? in
guard group.resetsAt < currentSessionResetsAt.addingTimeInterval(-Self.resetEquivalenceTolerance),
group.resetsAt <= now,
group.maximumUsedPercent > 0
else {
return nil
}
return group
}
let weeklyEntries = weeklyHistory.entries.filter { entry in
entry.usedPercent.isFinite
&& (0...100).contains(entry.usedPercent)
&& entry.resetsAt.map {
Self.isPlausibleReset($0, capturedAt: entry.capturedAt, duration: weeklyDuration)
} == true
}
guard !weeklyEntries.isEmpty else { return nil }
var burns: [Double] = []
let candidateGroups = completedActiveGroups.prefix(sampleLimit)
burns.reserveCapacity(candidateGroups.count)
for group in candidateGroups {
guard let fullAllowanceBurn = Self.normalizedBurn(
for: group,
weeklyEntries: weeklyEntries,
sessionDuration: sessionDuration)
else { continue }
burns.append(fullAllowanceBurn)
}
guard burns.count >= Self.minimumSampleCount else { return nil }
burns.sort()
let middle = burns.count / 2
let median = burns.count.isMultiple(of: 2)
? (burns[middle - 1] + burns[middle]) / 2
: burns[middle]
guard median.isFinite, median > 0 else { return nil }
return SessionEquivalentBurnEstimate(
medianWeeklyPercentPerWindow: median,
sampleCount: burns.count)
}
private static func normalizedBurn(
for group: SessionGroup,
weeklyEntries: [PlanUtilizationHistoryEntry],
sessionDuration: TimeInterval) -> Double?
{
guard let firstSessionEntry = group.entries.first,
let lastSessionEntry = group.entries.last
else {
return nil
}
var observations: [BurnObservation] = []
let windowStart = group.resetsAt.addingTimeInterval(-sessionDuration)
if let weeklyStart = Self.nearestEntry(
to: windowStart,
entries: weeklyEntries,
tolerance: Self.resetEquivalenceTolerance,
requireNotAfterTarget: true),
weeklyStart.capturedAt <= windowStart,
weeklyStart.capturedAt < firstSessionEntry.capturedAt
{
observations.append(BurnObservation(sessionUsedPercent: 0, weeklyEntry: weeklyStart))
}
for sessionEntry in group.entries {
guard let weeklyEntry = Self.nearestEntry(
to: sessionEntry.capturedAt,
entries: weeklyEntries,
tolerance: Self.observationAlignmentTolerance)
else {
continue
}
observations.append(BurnObservation(
sessionUsedPercent: sessionEntry.usedPercent,
weeklyEntry: weeklyEntry))
}
if group.maximumUsedPercent >= 100,
let weeklyEnd = Self.nearestEntry(
to: group.resetsAt,
entries: weeklyEntries,
tolerance: Self.resetEquivalenceTolerance,
requireNotAfterTarget: true),
weeklyEnd.capturedAt <= group.resetsAt,
lastSessionEntry.capturedAt < weeklyEnd.capturedAt
{
observations.append(BurnObservation(sessionUsedPercent: 100, weeklyEntry: weeklyEnd))
}
observations.sort { lhs, rhs in
if lhs.weeklyEntry.capturedAt != rhs.weeklyEntry.capturedAt {
return lhs.weeklyEntry.capturedAt < rhs.weeklyEntry.capturedAt
}
return lhs.sessionUsedPercent < rhs.sessionUsedPercent
}
guard let start = observations.first,
let end = observations.last,
start.weeklyEntry.capturedAt < end.weeklyEntry.capturedAt,
let startReset = start.weeklyEntry.resetsAt,
let endReset = end.weeklyEntry.resetsAt,
abs(startReset.timeIntervalSince(endReset)) <= Self.resetEquivalenceTolerance
else {
return nil
}
let sessionConsumption = end.sessionUsedPercent - start.sessionUsedPercent
let weeklyBurn = end.weeklyEntry.usedPercent - start.weeklyEntry.usedPercent
guard sessionConsumption.isFinite,
sessionConsumption > 0,
weeklyBurn.isFinite,
weeklyBurn > 0
else {
return nil
}
let fullAllowanceBurn = 100 * weeklyBurn / sessionConsumption
guard fullAllowanceBurn.isFinite, fullAllowanceBurn > 0 else { return nil }
return fullAllowanceBurn
}
private static func nearestEntry(
to target: Date,
entries: [PlanUtilizationHistoryEntry],
tolerance: TimeInterval,
requireNotAfterTarget: Bool = false) -> PlanUtilizationHistoryEntry?
{
var lower = 0
var upper = entries.count
while lower < upper {
let middle = (lower + upper) / 2
if entries[middle].capturedAt < target {
lower = middle + 1
} else {
upper = middle
}
}
var candidates: [PlanUtilizationHistoryEntry] = []
if lower < entries.count {
candidates.append(entries[lower])
}
if lower > 0 {
candidates.append(entries[lower - 1])
}
return candidates
.filter { !requireNotAfterTarget || $0.capturedAt <= target }
.filter { abs($0.capturedAt.timeIntervalSince(target)) <= tolerance }
.min { lhs, rhs in
abs(lhs.capturedAt.timeIntervalSince(target)) < abs(rhs.capturedAt.timeIntervalSince(target))
}
}
private static func isChronologicallyOrdered(_ entries: [PlanUtilizationHistoryEntry]) -> Bool {
guard entries.allSatisfy(\.capturedAt.timeIntervalSinceReferenceDate.isFinite) else { return false }
return zip(entries, entries.dropFirst()).allSatisfy { pair in
pair.0.capturedAt <= pair.1.capturedAt
}
}
private static func isPlausibleReset(
_ resetsAt: Date,
capturedAt: Date,
duration: TimeInterval) -> Bool
{
let remaining = resetsAt.timeIntervalSince(capturedAt)
return remaining.isFinite
&& remaining >= -Self.resetEquivalenceTolerance
&& remaining <= duration + Self.resetEquivalenceTolerance
}
}
private struct SessionEquivalentBurnCacheKey: Equatable {
let historyRevision: Int
let historySelectionIdentity: String
let currentSessionResetsAt: Date
let weeklyWindowID: String?
}
struct SessionEquivalentBurnCacheEntry {
fileprivate let key: SessionEquivalentBurnCacheKey
fileprivate let estimate: SessionEquivalentBurnEstimate?
}
@MainActor
extension UsageStore {
func sessionEquivalentForecast(
provider: UsageProvider,
sessionWindow: RateWindow,
weeklyWindow: RateWindow,
weeklyWindowID: String? = nil,
historyIdentity: String? = nil,
historySelection: PlanUtilizationHistorySelection? = nil,
now: Date = .init()) -> SessionEquivalentForecast?
{
guard sessionWindow.windowMinutes.map({ PlanUtilizationSeriesName.session.canonicalWindowMinutes($0) })
== SessionEquivalentForecast.sessionWindowMinutes,
let currentSessionResetsAt = sessionWindow.resetsAt,
currentSessionResetsAt.timeIntervalSinceReferenceDate.isFinite
else {
return nil
}
let selection = historySelection ?? self.planUtilizationHistorySelection(for: provider)
guard self.sessionEquivalentHistoryIdentityMatches(
provider: provider,
accountKey: selection.accountKey,
historyIdentity: historyIdentity)
else {
return nil
}
let cacheKey = SessionEquivalentBurnCacheKey(
historyRevision: self.planUtilizationHistoryRevision,
historySelectionIdentity: selection.cacheIdentity,
currentSessionResetsAt: currentSessionResetsAt,
weeklyWindowID: weeklyWindowID)
let burnEstimate: SessionEquivalentBurnEstimate?
if let cached = self.sessionEquivalentBurnCache[provider], cached.key == cacheKey {
burnEstimate = cached.estimate
} else {
burnEstimate = SessionEquivalentBurnEstimator.estimate(
histories: selection.histories,
currentSessionResetsAt: currentSessionResetsAt,
now: now)
self.sessionEquivalentHistoryScanCount &+= 1
self.sessionEquivalentBurnCache[provider] = SessionEquivalentBurnCacheEntry(
key: cacheKey,
estimate: burnEstimate)
}
guard let burnEstimate else { return nil }
return SessionEquivalentForecast.make(
sessionWindow: sessionWindow,
weeklyWindow: weeklyWindow,
burnEstimate: burnEstimate,
weeklyWindowID: weeklyWindowID,
now: now,
workDays: self.settings.weeklyProgressWorkDays)
}
#if DEBUG
var _sessionEquivalentHistoryScanCountForTesting: Int {
self.sessionEquivalentHistoryScanCount
}
#endif
}