From 7a021557f87cfd90c0b53ec2497a020212bf3848 Mon Sep 17 00:00:00 2001 From: iBobby Date: Tue, 20 Oct 2026 12:31:00 -0600 Subject: [PATCH] core: rate-limit anomaly reports with a 60s cooldown and collapse repeated dumps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bare two-tier thresholds misfire continuously on Apple Silicon: P-core peak sensors oscillate ±5°C on a 2-4s scale under normal load, so the detector logged an "Instant spike/drop" event — plus a 15-line process-history dump — every few seconds: 11,700+ events/day in measured logs, roughly 200k log lines/day, burying the real events. The detection logic moves into a pure, hardware-free AnomalyGate: a genuine event still reports immediately, but samples inside the 60s cooldown feed the rolling history silently, filtering the threshold-edge oscillation echo. Calibration now suppresses only reporting — the gate is fed every sample, so the first post-calibration reading compares against a fresh previous instead of a stale pre-calibration one. The pre-spike dump collapses consecutive identical snapshots to a single line ("ts → ts: idle (×n)"). Six unit tests cover thresholds, cooldown, prev-tracking through suppressed samples, and window reset. --- Sources/ThermalForgeCore/AnomalyGate.swift | 79 +++++++++++++++ Sources/ThermalForgeCore/ThermalMonitor.swift | 98 ++++++++++--------- .../ThermalForgeTests/AnomalyGateTests.swift | 89 +++++++++++++++++ 3 files changed, 220 insertions(+), 46 deletions(-) create mode 100644 Sources/ThermalForgeCore/AnomalyGate.swift create mode 100644 Tests/ThermalForgeTests/AnomalyGateTests.swift diff --git a/Sources/ThermalForgeCore/AnomalyGate.swift b/Sources/ThermalForgeCore/AnomalyGate.swift new file mode 100644 index 00000000..71597677 --- /dev/null +++ b/Sources/ThermalForgeCore/AnomalyGate.swift @@ -0,0 +1,79 @@ +// +// AnomalyGate.swift +// ThermalForge +// +// Pure decision logic for the monitor-cadence anomaly detector (temperature +// spikes/drops). Hardware-free so it unit-tests without SMC — +// ThermalMonitor performs the logging; this type only decides. +// +// The cooldown exists because the bare thresholds misfire continuously on +// Apple Silicon: P-core peak sensors oscillate ±5°C on a 2-4s scale under +// normal load, so the old code logged an "Instant spike/drop" event (plus a +// 15-line process-history dump) every few seconds — 11,700+ events/day in +// measured logs (~200k log lines/day). A genuine thermal event reports +// immediately; the echo-chamber of threshold-edge oscillation does not. +// + +import Foundation + +public struct AnomalyGate { + /// 2-second delta above which an instant event is reported. + public let instantThreshold: Float + /// 30-second delta above which a sustained event is reported. + public let sustainedThreshold: Float + /// Minimum spacing between REPORTED events. Samples arriving inside the + /// window still feed the history (the detector stays warm) but are not + /// reported — that is the threshold-edge oscillation filter. + public let cooldownSeconds: TimeInterval + + /// Rolling temperature window (15 readings × 2s = 30s). + private(set) var history: [Float] = [] + private var lastReportedAt: Date? + + private static let historyCapacity = 15 + + public init(instantThreshold: Float = 5, + sustainedThreshold: Float = 10, + cooldownSeconds: TimeInterval = 60) { + self.instantThreshold = instantThreshold + self.sustainedThreshold = sustainedThreshold + self.cooldownSeconds = cooldownSeconds + } + + public enum Event: Equatable { + /// |temp − previous| above the 2-second threshold. + case instant(from: Float, to: Float) + /// |temp − 30s-ago| above the window threshold. + case sustained(from: Float, to: Float) + /// Nothing reportable this sample (below thresholds, or in cooldown). + case none + } + + /// Feed one 2-second sample. Maintains the rolling history and returns the + /// event to report, if any. At most one event per call: when both tiers + /// qualify the instant tier wins (it is the tighter, earlier signal; the + /// sustained tier fires on a later sample once the instant one is reported). + public mutating func evaluate(temp: Float, at now: Date = Date()) -> Event { + defer { + history.append(temp) + if history.count > Self.historyCapacity { history.removeFirst() } + } + + let candidate: Event + if let prev = history.last, abs(temp - prev) > instantThreshold { + candidate = .instant(from: prev, to: temp) + } else if history.count >= Self.historyCapacity, let oldest = history.first, + abs(temp - oldest) > sustainedThreshold { + candidate = .sustained(from: oldest, to: temp) + } else { + return .none + } + + if let last = lastReportedAt, now.timeIntervalSince(last) < cooldownSeconds { + return .none + } + lastReportedAt = now + if case .sustained = candidate { history.removeAll() } + return candidate + } +} diff --git a/Sources/ThermalForgeCore/ThermalMonitor.swift b/Sources/ThermalForgeCore/ThermalMonitor.swift index 39b0d947..29364b9f 100644 --- a/Sources/ThermalForgeCore/ThermalMonitor.swift +++ b/Sources/ThermalForgeCore/ThermalMonitor.swift @@ -83,8 +83,11 @@ public final class ThermalMonitor { // MARK: - Anomaly Detection - /// Tracks temps over 30 seconds (15 readings at 2s monitor cadence) - private var anomalyHistory: [Float] = [] + /// Two-tier spike/drop detector (2s and 30s windows) with a 60s report + /// cooldown — P-core peaks oscillate ±5°C at normal load, and a + /// cooldown-less detector logs an event every few seconds all day. + /// See AnomalyGate. + private var anomalyGate = AnomalyGate() private var isCalibrating = false // MARK: - Process Buffer @@ -235,57 +238,60 @@ public final class ThermalMonitor { processBuffer.append((timestamp: ts, processes: currentProcs)) if processBuffer.count > 15 { processBuffer.removeFirst() } - // Anomaly detection: two tiers - // Tier 1: instant spike — >5°C between consecutive readings (2 seconds) - // Tier 2: sustained change — >10°C over 30 seconds + // Anomaly detection: two tiers (Tier 1: >5°C between 2s readings; + // Tier 2: >10°C over 30s), decided by the pure AnomalyGate, both + // rate-limited by its 60s report cooldown. The gate is fed EVERY + // sample — the history stays warm even while calibration suppresses + // reporting — so the first post-calibration sample compares against + // a fresh reading, not a stale pre-calibration one. + let anomalyEvent = anomalyGate.evaluate(temp: maxTemp) if !isCalibrating { - var spikeDetected = false - - // Tier 1: check against previous reading - if let prevTemp = anomalyHistory.last { - let instantDelta = maxTemp - prevTemp - if abs(instantDelta) > 5 { - let direction = instantDelta > 0 ? "spike" : "drop" - let fan0 = status.fans.first - TFLogger.shared.info( - "Instant \(direction): \(String(format: "%.1f", prevTemp))→\(String(format: "%.1f", maxTemp))°C " + - "(\(String(format: "%+.1f", instantDelta))°C in 2s) | " + - "Fan0: \(fan0?.actualRPM ?? 0) RPM (\(fan0?.mode ?? "?")) | " + - "Profile: \(activeProfile.name)" - ) - spikeDetected = true - } + func report(_ tier: String, _ window: String, from: Float, to: Float) { + let delta = to - from + let direction = delta > 0 ? "spike" : "drop" + let fan0 = status.fans.first + TFLogger.shared.info( + "\(tier) \(direction): \(String(format: "%.1f", from))→\(String(format: "%.1f", to))°C " + + "(\(String(format: "%+.1f", delta))°C in \(window)) | " + + "Fan0: \(fan0?.actualRPM ?? 0) RPM (\(fan0?.mode ?? "?")) | " + + "Profile: \(activeProfile.name)" + ) } - // Tier 2: check over 30-second window - if anomalyHistory.count >= 15 { - let oldest = anomalyHistory.first! - let sustainedDelta = maxTemp - oldest - if abs(sustainedDelta) > 10 { - let direction = sustainedDelta > 0 ? "spike" : "drop" - let fan0 = status.fans.first - TFLogger.shared.info( - "Sustained \(direction): \(String(format: "%.1f", oldest))→\(String(format: "%.1f", maxTemp))°C " + - "(\(String(format: "%+.1f", sustainedDelta))°C in 30s) | " + - "Fan0: \(fan0?.actualRPM ?? 0) RPM (\(fan0?.mode ?? "?")) | " + - "Profile: \(activeProfile.name)" - ) - spikeDetected = true - anomalyHistory.removeAll() - } + switch anomalyEvent { + case .instant(let from, let to): + report("Instant", "2s", from: from, to: to) + dumpProcessHistory() + case .sustained(let from, let to): + report("Sustained", "30s", from: from, to: to) + dumpProcessHistory() + case .none: + break } + } + } - // Dump the rolling buffer on any spike — shows what was running BEFORE - if spikeDetected { - TFLogger.shared.info("Pre-spike process history (last \(processBuffer.count * 2)s):") - for entry in processBuffer { - TFLogger.shared.info(" \(entry.timestamp): \(entry.processes)") - } + /// Dump the rolling buffer on a reported event — shows what was running + /// BEFORE it. Consecutive identical snapshots collapse to one line: an + /// idle stretch used to cost 15 " ts: idle" lines per event, which at + /// one event every few seconds swamped the daily log (~200k lines/day). + private func dumpProcessHistory() { + TFLogger.shared.info("Pre-spike process history (last \(processBuffer.count * 2)s):") + var idx = 0 + while idx < processBuffer.count { + let entry = processBuffer[idx] + var run = 1 + while idx + run < processBuffer.count, processBuffer[idx + run].processes == entry.processes { + run += 1 + } + if run == 1 { + TFLogger.shared.info(" \(entry.timestamp): \(entry.processes)") + } else { + let last = processBuffer[idx + run - 1].timestamp + TFLogger.shared.info(" \(entry.timestamp) → \(last): \(entry.processes) (×\(run))") } + idx += run } - - anomalyHistory.append(maxTemp) - if anomalyHistory.count > 15 { anomalyHistory.removeFirst() } } // MARK: - Smart Profile diff --git a/Tests/ThermalForgeTests/AnomalyGateTests.swift b/Tests/ThermalForgeTests/AnomalyGateTests.swift new file mode 100644 index 00000000..34147943 --- /dev/null +++ b/Tests/ThermalForgeTests/AnomalyGateTests.swift @@ -0,0 +1,89 @@ +// +// AnomalyGateTests.swift +// ThermalForge +// + +import Foundation +import Testing + +@testable import ThermalForgeCore + +@Suite("Anomaly gate") +struct AnomalyGateTests { + + private func date(_ seconds: Double) -> Date { + Date(timeIntervalSince1970: seconds) + } + + /// Evaluate away from the #expect macro: it captures the gate as an + /// immutable copy, so the mutating call must run first. + private func eval(_ gate: inout AnomalyGate, _ temp: Float, at seconds: Double) -> AnomalyGate.Event { + gate.evaluate(temp: temp, at: date(seconds)) + } + + @Test("instant event fires when the 2s delta exceeds the threshold") + func instantFires() { + var gate = AnomalyGate() + #expect(eval(&gate, 75, at: 0) == .none) + #expect(eval(&gate, 82, at: 2) == .instant(from: 75, to: 82)) + } + + @Test("delta exactly at the threshold does not fire (strict >)") + func thresholdStrict() { + var gate = AnomalyGate() + _ = eval(&gate, 75, at: 0) + #expect(eval(&gate, 80, at: 2) == .none) + #expect(eval(&gate, 75, at: 4) == .none) // exact -5 also quiet + } + + @Test("cooldown suppresses the echo of threshold-edge oscillation") + func cooldownSuppresses() { + var gate = AnomalyGate() + _ = eval(&gate, 75, at: 0) + // First crossing reports. + #expect(eval(&gate, 82, at: 2) == .instant(from: 75, to: 82)) + // The rebound crossing, 2s later, is inside the 60s cooldown: silent. + #expect(eval(&gate, 76, at: 4) == .none) + // Still silent at 59s. + #expect(eval(&gate, 83, at: 59) == .none) + // Past the cooldown, a real crossing reports again. + #expect(eval(&gate, 90, at: 62) == .instant(from: 83, to: 90)) + } + + @Test("history keeps recording during cooldown — prev-tracking survives") + func historyLivesThroughCooldown() { + var gate = AnomalyGate() + _ = eval(&gate, 75, at: 0) + #expect(eval(&gate, 82, at: 2) == .instant(from: 75, to: 82)) + // Suppressed sample still becomes the new "previous". + #expect(eval(&gate, 89, at: 4) == .none) + // After the cooldown the instant tier compares against 89, not 82. + #expect(eval(&gate, 96, at: 63) == .instant(from: 89, to: 96)) + } + + @Test("sustained event fires over the 30s window and resets it") + func sustainedFiresAndResets() { + var gate = AnomalyGate() + // Fill the 15-sample window with a slowly rising ramp (no instant hits). + for i in 0..<15 { + #expect(eval(&gate, Float(50 + i), at: Double(i) * 2) == .none) + } + // +11 over the 30s-ago sample: sustained, not instant (+1 from prev). + #expect(eval(&gate, 61, at: 30) == .sustained(from: 50, to: 61)) + // Window was cleared (prev now 61), but the just-reported sustained + // event starts the cooldown: the next crossing is suppressed... + #expect(eval(&gate, 68, at: 32) == .none) + _ = eval(&gate, 75, at: 34) + // ...until the cooldown lapses, and prev-tracking survived the reset. + #expect(eval(&gate, 82, at: 94) == .instant(from: 75, to: 82)) + } + + @Test("rolling history caps at 15 samples") + func historyCaps() { + var gate = AnomalyGate() + for i in 0..<20 { + _ = gate.evaluate(temp: Float(50 + i % 2), at: date(Double(i) * 2)) + } + #expect(gate.history.count == 15) + } +}