Skip to content
Merged
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
14 changes: 14 additions & 0 deletions Sources/CodexBar/PlanUtilizationHistoryStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,12 @@ struct PlanUtilizationSeriesHistory: Codable, Equatable, Sendable {
let windowMinutes: Int
let entries: [PlanUtilizationHistoryEntry]

private enum CodingKeys: String, CodingKey {
case name
case windowMinutes
case entries
}

init(name: PlanUtilizationSeriesName, windowMinutes: Int, entries: [PlanUtilizationHistoryEntry]) {
self.name = name
self.windowMinutes = windowMinutes
Expand All @@ -56,6 +62,14 @@ struct PlanUtilizationSeriesHistory: Codable, Equatable, Sendable {
}
}

init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let name = try container.decode(PlanUtilizationSeriesName.self, forKey: .name)
let windowMinutes = try container.decode(Int.self, forKey: .windowMinutes)
let entries = try container.decode([PlanUtilizationHistoryEntry].self, forKey: .entries)
self.init(name: name, windowMinutes: windowMinutes, entries: entries)
}

var latestCapturedAt: Date? {
self.entries.last?.capturedAt
}
Expand Down
121 changes: 93 additions & 28 deletions Sources/CodexBar/UsageStore+PlanUtilization.swift
Original file line number Diff line number Diff line change
Expand Up @@ -373,8 +373,10 @@ extension UsageStore {
let canonicalWindowMinutes = sample.name.canonicalWindowMinutes(sample.windowMinutes)
let key = PlanUtilizationSeriesKey(name: sample.name, windowMinutes: canonicalWindowMinutes)
if let existingHistory = historiesByKey[key] {
guard let updatedEntries = self.updatedPlanUtilizationEntries(
existingEntries: existingHistory.entries,
var updatedEntries = existingHistory.entries
self.assertPlanUtilizationEntriesSorted(updatedEntries)
guard self.updatedPlanUtilizationEntries(
existingEntries: &updatedEntries,
entry: sample.entry)
else {
continue
Expand Down Expand Up @@ -411,36 +413,91 @@ extension UsageStore {
}

private nonisolated static func updatedPlanUtilizationEntries(
existingEntries: [PlanUtilizationHistoryEntry],
entry: PlanUtilizationHistoryEntry) -> [PlanUtilizationHistoryEntry]?
existingEntries: inout [PlanUtilizationHistoryEntry],
entry: PlanUtilizationHistoryEntry) -> Bool
{
var entries = existingEntries
let insertionIndex = entries.firstIndex(where: { $0.capturedAt > entry.capturedAt }) ?? entries.endIndex
let insertionIndex = self.planUtilizationEntryInsertionIndex(
entries: existingEntries,
capturedAt: entry.capturedAt)
let sampleHourBucket = self.planUtilizationHourBucket(for: entry.capturedAt)
let sameHourRange = self.planUtilizationHourRange(
entries: entries,
entries: existingEntries,
insertionIndex: insertionIndex,
hourBucket: sampleHourBucket)
let existingHourEntries = Array(entries[sameHourRange])
let existingHourEntries = Array(existingEntries[sameHourRange])
let canonicalHourEntries = self.canonicalPlanUtilizationHourEntries(
existingHourEntries: existingHourEntries,
incomingEntry: entry)

guard canonicalHourEntries != existingHourEntries else { return nil }
entries.replaceSubrange(sameHourRange, with: canonicalHourEntries)
guard canonicalHourEntries != existingHourEntries else { return false }
existingEntries.replaceSubrange(sameHourRange, with: canonicalHourEntries)

if entries.count > self.planUtilizationMaxSamples {
entries.removeFirst(entries.count - self.planUtilizationMaxSamples)
if existingEntries.count > self.planUtilizationMaxSamples {
existingEntries.removeFirst(existingEntries.count - self.planUtilizationMaxSamples)
}
return entries
return true
}

/// The insertion search assumes `capturedAt` order. Checked once per merged series rather than per entry —
/// a per-entry check would reintroduce the O(n) scan this insertion path exists to remove.
private nonisolated static func assertPlanUtilizationEntriesSorted(
_ entries: [PlanUtilizationHistoryEntry])
{
#if DEBUG
assert(
zip(entries, entries.dropFirst()).allSatisfy { $0.capturedAt <= $1.capturedAt },
"plan-utilization entries must be sorted by capturedAt before insertion")
#endif
}

private nonisolated static func planUtilizationEntryInsertionIndex(
entries: [PlanUtilizationHistoryEntry],
capturedAt: Date) -> Int
{
guard let lastCapturedAt = entries.last?.capturedAt else {
return entries.endIndex
}
if lastCapturedAt <= capturedAt {
return entries.endIndex
}
return self.planUtilizationEntryUpperBound(entries: entries, capturedAt: capturedAt)
}

/// First index i such that entries[i].capturedAt > capturedAt (endIndex if none).
private nonisolated static func planUtilizationEntryUpperBound(
entries: [PlanUtilizationHistoryEntry],
capturedAt: Date) -> Int
{
var low = entries.startIndex
var high = entries.endIndex
while low < high {
let mid = low + ((high - low) / 2)
if entries[mid].capturedAt > capturedAt {
high = mid
} else {
low = mid + 1
}
}
return low
}

#if DEBUG
nonisolated static func _updatedPlanUtilizationEntriesForTesting(
existingEntries: [PlanUtilizationHistoryEntry],
entry: PlanUtilizationHistoryEntry) -> [PlanUtilizationHistoryEntry]?
{
self.updatedPlanUtilizationEntries(existingEntries: existingEntries, entry: entry)
var entries = existingEntries
guard self.updatedPlanUtilizationEntries(existingEntries: &entries, entry: entry) else {
return nil
}
return entries
}

nonisolated static func _planUtilizationEntryUpperBoundForTesting(
entries: [PlanUtilizationHistoryEntry],
capturedAt: Date) -> Int
{
self.planUtilizationEntryUpperBound(entries: entries, capturedAt: capturedAt)
}

nonisolated static func _updatedPlanUtilizationHistoriesForTesting(
Expand Down Expand Up @@ -1202,6 +1259,7 @@ extension UsageStore {
var historiesToMerge: [[PlanUtilizationSeriesHistory]] = []
let scopedRawKeys = Array(providerBuckets.accounts.keys)
var legacyRawKeysToRemove: [String] = []
var hasForeignHistoryToMerge = false

for rawKey in scopedRawKeys {
let owner = CodexHistoryOwnership.classifyPersistedKey(
Expand All @@ -1219,6 +1277,7 @@ extension UsageStore {
historiesToMerge.append(accountHistories)
if rawKey != canonicalKey {
legacyRawKeysToRemove.append(rawKey)
hasForeignHistoryToMerge = true
}
}
}
Expand All @@ -1232,6 +1291,7 @@ extension UsageStore {
{
historiesToMerge.append(opaqueHistories)
legacyRawKeysToRemove.append(recoverableOpaqueRawKey)
hasForeignHistoryToMerge = true
}

if shouldAdoptUnscopedHistory,
Expand All @@ -1245,9 +1305,14 @@ extension UsageStore {
{
historiesToMerge.append(providerBuckets.unscoped)
providerBuckets.unscoped = []
hasForeignHistoryToMerge = true
}

guard !historiesToMerge.isEmpty else { return canonicalKey }
// Canonical-only contribution is not a migration; re-merging it with itself is quadratic.
// Skipping also skips incidental re-canonicalization of already-canonical series (duplicate
// (name, windowMinutes) collapse and per-hour peak replay). Repairing legacy data belongs
// at load time, not on every refresh and menu open.
guard hasForeignHistoryToMerge else { return canonicalKey }
for rawKey in legacyRawKeysToRemove {
providerBuckets.accounts.removeValue(forKey: rawKey)
}
Expand Down Expand Up @@ -1523,29 +1588,29 @@ extension UsageStore {
provider _: UsageProvider,
histories: [[PlanUtilizationSeriesHistory]]) -> [PlanUtilizationSeriesHistory]
{
var mergedByKey: [PlanUtilizationSeriesKey: PlanUtilizationSeriesHistory] = [:]
var mergedEntriesByKey: [PlanUtilizationSeriesKey: [PlanUtilizationHistoryEntry]] = [:]

for historyGroup in histories {
for history in historyGroup {
let key = PlanUtilizationSeriesKey(name: history.name, windowMinutes: history.windowMinutes)
let existingEntries = mergedByKey[key]?.entries ?? []
var mergedEntries = existingEntries
var mergedEntries = mergedEntriesByKey[key] ?? []
self.assertPlanUtilizationEntriesSorted(mergedEntries)
for entry in history.entries.sorted(by: { $0.capturedAt < $1.capturedAt }) {
if let updatedEntries = self.updatedPlanUtilizationEntries(
existingEntries: mergedEntries,
_ = self.updatedPlanUtilizationEntries(
existingEntries: &mergedEntries,
entry: entry)
{
mergedEntries = updatedEntries
}
}
mergedByKey[key] = PlanUtilizationSeriesHistory(
name: history.name,
windowMinutes: history.windowMinutes,
entries: mergedEntries)
mergedEntriesByKey[key] = mergedEntries
}
}

return mergedByKey.values.sorted { lhs, rhs in
return mergedEntriesByKey.map { key, entries in
PlanUtilizationSeriesHistory(
name: key.name,
windowMinutes: key.windowMinutes,
entries: entries)
}
.sorted { lhs, rhs in
if lhs.windowMinutes != rhs.windowMinutes {
return lhs.windowMinutes < rhs.windowMinutes
}
Expand Down
8 changes: 4 additions & 4 deletions Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2736,31 +2736,31 @@ struct ProviderArchitectureGatekeeperTests {
reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."),
AllowedProviderConstruct(
path: "Sources/CodexBar/UsageStore+PlanUtilization.swift",
line: 846,
line: 903,
anchor: "if provider == .claude {",
expectedProviderIDs: ["claude"],
expectedReferenceCount: 1,
expectedReferenceFingerprint: ["claude@0"],
reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."),
AllowedProviderConstruct(
path: "Sources/CodexBar/UsageStore+PlanUtilization.swift",
line: 861,
line: 918,
anchor: "guard let identity = snapshot.identity(for: .claude) else { return nil }",
expectedProviderIDs: ["claude"],
expectedReferenceCount: 3,
expectedReferenceFingerprint: ["claude@0", "claude@9", "claude@9"],
reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."),
AllowedProviderConstruct(
path: "Sources/CodexBar/UsageStore+PlanUtilization.swift",
line: 888,
line: 945,
anchor: "key.hasPrefix(\"\\(UsageProvider.claude.rawValue):\")",
expectedProviderIDs: ["claude"],
expectedReferenceCount: 1,
expectedReferenceFingerprint: ["claude@0"],
reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."),
AllowedProviderConstruct(
path: "Sources/CodexBar/UsageStore+PlanUtilization.swift",
line: 1318,
line: 1383,
anchor: "if ![UsageProvider.codex, .claude, .antigravity].contains(provider) {",
expectedProviderIDs: ["antigravity", "claude", "codex"],
expectedReferenceCount: 3,
Expand Down
13 changes: 8 additions & 5 deletions Tests/CodexBarTests/SessionEquivalentForecastTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -341,7 +341,7 @@ struct SessionEquivalentForecastTests {
}

@Test
func `rejects hostile dates percentages and unsorted history`() throws {
func `rejects hostile dates and percentages and normalizes unsorted history`() throws {
let now = Date(timeIntervalSince1970: 1_900_000_000)
let session = RateWindow(
usedPercent: 20,
Expand Down Expand Up @@ -380,15 +380,18 @@ struct SessionEquivalentForecastTests {
let encodedSession = try JSONEncoder().encode(fixture.histories[0])
var sessionJSON = try #require(JSONSerialization.jsonObject(with: encodedSession) as? [String: Any])
let entriesJSON = try #require(sessionJSON["entries"] as? [[String: Any]])
// Decoding sorts entries (PlanUtilizationSeriesHistory.init(from:)), so a reversed payload
// normalizes to the same series and yields the same estimate as the sorted fixture above.
sessionJSON["entries"] = Array(entriesJSON.reversed())
let shuffledData = try JSONSerialization.data(withJSONObject: sessionJSON)
let shuffledSession = try JSONDecoder().decode(PlanUtilizationSeriesHistory.self, from: shuffledData)
#expect((shuffledSession.entries.first?.capturedAt ?? .distantPast)
> (shuffledSession.entries.last?.capturedAt ?? .distantFuture))
#expect(SessionEquivalentBurnEstimator.estimate(
#expect(shuffledSession == fixture.histories[0])
let shuffledEstimate = try #require(SessionEquivalentBurnEstimator.estimate(
histories: [shuffledSession, fixture.histories[1]],
currentSessionResetsAt: fixture.currentSessionReset,
now: fixture.currentSessionReset.addingTimeInterval(-3600)) == nil)
now: fixture.currentSessionReset.addingTimeInterval(-3600)))
#expect(shuffledEstimate.sampleCount == 3)
#expect(shuffledEstimate.medianWeeklyPercentPerWindow == 6)

let huge = UsagePaceText.sessionEquivalentDetail(forecast: SessionEquivalentForecast(
estimatedWindowsToExhaustWeekly: .greatestFiniteMagnitude,
Expand Down
Loading