Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions Sources/ThermalForgeCore/AnomalyGate.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
98 changes: 52 additions & 46 deletions Sources/ThermalForgeCore/ThermalMonitor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
89 changes: 89 additions & 0 deletions Tests/ThermalForgeTests/AnomalyGateTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}