Pulse/internal/ai/baseline/store.go
rcourtman f3e95c24ae feat(ai): Add baseline learning and anomaly detection (Phase 2)
Phase 2 of Pulse AI differentiation:

- Create internal/ai/baseline package for learned baselines
- Implement statistical baseline learning with mean, stddev, percentiles
- Add z-score based anomaly detection with severity classification
  (low, medium, high, critical based on standard deviations)
- Integrate baseline provider into context builder
- Wire baseline store into patrol service with adapters
- Add anomaly enrichment to resource contexts

Key features:
- Learn computes baseline from historical metric data points
- IsAnomaly and CheckAnomaly detect deviations from normal
- Persists baselines to disk as JSON for durability
- Formatted anomaly descriptions for AI consumption
  Example: 'Memory is high above normal (85.2% vs typical 42.1% ± 8.3%)'

The baseline store needs to be initialized and triggered to learn
from metrics history. Next step is adding the learning loop.

All tests passing.
2025-12-12 11:26:31 +00:00

395 lines
9.7 KiB
Go

// Package baseline provides learned baseline metrics for anomaly detection.
// It learns what "normal" looks like for each resource by analyzing historical
// metrics and can then flag current values that deviate significantly from the baseline.
package baseline
import (
"encoding/json"
"math"
"os"
"path/filepath"
"sort"
"sync"
"time"
"github.com/rs/zerolog/log"
)
// MetricBaseline represents learned "normal" behavior for a single metric
type MetricBaseline struct {
Mean float64 `json:"mean"` // Average value
StdDev float64 `json:"stddev"` // Standard deviation
Percentiles map[int]float64 `json:"percentiles"` // 5, 25, 50, 75, 95
SampleCount int `json:"sample_count"` // Number of samples used
// Time-of-day patterns (future enhancement)
HourlyMeans [24]float64 `json:"hourly_means,omitempty"`
}
// ResourceBaseline contains baselines for all metrics of a resource
type ResourceBaseline struct {
ResourceID string `json:"resource_id"`
ResourceType string `json:"resource_type"` // node, vm, container, storage
LastUpdated time.Time `json:"last_updated"`
Metrics map[string]*MetricBaseline `json:"metrics"` // cpu, memory, disk
}
// Store manages baseline storage and learning
type Store struct {
mu sync.RWMutex
baselines map[string]*ResourceBaseline // resourceID -> baseline
// Configuration
learningWindow time.Duration // How far back to learn from (default: 7 days)
minSamples int // Minimum samples needed (default: 50)
updateInterval time.Duration // How often to recompute (default: 1 hour)
// Persistence
dataDir string
persistence Persistence
}
// Persistence interface for saving/loading baselines
type Persistence interface {
Save(baselines map[string]*ResourceBaseline) error
Load() (map[string]*ResourceBaseline, error)
}
// StoreConfig configures the baseline store
type StoreConfig struct {
LearningWindow time.Duration
MinSamples int
UpdateInterval time.Duration
DataDir string
}
// DefaultConfig returns sensible defaults
func DefaultConfig() StoreConfig {
return StoreConfig{
LearningWindow: 7 * 24 * time.Hour, // 7 days
MinSamples: 50,
UpdateInterval: 1 * time.Hour,
}
}
// NewStore creates a new baseline store
func NewStore(cfg StoreConfig) *Store {
if cfg.LearningWindow == 0 {
cfg.LearningWindow = 7 * 24 * time.Hour
}
if cfg.MinSamples == 0 {
cfg.MinSamples = 50
}
if cfg.UpdateInterval == 0 {
cfg.UpdateInterval = 1 * time.Hour
}
s := &Store{
baselines: make(map[string]*ResourceBaseline),
learningWindow: cfg.LearningWindow,
minSamples: cfg.MinSamples,
updateInterval: cfg.UpdateInterval,
dataDir: cfg.DataDir,
}
// Try to load existing baselines from disk
if cfg.DataDir != "" {
if err := s.loadFromDisk(); err != nil {
log.Warn().Err(err).Msg("Failed to load baselines from disk, starting fresh")
} else {
log.Info().Int("count", len(s.baselines)).Msg("Loaded baselines from disk")
}
}
return s
}
// MetricPoint represents a single metric value at a point in time
type MetricPoint struct {
Value float64
Timestamp time.Time
}
// Learn computes baseline from historical data points
func (s *Store) Learn(resourceID, resourceType, metric string, points []MetricPoint) error {
if len(points) < s.minSamples {
log.Debug().
Str("resource", resourceID).
Str("metric", metric).
Int("samples", len(points)).
Int("required", s.minSamples).
Msg("Insufficient data for baseline learning")
return nil // Not an error, just not enough data yet
}
// Extract values
values := make([]float64, len(points))
for i, p := range points {
values[i] = p.Value
}
// Compute statistics
baseline := &MetricBaseline{
Mean: computeMean(values),
StdDev: computeStdDev(values),
Percentiles: computePercentiles(values),
SampleCount: len(values),
}
s.mu.Lock()
defer s.mu.Unlock()
// Get or create resource baseline
rb, exists := s.baselines[resourceID]
if !exists {
rb = &ResourceBaseline{
ResourceID: resourceID,
ResourceType: resourceType,
Metrics: make(map[string]*MetricBaseline),
}
s.baselines[resourceID] = rb
}
rb.Metrics[metric] = baseline
rb.LastUpdated = time.Now()
log.Debug().
Str("resource", resourceID).
Str("metric", metric).
Float64("mean", baseline.Mean).
Float64("stddev", baseline.StdDev).
Int("samples", baseline.SampleCount).
Msg("Baseline learned")
return nil
}
// GetBaseline returns the baseline for a resource/metric
func (s *Store) GetBaseline(resourceID, metric string) (*MetricBaseline, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
rb, exists := s.baselines[resourceID]
if !exists {
return nil, false
}
mb, exists := rb.Metrics[metric]
return mb, exists
}
// GetResourceBaseline returns all baselines for a resource
func (s *Store) GetResourceBaseline(resourceID string) (*ResourceBaseline, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
rb, exists := s.baselines[resourceID]
if !exists {
return nil, false
}
// Return a copy to prevent mutation
copy := &ResourceBaseline{
ResourceID: rb.ResourceID,
ResourceType: rb.ResourceType,
LastUpdated: rb.LastUpdated,
Metrics: make(map[string]*MetricBaseline),
}
for k, v := range rb.Metrics {
copy.Metrics[k] = v
}
return copy, true
}
// IsAnomaly checks if a value is anomalous for the given resource/metric
// Returns: isAnomaly, zScore (number of standard deviations from mean)
func (s *Store) IsAnomaly(resourceID, metric string, value float64) (bool, float64) {
baseline, ok := s.GetBaseline(resourceID, metric)
if !ok || baseline.SampleCount < s.minSamples {
return false, 0 // Not enough data to determine
}
if baseline.StdDev == 0 {
// No variance - any different value is anomalous
if value != baseline.Mean {
return true, math.Inf(1)
}
return false, 0
}
zScore := (value - baseline.Mean) / baseline.StdDev
// Consider anything > 2 standard deviations as anomalous
// (covers ~95% of normal distribution)
isAnomaly := math.Abs(zScore) > 2.0
return isAnomaly, zScore
}
// AnomalySeverity categorizes how severe an anomaly is
type AnomalySeverity string
const (
AnomalyNone AnomalySeverity = ""
AnomalyLow AnomalySeverity = "low" // 2-2.5 std devs
AnomalyMedium AnomalySeverity = "medium" // 2.5-3 std devs
AnomalyHigh AnomalySeverity = "high" // 3-4 std devs
AnomalyCritical AnomalySeverity = "critical" // > 4 std devs
)
// CheckAnomaly performs a detailed anomaly check with severity classification
func (s *Store) CheckAnomaly(resourceID, metric string, value float64) (AnomalySeverity, float64, *MetricBaseline) {
baseline, ok := s.GetBaseline(resourceID, metric)
if !ok || baseline.SampleCount < s.minSamples {
return AnomalyNone, 0, nil
}
if baseline.StdDev == 0 {
if value != baseline.Mean {
return AnomalyCritical, math.Inf(1), baseline
}
return AnomalyNone, 0, baseline
}
zScore := (value - baseline.Mean) / baseline.StdDev
absZ := math.Abs(zScore)
var severity AnomalySeverity
switch {
case absZ < 2.0:
severity = AnomalyNone
case absZ < 2.5:
severity = AnomalyLow
case absZ < 3.0:
severity = AnomalyMedium
case absZ < 4.0:
severity = AnomalyHigh
default:
severity = AnomalyCritical
}
return severity, zScore, baseline
}
// ResourceCount returns the number of resources with baselines
func (s *Store) ResourceCount() int {
s.mu.RLock()
defer s.mu.RUnlock()
return len(s.baselines)
}
// Save persists baselines to disk
func (s *Store) Save() error {
if s.dataDir == "" {
return nil
}
s.mu.RLock()
defer s.mu.RUnlock()
return s.saveToDisk()
}
// saveToDisk writes baselines to JSON file
func (s *Store) saveToDisk() error {
if s.dataDir == "" {
return nil
}
path := filepath.Join(s.dataDir, "baselines.json")
data, err := json.MarshalIndent(s.baselines, "", " ")
if err != nil {
return err
}
// Write to temp file first, then rename for atomicity
tmpPath := path + ".tmp"
if err := os.WriteFile(tmpPath, data, 0600); err != nil {
return err
}
return os.Rename(tmpPath, path)
}
// loadFromDisk reads baselines from JSON file
func (s *Store) loadFromDisk() error {
path := filepath.Join(s.dataDir, "baselines.json")
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil // No saved baselines yet
}
return err
}
return json.Unmarshal(data, &s.baselines)
}
// Helper functions for statistics
func computeMean(values []float64) float64 {
if len(values) == 0 {
return 0
}
sum := 0.0
for _, v := range values {
sum += v
}
return sum / float64(len(values))
}
func computeStdDev(values []float64) float64 {
if len(values) < 2 {
return 0
}
mean := computeMean(values)
sumSqDiff := 0.0
for _, v := range values {
diff := v - mean
sumSqDiff += diff * diff
}
variance := sumSqDiff / float64(len(values)-1) // Sample standard deviation
return math.Sqrt(variance)
}
func computePercentiles(values []float64) map[int]float64 {
if len(values) == 0 {
return nil
}
// Sort a copy
sorted := make([]float64, len(values))
copy(sorted, values)
sort.Float64s(sorted)
percentiles := map[int]float64{
5: percentile(sorted, 5),
25: percentile(sorted, 25),
50: percentile(sorted, 50),
75: percentile(sorted, 75),
95: percentile(sorted, 95),
}
return percentiles
}
func percentile(sorted []float64, p int) float64 {
if len(sorted) == 0 {
return 0
}
// Use linear interpolation
rank := float64(p) / 100.0 * float64(len(sorted)-1)
lower := int(rank)
upper := lower + 1
if upper >= len(sorted) {
return sorted[len(sorted)-1]
}
// Interpolate
weight := rank - float64(lower)
return sorted[lower]*(1-weight) + sorted[upper]*weight
}