codeburn/mac/Sources/CodeBurnMenubar/Data/CapacityEstimator.swift
Resham Joshi 495a254338 feat(mac): native Swift menubar app + one-command install
Introduces mac/ with a native SwiftUI menubar app that replaces the
previous SwiftBar plugin entirely. Install via `npx codeburn menubar`,
which downloads the .app from GitHub Releases, strips Gatekeeper
quarantine, and drops it into ~/Applications.

Highlights

- mac/ SwiftUI app: agent tabs, Today/7/30/Month/All period switcher,
  Trend/Forecast/Pulse/Stats/Plan insights, activity + model
  breakdowns, optimize findings, CSV/JSON export, Star-on-GitHub
  banner, live 60s refresh, instant currency switching with offline FX
  cache.
- Security: CodeburnCLI argv-based spawn (no shell interpretation),
  SafeFile symlink guards + O_NOFOLLOW writes, FX rate clamping to
  [0.0001, 1_000_000], keychain filtered to account == "default",
  removed byte-window credential log, in-flight refresh guard, POSIX
  flock on config.json writes, TerminalLauncher validates argv before
  AppleScript interpolation.
- Performance: shared static NumberFormatter (thousands of allocations
  per popover redraw eliminated), concurrent pipe drain with 20 MB cap
  + 60s timeout in DataClient, Observation-tracked reactive UI, 5-min
  payload cache keyed on (period, provider).
- CLI: new `codeburn menubar` subcommand that downloads + installs +
  launches the .app (no clone, no build). New `status --format
  menubar-json` payload builder. `export` rewritten to produce a
  folder of one-table-per-file CSVs with a `.codeburn-export` marker
  so arbitrary -o paths cannot be silently deleted.
- Removed: src/menubar.ts (SwiftBar plugin generator),
  install-menubar / uninstall-menubar subcommands, `status --format
  menubar` directive output, tests/menubar.test.ts,
  tests/security/menubar-injection.test.ts.
- Release: .github/workflows/release-menubar.yml builds universal
  binary, assembles .app, ad-hoc signs, zips, uploads on mac-v* tag
  push. Runs on the free macos-latest runner.

Tests

- 230 TypeScript tests pass
- 10 Swift CapacityEstimator tests pass
- TypeScript typecheck clean
- Swift release build clean
2026-04-17 16:55:56 -07:00

127 lines
5.1 KiB
Swift

import Foundation
public struct CapacitySnapshot: Sendable, Equatable {
public let percent: Double // 0..100, Anthropic-reported utilization
public let effectiveTokens: Double // weighted sum of input/output/cache tokens consumed at capture
public let capturedAt: Date
public init(percent: Double, effectiveTokens: Double, capturedAt: Date) {
self.percent = percent
self.effectiveTokens = effectiveTokens
self.capturedAt = capturedAt
}
}
public enum CapacityConfidence: String, Sendable {
case low, medium, solid
}
public struct CapacityEstimate: Sendable, Equatable {
public let capacity: Double // tokens equivalent to 100%
public let confidence: CapacityConfidence
public let sampleSize: Int // post-decorrelation count
public let nonLinearityWarning: Bool
public init(capacity: Double, confidence: CapacityConfidence, sampleSize: Int, nonLinearityWarning: Bool) {
self.capacity = capacity
self.confidence = confidence
self.sampleSize = sampleSize
self.nonLinearityWarning = nonLinearityWarning
}
}
public enum CapacityEstimator {
private static let minSampleSize = 5
private static let minPercentRange = 15.0
private static let recencyHalfLifeSeconds: Double = 30 * 86400
private static let solidR2 = 0.97
private static let mediumR2 = 0.85
private static let solidSampleThreshold = 15
private static let mediumSampleThreshold = 6
private static let nonLinearityRunLengthThreshold = 0.7
public static func estimate(_ snapshots: [CapacitySnapshot], asOf now: Date = Date()) -> CapacityEstimate? {
guard snapshots.count >= minSampleSize else { return nil }
let percents = snapshots.map(\.percent)
let range = (percents.max() ?? 0) - (percents.min() ?? 0)
guard range >= minPercentRange else { return nil }
let weighted = snapshots.map { snap -> (p: Double, t: Double, w: Double) in
let ageSeconds = now.timeIntervalSince(snap.capturedAt)
let weight = pow(0.5, max(0, ageSeconds) / recencyHalfLifeSeconds)
return (snap.percent, snap.effectiveTokens, weight)
}
// Weighted least squares through origin: minimize sum(w * (t - p * cap/100)^2)
// Solution: cap = 100 * sum(w * t * p) / sum(w * p * p)
let numerator = weighted.reduce(0.0) { $0 + $1.w * $1.t * $1.p }
let denominator = weighted.reduce(0.0) { $0 + $1.w * $1.p * $1.p }
guard denominator > 0 else { return nil }
let capacity = 100.0 * numerator / denominator
guard capacity > 0 else { return nil }
// Weighted R^2 against the through-origin fit.
let weightedTokenSum = weighted.reduce(0.0) { $0 + $1.w * $1.t }
let weightSum = weighted.reduce(0.0) { $0 + $1.w }
let weightedMeanT = weightedTokenSum / max(weightSum, .ulpOfOne)
let ssRes = weighted.reduce(0.0) { acc, s in
let predicted = s.p * capacity / 100
let diff = s.t - predicted
return acc + s.w * diff * diff
}
let ssTot = weighted.reduce(0.0) { acc, s in
let diff = s.t - weightedMeanT
return acc + s.w * diff * diff
}
let r2 = ssTot > 0 ? max(0.0, 1.0 - ssRes / ssTot) : 0.0
let n = snapshots.count
let confidence: CapacityConfidence = {
if n >= solidSampleThreshold && r2 >= solidR2 { return .solid }
if n >= mediumSampleThreshold && r2 >= mediumR2 { return .medium }
return .low
}()
let nonLinearityWarning = detectNonLinearity(snapshots: weighted, capacity: capacity)
return CapacityEstimate(
capacity: capacity,
confidence: confidence,
sampleSize: n,
nonLinearityWarning: nonLinearityWarning
)
}
/// Sign-test on residuals across the percent range. If residuals form a long monotonic run
/// (e.g. all-negative in low percents then all-positive at high), the relationship isn't linear.
private static func detectNonLinearity(
snapshots: [(p: Double, t: Double, w: Double)],
capacity: Double
) -> Bool {
let sorted = snapshots.sorted { $0.p < $1.p }
let signs = sorted.map { s -> Int in
let predicted = s.p * capacity / 100
let diff = s.t - predicted
if abs(diff) < .ulpOfOne { return 0 }
return diff > 0 ? 1 : -1
}.filter { $0 != 0 }
guard signs.count >= minSampleSize else { return false }
// Longest single-sign run length / total
var longestRun = 0
var currentRun = 0
var currentSign = 0
for s in signs {
if s == currentSign {
currentRun += 1
} else {
longestRun = max(longestRun, currentRun)
currentSign = s
currentRun = 1
}
}
longestRun = max(longestRun, currentRun)
let runFraction = Double(longestRun) / Double(signs.count)
return runFraction >= nonLinearityRunLengthThreshold
}
}