feat(monitor/collectors/gdelt): per-theme rolling z-score baseline detector
This commit is contained in:
parent
e1d021a99a
commit
066e2debc2
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
Loading…
Reference in New Issue