diff --git a/Sources/BarUICore/Archive+InferredResets.swift b/Sources/BarUICore/Archive+InferredResets.swift new file mode 100644 index 0000000..25883ed --- /dev/null +++ b/Sources/BarUICore/Archive+InferredResets.swift @@ -0,0 +1,70 @@ +import Foundation + +/// What one Limit's samples have shown so far: every rollover up to the newest +/// paired poll, and enough to carry on from there. +struct DetectedRollovers: Sendable { + let windowMinutes: Int? + /// The newest poll scanned; the next scan pairs it with the first newer one. + let lastPoll: InferredResets.Poll + /// How many reset-time samples were at or before `lastPoll`. A different + /// count later means older samples were imported since, and the whole + /// Limit is scanned again rather than missing a rollover among them. + let samplesThrough: Int + let resets: [LimitReset] +} + +extension Archive { + /// Every rollover the Limit's samples show, logged or not, oldest first. + /// Samples are kept forever and polled every few minutes, so this extends + /// what it found last time with newer polls instead of rescanning them all + /// on each refresh. + func detectedRollovers(of app: BarApp, limit: LimitDescriptor) throws -> [LimitReset] { + let key = "\(app.rawValue)/\(limit.id)" + if let cached = rolloverCache[key], + cached.windowMinutes == limit.windowDurationMinutes, + try resetsAtSampleCount(of: app, limitID: limit.id, through: cached.lastPoll.time) == cached.samplesThrough { + let newer = InferredResets.polls( + resetsAt: try samples(of: app, limitID: limit.id, metric: .resetsAt, since: cached.lastPoll.time + 1), + utilization: try samples(of: app, limitID: limit.id, metric: .utilization, since: cached.lastPoll.time + 1) + ) + guard let newest = newer.last else { + return cached.resets + } + let found = InferredResets.detected(app: app, limit: limit, in: [cached.lastPoll] + newer) + let updated = DetectedRollovers( + windowMinutes: limit.windowDurationMinutes, + lastPoll: newest, + samplesThrough: try resetsAtSampleCount(of: app, limitID: limit.id, through: newest.time), + resets: cached.resets + found + ) + rolloverCache[key] = updated + return updated.resets + } + + let polls = InferredResets.polls( + resetsAt: try samples(of: app, limitID: limit.id, metric: .resetsAt, since: Int64.min), + utilization: try samples(of: app, limitID: limit.id, metric: .utilization, since: Int64.min) + ) + let found = InferredResets.detected(app: app, limit: limit, in: polls) + if let newest = polls.last { + rolloverCache[key] = DetectedRollovers( + windowMinutes: limit.windowDurationMinutes, + lastPoll: newest, + samplesThrough: try resetsAtSampleCount(of: app, limitID: limit.id, through: newest.time), + resets: found + ) + } else { + rolloverCache[key] = nil + } + return found + } + + private func resetsAtSampleCount(of app: BarApp, limitID: String, through time: Int64) throws -> Int { + let statement = try prepare(""" + SELECT COUNT(*) FROM limit_samples + WHERE app = ?1 AND limit_id = ?2 AND metric = ?3 AND time_unix_nano <= ?4 + """) + try statement.bind(.text(app.rawValue), .text(limitID), .text(LimitMetric.resetsAt.rawValue), .integer(time)) + return try statement.step() ? Int(statement.int64(at: 0)) : 0 + } +} diff --git a/Sources/BarUICore/Archive+Limits.swift b/Sources/BarUICore/Archive+Limits.swift index 859812c..051e6de 100644 --- a/Sources/BarUICore/Archive+Limits.swift +++ b/Sources/BarUICore/Archive+Limits.swift @@ -117,7 +117,7 @@ extension Archive { try samples(of: app, limitID: limitID, metric: metric, since: Int64.min) } - private func samples(of app: BarApp, limitID: String, metric: LimitMetric, since floor: Int64) throws -> [ArchivedSample] { + func samples(of app: BarApp, limitID: String, metric: LimitMetric, since floor: Int64) throws -> [ArchivedSample] { let statement = try prepare(""" SELECT time_unix_nano, value FROM limit_samples WHERE app = ?1 AND limit_id = ?2 AND metric = ?3 AND time_unix_nano >= ?4 @@ -132,9 +132,22 @@ extension Archive { } /// Every reset the Archive holds for the Bar App, by Limit ID, oldest - /// first. A Window resets a handful of times a day at most, so the whole - /// list stays small. + /// first: the ones the Bar App logged, plus rollovers its samples show that + /// it never logged. A Window resets a handful of times a day at most, so + /// the whole list stays small. public func limitResets(of app: BarApp) throws -> [String: [LimitReset]] { + var byLimit = try loggedLimitResets(of: app) + for descriptor in try limitDescriptors(of: app) { + let logged = byLimit[descriptor.id] ?? [] + let all = try withInferredResets(logged, of: app, limit: descriptor, since: Int64.min) + if !all.isEmpty { + byLimit[descriptor.id] = all + } + } + return byLimit + } + + private func loggedLimitResets(of app: BarApp) throws -> [String: [LimitReset]] { let statement = try prepare(""" SELECT r.limit_id, r.time_unix_nano, r.utilization_last, r.next_resets_at, l.name, l.window_minutes, l.model_id @@ -171,22 +184,7 @@ extension Archive { /// A Limit with nothing in the range still comes back, so it can say so. public func limitHistories(of app: BarApp, since: Date?) throws -> [LimitHistory] { let floor = since?.unixNano ?? Int64.min - let limits = try prepare(""" - SELECT limit_id, name, window_minutes, model_id FROM limits WHERE app = ?1 ORDER BY limit_id - """) - try limits.bind(.text(app.rawValue)) - var descriptors: [LimitDescriptor] = [] - while try limits.step() { - guard let id = limits.text(at: 0), let name = limits.text(at: 1) else { - continue - } - descriptors.append(LimitDescriptor( - id: id, - name: name, - windowDurationMinutes: limits.optionalInt64(at: 2).map { Int($0) }, - modelID: limits.text(at: 3) - )) - } + let descriptors = try limitDescriptors(of: app) let resets = try prepare(""" SELECT time_unix_nano, utilization_last, next_resets_at FROM limit_resets @@ -200,7 +198,11 @@ extension Archive { """) var histories: [LimitHistory] = [] for descriptor in descriptors { - try resets.bind(.text(app.rawValue), .text(descriptor.id), .integer(floor)) + // Read from a Window before the range: the poll before a reset at the + // range's start is needed to recover it, and a reset just before the + // range bounds the peak the range starts from. + let lookback = Self.inferenceFloor(for: floor, limit: descriptor) + try resets.bind(.text(app.rawValue), .text(descriptor.id), .integer(lookback)) var events: [LimitReset] = [] while try resets.step() { events.append(LimitReset( @@ -215,23 +217,73 @@ extension Archive { let latestSample = try latest.step() ? ArchivedSample(timeUnixNano: latest.int64(at: 0), value: latest.double(at: 1)) : nil + let all = try withInferredResets(events, of: app, limit: descriptor, since: lookback) + let lastResetBefore = all.last { $0.timeUnixNano < floor }?.timeUnixNano histories.append(LimitHistory( app: app, limit: descriptor, utilization: try samples(of: app, limitID: descriptor.id, metric: .utilization, since: floor), latest: latestSample, - resets: events, - peakBeforeRange: since == nil ? nil : try peakBefore(floor, of: app, limit: descriptor) + resets: all.filter { $0.timeUnixNano >= floor }, + peakBeforeRange: since == nil ? nil : try peakBefore(floor, of: app, limit: descriptor, lastReset: lastResetBefore) )) } return histories } + private func limitDescriptors(of app: BarApp) throws -> [LimitDescriptor] { + let limits = try prepare(""" + SELECT limit_id, name, window_minutes, model_id FROM limits WHERE app = ?1 ORDER BY limit_id + """) + try limits.bind(.text(app.rawValue)) + var descriptors: [LimitDescriptor] = [] + while try limits.step() { + guard let id = limits.text(at: 0), let name = limits.text(at: 1) else { + continue + } + descriptors.append(LimitDescriptor( + id: id, + name: name, + windowDurationMinutes: limits.optionalInt64(at: 2).map { Int($0) }, + modelID: limits.text(at: 3) + )) + } + return descriptors + } + + /// One Window (and a day's margin) before `floor`, or everything when the + /// Window's length isn't known. The full read is only for a range; an + /// all-time read already starts at the beginning. + static func inferenceFloor(for floor: Int64, limit: LimitDescriptor) -> Int64 { + guard floor != Int64.min, let minutes = limit.windowDurationMinutes else { + return Int64.min + } + let reach = (Int64(minutes) * 60 + 86_400) * 1_000_000_000 + return floor > Int64.min + reach ? floor - reach : Int64.min + } + + /// The logged resets (read from `floor` on) with the rollovers recovered + /// from the samples from `floor` on that none of them stands for, oldest + /// first. + private func withInferredResets( + _ logged: [LimitReset], + of app: BarApp, + limit: LimitDescriptor, + since floor: Int64 + ) throws -> [LimitReset] { + let detected = try detectedRollovers(of: app, limit: limit).filter { $0.timeUnixNano >= floor } + let inferred = InferredResets.unlogged(detected, logged: logged, windowMinutes: limit.windowDurationMinutes) + guard !inferred.isEmpty else { + return logged + } + return (logged + inferred).sorted { $0.timeUnixNano < $1.timeUnixNano } + } + /// How high the Window open at `floor` had got before it. Reads back no - /// further than that Window: to its reset, or one Window length when the - /// reset wasn't logged. Without either, the Limit's last earlier sample - /// stands in for the Window. - private func peakBefore(_ floor: Int64, of app: BarApp, limit: LimitDescriptor) throws -> Double? { + /// further than that Window: to its reset (logged or recovered from the + /// samples, `lastReset`), or one Window length when there's none in reach. + /// Without either, the Limit's last earlier sample stands in for the Window. + private func peakBefore(_ floor: Int64, of app: BarApp, limit: LimitDescriptor, lastReset: Int64?) throws -> Double? { let latest = try prepare(""" SELECT time_unix_nano, value FROM limit_samples WHERE app = ?1 AND limit_id = ?2 AND metric = ?3 AND time_unix_nano < ?4 @@ -243,12 +295,6 @@ extension Archive { } let lastSample = ArchivedSample(timeUnixNano: latest.int64(at: 0), value: latest.double(at: 1)) - let reset = try prepare(""" - SELECT MAX(time_unix_nano) FROM limit_resets - WHERE app = ?1 AND limit_id = ?2 AND time_unix_nano < ?3 - """) - try reset.bind(.text(app.rawValue), .text(limit.id), .integer(floor)) - let lastReset = try reset.step() ? reset.optionalInt64(at: 0) : nil if let lastReset, lastReset > lastSample.timeUnixNano { return 0 } diff --git a/Sources/BarUICore/Archive.swift b/Sources/BarUICore/Archive.swift index 4b2ffd0..1b31023 100644 --- a/Sources/BarUICore/Archive.swift +++ b/Sources/BarUICore/Archive.swift @@ -12,6 +12,9 @@ public actor Archive { private let connection: Connection private var database: OpaquePointer { connection.handle } + /// Rollovers detected in each Limit's samples so far, by `app/limit`, so a + /// read only scans polls newer than the last one it saw. + var rolloverCache: [String: DetectedRollovers] = [:] public init(url: URL) throws { try FileManager.default.createDirectory( diff --git a/Sources/BarUICore/InferredResets.swift b/Sources/BarUICore/InferredResets.swift new file mode 100644 index 0000000..b792241 --- /dev/null +++ b/Sources/BarUICore/InferredResets.swift @@ -0,0 +1,140 @@ +import Foundation + +/// Window rollovers recovered from a Limit's archived samples, for the ones its +/// Bar App never logged as `bar.limit.reset`. A tool can reset a Window early, +/// days before its reset time, and a Bar App that only expects a Window to run +/// to its end misses those; the samples still show the reset time jumping and +/// usage falling back. +public enum InferredResets { + /// One poll: its time, the reset time it reported (Unix seconds), and usage. + struct Poll: Sendable, Equatable { + let time: Int64 + let resetsAt: Double + let usage: Double + } + + /// More than a reset time drifts between reads. Weaker signals (a Window + /// already over, a full Window ahead) need a jump past this. + static let jumpTolerance: Double = 3_600 + /// The least a reset time must move for a decisive usage drop to count: a + /// short Window reset early moves its deadline only by what had elapsed. + static let minimumJump: Double = 60 + /// A rollover this close to a logged reset is that reset, not another one. + /// Never more than half a Window, so a short Window's next rollover stays + /// its own reset. + static let loggedMatch: Int64 = 3_600 * 1_000_000_000 + /// Matching the Bar Apps' own reset check: a fall to zero, or a fall of + /// more than this that ends under `usageFloor`. + static let usageDrop: Double = 25 + static let usageFloor: Double = 10 + + /// Every rollover in the samples that no logged reset already covers, + /// oldest first. + public static func rollovers( + app: BarApp, + limit: LimitDescriptor, + resetsAt: [ArchivedSample], + utilization: [ArchivedSample], + logged: [LimitReset] + ) -> [LimitReset] { + let found = detected(app: app, limit: limit, in: polls(resetsAt: resetsAt, utilization: utilization)) + return unlogged(found, logged: logged, windowMinutes: limit.windowDurationMinutes) + } + + /// Reset-time and utilisation samples paired by their exact time, oldest + /// first. A Bar App writes both in the same sample; one without the other + /// is skipped rather than guessed at. + static func polls(resetsAt: [ArchivedSample], utilization: [ArchivedSample]) -> [Poll] { + let usageByTime = Dictionary(utilization.map { ($0.timeUnixNano, $0.value) }, uniquingKeysWith: { _, latest in latest }) + return resetsAt + .sorted { $0.timeUnixNano < $1.timeUnixNano } + .compactMap { sample in usageByTime[sample.timeUnixNano].map { Poll(time: sample.timeUnixNano, resetsAt: sample.value, usage: $0) } } + } + + /// Every rollover between consecutive polls, whether or not it was logged. + /// Doesn't depend on the logged resets, so a caller can keep these and + /// extend them with newer polls. + static func detected(app: BarApp, limit: LimitDescriptor, in polls: [Poll]) -> [LimitReset] { + var found: [LimitReset] = [] + for (before, after) in zip(polls, polls.dropFirst()) { + guard rolledOver(before: before, after: after, windowMinutes: limit.windowDurationMinutes) else { + continue + } + found.append(LimitReset( + app: app, + limit: limit, + timeUnixNano: after.time, + utilizationLast: before.usage, + nextResetsAt: Date(timeIntervalSince1970: after.resetsAt) + )) + } + return found + } + + /// The detected rollovers no logged reset already stands for. + static func unlogged(_ detected: [LimitReset], logged: [LimitReset], windowMinutes: Int?) -> [LimitReset] { + let radius = matchRadius(windowMinutes: windowMinutes) + let loggedTimes = logged.map(\.timeUnixNano) + return detected.filter { reset in + !loggedTimes.contains { abs($0 - reset.timeUnixNano) <= radius } + } + } + + static func matchRadius(windowMinutes: Int?) -> Int64 { + guard let windowMinutes else { + return loggedMatch + } + // Strictly under half a Window, so the next rollover is never matched. + let half = Int64(windowMinutes) * 30 * 1_000_000_000 - 1 + return min(loggedMatch, half) + } + + static func decisiveDrop(from old: Double, to new: Double) -> Bool { + (old > 0 && new == 0) || (old - new > usageDrop && new < usageFloor) + } + + /// The same rule CodexBar logs resets by, plus a Window whose deadline fell + /// between the two polls (the Mac asleep across its reset). + static func rolledOver(before: Poll, after: Poll, windowMinutes: Int?) -> Bool { + // A Window nobody has used floats a full Window ahead of every poll, so + // after a gap, once it expires, or when use finally starts, its reset + // time jumps like a real rollover. At 0% beforehand there was nothing + // to reset, so it never counts. + if before.usage == 0 { + return false + } + let then = Double(before.time) / 1_000_000_000 + let now = Double(after.time) / 1_000_000_000 + // A deadline already past at the earlier poll was reported stale and is + // only being corrected now; that rollover belongs to the pair before. + if before.resetsAt <= then { + return false + } + let jump = after.resetsAt - before.resetsAt + guard jump >= minimumJump else { + return false + } + let dropped = decisiveDrop(from: before.usage, to: after.usage) + guard let windowMinutes else { + // Without a Window length, only a decisive drop says a jump was a + // reset rather than a reset time being corrected. + return jump > jumpTolerance && dropped + } + let window = Double(windowMinutes) * 60 + // A full rollover, or a decisive drop with the deadline moving, counts + // whatever the Window's length, so a short Window isn't held to the + // hour of drift tolerance. + if jump >= window / 2 || dropped { + return true + } + guard jump > jumpTolerance else { + return false + } + if before.resetsAt <= now { + return true + } + // A full Window ahead alone could still be a floating Window; usage + // going down is what tells a real reset apart. + return after.usage < before.usage && abs(after.resetsAt - now - window) <= jumpTolerance + } +} diff --git a/Tests/BarUICoreTests/InferredResetArchiveTests.swift b/Tests/BarUICoreTests/InferredResetArchiveTests.swift new file mode 100644 index 0000000..3007fca --- /dev/null +++ b/Tests/BarUICoreTests/InferredResetArchiveTests.swift @@ -0,0 +1,124 @@ +import Foundation +import Testing +@testable import BarUICore + +@Suite("Recovered resets read back from the Archive") +struct InferredResetArchiveTests { + /// One CodexBar metrics line: the Weekly Limit's utilisation and reset time at `time`. + private static func metricsLine(time: Int64, utilization: Double, resetsAt: Int64) -> String { + let limit = #"[{"key":"limit.id","value":{"stringValue":"codex:10080"}},{"key":"limit.name","value":{"stringValue":"Weekly"}},{"key":"limit.window.duration_minutes","value":{"intValue":"10080"}}]"# + let nano = "\(time)000000000" + return #"{"resourceMetrics":[{"resource":{"attributes":[{"key":"service.name","value":{"stringValue":"codexbar"}},{"key":"service.version","value":{"stringValue":"0.9"}},{"key":"host.name","value":{"stringValue":"test-mac.local"}},{"key":"os.type","value":{"stringValue":"darwin"}},{"key":"bar.spec.version","value":{"stringValue":"1"}}]},"scopeMetrics":[{"scope":{"name":"com.gordonbeeming.CodexBar.telemetry","version":"0.9"},"metrics":["# + + #"{"name":"bar.limit.utilization","unit":"%","gauge":{"dataPoints":[{"asDouble":\#(utilization),"attributes":\#(limit),"timeUnixNano":"\#(nano)"}]}},"# + + #"{"name":"bar.limit.resets_at","unit":"s","gauge":{"dataPoints":[{"asInt":"\#(resetsAt)","attributes":\#(limit),"timeUnixNano":"\#(nano)"}]}}"# + + "]}]}]}" + } + + @Test("An early reset CodexBar never logged comes back from both the history and the all-time list") + func earlyResetIsRecovered() async throws { + let fixture = try ImportFixture() + let lines = [ + Self.metricsLine(time: 1_791_867_316, utilization: 6, resetsAt: 1_792_310_768), + Self.metricsLine(time: 1_791_867_609, utilization: 0, resetsAt: 1_792_472_409) + ] + try Data((lines.joined(separator: "\n") + "\n").utf8) + .write(to: fixture.codexFolder.appendingPathComponent("codexbar-metrics-2026-10-13.jsonl")) + let summary = try await fixture.importer.importMetrics(of: .codexBar, from: fixture.codexFolder) + #expect(summary.samplesRead == 4) + #expect(summary.droppedRecords == 0) + + let history = try #require(try await fixture.archive.limitHistories(of: .codexBar, since: nil).first) + let all = try await fixture.archive.limitResets(of: .codexBar) + + #expect(history.resets.map(\.timeUnixNano) == [1_791_867_609_000_000_000]) + #expect(history.resets.first?.utilizationLast == 6) + #expect(all["codex:10080"]?.map(\.timeUnixNano) == [1_791_867_609_000_000_000]) + } + + private func importLines(_ lines: [String], into fixture: ImportFixture) async throws { + try Data((lines.joined(separator: "\n") + "\n").utf8) + .write(to: fixture.codexFolder.appendingPathComponent("codexbar-metrics-2026-10-13.jsonl")) + _ = try await fixture.importer.importMetrics(of: .codexBar, from: fixture.codexFolder) + } + + @Test("A range starting between the last poll before a reset and the reset still includes it") + func resetJustInsideTheRange() async throws { + let fixture = try ImportFixture() + try await importLines([ + Self.metricsLine(time: 1_791_867_316, utilization: 6, resetsAt: 1_792_310_768), + Self.metricsLine(time: 1_791_867_609, utilization: 0, resetsAt: 1_792_472_409) + ], into: fixture) + + let since = Date(timeIntervalSince1970: 1_791_867_500) + let history = try #require(try await fixture.archive.limitHistories(of: .codexBar, since: since).first) + + #expect(history.resets.map(\.timeUnixNano) == [1_791_867_609_000_000_000]) + } + + @Test("A recovered reset before the range starts the Window over, so its old peak doesn't carry in") + func recoveredResetBoundsThePeakBeforeTheRange() async throws { + let fixture = try ImportFixture() + try await importLines([ + Self.metricsLine(time: 1_791_800_000, utilization: 85, resetsAt: 1_792_310_768), + Self.metricsLine(time: 1_791_867_609, utilization: 0, resetsAt: 1_792_472_409), + Self.metricsLine(time: 1_791_900_000, utilization: 80, resetsAt: 1_792_472_409) + ], into: fixture) + + let since = Date(timeIntervalSince1970: 1_791_880_000) + let history = try #require(try await fixture.archive.limitHistories(of: .codexBar, since: since).first) + + #expect(history.peakBeforeRange == 0) + } + + @Test("Rollovers found earlier are kept, and newer polls still add theirs") + func laterPollsExtendTheResets() async throws { + let fixture = try ImportFixture() + let folder = fixture.codexFolder + try Data((Self.metricsLine(time: 1_791_867_316, utilization: 6, resetsAt: 1_792_310_768) + "\n" + + Self.metricsLine(time: 1_791_867_609, utilization: 0, resetsAt: 1_792_472_409) + "\n").utf8) + .write(to: folder.appendingPathComponent("codexbar-metrics-2026-10-13.jsonl")) + _ = try await fixture.importer.importMetrics(of: .codexBar, from: folder) + let first = try await fixture.archive.limitResets(of: .codexBar) + + try Data((Self.metricsLine(time: 1_792_044_489, utilization: 5, resetsAt: 1_792_475_653) + "\n" + + Self.metricsLine(time: 1_792_044_791, utilization: 0, resetsAt: 1_792_649_591) + "\n").utf8) + .write(to: folder.appendingPathComponent("codexbar-metrics-2026-10-15.jsonl")) + _ = try await fixture.importer.importMetrics(of: .codexBar, from: folder) + let second = try await fixture.archive.limitResets(of: .codexBar) + + #expect(first["codex:10080"]?.count == 1) + #expect(second["codex:10080"]?.map(\.timeUnixNano) == [1_791_867_609_000_000_000, 1_792_044_791_000_000_000]) + } + + @Test("Older samples imported after a read are taken into account, not skipped") + func olderSamplesImportedLater() async throws { + let fixture = try ImportFixture() + let folder = fixture.codexFolder + try Data((Self.metricsLine(time: 1_792_044_489, utilization: 5, resetsAt: 1_792_475_653) + "\n" + + Self.metricsLine(time: 1_792_044_791, utilization: 0, resetsAt: 1_792_649_591) + "\n").utf8) + .write(to: folder.appendingPathComponent("codexbar-metrics-2026-10-15.jsonl")) + _ = try await fixture.importer.importMetrics(of: .codexBar, from: folder) + _ = try await fixture.archive.limitResets(of: .codexBar) + + try Data((Self.metricsLine(time: 1_791_867_316, utilization: 6, resetsAt: 1_792_310_768) + "\n" + + Self.metricsLine(time: 1_791_867_609, utilization: 0, resetsAt: 1_792_472_409) + "\n").utf8) + .write(to: folder.appendingPathComponent("codexbar-metrics-2026-10-13.jsonl")) + _ = try await fixture.importer.importMetrics(of: .codexBar, from: folder) + let after = try await fixture.archive.limitResets(of: .codexBar) + + #expect(after["codex:10080"]?.map(\.timeUnixNano) == [1_791_867_609_000_000_000, 1_792_044_791_000_000_000]) + } + + @Test("A ClaudeBar reset that was logged isn't added again from its samples") + func loggedResetsStayUnchanged() async throws { + let fixture = try ImportFixture() + try fixture.copyFixture("claudebar-metrics-2026-10-12.jsonl", to: fixture.claudeFolder) + try fixture.copyFixture("claudebar-logs-2026-10-12.jsonl", to: fixture.claudeFolder) + _ = try await fixture.importer.importMetrics(of: .claudeBar, from: fixture.claudeFolder) + + let histories = try await fixture.archive.limitHistories(of: .claudeBar, since: nil) + + #expect(histories.first { $0.limit.id == "session" }?.resets.map(\.utilizationLast) == [30, 71.5]) + #expect(histories.first { $0.limit.id == "weekly_all" }?.resets.map(\.utilizationLast) == [88]) + } +} diff --git a/Tests/BarUICoreTests/InferredResetsTests.swift b/Tests/BarUICoreTests/InferredResetsTests.swift new file mode 100644 index 0000000..62f79db --- /dev/null +++ b/Tests/BarUICoreTests/InferredResetsTests.swift @@ -0,0 +1,231 @@ +import Foundation +import Testing +@testable import BarUICore + +@Suite("Resets recovered from archived samples") +struct InferredResetsTests { + private static let second: Int64 = 1_000_000_000 + private static let weekly = LimitDescriptor(id: "codex:10080", name: "Weekly", windowDurationMinutes: 10_080, modelID: nil) + private static let session = LimitDescriptor(id: "session", name: "Session (5h)", windowDurationMinutes: 300, modelID: nil) + + /// One poll: when it was taken (Unix seconds), the reset time it reported, and utilisation. + private struct Poll { + let time: Int64 + let resetsAt: Double + let utilization: Double + } + + private func infer(_ polls: [Poll], limit: LimitDescriptor = weekly, logged: [LimitReset] = []) -> [LimitReset] { + InferredResets.rollovers( + app: .codexBar, + limit: limit, + resetsAt: polls.map { ArchivedSample(timeUnixNano: $0.time * Self.second, value: $0.resetsAt) }, + utilization: polls.map { ArchivedSample(timeUnixNano: $0.time * Self.second, value: $0.utilization) }, + logged: logged + ) + } + + @Test("CodexBar's three early Weekly resets, from the real archived samples, are recovered") + func earlyWeeklyResets() { + let polls = [ + Poll(time: 1_791_867_316, resetsAt: 1_792_310_768, utilization: 6), + Poll(time: 1_791_867_609, resetsAt: 1_792_472_409, utilization: 0), + Poll(time: 1_792_044_489, resetsAt: 1_792_475_653, utilization: 5), + Poll(time: 1_792_044_791, resetsAt: 1_792_649_591, utilization: 0), + Poll(time: 1_792_269_463, resetsAt: 1_792_652_811, utilization: 6), + Poll(time: 1_792_269_763, resetsAt: 1_792_874_563, utilization: 0) + ] + + let resets = infer(polls) + + #expect(resets.map { $0.timeUnixNano / Self.second } == [1_791_867_609, 1_792_044_791, 1_792_269_763]) + #expect(resets.map(\.utilizationLast) == [6, 5, 6]) + #expect(resets.first?.nextResetsAt == Date(timeIntervalSince1970: 1_792_472_409)) + #expect(resets.allSatisfy { $0.limit == Self.weekly && $0.app == .codexBar }) + } + + @Test("A rollover the Bar App already logged isn't listed twice") + func loggedResetIsNotDoubled() { + let polls = [ + Poll(time: 1_000_000, resetsAt: 1_000_100, utilization: 40), + Poll(time: 1_000_300, resetsAt: 1_018_300, utilization: 0) + ] + let logged = LimitReset(app: .claudeBar, limit: Self.session, timeUnixNano: 1_000_300 * Self.second, utilizationLast: 40, nextResetsAt: nil) + + #expect(infer(polls, limit: Self.session, logged: [logged]).isEmpty) + } + + @Test("Drift between reads isn't a reset") + func driftIsNotAReset() { + let polls = [ + Poll(time: 1_000_000, resetsAt: 1_100_000, utilization: 6), + Poll(time: 1_000_300, resetsAt: 1_100_240, utilization: 6) + ] + + #expect(infer(polls).isEmpty) + } + + @Test("A Window that hasn't started, its reset time creeping forward each poll, isn't a reset") + func notStartedWindow() { + // Typed step by step: the inline arithmetic times out the CI compiler's type checker. + var polls: [Poll] = [] + for step in 0..<6 { + let time: Int64 = 1_000_000 + Int64(step) * 600 + let resetsAt: Double = Double(time) + 604_800 + polls.append(Poll(time: time, resetsAt: resetsAt, utilization: 0)) + } + + #expect(infer(polls).isEmpty) + } + + @Test("A Window that hasn't started, seen across a two-hour poll gap, isn't a reset") + func notStartedWindowAcrossAGap() { + let polls = [ + Poll(time: 1_000_000, resetsAt: 1_604_800, utilization: 0), + Poll(time: 1_007_200, resetsAt: 1_612_000, utilization: 0) + ] + + #expect(infer(polls).isEmpty) + } + + @Test("A Window that ended while the Mac slept is a reset at the first poll after") + func resetAcrossSleep() { + let polls = [ + Poll(time: 1_000_000, resetsAt: 1_003_600, utilization: 30), + Poll(time: 1_010_000, resetsAt: 1_021_600, utilization: 2) + ] + + let resets = infer(polls, limit: Self.session) + + #expect(resets.map { $0.timeUnixNano / Self.second } == [1_010_000]) + #expect(resets.first?.utilizationLast == 30) + } + + @Test("A Window that runs to its end and comes back a whole Window later is a reset") + func fullWindowRollover() { + let polls = [ + Poll(time: 1_604_700, resetsAt: 1_604_800, utilization: 81), + Poll(time: 1_605_000, resetsAt: 2_209_600, utilization: 0) + ] + + #expect(infer(polls).count == 1) + } + + @Test("An unused Window that expired isn't a reset: there was nothing to reset") + func unusedExpiredWindow() { + let polls = [ + Poll(time: 1_000_000, resetsAt: 1_003_600, utilization: 0), + Poll(time: 1_010_000, resetsAt: 1_021_600, utilization: 0) + ] + + #expect(infer(polls, limit: Self.session).isEmpty) + } + + @Test("An unused Window that hasn't started, seen across a gap of half a Window or more, isn't a reset") + func floatingWindowAcrossALongGap() { + let polls = [ + Poll(time: 1_000_000, resetsAt: 1_018_000, utilization: 0), + Poll(time: 1_010_800, resetsAt: 1_028_800, utilization: 0) + ] + + #expect(infer(polls, limit: Self.session).isEmpty) + } + + @Test("A short Window's full rollover counts even though its reset time moved less than an hour") + func shortWindowRollover() { + let hourly = LimitDescriptor(id: "hourly", name: "Hourly", windowDurationMinutes: 60, modelID: nil) + let polls = [ + Poll(time: 1_000_000, resetsAt: 1_000_300, utilization: 80), + Poll(time: 1_000_600, resetsAt: 1_003_900, utilization: 0) + ] + + #expect(infer(polls, limit: hourly).count == 1) + } + + @Test("A Limit with no Window length needs usage to go down before a jump counts") + func unknownWindowNeedsUsageEvidence() { + let unknown = LimitDescriptor(id: "mystery", name: "Mystery", windowDurationMinutes: nil, modelID: nil) + let steady = [ + Poll(time: 1_000_000, resetsAt: 1_100_000, utilization: 5), + Poll(time: 1_000_300, resetsAt: 1_200_000, utilization: 5) + ] + let dropped = [ + Poll(time: 1_000_000, resetsAt: 1_100_000, utilization: 50), + Poll(time: 1_000_300, resetsAt: 1_200_000, utilization: 0) + ] + + #expect(infer(steady, limit: unknown).isEmpty) + #expect(infer(dropped, limit: unknown).count == 1) + } + + @Test("First use after a long idle gap isn't a reset, though the floating deadline jumped hours") + func firstUseAfterIdleGap() { + // Idle overnight at 0%, so each poll's deadline floated five hours ahead; the first + // poll after a morning's use reads 10% with a deadline from when use started. + let polls = [ + Poll(time: 1_000_000, resetsAt: 1_018_000, utilization: 0), + Poll(time: 1_014_400, resetsAt: 1_030_000, utilization: 10) + ] + + #expect(infer(polls, limit: Self.session).isEmpty) + } + + private static let hourly = LimitDescriptor(id: "hourly", name: "Hourly", windowDurationMinutes: 60, modelID: nil) + + @Test("A short Window reset early, its deadline moving only ten minutes, counts when usage falls to zero") + func shortWindowEarlyReset() { + let polls = [ + Poll(time: 1_000_000, resetsAt: 1_003_000, utilization: 80), + Poll(time: 1_000_600, resetsAt: 1_003_600, utilization: 0) + ] + + #expect(infer(polls, limit: Self.hourly).count == 1) + } + + @Test("A deadline already past at the earlier poll isn't a reset at the later one") + func staleDeadlineCorrectedLater() { + // The tool kept reporting the old deadline for one poll after it passed, then corrected + // it; the reset happened before the first of these polls, not between them. + let polls = [ + Poll(time: 1_010_000, resetsAt: 1_003_600, utilization: 2), + Poll(time: 1_010_300, resetsAt: 1_021_600, utilization: 3) + ] + + #expect(infer(polls, limit: Self.session).isEmpty) + } + + @Test("Without a Window length, usage jitter alongside a corrected reset time isn't a reset") + func unknownWindowJitter() { + let unknown = LimitDescriptor(id: "mystery", name: "Mystery", windowDurationMinutes: nil, modelID: nil) + let polls = [ + Poll(time: 1_000_000, resetsAt: 1_100_000, utilization: 50), + Poll(time: 1_000_300, resetsAt: 1_200_000, utilization: 49) + ] + + #expect(infer(polls, limit: unknown).isEmpty) + } + + @Test("An hourly rollover an hour after a logged one is its own reset, not that one again") + func consecutiveHourlyResets() { + let polls = [ + Poll(time: 1_003_500, resetsAt: 1_003_600, utilization: 70), + Poll(time: 1_003_700, resetsAt: 1_007_300, utilization: 0) + ] + let logged = LimitReset(app: .codexBar, limit: Self.hourly, timeUnixNano: 1_000_100 * Self.second, utilizationLast: 60, nextResetsAt: nil) + + #expect(infer(polls, limit: Self.hourly, logged: [logged]).count == 1) + } + + @Test("Samples without a matching reset time or utilisation are skipped, not guessed") + func unmatchedSamples() { + let resets = InferredResets.rollovers( + app: .codexBar, + limit: Self.weekly, + resetsAt: [ArchivedSample(timeUnixNano: 1 * Self.second, value: 100), ArchivedSample(timeUnixNano: 2 * Self.second, value: 900_000)], + utilization: [ArchivedSample(timeUnixNano: 1 * Self.second, value: 50)], + logged: [] + ) + + #expect(resets.isEmpty) + } +}