From 65b9b6c0d473cf625074cf82f1656c336b89b94e Mon Sep 17 00:00:00 2001 From: Gordon Beeming Date: Tue, 20 Oct 2026 14:55:23 +1100 Subject: [PATCH 1/5] Recover Window resets a Bar App never logged CodexBar never logged an early Weekly reset (13, 15 and 18 Oct), and ClaudeBar misses a Session rollover when the Mac sleeps across it, so the Events list and Limit History had nothing for them. The archived samples still show each one: the reset time jumping and usage falling. InferredResets walks a Limit's resets_at and utilisation samples and recovers a rollover by the rule CodexBar now logs by, plus a Window that ended between two polls. One within an hour of a logged reset is that reset and is skipped. limitHistories and limitResets merge them in, so the Events list, reset markers and averages all see them. On the real Archive this finds CodexBar's three and nine ClaudeBar Session rollovers across sleep, and adds nothing beside a logged reset. --- Sources/BarUICore/Archive+Limits.swift | 82 +++++++++--- Sources/BarUICore/InferredResets.swift | 85 +++++++++++++ .../InferredResetArchiveTests.swift | 50 ++++++++ .../BarUICoreTests/InferredResetsTests.swift | 120 ++++++++++++++++++ 4 files changed, 317 insertions(+), 20 deletions(-) create mode 100644 Sources/BarUICore/InferredResets.swift create mode 100644 Tests/BarUICoreTests/InferredResetArchiveTests.swift create mode 100644 Tests/BarUICoreTests/InferredResetsTests.swift diff --git a/Sources/BarUICore/Archive+Limits.swift b/Sources/BarUICore/Archive+Limits.swift index 859812c..987cdc8 100644 --- a/Sources/BarUICore/Archive+Limits.swift +++ b/Sources/BarUICore/Archive+Limits.swift @@ -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, utilization: nil) + 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 @@ -215,18 +213,62 @@ extension Archive { let latestSample = try latest.step() ? ArchivedSample(timeUnixNano: latest.int64(at: 0), value: latest.double(at: 1)) : nil + let utilization = try samples(of: app, limitID: descriptor.id, metric: .utilization, since: floor) histories.append(LimitHistory( app: app, limit: descriptor, - utilization: try samples(of: app, limitID: descriptor.id, metric: .utilization, since: floor), + utilization: utilization, latest: latestSample, - resets: events, + resets: try withInferredResets(events, of: app, limit: descriptor, since: floor, utilization: utilization), peakBeforeRange: since == nil ? nil : try peakBefore(floor, of: app, limit: descriptor) )) } 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 + } + + /// The logged resets with the rollovers recovered from the samples from + /// `floor` on, oldest first. `utilization` is passed in when the caller has + /// already read it, so it isn't read twice. + private func withInferredResets( + _ logged: [LimitReset], + of app: BarApp, + limit: LimitDescriptor, + since floor: Int64, + utilization: [ArchivedSample]? + ) throws -> [LimitReset] { + let inferred = InferredResets.rollovers( + app: app, + limit: limit, + resetsAt: try samples(of: app, limitID: limit.id, metric: .resetsAt, since: floor), + utilization: try utilization ?? samples(of: app, limitID: limit.id, metric: .utilization, since: floor), + logged: logged + ) + 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 diff --git a/Sources/BarUICore/InferredResets.swift b/Sources/BarUICore/InferredResets.swift new file mode 100644 index 0000000..54912e2 --- /dev/null +++ b/Sources/BarUICore/InferredResets.swift @@ -0,0 +1,85 @@ +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 { + /// More than a reset time drifts between reads, and more than a Window that + /// hasn't started yet creeps forward per poll. + static let jumpTolerance: Double = 3_600 + /// A rollover this close to a logged reset is that reset, not another one. + 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. Reset-time and utilisation samples are paired by their + /// exact time, because a Bar App writes both in the same sample; one + /// without the other is skipped rather than guessed at. + public static func rollovers( + app: BarApp, + limit: LimitDescriptor, + resetsAt: [ArchivedSample], + utilization: [ArchivedSample], + logged: [LimitReset] + ) -> [LimitReset] { + let usageByTime = Dictionary(utilization.map { ($0.timeUnixNano, $0.value) }, uniquingKeysWith: { _, latest in latest }) + let polls = resetsAt + .sorted { $0.timeUnixNano < $1.timeUnixNano } + .compactMap { sample in usageByTime[sample.timeUnixNano].map { (time: sample.timeUnixNano, resetsAt: sample.value, usage: $0) } } + let loggedTimes = logged.map(\.timeUnixNano) + + var found: [LimitReset] = [] + for (before, after) in zip(polls, polls.dropFirst()) { + let now = Double(after.time) / 1_000_000_000 + guard rolledOver( + oldResetsAt: before.resetsAt, oldUsage: before.usage, + newResetsAt: after.resetsAt, newUsage: after.usage, + now: now, windowMinutes: limit.windowDurationMinutes + ) else { + continue + } + guard !loggedTimes.contains(where: { abs($0 - after.time) <= loggedMatch }) else { + continue + } + found.append(LimitReset( + app: app, + limit: limit, + timeUnixNano: after.time, + utilizationLast: before.usage, + nextResetsAt: Date(timeIntervalSince1970: after.resetsAt) + )) + } + return found + } + + /// The same rule CodexBar logs resets by, plus a Window that had already + /// ended by the next poll (the Mac asleep across its reset). + static func rolledOver( + oldResetsAt: Double, oldUsage: Double, + newResetsAt: Double, newUsage: Double, + now: Double, windowMinutes: Int? + ) -> Bool { + let jump = newResetsAt - oldResetsAt + guard jump > jumpTolerance else { + return false + } + guard let windowMinutes else { + return true + } + let window = Double(windowMinutes) * 60 + if jump >= window / 2 || oldResetsAt <= now { + return true + } + if (oldUsage > 0 && newUsage == 0) || (oldUsage - newUsage > usageDrop && newUsage < usageFloor) { + return true + } + // A Window not started yet also reads as a full Window ahead; usage + // going down is what tells a real reset apart. + return newUsage < oldUsage && abs(newResetsAt - now - window) <= jumpTolerance + } +} diff --git a/Tests/BarUICoreTests/InferredResetArchiveTests.swift b/Tests/BarUICoreTests/InferredResetArchiveTests.swift new file mode 100644 index 0000000..afbc83f --- /dev/null +++ b/Tests/BarUICoreTests/InferredResetArchiveTests.swift @@ -0,0 +1,50 @@ +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]) + } + + @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..419e887 --- /dev/null +++ b/Tests/BarUICoreTests/InferredResetsTests.swift @@ -0,0 +1,120 @@ +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() { + let polls = (0..<6).map { Poll(time: 1_000_000 + Int64($0) * 600, resetsAt: Double(1_000_000 + $0 * 600 + 604_800), 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("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) + } +} From 87427a18df949ad7abb9d02922b40b577d68e136 Mon Sep 17 00:00:00 2001 From: Gordon Beeming Date: Tue, 20 Oct 2026 14:57:37 +1100 Subject: [PATCH 2/5] Type the not-started Window fixture step by step CI's compiler gave up type-checking the inline Int64/Int/Double arithmetic in one closure; each value is now typed on its own line. --- Tests/BarUICoreTests/InferredResetsTests.swift | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/Tests/BarUICoreTests/InferredResetsTests.swift b/Tests/BarUICoreTests/InferredResetsTests.swift index 419e887..53f8f0e 100644 --- a/Tests/BarUICoreTests/InferredResetsTests.swift +++ b/Tests/BarUICoreTests/InferredResetsTests.swift @@ -67,7 +67,13 @@ struct InferredResetsTests { @Test("A Window that hasn't started, its reset time creeping forward each poll, isn't a reset") func notStartedWindow() { - let polls = (0..<6).map { Poll(time: 1_000_000 + Int64($0) * 600, resetsAt: Double(1_000_000 + $0 * 600 + 604_800), utilization: 0) } + // 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) } From b668795ed5c767444e3765e945c01fcd402d6e8c Mon Sep 17 00:00:00 2001 From: Gordon Beeming Date: Wed, 21 Oct 2026 10:42:09 +1100 Subject: [PATCH 3/5] Tighten recovered resets, and read them from a Window before the range Review found four ways the recovery was wrong: - An unused Window floats a full Window ahead of every poll, so after a gap, or once it expired, its reset time jumped like a real rollover. A reset is never recovered with usage at 0% before and after, and a Limit with no Window length needs usage to go down. - A short Window's full rollover was held to the hour of drift tolerance; a jump of half a Window now counts first. - Inference read samples from the range start, so it lost the poll before a reset at the start of the range. It now reads from a Window (and a day) before, then keeps the resets in range. - peakBefore only knew logged resets, so a recovered early reset before the range didn't bound the peak the range starts from; it now gets the last reset from the merged list. --- Sources/BarUICore/Archive+Limits.swift | 40 ++++++++++------ Sources/BarUICore/InferredResets.swift | 23 +++++++-- .../InferredResetArchiveTests.swift | 35 ++++++++++++++ .../BarUICoreTests/InferredResetsTests.swift | 47 +++++++++++++++++++ 4 files changed, 125 insertions(+), 20 deletions(-) diff --git a/Sources/BarUICore/Archive+Limits.swift b/Sources/BarUICore/Archive+Limits.swift index 987cdc8..740beee 100644 --- a/Sources/BarUICore/Archive+Limits.swift +++ b/Sources/BarUICore/Archive+Limits.swift @@ -198,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( @@ -213,14 +217,15 @@ extension Archive { let latestSample = try latest.step() ? ArchivedSample(timeUnixNano: latest.int64(at: 0), value: latest.double(at: 1)) : nil - let utilization = try samples(of: app, limitID: descriptor.id, metric: .utilization, since: floor) + let all = try withInferredResets(events, of: app, limit: descriptor, since: lookback, utilization: nil) + let lastResetBefore = all.last { $0.timeUnixNano < floor }?.timeUnixNano histories.append(LimitHistory( app: app, limit: descriptor, - utilization: utilization, + utilization: try samples(of: app, limitID: descriptor.id, metric: .utilization, since: floor), latest: latestSample, - resets: try withInferredResets(events, of: app, limit: descriptor, since: floor, utilization: utilization), - 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 @@ -246,6 +251,17 @@ extension Archive { 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 with the rollovers recovered from the samples from /// `floor` on, oldest first. `utilization` is passed in when the caller has /// already read it, so it isn't read twice. @@ -270,10 +286,10 @@ extension Archive { } /// 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 @@ -285,12 +301,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/InferredResets.swift b/Sources/BarUICore/InferredResets.swift index 54912e2..e1d5f44 100644 --- a/Sources/BarUICore/InferredResets.swift +++ b/Sources/BarUICore/InferredResets.swift @@ -64,21 +64,34 @@ public enum InferredResets { newResetsAt: Double, newUsage: Double, now: Double, windowMinutes: Int? ) -> Bool { - let jump = newResetsAt - oldResetsAt - guard jump > jumpTolerance else { + // A Window nobody has used floats a full Window ahead of every poll, so + // after a gap, or once it expires, its reset time jumps like a real + // rollover. With no usage either side nothing reset that matters. + if oldUsage == 0 && newUsage == 0 { return false } + let jump = newResetsAt - oldResetsAt guard let windowMinutes else { - return true + // Without a Window length, only usage going down says a jump was + // a reset rather than a reset time being corrected. + return jump > jumpTolerance && newUsage < oldUsage } let window = Double(windowMinutes) * 60 - if jump >= window / 2 || oldResetsAt <= now { + // A full rollover counts whatever the Window's length, so a short + // Window isn't held to the hour of drift tolerance. + if jump >= window / 2 { + return true + } + guard jump > jumpTolerance else { + return false + } + if oldResetsAt <= now { return true } if (oldUsage > 0 && newUsage == 0) || (oldUsage - newUsage > usageDrop && newUsage < usageFloor) { return true } - // A Window not started yet also reads as a full Window ahead; usage + // A full Window ahead alone could still be a floating Window; usage // going down is what tells a real reset apart. return newUsage < oldUsage && abs(newResetsAt - now - window) <= jumpTolerance } diff --git a/Tests/BarUICoreTests/InferredResetArchiveTests.swift b/Tests/BarUICoreTests/InferredResetArchiveTests.swift index afbc83f..5283d14 100644 --- a/Tests/BarUICoreTests/InferredResetArchiveTests.swift +++ b/Tests/BarUICoreTests/InferredResetArchiveTests.swift @@ -35,6 +35,41 @@ struct InferredResetArchiveTests { #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("A ClaudeBar reset that was logged isn't added again from its samples") func loggedResetsStayUnchanged() async throws { let fixture = try ImportFixture() diff --git a/Tests/BarUICoreTests/InferredResetsTests.swift b/Tests/BarUICoreTests/InferredResetsTests.swift index 53f8f0e..1e0d9e0 100644 --- a/Tests/BarUICoreTests/InferredResetsTests.swift +++ b/Tests/BarUICoreTests/InferredResetsTests.swift @@ -111,6 +111,53 @@ struct InferredResetsTests { #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("Samples without a matching reset time or utilisation are skipped, not guessed") func unmatchedSamples() { let resets = InferredResets.rollovers( From e375085887f7e326aca83000a82057e8a026bf4e Mon Sep 17 00:00:00 2001 From: Gordon Beeming Date: Wed, 21 Oct 2026 10:54:51 +1100 Subject: [PATCH 4/5] Keep detected rollovers between reads, and sharpen the rollover rule limitResets rescanned every sample the Archive holds on each 15-second refresh, so its cost grew with the whole history. The Archive now keeps each Limit's detected rollovers and extends them with newer polls only; a change in how many samples sit at or before the last poll seen means older ones were imported, and that Limit is scanned again. Detection is split from matching against logged resets, so the cached part doesn't depend on them. Review also found: - a short Window reset early moves its deadline by minutes; a decisive usage drop now counts once the deadline has moved at all; - a deadline already past at the earlier poll was being corrected, not rolled over, and is no longer counted against the wrong poll; - with no Window length, only a decisive drop counts, not jitter; - the match radius against logged resets stays under half a Window, so an hourly Window's next rollover isn't taken for the logged one. --- .../BarUICore/Archive+InferredResets.swift | 70 +++++++++++ Sources/BarUICore/Archive+Limits.swift | 24 ++-- Sources/BarUICore/Archive.swift | 3 + Sources/BarUICore/InferredResets.swift | 115 ++++++++++++------ .../InferredResetArchiveTests.swift | 39 ++++++ .../BarUICoreTests/InferredResetsTests.swift | 46 +++++++ 6 files changed, 245 insertions(+), 52 deletions(-) create mode 100644 Sources/BarUICore/Archive+InferredResets.swift 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 740beee..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 @@ -139,7 +139,7 @@ extension Archive { 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, utilization: nil) + let all = try withInferredResets(logged, of: app, limit: descriptor, since: Int64.min) if !all.isEmpty { byLimit[descriptor.id] = all } @@ -217,7 +217,7 @@ 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, utilization: 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, @@ -262,23 +262,17 @@ extension Archive { return floor > Int64.min + reach ? floor - reach : Int64.min } - /// The logged resets with the rollovers recovered from the samples from - /// `floor` on, oldest first. `utilization` is passed in when the caller has - /// already read it, so it isn't read twice. + /// 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, - utilization: [ArchivedSample]? + since floor: Int64 ) throws -> [LimitReset] { - let inferred = InferredResets.rollovers( - app: app, - limit: limit, - resetsAt: try samples(of: app, limitID: limit.id, metric: .resetsAt, since: floor), - utilization: try utilization ?? samples(of: app, limitID: limit.id, metric: .utilization, since: floor), - logged: logged - ) + 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 } 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 index e1d5f44..89686f2 100644 --- a/Sources/BarUICore/InferredResets.swift +++ b/Sources/BarUICore/InferredResets.swift @@ -6,10 +6,22 @@ import Foundation /// to its end misses those; the samples still show the reset time jumping and /// usage falling back. public enum InferredResets { - /// More than a reset time drifts between reads, and more than a Window that - /// hasn't started yet creeps forward per poll. + /// 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`. @@ -17,9 +29,7 @@ public enum InferredResets { static let usageFloor: Double = 10 /// Every rollover in the samples that no logged reset already covers, - /// oldest first. Reset-time and utilisation samples are paired by their - /// exact time, because a Bar App writes both in the same sample; one - /// without the other is skipped rather than guessed at. + /// oldest first. public static func rollovers( app: BarApp, limit: LimitDescriptor, @@ -27,23 +37,27 @@ public enum InferredResets { 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 }) - let polls = resetsAt + return resetsAt .sorted { $0.timeUnixNano < $1.timeUnixNano } - .compactMap { sample in usageByTime[sample.timeUnixNano].map { (time: sample.timeUnixNano, resetsAt: sample.value, usage: $0) } } - let loggedTimes = logged.map(\.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()) { - let now = Double(after.time) / 1_000_000_000 - guard rolledOver( - oldResetsAt: before.resetsAt, oldUsage: before.usage, - newResetsAt: after.resetsAt, newUsage: after.usage, - now: now, windowMinutes: limit.windowDurationMinutes - ) else { - continue - } - guard !loggedTimes.contains(where: { abs($0 - after.time) <= loggedMatch }) else { + guard rolledOver(before: before, after: after, windowMinutes: limit.windowDurationMinutes) else { continue } found.append(LimitReset( @@ -57,42 +71,69 @@ public enum InferredResets { return found } - /// The same rule CodexBar logs resets by, plus a Window that had already - /// ended by the next poll (the Mac asleep across its reset). - static func rolledOver( - oldResetsAt: Double, oldUsage: Double, - newResetsAt: Double, newUsage: Double, - now: Double, windowMinutes: Int? - ) -> Bool { + /// 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, or once it expires, its reset time jumps like a real // rollover. With no usage either side nothing reset that matters. - if oldUsage == 0 && newUsage == 0 { + if before.usage == 0 && after.usage == 0 { return false } - let jump = newResetsAt - oldResetsAt + 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 usage going down says a jump was - // a reset rather than a reset time being corrected. - return jump > jumpTolerance && newUsage < oldUsage + // 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 counts whatever the Window's length, so a short - // Window isn't held to the hour of drift tolerance. - if jump >= window / 2 { + // 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 oldResetsAt <= now { - return true - } - if (oldUsage > 0 && newUsage == 0) || (oldUsage - newUsage > usageDrop && newUsage < usageFloor) { + 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 newUsage < oldUsage && abs(newResetsAt - now - window) <= jumpTolerance + return after.usage < before.usage && abs(after.resetsAt - now - window) <= jumpTolerance } } diff --git a/Tests/BarUICoreTests/InferredResetArchiveTests.swift b/Tests/BarUICoreTests/InferredResetArchiveTests.swift index 5283d14..3007fca 100644 --- a/Tests/BarUICoreTests/InferredResetArchiveTests.swift +++ b/Tests/BarUICoreTests/InferredResetArchiveTests.swift @@ -70,6 +70,45 @@ struct InferredResetArchiveTests { #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() diff --git a/Tests/BarUICoreTests/InferredResetsTests.swift b/Tests/BarUICoreTests/InferredResetsTests.swift index 1e0d9e0..4f2a1d6 100644 --- a/Tests/BarUICoreTests/InferredResetsTests.swift +++ b/Tests/BarUICoreTests/InferredResetsTests.swift @@ -158,6 +158,52 @@ struct InferredResetsTests { #expect(infer(dropped, limit: unknown).count == 1) } + 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( From c17d24b1181463b64956ff395e5e2c35c9da55a3 Mon Sep 17 00:00:00 2001 From: Gordon Beeming Date: Wed, 21 Oct 2026 10:55:43 +1100 Subject: [PATCH 5/5] Never count a rollover from a Window at 0% A Window nobody has used floats a full Window ahead of every poll, so when use starts after an idle gap the deadline jumps as far as a real rollover. At 0% beforehand there's nothing to reset; that alone now rules it out, not only 0% on both sides. --- Sources/BarUICore/InferredResets.swift | 7 ++++--- Tests/BarUICoreTests/InferredResetsTests.swift | 12 ++++++++++++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/Sources/BarUICore/InferredResets.swift b/Sources/BarUICore/InferredResets.swift index 89686f2..b792241 100644 --- a/Sources/BarUICore/InferredResets.swift +++ b/Sources/BarUICore/InferredResets.swift @@ -97,9 +97,10 @@ public enum InferredResets { /// 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, or once it expires, its reset time jumps like a real - // rollover. With no usage either side nothing reset that matters. - if before.usage == 0 && after.usage == 0 { + // 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 diff --git a/Tests/BarUICoreTests/InferredResetsTests.swift b/Tests/BarUICoreTests/InferredResetsTests.swift index 4f2a1d6..62f79db 100644 --- a/Tests/BarUICoreTests/InferredResetsTests.swift +++ b/Tests/BarUICoreTests/InferredResetsTests.swift @@ -158,6 +158,18 @@ struct InferredResetsTests { #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")