feat(monitor/collectors/gdelt): per-theme rolling z-score baseline detector

This commit is contained in:
CarterPerez-dev 2026-05-02 04:31:10 -04:00
parent e1d021a99a
commit 066e2debc2
2 changed files with 124 additions and 0 deletions

View File

@ -0,0 +1,66 @@
// ©AngelaMos | 2026
// baseline.go
package gdelt
import "math"
const (
minStdDev = 1.0
)
type Bucket struct {
Score int64
Count int
}
type ThemeState struct {
buckets []Bucket
capN int
}
func NewThemeState(capN int) *ThemeState {
if capN <= 0 {
capN = 96
}
return &ThemeState{capN: capN}
}
func (s *ThemeState) Push(b Bucket) {
s.buckets = append(s.buckets, b)
if len(s.buckets) > s.capN {
s.buckets = s.buckets[len(s.buckets)-s.capN:]
}
}
func (s *ThemeState) Len() int {
return len(s.buckets)
}
func (s *ThemeState) Stats() (mean, stddev float64) {
if len(s.buckets) == 0 {
return 0, 0
}
sum := 0.0
for _, b := range s.buckets {
sum += float64(b.Count)
}
mean = sum / float64(len(s.buckets))
variance := 0.0
for _, b := range s.buckets {
d := float64(b.Count) - mean
variance += d * d
}
variance /= float64(len(s.buckets))
stddev = math.Sqrt(variance)
return mean, stddev
}
func (s *ThemeState) ZScore(count int) float64 {
mean, stddev := s.Stats()
if stddev < minStdDev {
return 0
}
return (float64(count) - mean) / stddev
}

View File

@ -0,0 +1,58 @@
// ©AngelaMos | 2026
// baseline_test.go
package gdelt_test
import (
"math"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/gdelt"
)
func TestBaseline_EmptyReturnsZeroZ(t *testing.T) {
b := gdelt.NewThemeState(96)
require.InDelta(t, 0.0, b.ZScore(100), 0.0001)
}
func TestBaseline_StableThenSpikeReturnsHighZ(t *testing.T) {
b := gdelt.NewThemeState(96)
now := time.Now().UTC()
for i := 0; i < 96; i++ {
b.Push(gdelt.Bucket{Score: now.Add(-time.Duration(i) * 15 * time.Minute).UnixMilli(), Count: 100 + (i % 10)})
}
z := b.ZScore(500)
require.Greater(t, z, 3.0)
}
func TestBaseline_StableThenNormalReturnsLowZ(t *testing.T) {
b := gdelt.NewThemeState(96)
now := time.Now().UTC()
for i := 0; i < 96; i++ {
b.Push(gdelt.Bucket{Score: now.Add(-time.Duration(i) * 15 * time.Minute).UnixMilli(), Count: 100 + (i % 10)})
}
z := b.ZScore(108)
require.Less(t, z, 3.0)
}
func TestBaseline_CapacityCapsBuckets(t *testing.T) {
b := gdelt.NewThemeState(10)
for i := 0; i < 200; i++ {
b.Push(gdelt.Bucket{Score: int64(i), Count: i})
}
require.Equal(t, 10, b.Len())
}
func TestBaseline_TinyStdDevDoesNotDivByZero(t *testing.T) {
b := gdelt.NewThemeState(96)
now := time.Now().UTC()
for i := 0; i < 96; i++ {
b.Push(gdelt.Bucket{Score: now.Add(-time.Duration(i) * 15 * time.Minute).UnixMilli(), Count: 100})
}
z := b.ZScore(101)
require.False(t, math.IsInf(z, 0))
require.False(t, math.IsNaN(z))
}