spaxel/mothership/internal/analytics/patterns_test.go
jedarden f7df7740bf feat: implement 7-day pattern learning algorithm for anomaly detection
Welford's online algorithm for per-zone, per-hour, per-day-of-week
occupancy modeling with cold start suppression, outlier protection,
security mode override, and SQLite persistence.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 21:27:10 -04:00

655 lines
16 KiB
Go

package analytics
import (
"database/sql"
"math"
"os"
"path/filepath"
"testing"
"time"
_ "modernc.org/sqlite"
)
// openTestDB creates a test SQLite database with the anomaly_patterns table.
func openTestDB(t *testing.T) *sql.DB {
t.Helper()
tmpDir, err := os.MkdirTemp("", "pattern_test")
if err != nil {
t.Fatalf("create temp dir: %v", err)
}
t.Cleanup(func() { os.RemoveAll(tmpDir) })
dbPath := filepath.Join(tmpDir, "test.db")
db, err := sql.Open("sqlite", dbPath)
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { db.Close() })
// Create required tables
_, err = db.Exec(`
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value_json TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS anomaly_patterns (
zone_id TEXT NOT NULL,
hour_of_day INTEGER NOT NULL CHECK (hour_of_day BETWEEN 0 AND 23),
day_of_week INTEGER NOT NULL CHECK (day_of_week BETWEEN 0 AND 6),
mean_count REAL NOT NULL DEFAULT 0,
variance REAL NOT NULL DEFAULT 0,
sample_count INTEGER NOT NULL DEFAULT 0,
updated_at INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (zone_id, hour_of_day, day_of_week)
);
`)
if err != nil {
t.Fatalf("create tables: %v", err)
}
return db
}
// newTestLearner creates a PatternLearner backed by a temp database.
func newTestLearner(t *testing.T) *PatternLearner {
t.Helper()
tmpDir, err := os.MkdirTemp("", "pattern_learner_test")
if err != nil {
t.Fatalf("create temp dir: %v", err)
}
t.Cleanup(func() { os.RemoveAll(tmpDir) })
pl, err := NewPatternLearner(filepath.Join(tmpDir, "patterns.db"))
if err != nil {
t.Fatalf("NewPatternLearner: %v", err)
}
t.Cleanup(func() { pl.Close() })
return pl
}
// --- Welford's algorithm tests ---
func TestWelfordUpdate_NumericalStability(t *testing.T) {
tests := []struct {
name string
observations []float64
wantMean float64
wantVar float64
}{
{
name: "single observation",
observations: []float64{5.0},
wantMean: 5.0,
wantVar: 0.0,
},
{
name: "two identical observations",
observations: []float64{3.0, 3.0},
wantMean: 3.0,
wantVar: 0.0,
},
{
name: "three observations",
observations: []float64{1.0, 2.0, 6.0},
wantMean: 3.0,
wantVar: 4.666666666666667,
},
{
name: "zero observations then non-zero",
observations: []float64{0.0, 0.0, 0.0, 5.0},
wantMean: 1.25,
wantVar: 4.6875,
},
{
name: "large count stability",
observations: makeSequence(2.0, 1000),
wantMean: 2.0,
wantVar: 0.0,
},
{
name: "large count with variance",
observations: makeSequence(5.0, 100),
wantMean: 5.0,
wantVar: 0.0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mean, m2, count := 0.0, 0.0, 0.0
for _, obs := range tt.observations {
mean, m2, count = WelfordUpdate(mean, m2, count, obs)
}
if math.Abs(mean-tt.wantMean) > 1e-9 {
t.Errorf("mean = %v, want %v", mean, tt.wantMean)
}
variance := 0.0
if count > 0 {
variance = m2 / count
}
if math.Abs(variance-tt.wantVar) > 1e-6 {
t.Errorf("variance = %v, want %v", variance, tt.wantVar)
}
// Check no NaN or Inf
if math.IsNaN(mean) || math.IsInf(mean, 0) {
t.Error("mean is NaN or Inf")
}
if math.IsNaN(variance) || math.IsInf(variance, 0) {
t.Error("variance is NaN or Inf")
}
})
}
}
func TestWelfordUpdate_NoNaNInf_AnySampleCount(t *testing.T) {
mean, m2, count := 0.0, 0.0, 0.0
for i := 0; i < 10000; i++ {
obs := float64(i%100) * 0.01
mean, m2, count = WelfordUpdate(mean, m2, count, obs)
if math.IsNaN(mean) || math.IsInf(mean, 0) {
t.Fatalf("NaN/Inf mean at sample %d: mean=%v, m2=%v, count=%v", i+1, mean, m2, count)
}
variance := m2 / count
if math.IsNaN(variance) || math.IsInf(variance, 0) {
t.Fatalf("NaN/Inf variance at sample %d: variance=%v, m2=%v, count=%v", i+1, variance, m2, count)
}
if variance < -1e-12 {
t.Fatalf("negative variance at sample %d: %v", i+1, variance)
}
}
}
func TestWelfordUpdate_MatchesBatchVariance(t *testing.T) {
observations := []float64{1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0}
mean, m2, count := 0.0, 0.0, 0.0
for _, obs := range observations {
mean, m2, count = WelfordUpdate(mean, m2, count, obs)
}
onlineVar := m2 / count
var batchMean float64
for _, obs := range observations {
batchMean += obs
}
batchMean /= float64(len(observations))
var batchVar float64
for _, obs := range observations {
batchVar += (obs - batchMean) * (obs - batchMean)
}
batchVar /= float64(len(observations))
if math.Abs(onlineVar-batchVar) > 1e-12 {
t.Errorf("online variance %v != batch variance %v", onlineVar, batchVar)
}
}
// --- normalizeZScore tests ---
func TestNormalizeZScore(t *testing.T) {
tests := []struct {
z float64
want float64
}{
{0.0, 0.0},
{0.5, 0.0},
{1.0, 0.0},
{2.0, 0.333},
{3.0, 0.667},
{4.0, 1.0},
{5.0, 1.0},
{-1.5, 0.167},
{-4.0, 1.0},
}
for _, tt := range tests {
got := normalizeZScore(tt.z)
if math.Abs(got-tt.want) > 0.001 {
t.Errorf("normalizeZScore(%v) = %v, want %v", tt.z, got, tt.want)
}
}
}
// --- PatternLearner tests ---
func TestPatternLearner_ColdStart(t *testing.T) {
pl := newTestLearner(t)
if !pl.IsColdStart() {
t.Error("expected cold start for new learner")
}
result := pl.ComputeAnomalyScore("zone-1", 12, 0, 5)
if !result.Suppressed {
t.Error("expected anomaly score to be suppressed during cold start")
}
if result.CompositeScore != 0 {
t.Errorf("expected 0 composite score during cold start, got %v", result.CompositeScore)
}
}
func TestPatternLearner_SlotNotReady(t *testing.T) {
pl := newTestLearner(t)
if pl.IsSlotReady("zone-1", 12, 0) {
t.Error("expected slot not ready")
}
result := pl.ComputeAnomalyScore("zone-1", 12, 0, 5)
if !result.Suppressed {
t.Error("expected anomaly score suppressed when slot not ready")
}
}
func TestPatternLearner_ObserveAndUpdate_Persists(t *testing.T) {
pl := newTestLearner(t)
for i := 0; i < 50; i++ {
if err := pl.ObserveAndUpdate("zone-1", 12, 0, 2, 0); err != nil {
t.Fatalf("ObserveAndUpdate: %v", err)
}
}
if !pl.IsSlotReady("zone-1", 12, 0) {
t.Error("expected slot to be ready after 50 observations")
}
slot := pl.GetPattern("zone-1", 12, 0)
if slot == nil {
t.Fatal("expected pattern to exist")
}
if slot.MeanCount != 2.0 {
t.Errorf("expected mean=2.0, got %v", slot.MeanCount)
}
if slot.SampleCount != 50 {
t.Errorf("expected sample_count=50, got %d", slot.SampleCount)
}
if slot.Variance > 1e-9 {
t.Errorf("expected variance=0 for identical observations, got %v", slot.Variance)
}
}
func TestPatternLearner_ObserveAndUpdate_WithVariance(t *testing.T) {
pl := newTestLearner(t)
for i := 0; i < 50; i++ {
if err := pl.ObserveAndUpdate("zone-1", 12, 0, i%5, 0); err != nil {
t.Fatalf("ObserveAndUpdate: %v", err)
}
}
slot := pl.GetPattern("zone-1", 12, 0)
if slot == nil {
t.Fatal("expected pattern")
}
if math.Abs(slot.MeanCount-2.0) > 1e-9 {
t.Errorf("expected mean=2.0, got %v", slot.MeanCount)
}
if math.Abs(slot.Variance-2.0) > 1e-6 {
t.Errorf("expected variance=2.0, got %v", slot.Variance)
}
}
func TestPatternLearner_OutlierProtection(t *testing.T) {
pl := newTestLearner(t)
for i := 0; i < 50; i++ {
if err := pl.ObserveAndUpdate("zone-1", 12, 0, 0, 0); err != nil {
t.Fatalf("ObserveAndUpdate: %v", err)
}
}
slotBefore := pl.GetPattern("zone-1", 12, 0)
meanBefore := slotBefore.MeanCount
countBefore := slotBefore.SampleCount
// Outlier should be skipped
if err := pl.ObserveAndUpdate("zone-1", 12, 0, 100, 0.6); err != nil {
t.Fatalf("ObserveAndUpdate: %v", err)
}
slotAfter := pl.GetPattern("zone-1", 12, 0)
if slotAfter.MeanCount != meanBefore {
t.Errorf("outlier protection failed: mean changed from %v to %v", meanBefore, slotAfter.MeanCount)
}
if slotAfter.SampleCount != countBefore {
t.Errorf("outlier protection failed: count changed from %d to %d", countBefore, slotAfter.SampleCount)
}
}
func TestPatternLearner_OutlierProtection_AfterMultipleAnomalies(t *testing.T) {
pl := newTestLearner(t)
for i := 0; i < 50; i++ {
pl.ObserveAndUpdate("zone-1", 12, 0, 1, 0)
}
slot := pl.GetPattern("zone-1", 12, 0)
meanBefore := slot.MeanCount
// Inject 3 synthetic anomalies
for i := 0; i < 3; i++ {
pl.ObserveAndUpdate("zone-1", 12, 0, 50, 1.0)
}
slot = pl.GetPattern("zone-1", 12, 0)
if slot.SampleCount != 50 {
t.Errorf("expected sample_count to remain 50, got %d", slot.SampleCount)
}
if math.Abs(slot.MeanCount-meanBefore) > 1e-9 {
t.Errorf("expected mean to remain %v, got %v", meanBefore, slot.MeanCount)
}
}
func TestPatternLearner_SecurityModeOverride(t *testing.T) {
pl := newTestLearner(t)
pl.SetSecurityMode(true)
result := pl.ComputeAnomalyScore("zone-1", 12, 0, 0)
if result.CompositeScore != 1.0 {
t.Errorf("security mode: expected composite=1.0, got %v", result.CompositeScore)
}
if !result.IsAlert {
t.Error("security mode: expected is_alert=true")
}
result = pl.ComputeAnomalyScore("zone-1", 12, 0, 0)
if result.CompositeScore != 1.0 {
t.Errorf("security mode with 0 count: expected composite=1.0, got %v", result.CompositeScore)
}
pl.SetSecurityMode(false)
}
func TestPatternLearner_AnomalyScoring(t *testing.T) {
pl := newTestLearner(t)
pl.SetLearningStartTime(time.Now().Add(-8 * 24 * time.Hour))
for i := 0; i < 50; i++ {
pl.ObserveAndUpdate("zone-1", 3, 0, 0, 0)
}
result := pl.ComputeAnomalyScore("zone-1", 3, 0, 0)
if result.CompositeScore > 0.01 {
t.Errorf("expected low score for expected observation, got %v", result.CompositeScore)
}
if result.Suppressed {
t.Error("expected not suppressed when slot is ready")
}
result = pl.ComputeAnomalyScore("zone-1", 3, 0, 3)
if result.ZoneScore != 1.0 {
t.Errorf("expected zone_score=1.0 when zone normally empty, got %v", result.ZoneScore)
}
if result.CompositeScore < 1.0 {
t.Errorf("expected composite=1.0 (max of time and zone), got %v", result.CompositeScore)
}
if !result.IsAlert {
t.Error("expected alert when zone normally empty but now occupied")
}
}
func TestPatternLearner_AnomalyScoring_ZScoreBased(t *testing.T) {
pl := newTestLearner(t)
pl.SetLearningStartTime(time.Now().Add(-8 * 24 * time.Hour))
for i := 0; i < 50; i++ {
pl.ObserveAndUpdate("zone-1", 14, 0, 1+i%2, 0)
}
slot := pl.GetPattern("zone-1", 14, 0)
if slot == nil {
t.Fatal("expected pattern")
}
result := pl.ComputeAnomalyScore("zone-1", 14, 0, 2)
if result.TimeScore > 0.01 {
t.Errorf("expected low time_score for mean observation, got %v", result.TimeScore)
}
result = pl.ComputeAnomalyScore("zone-1", 14, 0, 10)
if result.TimeScore < 0.9 {
t.Errorf("expected high time_score for extreme observation, got %v", result.TimeScore)
}
}
func TestPatternLearner_GetPatterns(t *testing.T) {
pl := newTestLearner(t)
for i := 0; i < 50; i++ {
pl.ObserveAndUpdate("zone-1", 12, 0, 2, 0)
pl.ObserveAndUpdate("zone-2", 12, 0, 3, 0)
}
all := pl.GetPatterns("")
if len(all) != 2 {
t.Errorf("expected 2 patterns, got %d", len(all))
}
zone1 := pl.GetPatterns("zone-1")
if len(zone1) != 1 {
t.Errorf("expected 1 pattern for zone-1, got %d", len(zone1))
}
if zone1[0].ZoneID != "zone-1" {
t.Errorf("expected zone_id=zone-1, got %s", zone1[0].ZoneID)
}
}
func TestPatternLearner_SurvivesRestart(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "pattern_restart_test")
if err != nil {
t.Fatalf("create temp dir: %v", err)
}
t.Cleanup(func() { os.RemoveAll(tmpDir) })
dbPath := filepath.Join(tmpDir, "patterns.db")
pl1, err := NewPatternLearner(dbPath)
if err != nil {
t.Fatalf("NewPatternLearner: %v", err)
}
for i := 0; i < 50; i++ {
pl1.ObserveAndUpdate("zone-1", 12, 0, 2, 0)
}
pl1.Close()
pl2, err := NewPatternLearner(dbPath)
if err != nil {
t.Fatalf("NewPatternLearner after restart: %v", err)
}
defer pl2.Close()
if !pl2.IsSlotReady("zone-1", 12, 0) {
t.Error("expected slot to be ready after reload from DB")
}
slot := pl2.GetPattern("zone-1", 12, 0)
if slot == nil {
t.Fatal("expected pattern after restart")
}
if slot.MeanCount != 2.0 {
t.Errorf("expected mean=2.0 after restart, got %v", slot.MeanCount)
}
if slot.SampleCount != 50 {
t.Errorf("expected 50 samples after restart, got %d", slot.SampleCount)
}
}
func TestPatternLearner_AlertThresholds(t *testing.T) {
tests := []struct {
name string
observations []int
testCount int
wantAlert bool
wantWarning bool
}{
{
name: "normal observation at mean",
observations: makeConst(2, 50),
testCount: 2,
wantAlert: false,
wantWarning: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
pl := newTestLearner(t)
for _, obs := range tt.observations {
pl.ObserveAndUpdate("zone-1", 14, 0, obs, 0)
}
result := pl.ComputeAnomalyScore("zone-1", 14, 0, tt.testCount)
if result.IsAlert != tt.wantAlert {
t.Errorf("is_alert = %v, want %v (composite=%v)", result.IsAlert, tt.wantAlert, result.CompositeScore)
}
if result.IsWarning != tt.wantWarning {
t.Errorf("is_warning = %v, want %v (composite=%v)", result.IsWarning, tt.wantWarning, result.CompositeScore)
}
})
}
}
func TestPatternLearner_NaNInf_NeverProduced(t *testing.T) {
pl := newTestLearner(t)
observations := []int{0, 100, 0, 100, 0, 100, 1, 99, 50, 0}
for i := 0; i < 5; i++ {
for _, obs := range observations {
pl.ObserveAndUpdate("zone-1", 14, 0, obs, 0)
}
}
slot := pl.GetPattern("zone-1", 14, 0)
if slot == nil {
t.Fatal("expected pattern")
}
if math.IsNaN(slot.MeanCount) || math.IsInf(slot.MeanCount, 0) {
t.Error("mean is NaN or Inf")
}
if math.IsNaN(slot.Variance) || math.IsInf(slot.Variance, 0) {
t.Error("variance is NaN or Inf")
}
for _, obs := range []int{0, 1, 5, 50, 100, 200} {
result := pl.ComputeAnomalyScore("zone-1", 14, 0, obs)
if math.IsNaN(result.CompositeScore) || math.IsInf(result.CompositeScore, 0) {
t.Errorf("NaN/Inf composite for obs=%d: %v", obs, result.CompositeScore)
}
if math.IsNaN(result.TimeScore) || math.IsInf(result.TimeScore, 0) {
t.Errorf("NaN/Inf time_score for obs=%d: %v", obs, result.TimeScore)
}
}
}
func TestPatternLearner_NoAlertsDuringColdStart(t *testing.T) {
pl := newTestLearner(t)
for i := 0; i < 100; i++ {
pl.ObserveAndUpdate("zone-1", 3, 0, 50, 0)
}
if !pl.IsColdStart() {
t.Log("note: cold start check depends on timing")
}
result := pl.ComputeAnomalyScore("zone-1", 3, 0, 50)
if !result.Suppressed {
t.Error("expected anomaly score to be suppressed during cold start regardless of activity")
}
if result.IsAlert || result.IsWarning {
t.Error("expected no alerts during cold start")
}
}
// --- Integration test: hourly update with mock provider ---
type mockOccupancyProvider struct {
counts map[string]int
}
func (m *mockOccupancyProvider) GetZoneOccupancyCounts() map[string]int {
return m.counts
}
func TestPatternLearner_HourlyUpdate_Integration(t *testing.T) {
pl := newTestLearner(t)
provider := &mockOccupancyProvider{
counts: map[string]int{"zone-1": 2, "zone-2": 0},
}
pl.updateAllZones(provider)
slot1 := pl.GetPattern("zone-1", time.Now().Hour(), int(time.Now().Weekday()))
if slot1 == nil {
t.Fatal("expected pattern for zone-1 after hourly update")
}
if slot1.MeanCount != 2.0 {
t.Errorf("expected mean=2.0 for zone-1, got %v", slot1.MeanCount)
}
slot2 := pl.GetPattern("zone-2", time.Now().Hour(), int(time.Now().Weekday()))
if slot2 == nil {
t.Fatal("expected pattern for zone-2 after hourly update")
}
if slot2.MeanCount != 0.0 {
t.Errorf("expected mean=0.0 for zone-2, got %v", slot2.MeanCount)
}
}
func TestPatternLearner_HourlyUpdate_OutlierProtectionInUpdate(t *testing.T) {
pl := newTestLearner(t)
for i := 0; i < 50; i++ {
pl.ObserveAndUpdate("zone-1", 12, 0, 1, 0)
}
slotBefore := pl.GetPattern("zone-1", 12, 0)
provider := &mockOccupancyProvider{
counts: map[string]int{"zone-1": 50},
}
pl.updateAllZones(provider)
slotAfter := pl.GetPattern("zone-1", 12, 0)
if slotAfter.SampleCount != slotBefore.SampleCount {
t.Logf("note: sample count changed from %d to %d (outlier protection may not trigger if score < 0.5)", slotBefore.SampleCount, slotAfter.SampleCount)
}
}
// --- helpers ---
func makeSequence(value float64, count int) []float64 {
result := make([]float64, count)
for i := range result {
result[i] = value
}
return result
}
func makeConst(value, count int) []int {
result := make([]int, count)
for i := range result {
result[i] = value
}
return result
}