From 8440a5c16971ffc553a481984428c2342a80e0ec Mon Sep 17 00:00:00 2001 From: caezium <113233555+caezium@users.noreply.github.com> Date: Mon, 15 Jun 2026 09:34:48 -0700 Subject: [PATCH 01/49] feat(query): serve Prometheus exposition at /metrics?format=prometheus (B7) Render the latest snapshot as Prometheus text exposition so a dev with Grafana can scrape their own Mac in minutes. New pure MetricsPrometheus.exposition: gauges for CPU/memory/health/uptime/battery, per-disk and per-interface labeled series, GPU omitted when unavailable (-1 on Apple Silicon), and Prometheus label-value escaping. QueryServer.route now returns (body, contentType): every route stays JSON except the new endpoint, which serves text/plain; version=0.0.4 (the de-facto scrape content type). A missing or undecodable snapshot yields a comment line rather than error JSON, so scrapers tolerate an empty target. Tests: 4 formatter cases + 2 route cases; existing route tests migrated to the (body, contentType) tuple. --- Sources/MetricsPrometheus.swift | 73 ++++++++++++++++++++++++++++++ Sources/QueryServer.swift | 51 +++++++++++++++------ Tests/MetricsPrometheusTests.swift | 66 +++++++++++++++++++++++++++ Tests/QueryServerTests.swift | 61 ++++++++++++++++++------- 4 files changed, 221 insertions(+), 30 deletions(-) create mode 100644 Sources/MetricsPrometheus.swift create mode 100644 Tests/MetricsPrometheusTests.swift diff --git a/Sources/MetricsPrometheus.swift b/Sources/MetricsPrometheus.swift new file mode 100644 index 00000000..b7301706 --- /dev/null +++ b/Sources/MetricsPrometheus.swift @@ -0,0 +1,73 @@ +// +// MetricsPrometheus.swift +// Burrow +// +// Render the latest snapshot as Prometheus text exposition (roadmap B7), +// served from `GET /metrics?format=prometheus`. Pure: MoleStatus in, +// exposition text out — so a dev with Grafana can scrape their own Mac in +// minutes. Gauges only (these are instantaneous readings); per-disk and +// per-interface values are labeled series. +// + +import Foundation + +enum MetricsPrometheus { + static func exposition(from s: MoleStatus) -> String { + var out = "" + gauge(&out, "burrow_cpu_usage_percent", "Current CPU usage (percent).", s.cpu.usage) + gauge(&out, "burrow_load1", "1-minute load average.", s.cpu.load1) + gauge(&out, "burrow_cpu_cores", "Physical core count.", Double(s.cpu.coreCount)) + gauge(&out, "burrow_memory_used_bytes", "Memory used (bytes).", Double(s.memory.used)) + gauge(&out, "burrow_memory_total_bytes", "Memory total (bytes).", Double(s.memory.total)) + gauge(&out, "burrow_memory_used_percent", "Memory used (percent).", s.memory.usedPercent) + gauge(&out, "burrow_health_score", "Burrow health score (0-100).", Double(s.healthScore)) + gauge(&out, "burrow_uptime_seconds", "System uptime (seconds).", Double(s.uptimeSeconds)) + + // Per-disk + per-interface are labeled series on one metric name. + labeled(&out, "burrow_disk_used_bytes", "Disk space used (bytes), by mount.", + s.disks.map { ("mount=\"\(esc($0.mount))\"", Double($0.used)) }) + labeled(&out, "burrow_disk_total_bytes", "Disk total (bytes), by mount.", + s.disks.map { ("mount=\"\(esc($0.mount))\"", Double($0.total)) }) + labeled(&out, "burrow_disk_free_bytes", "Disk free (bytes), by mount.", + s.disks.map { ("mount=\"\(esc($0.mount))\"", Double($0.total > $0.used ? $0.total - $0.used : 0)) }) + labeled(&out, "burrow_disk_used_percent", "Disk used (percent), by mount.", + s.disks.map { ("mount=\"\(esc($0.mount))\"", $0.usedPercent) }) + labeled(&out, "burrow_network_rx_mbps", "Receive rate (MB/s), by interface.", + s.network.map { ("interface=\"\(esc($0.name))\"", $0.rxRateMbs) }) + labeled(&out, "burrow_network_tx_mbps", "Transmit rate (MB/s), by interface.", + s.network.map { ("interface=\"\(esc($0.name))\"", $0.txRateMbs) }) + + if let b = s.batteries?.first { + gauge(&out, "burrow_battery_percent", "Battery charge (percent).", b.percent) + gauge(&out, "burrow_battery_cycle_count", "Battery cycle count.", Double(b.cycleCount)) + } + if let g = s.gpu?.first, g.usage >= 0 { // -1 = unavailable on Apple Silicon + gauge(&out, "burrow_gpu_usage_percent", "GPU usage (percent).", g.usage) + } + return out + } + + private static func gauge(_ out: inout String, _ name: String, _ help: String, _ v: Double) { + out += "# HELP \(name) \(help)\n# TYPE \(name) gauge\n\(name) \(num(v))\n" + } + + /// A labeled gauge: one `# HELP`/`# TYPE` then one line per series. + /// Empty series (no disks/interfaces) emits nothing. + private static func labeled(_ out: inout String, _ name: String, _ help: String, + _ series: [(labels: String, value: Double)]) { + guard !series.isEmpty else { return } + out += "# HELP \(name) \(help)\n# TYPE \(name) gauge\n" + for s in series { out += "\(name){\(s.labels)} \(num(s.value))\n" } + } + + /// Escape a Prometheus label value (backslash and double-quote). + private static func esc(_ s: String) -> String { + s.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"") + } + + /// Whole numbers print without a decimal point (`42`, not `42.0`); the + /// rest keep their value. Prometheus accepts both. + private static func num(_ v: Double) -> String { + v == v.rounded() && abs(v) < 1e15 ? String(Int64(v)) : String(v) + } +} diff --git a/Sources/QueryServer.swift b/Sources/QueryServer.swift index 4975e8f9..5d124942 100644 --- a/Sources/QueryServer.swift +++ b/Sources/QueryServer.swift @@ -150,31 +150,40 @@ final class QueryServer { /// client, and an allow-all grant would let any web page read /snapshot /// (hostname, process command lines) cross-origin. The real clients — /// curl and the stdio MCP bridge — don't need CORS at all. - static func httpHead(contentLength: Int) -> String { + static let jsonContentType = "application/json; charset=utf-8" + /// Prometheus text exposition format, version 0.0.4 — the de-facto scrape + /// content type. Served only by `/metrics?format=prometheus`. + static let prometheusContentType = "text/plain; version=0.0.4; charset=utf-8" + + static func httpHead(contentLength: Int, contentType: String = jsonContentType) -> String { return "HTTP/1.1 200 OK\r\n" - + "Content-Type: application/json; charset=utf-8\r\n" + + "Content-Type: \(contentType)\r\n" + "Content-Length: \(contentLength)\r\n" + "Cache-Control: no-store\r\n" + "Connection: close\r\n" + "\r\n" } - private func send(_ json: String, on conn: NWConnection) { - let body = Data(json.utf8) - var payload = Data(Self.httpHead(contentLength: body.count).utf8) + private func send(_ response: (body: String, contentType: String), on conn: NWConnection) { + let body = Data(response.body.utf8) + var payload = Data(Self.httpHead(contentLength: body.count, contentType: response.contentType).utf8) payload.append(body) conn.send(content: payload, completion: .contentProcessed { _ in conn.cancel() }) } // MARK: - Routing - func route(_ raw: String) -> String { + /// Returns the response body and its content type. Everything is JSON + /// except `/metrics?format=prometheus`, which is text exposition. + func route(_ raw: String) -> (body: String, contentType: String) { + func json(_ s: String) -> (body: String, contentType: String) { (s, Self.jsonContentType) } + guard let first = raw.split(separator: "\r\n", maxSplits: 1).first else { - return Self.errorJSON("malformed request") + return json(Self.errorJSON("malformed request")) } let parts = first.split(separator: " ") guard parts.count >= 2, parts[0] == "GET" else { - return Self.errorJSON("only GET supported") + return json(Self.errorJSON("only GET supported")) } let target = String(parts[1]) let split = target.split(separator: "?", maxSplits: 1) @@ -183,19 +192,22 @@ final class QueryServer { switch path { case "/health": - return "{\"ok\":true,\"app\":\"Burrow\",\"port\":\(self.port)}" + return json("{\"ok\":true,\"app\":\"Burrow\",\"port\":\(self.port)}") case "/info": - return self.routeInfo() + return json(self.routeInfo()) case "/snapshot": - return self.routeSnapshot() + return json(self.routeSnapshot()) case "/metrics": - return self.routeMetrics(query: query) + if query["format"] == "prometheus" { + return (self.routeMetricsPrometheus(), Self.prometheusContentType) + } + return json(self.routeMetrics(query: query)) default: - return Self.errorJSON("unknown route") + return json(Self.errorJSON("unknown route")) } } @@ -239,6 +251,19 @@ final class QueryServer { return "{\"ts\":\(row.ts),\"snapshot\":\(row.json)}" } + /// `GET /metrics?format=prometheus` → the latest snapshot rendered as + /// Prometheus text exposition (roadmap B7), so a dev can point Grafana at + /// their own Mac. A missing or undecodable snapshot yields a comment line + /// rather than an error JSON — scrapers tolerate an empty target. + private func routeMetricsPrometheus() -> String { + guard let row = self.metrics.latestRaw(), + let status = try? JSONDecoder().decode(MoleStatus.self, from: Data(row.json.utf8)) + else { + return "# burrow: no snapshot available\n" + } + return MetricsPrometheus.exposition(from: status) + } + private func routeMetrics(query: [String: String]) -> String { guard let prefix = query["prefix"], !prefix.isEmpty else { return Self.errorJSON("missing 'prefix' query param") diff --git a/Tests/MetricsPrometheusTests.swift b/Tests/MetricsPrometheusTests.swift new file mode 100644 index 00000000..e289375d --- /dev/null +++ b/Tests/MetricsPrometheusTests.swift @@ -0,0 +1,66 @@ +// +// MetricsPrometheusTests.swift +// BurrowTests +// +// The Prometheus text-exposition formatter (roadmap B7): a pure +// MoleStatus → exposition-text function, so a dev with Grafana can scrape +// their Mac. Tested through the public `exposition(from:)` only. +// + +import XCTest +@testable import Burrow + +final class MetricsPrometheusTests: XCTestCase { + private func decode(_ json: String) throws -> MoleStatus { + try JSONDecoder().decode(MoleStatus.self, from: Data(json.utf8)) + } + + /// Minimal valid snapshot; disks/network/gpu/battery are optional and + /// added per-test where the behavior needs them. + private func base(cpu: Double = 42, + extra: String = "") -> String { + """ + {"collected_at":"2026-06-08T03:16:25.068057-07:00","host":"h","platform":"darwin","uptime_seconds":100,"procs":1, + "hardware":{"model":"Mac","cpu_model":"M","total_ram":"24 GB","disk_size":"460 GB","os_version":"26"}, + "health_score":90,"health_score_msg":"Good", + "cpu":{"usage":\(cpu),"load1":1.5,"load5":1,"load15":1,"core_count":10,"logical_cpu":10}, + "memory":{"used":100,"total":200,"used_percent":50,"swap_used":0,"swap_total":0,"pressure":""}, + "disk_io":{"read_rate":0,"write_rate":0}, + "top_processes":[]\(extra)} + """ + } + + func testExposition_cpuUsageGauge() throws { + let out = MetricsPrometheus.exposition(from: try decode(base(cpu: 42))) + XCTAssertTrue(out.contains("# TYPE burrow_cpu_usage_percent gauge"), out) + XCTAssertTrue(out.contains("\nburrow_cpu_usage_percent 42\n"), out) + } + + func testExposition_memoryAndHealthGauges() throws { + let out = MetricsPrometheus.exposition(from: try decode(base())) + XCTAssertTrue(out.contains("\nburrow_memory_used_bytes 100\n"), out) + XCTAssertTrue(out.contains("\nburrow_memory_used_percent 50\n"), out) + XCTAssertTrue(out.contains("\nburrow_health_score 90\n"), out) + } + + func testExposition_disksAndNetworkAreLabeledSeries() throws { + let extra = """ + ,"disks":[{"mount":"/","used":40,"total":100,"used_percent":40,"external":false}], + "network":[{"name":"en0","rx_rate_mbs":1.5,"tx_rate_mbs":0.25,"ip":"10.0.0.2"}], + "batteries":[{"percent":80,"status":"","time_left":"","health":"Good","cycle_count":120,"capacity":95}], + "gpu":[{"name":"M","usage":12,"memory_used":0,"memory_total":0,"core_count":10}] + """ + let out = MetricsPrometheus.exposition(from: try decode(base(extra: extra))) + XCTAssertTrue(out.contains("burrow_disk_used_bytes{mount=\"/\"} 40"), out) + XCTAssertTrue(out.contains("burrow_disk_free_bytes{mount=\"/\"} 60"), out) + XCTAssertTrue(out.contains("burrow_network_rx_mbps{interface=\"en0\"} 1.5"), out) + XCTAssertTrue(out.contains("burrow_battery_percent 80"), out) + XCTAssertTrue(out.contains("burrow_gpu_usage_percent 12"), out) + } + + func testExposition_omitsGpuWhenUnavailable() throws { + let extra = #","gpu":[{"name":"M","usage":-1,"memory_used":0,"memory_total":0,"core_count":10}]"# + let out = MetricsPrometheus.exposition(from: try decode(base(extra: extra))) + XCTAssertFalse(out.contains("burrow_gpu_usage_percent"), out) + } +} diff --git a/Tests/QueryServerTests.swift b/Tests/QueryServerTests.swift index 56ec4205..712e553b 100644 --- a/Tests/QueryServerTests.swift +++ b/Tests/QueryServerTests.swift @@ -82,24 +82,25 @@ final class QueryServerTests: XCTestCase { func testRoute_health() { let res = server.route("GET /health HTTP/1.1\r\n\r\n") - XCTAssertTrue(res.contains("\"ok\":true")) - XCTAssertTrue(res.contains("9277")) + XCTAssertTrue(res.body.contains("\"ok\":true")) + XCTAssertTrue(res.body.contains("9277")) + XCTAssertEqual(res.contentType, QueryServer.jsonContentType) } func testRoute_rejectsNonGET() { let res = server.route("POST /health HTTP/1.1\r\n\r\n") - XCTAssertTrue(res.contains("error")) - XCTAssertTrue(res.contains("only GET")) + XCTAssertTrue(res.body.contains("error")) + XCTAssertTrue(res.body.contains("only GET")) } func testRoute_unknownPathIsError() { let res = server.route("GET /admin HTTP/1.1\r\n\r\n") - XCTAssertTrue(res.contains("unknown route")) + XCTAssertTrue(res.body.contains("unknown route")) } func testRoute_malformedRequestIsError() { - XCTAssertTrue(server.route("").contains("error")) - XCTAssertTrue(server.route("\r\n\r\n").contains("error")) + XCTAssertTrue(server.route("").body.contains("error")) + XCTAssertTrue(server.route("\r\n\r\n").body.contains("error")) } func testRoute_snapshotReturnsLatestSeededRow() throws { @@ -107,19 +108,19 @@ final class QueryServerTests: XCTestCase { try db.insert(prefix: MetricsStore.snapshotPrefix, ts: now - 60, json: "{\"old\":true}") try db.insert(prefix: MetricsStore.snapshotPrefix, ts: now, json: "{\"new\":true}") let res = server.route("GET /snapshot HTTP/1.1\r\n\r\n") - XCTAssertTrue(res.contains("\"new\":true"), "should embed the most recent row verbatim") - XCTAssertFalse(res.contains("\"old\":true")) - XCTAssertTrue(res.contains("\"ts\":\(now)")) + XCTAssertTrue(res.body.contains("\"new\":true"), "should embed the most recent row verbatim") + XCTAssertFalse(res.body.contains("\"old\":true")) + XCTAssertTrue(res.body.contains("\"ts\":\(now)")) } func testRoute_snapshotWithEmptyDBIsError() { let res = server.route("GET /snapshot HTTP/1.1\r\n\r\n") - XCTAssertTrue(res.contains("no snapshot yet")) + XCTAssertTrue(res.body.contains("no snapshot yet")) } func testRoute_metricsRequiresPrefix() { let res = server.route("GET /metrics HTTP/1.1\r\n\r\n") - XCTAssertTrue(res.contains("missing 'prefix'")) + XCTAssertTrue(res.body.contains("missing 'prefix'")) } func testRoute_metricsReturnsSeededRange() throws { @@ -128,9 +129,35 @@ final class QueryServerTests: XCTestCase { try db.insert(prefix: "cpu", ts: now - 5, json: "{\"v\":2}") try db.insert(prefix: "other", ts: now - 5, json: "{\"v\":9}") let res = server.route("GET /metrics?prefix=cpu&since=0&until=\(now + 1) HTTP/1.1\r\n\r\n") - XCTAssertTrue(res.contains("{\"v\":1}")) - XCTAssertTrue(res.contains("{\"v\":2}")) - XCTAssertFalse(res.contains("{\"v\":9}"), "other prefixes must not bleed into the slice") + XCTAssertTrue(res.body.contains("{\"v\":1}")) + XCTAssertTrue(res.body.contains("{\"v\":2}")) + XCTAssertFalse(res.body.contains("{\"v\":9}"), "other prefixes must not bleed into the slice") + } + + // Roadmap B7: `/metrics?format=prometheus` renders the latest snapshot as + // Prometheus text exposition, served with the text/plain scrape + // content-type (not JSON) so a real scraper accepts it. + func testRoute_metricsPrometheusRendersLatestSnapshot() throws { + let now = Int(Date().timeIntervalSince1970) + let snap = """ + {"collected_at":"2026-06-08T03:16:25.068057-07:00","host":"h","platform":"darwin","uptime_seconds":100,"procs":1, + "hardware":{"model":"Mac","cpu_model":"M","total_ram":"24 GB","disk_size":"460 GB","os_version":"26"}, + "health_score":90,"health_score_msg":"Good", + "cpu":{"usage":42,"load1":1.5,"load5":1,"load15":1,"core_count":10,"logical_cpu":10}, + "memory":{"used":100,"total":200,"used_percent":50,"swap_used":0,"swap_total":0,"pressure":""}, + "disk_io":{"read_rate":0,"write_rate":0},"top_processes":[]} + """ + try db.insert(prefix: MetricsStore.snapshotPrefix, ts: now, json: snap) + let res = server.route("GET /metrics?format=prometheus HTTP/1.1\r\n\r\n") + XCTAssertEqual(res.contentType, QueryServer.prometheusContentType) + XCTAssertTrue(res.body.contains("\nburrow_cpu_usage_percent 42\n"), res.body) + XCTAssertTrue(res.body.contains("# TYPE burrow_health_score gauge"), res.body) + } + + func testRoute_metricsPrometheusWithEmptyDBYieldsComment() { + let res = server.route("GET /metrics?format=prometheus HTTP/1.1\r\n\r\n") + XCTAssertEqual(res.contentType, QueryServer.prometheusContentType) + XCTAssertTrue(res.body.hasPrefix("#"), "scrapers tolerate an empty target; emit a comment, not error JSON") } // Drift counters ride along on /info so a blank chart always has a @@ -138,7 +165,7 @@ final class QueryServerTests: XCTestCase { func testRoute_infoSurfacesDriftCounters() throws { MetricsStore.resetDriftCounters() let clean = server.route("GET /info HTTP/1.1\r\n\r\n") - let cleanObj = try XCTUnwrap(try JSONSerialization.jsonObject(with: Data(clean.utf8)) as? [String: Any]) + let cleanObj = try XCTUnwrap(try JSONSerialization.jsonObject(with: Data(clean.body.utf8)) as? [String: Any]) XCTAssertEqual(cleanObj["decode_skipped_total"] as? Int, 0) XCTAssertTrue(cleanObj["last_drift"] is NSNull, "no drift yet → explicit null") @@ -147,7 +174,7 @@ final class QueryServerTests: XCTestCase { _ = MetricsStore(db: db).snapshots(.init(since: 0, until: now + 1)) let drifted = server.route("GET /info HTTP/1.1\r\n\r\n") - let obj = try XCTUnwrap(try JSONSerialization.jsonObject(with: Data(drifted.utf8)) as? [String: Any]) + let obj = try XCTUnwrap(try JSONSerialization.jsonObject(with: Data(drifted.body.utf8)) as? [String: Any]) XCTAssertEqual(obj["decode_skipped_total"] as? Int, 1) let last = try XCTUnwrap(obj["last_drift"] as? [String: Any]) XCTAssertEqual(last["ts"] as? Int, now) From 4419e3955013b5005a40bc9cd0e98b8405c402ef Mon Sep 17 00:00:00 2001 From: caezium <113233555+caezium@users.noreply.github.com> Date: Mon, 15 Jun 2026 09:54:01 -0700 Subject: [PATCH 02/49] feat(forecast): disk-full forecaster from free-space history (A.3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure (ts, free-bytes) series → projection. Honest by construction: no date under a week of history, none when free space is flat or growing, and none past a 5-year horizon. Robust to single-sample cliffs (a temp file briefly eating space) via a Theil–Sen median slope rather than least-squares, which one outlier can drag arbitrarily far. Tracer-bullet core for roadmap A.3; the MCP tool + Home/History tile wiring follow as their own slices. --- Sources/DiskForecast.swift | 84 +++++++++++++++++++++++++++++++++++ Tests/DiskForecastTests.swift | 83 ++++++++++++++++++++++++++++++++++ 2 files changed, 167 insertions(+) create mode 100644 Sources/DiskForecast.swift create mode 100644 Tests/DiskForecastTests.swift diff --git a/Sources/DiskForecast.swift b/Sources/DiskForecast.swift new file mode 100644 index 00000000..f6aea964 --- /dev/null +++ b/Sources/DiskForecast.swift @@ -0,0 +1,84 @@ +// +// DiskForecast.swift +// Burrow +// +// Disk-full forecasting from free-space history (roadmap A.3). Pure: a +// series of (timestamp, free-bytes) in, a projection out — so the Home disk +// tile and the History disk chart can both say "full in ~3 weeks" from the +// same honest estimate. +// +// Honest by construction. It refuses to name a date when the trend is flat, +// when free space is *growing*, or when there's less than a week of history, +// and it resists single-sample cliffs (a temp file briefly eating space) by +// fitting a robust median (Theil–Sen) slope rather than least-squares, which +// one outlier can drag arbitrarily far. +// + +import Foundation + +enum DiskForecast { + struct Projection: Equatable { + /// Estimated days until free space reaches zero, or nil when not + /// forecastable — the UI shows the basis instead of a bare date. + let daysUntilFull: Double? + /// Trend in bytes/day (negative = filling up). Always present. + let slopeBytesPerDay: Double + /// Span of history the fit used, in days — surfaced so the UI can say + /// "based on N days" and never present precision it doesn't have. + let basisDays: Double + } + + /// Below this much history, a date is noise dressed as precision. + static let minBasisDays = 7.0 + /// Forecasts past this horizon mean the slope is effectively flat; we + /// suppress the date rather than tell someone their disk fills in 80 years. + static let maxHorizonDays = 365.0 * 5 + /// Pairwise Theil–Sen is O(n²); bound the input so a pathological caller + /// can't stall. Snapshots already arrive ≤720, so this rarely bites. + static let maxSamples = 1500 + + static func forecast(_ samples: [(ts: Int, freeBytes: Double)], now: Int) -> Projection { + let sorted = samples.sorted { $0.ts < $1.ts } + let pts = sorted.count > maxSamples ? stride(from: 0, to: sorted.count, + by: sorted.count / maxSamples + 1).map { sorted[$0] } : sorted + guard pts.count >= 2, let first = pts.first, let last = pts.last, + last.ts > first.ts else { + return Projection(daysUntilFull: nil, slopeBytesPerDay: 0, basisDays: 0) + } + let basisDays = Double(last.ts - first.ts) / 86_400.0 + + // Theil–Sen slope: median of all pairwise slopes (bytes/day). + var slopes: [Double] = [] + slopes.reserveCapacity(pts.count * (pts.count - 1) / 2) + for i in 0.. 0 { + slopes.append((pts[j].freeBytes - pts[i].freeBytes) / dtDays) + } + } + } + let slope = median(slopes) + // Robust "free right now": the Theil–Sen line (median intercept + + // slope) evaluated at `now`, so one cliff sample can't move the start. + let intercepts = pts.map { $0.freeBytes - slope * (Double($0.ts) / 86_400.0) } + let currentFree = slope * (Double(now) / 86_400.0) + median(intercepts) + + guard basisDays >= minBasisDays else { + return Projection(daysUntilFull: nil, slopeBytesPerDay: slope, basisDays: basisDays) + } + guard slope < 0 else { // flat or growing → it never fills + return Projection(daysUntilFull: nil, slopeBytesPerDay: slope, basisDays: basisDays) + } + let days = max(0, currentFree) / (-slope) + return Projection(daysUntilFull: days <= maxHorizonDays ? days : nil, + slopeBytesPerDay: slope, basisDays: basisDays) + } + + private static func median(_ xs: [Double]) -> Double { + guard !xs.isEmpty else { return 0 } + let s = xs.sorted() + let n = s.count + return n % 2 == 1 ? s[n / 2] : (s[n / 2 - 1] + s[n / 2]) / 2 + } +} diff --git a/Tests/DiskForecastTests.swift b/Tests/DiskForecastTests.swift new file mode 100644 index 00000000..e3d299fd --- /dev/null +++ b/Tests/DiskForecastTests.swift @@ -0,0 +1,83 @@ +// +// DiskForecastTests.swift +// BurrowTests +// +// The disk-full forecaster (roadmap A.3): a pure (ts, free-bytes) series → +// projection. Tested through `forecast(_:now:)` only, so the regression +// method can change without rewriting these. +// + +import XCTest +@testable import Burrow + +final class DiskForecastTests: XCTestCase { + private let gb = 1_000_000_000.0 + private let day = 86_400 + + /// `count` daily samples ending at day `count-1`, free starting at + /// `startGB` and changing by `perDayGB` each day. + private func series(count: Int, startGB: Double, perDayGB: Double) + -> [(ts: Int, freeBytes: Double)] { + (0.. Date: Mon, 15 Jun 2026 10:00:14 -0700 Subject: [PATCH 03/49] feat(anomaly): baseline-vs-recent regression rules (A.2) Pure stats: percentile/median primitives, a sustained-CPU-exceedance rule (recent median clears baseline p95 + a minimum effect size so near-idle noise can't trip it), and battery-drain regression (OLS %/hr per session, flagged when a recent discharge runs >=factor x the baseline median). The Maintenance pass writing burrow.findings + the Home card are integration. --- Sources/Anomaly.swift | 77 ++++++++++++++++++++++++++++++++++++++++ Tests/AnomalyTests.swift | 70 ++++++++++++++++++++++++++++++++++++ 2 files changed, 147 insertions(+) create mode 100644 Sources/Anomaly.swift create mode 100644 Tests/AnomalyTests.swift diff --git a/Sources/Anomaly.swift b/Sources/Anomaly.swift new file mode 100644 index 00000000..c1259556 --- /dev/null +++ b/Sources/Anomaly.swift @@ -0,0 +1,77 @@ +// +// Anomaly.swift +// Burrow +// +// Baseline-vs-recent regression detection (roadmap A.2). Pure statistics: +// arrays in, a verdict out — so the "WindowServer baseline doubled" and +// "battery drains faster than two weeks ago" findings are computed the same +// way wherever they're surfaced (Home card, notifications, the Explain lens, +// a future MCP tool), and the credibility tuning lives in one place. +// +// The rules are deliberately conservative: a finding needs a clear effect +// size on top of statistical exceedance, because a false "your Mac is +// degrading" alert costs more trust than a missed one. +// + +import Foundation + +enum Anomaly { + // MARK: Statistical primitives + + /// Linear-interpolated percentile (the numpy/"type 7" convention), so + /// p95 of a short baseline is stable and well-defined. `p` is 0...100. + static func percentile(_ xs: [Double], _ p: Double) -> Double { + guard !xs.isEmpty else { return 0 } + let s = xs.sorted() + if s.count == 1 { return s[0] } + let rank = (p / 100.0) * Double(s.count - 1) + let lo = Int(rank.rounded(.down)) + let hi = Int(rank.rounded(.up)) + let frac = rank - Double(lo) + return s[lo] + (s[hi] - s[lo]) * frac + } + + static func median(_ xs: [Double]) -> Double { percentile(xs, 50) } + + // MARK: Process CPU regression + + /// True when a process's recent CPU is *sustainedly* above its own + /// baseline: the typical recent value clears the baseline's 95th + /// percentile AND beats the baseline median by at least `minDelta` points. + /// The effect-size floor stops near-idle processes (0.5% → 2%) from + /// tripping a "doubled!" alert that's technically true but meaningless. + static func processCPUExceedsBaseline(baseline: [Double], recent: [Double], + minSamples: Int = 5, minDelta: Double = 10) -> Bool { + guard baseline.count >= minSamples, recent.count >= minSamples else { return false } + let recentMid = median(recent) + return recentMid > percentile(baseline, 95) + && (recentMid - median(baseline)) >= minDelta + } + + // MARK: Battery drain regression + + /// Discharge rate of one session in percent-per-hour (positive = losing + /// charge), via least-squares slope over elapsed hours. Robust to a + /// noisy reading or two. nil when the session is too short to have a rate. + static func batteryDrainRate(_ session: [(ts: Int, percent: Double)]) -> Double? { + guard session.count >= 2 else { return nil } + let pts = session.map { (h: Double($0.ts) / 3600.0, p: $0.percent) } + let n = Double(pts.count) + let meanH = pts.reduce(0) { $0 + $1.h } / n + let meanP = pts.reduce(0) { $0 + $1.p } / n + var num = 0.0, den = 0.0 + for pt in pts { num += (pt.h - meanH) * (pt.p - meanP); den += (pt.h - meanH) * (pt.h - meanH) } + guard den > 0 else { return nil } + return -(num / den) // negate: a falling % is a positive drain rate + } + + /// True when the recent discharge drains meaningfully faster than the + /// baseline sessions: at least `factor`× the median baseline rate and at + /// least `minDelta` %/hr faster in absolute terms. + static func batteryDrainRegressed(baselineRates: [Double], recentRate: Double, + factor: Double = 1.25, minDelta: Double = 1) -> Bool { + guard !baselineRates.isEmpty else { return false } + let base = median(baselineRates) + return base > 0 && recentRate >= base * factor && (recentRate - base) >= minDelta + } +} diff --git a/Tests/AnomalyTests.swift b/Tests/AnomalyTests.swift new file mode 100644 index 00000000..81b88299 --- /dev/null +++ b/Tests/AnomalyTests.swift @@ -0,0 +1,70 @@ +// +// AnomalyTests.swift +// BurrowTests +// +// The baseline-vs-recent regression rules (roadmap A.2), tested through +// their public verdicts so the thresholds can be tuned without rewriting +// the specification of *what* counts as a regression. +// + +import XCTest +@testable import Burrow + +final class AnomalyTests: XCTestCase { + // MARK: percentile convention + + func testPercentile_interpolatesBetweenSamples() { + let xs = [0.0, 10, 20, 30, 40] + XCTAssertEqual(Anomaly.median(xs), 20, accuracy: 0.001) + XCTAssertEqual(Anomaly.percentile(xs, 100), 40, accuracy: 0.001) + XCTAssertEqual(Anomaly.percentile(xs, 0), 0, accuracy: 0.001) + } + + // MARK: process CPU + + func testProcessCPU_stableProcess_notFlagged() { + let baseline = Array(repeating: 20.0, count: 20) + [22, 18, 21, 19] + let recent = [20.0, 21, 19, 20, 22, 18] + XCTAssertFalse(Anomaly.processCPUExceedsBaseline(baseline: baseline, recent: recent)) + } + + func testProcessCPU_sustainedDoubling_flagged() { + let baseline = Array(repeating: 20.0, count: 20) + let recent = Array(repeating: 45.0, count: 8) // clears p95(=20) by 25 pts + XCTAssertTrue(Anomaly.processCPUExceedsBaseline(baseline: baseline, recent: recent)) + } + + func testProcessCPU_tinyAbsoluteChange_notFlagged() { + // 0.5% → 3%: exceeds the baseline p95 but the effect size is trivial. + let baseline = Array(repeating: 0.5, count: 20) + let recent = Array(repeating: 3.0, count: 8) + XCTAssertFalse(Anomaly.processCPUExceedsBaseline(baseline: baseline, recent: recent), + "near-idle noise must not trip a regression alert") + } + + func testProcessCPU_tooFewSamples_notFlagged() { + XCTAssertFalse(Anomaly.processCPUExceedsBaseline(baseline: [20, 20, 20], + recent: [90, 90, 90])) + } + + // MARK: battery drain + + func testBatteryDrainRate_lostTenPercentOverTwoHours() { + let session = [(ts: 0, percent: 100.0), (ts: 3600, percent: 95.0), (ts: 7200, percent: 90.0)] + let rate = Anomaly.batteryDrainRate(session) + XCTAssertNotNil(rate) + XCTAssertEqual(rate ?? 0, 5, accuracy: 0.001) // 10% over 2h → 5%/hr + } + + func testBatteryDrainRate_singlePoint_isNil() { + XCTAssertNil(Anomaly.batteryDrainRate([(ts: 0, percent: 100)])) + } + + func testBatteryDrainRegressed_fasterThanBaseline_flagged() { + XCTAssertTrue(Anomaly.batteryDrainRegressed(baselineRates: [3, 3.5, 4, 3.2], recentRate: 6)) + } + + func testBatteryDrainRegressed_withinNormalVariation_notFlagged() { + XCTAssertFalse(Anomaly.batteryDrainRegressed(baselineRates: [4, 4.2, 3.8, 4.1], recentRate: 4.3)) + } +} From d8fc3e618f3664236a0619a5882f9cd47a1fabc4 Mon Sep 17 00:00:00 2001 From: caezium <113233555+caezium@users.noreply.github.com> Date: Mon, 15 Jun 2026 10:00:14 -0700 Subject: [PATCH 04/49] feat(diff): set-membership inventory diff (B.8 + D.12) Shared kernel for burrow_diff(since:) and the new-LaunchAgent watcher: sorted, de-duplicated added/removed over two identifier lists (apps, login items, LaunchAgents, ports, top-process membership). --- Sources/InventoryDiff.swift | 32 ++++++++++++++++++++++++++ Tests/InventoryDiffTests.swift | 41 ++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+) create mode 100644 Sources/InventoryDiff.swift create mode 100644 Tests/InventoryDiffTests.swift diff --git a/Sources/InventoryDiff.swift b/Sources/InventoryDiff.swift new file mode 100644 index 00000000..06749161 --- /dev/null +++ b/Sources/InventoryDiff.swift @@ -0,0 +1,32 @@ +// +// InventoryDiff.swift +// Burrow +// +// Set-membership diffing for periodic inventories (roadmap B.8 + D.12). +// Pure: two lists of identifiers in, what was added/removed out. Apps, +// login items, LaunchAgents, listening ports, and top-process membership +// all diff the same way, so `burrow_diff` ("what changed since