114 lines
3.7 KiB
Go
114 lines
3.7 KiB
Go
package detect
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
|
|
"codeberg.org/pata1704/guenther/pkg/types"
|
|
"github.com/stretchr/testify/assert"
|
|
)
|
|
|
|
func TestMADDetector_Score(t *testing.T) {
|
|
detector := NewMADDetector(3.0, []float64{10.0}, []float64{1.0})
|
|
|
|
// 1. Score a normal value
|
|
res, err := detector.Score(types.FeatureVector{
|
|
Timestamp: time.Now(),
|
|
NormalizedVector: []float64{11},
|
|
})
|
|
assert.NoError(t, err)
|
|
assert.False(t, res.IsAnomaly, "Value 11 should not be an anomaly")
|
|
|
|
// 2. Score an extreme outlier
|
|
res, err = detector.Score(types.FeatureVector{
|
|
Timestamp: time.Now(),
|
|
NormalizedVector: []float64{100},
|
|
})
|
|
assert.NoError(t, err)
|
|
assert.True(t, res.IsAnomaly, "Value 100 should be an anomaly")
|
|
assert.Greater(t, res.Score, 3.0)
|
|
}
|
|
|
|
func TestMADDetector_CalibrationStability(t *testing.T) {
|
|
// 1. Create a detector that auto-calibrates on 100 idle vectors.
|
|
detector := NewMADDetectorAutoCalibrate(3.5, 100)
|
|
now := time.Now()
|
|
|
|
// 2. Feed 99 perfectly idle vectors.
|
|
// They should all use "Identity" fallback and return low scores (or 0 if val is 0).
|
|
for i := 0; i < 99; i++ {
|
|
fv := types.FeatureVector{
|
|
Timestamp: now.Add(time.Duration(i) * time.Second),
|
|
NormalizedVector: []float64{0.0, 0.0},
|
|
}
|
|
res, err := detector.Score(fv)
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, 0.0, res.Score)
|
|
assert.Contains(t, res.Method, "warmup")
|
|
}
|
|
|
|
// 3. Feed the 100th vector. This triggers calibration.
|
|
// Since all 100 vectors were 0, the learned medians will be 0 and mads will be 0.
|
|
fv100 := types.FeatureVector{
|
|
Timestamp: now.Add(100 * time.Second),
|
|
NormalizedVector: []float64{0.0, 0.0},
|
|
}
|
|
res100, err := detector.Score(fv100)
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, 0.0, res100.Score)
|
|
// After this call, mads should be [0.0, 0.0] but clamped to 0.01 during Score.
|
|
|
|
// 4. Feed the 101st vector: A "normal" burst (e.g. 1.0 baseline IQR).
|
|
// Without the floor, this would be 1.0 / (1.48 * 0) -> infinity (clamped).
|
|
// With the floor (0.01), it should be 1.0 / (1.4826 * 0.01) ≈ 67.45.
|
|
fv101 := types.FeatureVector{
|
|
Timestamp: now.Add(101 * time.Second),
|
|
NormalizedVector: []float64{1.0, 0.0},
|
|
}
|
|
res101, err := detector.Score(fv101)
|
|
assert.NoError(t, err)
|
|
|
|
// Check that the score is contained.
|
|
// 1.0 / (1.4826 * 0.01) = 67.449
|
|
assert.InDelta(t, 67.449, res101.Score, 0.1)
|
|
assert.True(t, res101.IsAnomaly)
|
|
assert.Equal(t, "MAD", res101.Method) // No longer "warmup"
|
|
|
|
// 5. Test with a very small variance but not 0.
|
|
// Suppose learned MAD was 0.0001. Score for val=1.0 would be 1.0 / 0.000148... ≈ 6745.
|
|
// Our floor (0.01) should still clamp this to 67.45.
|
|
detector.mu.Lock()
|
|
detector.mads = []float64{0.0001, 0.0}
|
|
detector.medians = []float64{0.0, 0.0}
|
|
detector.mu.Unlock()
|
|
|
|
resSmall, err := detector.Score(fv101)
|
|
assert.NoError(t, err)
|
|
assert.InDelta(t, 67.449, resSmall.Score, 0.1)
|
|
}
|
|
|
|
func TestMADDetector_IdentityPrior(t *testing.T) {
|
|
detector := NewMADDetectorAutoCalibrate(3.5, 10)
|
|
|
|
// Feature vector with a deviation of 2.0 baseline IQR.
|
|
// Using identity prior (mad=1.0), the score should be:
|
|
// score = |2.0| / (1.4826 * 1.0) = 2.0 / 1.4826 ≈ 1.3489
|
|
// Wait, scoreIdentity uses 0.6745 directly: math.Abs(val) / 0.6745
|
|
// 2.0 / 0.6745 ≈ 2.965
|
|
fv := types.FeatureVector{
|
|
NormalizedVector: []float64{2.0},
|
|
}
|
|
res, _ := detector.Score(fv)
|
|
assert.InDelta(t, 2.965, res.Score, 0.1)
|
|
assert.False(t, res.IsAnomaly) // 2.96 < 3.5
|
|
|
|
// Feature vector with deviation of 3.0.
|
|
// score = 3.0 / 0.6745 ≈ 4.44
|
|
fv2 := types.FeatureVector{
|
|
NormalizedVector: []float64{3.0},
|
|
}
|
|
res2, _ := detector.Score(fv2)
|
|
assert.InDelta(t, 4.44, res2.Score, 0.1)
|
|
assert.True(t, res2.IsAnomaly)
|
|
assert.Contains(t, res2.Details, "identity prior")
|
|
}
|