Phase 1 of Pulse AI differentiation: - Create internal/ai/context package with types, trends, builder, formatter - Implement linear regression for trend computation (growing/declining/stable/volatile) - Add storage capacity predictions (predicts days until 90% and 100%) - Wire MetricsHistory from monitor to patrol service - Update patrol to use buildEnrichedContext instead of basic summary - Update patrol prompt to reference trend indicators and predictions This gives the AI awareness of historical patterns, enabling it to: - Identify resources with concerning growth rates - Predict capacity exhaustion before it happens - Distinguish between stable high usage vs growing problems - Provide more actionable, time-aware insights All tests passing. Falls back to basic summary if metrics history unavailable.
250 lines
5.5 KiB
Go
250 lines
5.5 KiB
Go
package context
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestComputeTrend_Growing(t *testing.T) {
|
|
// Create growing data (10% per day)
|
|
now := time.Now()
|
|
points := make([]MetricPoint, 24)
|
|
for i := 0; i < 24; i++ {
|
|
// 10% per day = ~0.417% per hour
|
|
points[i] = MetricPoint{
|
|
Value: 50 + float64(i)*0.417,
|
|
Timestamp: now.Add(time.Duration(-24+i) * time.Hour),
|
|
}
|
|
}
|
|
|
|
trend := ComputeTrend(points, "memory", 24*time.Hour)
|
|
|
|
if trend.Direction != TrendGrowing {
|
|
t.Errorf("Expected TrendGrowing, got %s", trend.Direction)
|
|
}
|
|
|
|
// Rate should be ~10% per day
|
|
if trend.RatePerDay < 8 || trend.RatePerDay > 12 {
|
|
t.Errorf("Expected rate ~10/day, got %.2f", trend.RatePerDay)
|
|
}
|
|
|
|
if trend.DataPoints != 24 {
|
|
t.Errorf("Expected 24 data points, got %d", trend.DataPoints)
|
|
}
|
|
}
|
|
|
|
func TestComputeTrend_Stable(t *testing.T) {
|
|
// Create stable data with small fluctuations
|
|
now := time.Now()
|
|
points := make([]MetricPoint, 24)
|
|
for i := 0; i < 24; i++ {
|
|
// Small random-looking variation around 50%, but no trend
|
|
offset := float64(i%3 - 1) * 0.2
|
|
points[i] = MetricPoint{
|
|
Value: 50 + offset,
|
|
Timestamp: now.Add(time.Duration(-24+i) * time.Hour),
|
|
}
|
|
}
|
|
|
|
trend := ComputeTrend(points, "cpu", 24*time.Hour)
|
|
|
|
if trend.Direction != TrendStable {
|
|
t.Errorf("Expected TrendStable, got %s (rate: %.4f/hr)", trend.Direction, trend.RatePerHour)
|
|
}
|
|
}
|
|
|
|
func TestComputeTrend_Declining(t *testing.T) {
|
|
// Create declining data
|
|
now := time.Now()
|
|
points := make([]MetricPoint, 24)
|
|
for i := 0; i < 24; i++ {
|
|
points[i] = MetricPoint{
|
|
Value: 80 - float64(i)*0.5, // -12% per day
|
|
Timestamp: now.Add(time.Duration(-24+i) * time.Hour),
|
|
}
|
|
}
|
|
|
|
trend := ComputeTrend(points, "disk", 24*time.Hour)
|
|
|
|
if trend.Direction != TrendDeclining {
|
|
t.Errorf("Expected TrendDeclining, got %s", trend.Direction)
|
|
}
|
|
}
|
|
|
|
func TestComputeTrend_Volatile(t *testing.T) {
|
|
// Create volatile data with high variance
|
|
now := time.Now()
|
|
points := make([]MetricPoint, 24)
|
|
for i := 0; i < 24; i++ {
|
|
// Alternating high/low values
|
|
value := 50.0
|
|
if i%2 == 0 {
|
|
value = 80.0
|
|
} else {
|
|
value = 20.0
|
|
}
|
|
points[i] = MetricPoint{
|
|
Value: value,
|
|
Timestamp: now.Add(time.Duration(-24+i) * time.Hour),
|
|
}
|
|
}
|
|
|
|
trend := ComputeTrend(points, "cpu", 24*time.Hour)
|
|
|
|
if trend.Direction != TrendVolatile {
|
|
t.Errorf("Expected TrendVolatile, got %s (stddev: %.2f, mean: %.2f)",
|
|
trend.Direction, trend.StdDev, trend.Average)
|
|
}
|
|
}
|
|
|
|
func TestComputeTrend_InsufficientData(t *testing.T) {
|
|
// Only one data point
|
|
points := []MetricPoint{
|
|
{Value: 50, Timestamp: time.Now()},
|
|
}
|
|
|
|
trend := ComputeTrend(points, "memory", 24*time.Hour)
|
|
|
|
if trend.Confidence != 0 {
|
|
t.Errorf("Expected 0 confidence with insufficient data, got %.2f", trend.Confidence)
|
|
}
|
|
}
|
|
|
|
func TestLinearRegression_Perfect(t *testing.T) {
|
|
// Perfect linear data: y = 2x + 10
|
|
now := time.Now()
|
|
points := make([]MetricPoint, 10)
|
|
for i := 0; i < 10; i++ {
|
|
points[i] = MetricPoint{
|
|
Value: 10 + float64(i)*2,
|
|
Timestamp: now.Add(time.Duration(i) * time.Second),
|
|
}
|
|
}
|
|
|
|
result := linearRegression(points)
|
|
|
|
// Slope should be 2 per second
|
|
if result.Slope < 1.9 || result.Slope > 2.1 {
|
|
t.Errorf("Expected slope ~2, got %.4f", result.Slope)
|
|
}
|
|
|
|
// R² should be 1 (perfect fit)
|
|
if result.R2 < 0.99 {
|
|
t.Errorf("Expected R² ~1, got %.4f", result.R2)
|
|
}
|
|
}
|
|
|
|
func TestComputePercentiles(t *testing.T) {
|
|
now := time.Now()
|
|
// Create 100 points with values 1-100
|
|
points := make([]MetricPoint, 100)
|
|
for i := 0; i < 100; i++ {
|
|
points[i] = MetricPoint{
|
|
Value: float64(i + 1),
|
|
Timestamp: now.Add(time.Duration(i) * time.Second),
|
|
}
|
|
}
|
|
|
|
percentiles := ComputePercentiles(points, 5, 50, 95)
|
|
|
|
// P5 should be ~5
|
|
if percentiles[5] < 4 || percentiles[5] > 6 {
|
|
t.Errorf("Expected P5 ~5, got %.2f", percentiles[5])
|
|
}
|
|
|
|
// P50 should be ~50
|
|
if percentiles[50] < 49 || percentiles[50] > 51 {
|
|
t.Errorf("Expected P50 ~50, got %.2f", percentiles[50])
|
|
}
|
|
|
|
// P95 should be ~95
|
|
if percentiles[95] < 94 || percentiles[95] > 96 {
|
|
t.Errorf("Expected P95 ~95, got %.2f", percentiles[95])
|
|
}
|
|
}
|
|
|
|
func TestTrendSummary(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
trend Trend
|
|
expected string
|
|
}{
|
|
{
|
|
name: "growing fast",
|
|
trend: Trend{
|
|
Direction: TrendGrowing,
|
|
RatePerDay: 5.5,
|
|
RatePerHour: 0.23,
|
|
DataPoints: 24,
|
|
},
|
|
expected: "growing 5.5/day",
|
|
},
|
|
{
|
|
name: "growing slow",
|
|
trend: Trend{
|
|
Direction: TrendGrowing,
|
|
RatePerDay: 0.5,
|
|
RatePerHour: 0.02,
|
|
DataPoints: 24,
|
|
},
|
|
expected: "growing 0.02/hr",
|
|
},
|
|
{
|
|
name: "stable",
|
|
trend: Trend{
|
|
Direction: TrendStable,
|
|
DataPoints: 24,
|
|
},
|
|
expected: "stable",
|
|
},
|
|
{
|
|
name: "volatile",
|
|
trend: Trend{
|
|
Direction: TrendVolatile,
|
|
DataPoints: 24,
|
|
},
|
|
expected: "volatile",
|
|
},
|
|
{
|
|
name: "insufficient data",
|
|
trend: Trend{
|
|
DataPoints: 1,
|
|
},
|
|
expected: "insufficient data",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result := TrendSummary(tt.trend)
|
|
if result != tt.expected {
|
|
t.Errorf("Expected %q, got %q", tt.expected, result)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestComputeStats(t *testing.T) {
|
|
points := []MetricPoint{
|
|
{Value: 10},
|
|
{Value: 20},
|
|
{Value: 30},
|
|
{Value: 40},
|
|
{Value: 50},
|
|
}
|
|
|
|
stats := computeStats(points)
|
|
|
|
if stats.Count != 5 {
|
|
t.Errorf("Expected count 5, got %d", stats.Count)
|
|
}
|
|
if stats.Min != 10 {
|
|
t.Errorf("Expected min 10, got %.2f", stats.Min)
|
|
}
|
|
if stats.Max != 50 {
|
|
t.Errorf("Expected max 50, got %.2f", stats.Max)
|
|
}
|
|
if stats.Mean != 30 {
|
|
t.Errorf("Expected mean 30, got %.2f", stats.Mean)
|
|
}
|
|
}
|