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.
327 lines
7.8 KiB
Go
327 lines
7.8 KiB
Go
package context
|
|
|
|
import (
|
|
"math"
|
|
"sort"
|
|
"time"
|
|
)
|
|
|
|
// ComputeTrend calculates trend from historical data points.
|
|
// This is the core function that transforms raw metrics into meaningful insights.
|
|
func ComputeTrend(points []MetricPoint, metricName string, period time.Duration) Trend {
|
|
trend := Trend{
|
|
Metric: metricName,
|
|
Direction: TrendStable,
|
|
Period: period,
|
|
DataPoints: len(points),
|
|
}
|
|
|
|
if len(points) < 2 {
|
|
trend.Confidence = 0
|
|
return trend
|
|
}
|
|
|
|
// Sort by timestamp to ensure correct order
|
|
sorted := make([]MetricPoint, len(points))
|
|
copy(sorted, points)
|
|
sort.Slice(sorted, func(i, j int) bool {
|
|
return sorted[i].Timestamp.Before(sorted[j].Timestamp)
|
|
})
|
|
|
|
// Calculate basic statistics
|
|
stats := computeStats(sorted)
|
|
trend.Average = stats.Mean
|
|
trend.Min = stats.Min
|
|
trend.Max = stats.Max
|
|
trend.StdDev = stats.StdDev
|
|
trend.Current = sorted[len(sorted)-1].Value
|
|
|
|
// Perform linear regression to get slope and fit quality
|
|
regression := linearRegression(sorted)
|
|
trend.Confidence = regression.R2
|
|
|
|
// Convert slope from "per second" to "per hour" and "per day"
|
|
// Slope is in units/second
|
|
trend.RatePerHour = regression.Slope * 3600
|
|
trend.RatePerDay = regression.Slope * 86400
|
|
|
|
// Classify the trend direction
|
|
trend.Direction = classifyTrend(regression.Slope, stats.Mean, stats.StdDev)
|
|
|
|
return trend
|
|
}
|
|
|
|
// computeStats calculates basic statistics for a set of metric points
|
|
func computeStats(points []MetricPoint) Stats {
|
|
if len(points) == 0 {
|
|
return Stats{}
|
|
}
|
|
|
|
stats := Stats{
|
|
Count: len(points),
|
|
Min: points[0].Value,
|
|
Max: points[0].Value,
|
|
}
|
|
|
|
for _, p := range points {
|
|
stats.Sum += p.Value
|
|
if p.Value < stats.Min {
|
|
stats.Min = p.Value
|
|
}
|
|
if p.Value > stats.Max {
|
|
stats.Max = p.Value
|
|
}
|
|
}
|
|
|
|
stats.Mean = stats.Sum / float64(stats.Count)
|
|
|
|
// Calculate standard deviation
|
|
var sumSquares float64
|
|
for _, p := range points {
|
|
diff := p.Value - stats.Mean
|
|
sumSquares += diff * diff
|
|
}
|
|
stats.StdDev = math.Sqrt(sumSquares / float64(stats.Count))
|
|
|
|
return stats
|
|
}
|
|
|
|
// linearRegression performs simple linear regression on time-series data.
|
|
// Returns slope (change per second), intercept, and R² (goodness of fit).
|
|
func linearRegression(points []MetricPoint) LinearRegressionResult {
|
|
if len(points) < 2 {
|
|
return LinearRegressionResult{}
|
|
}
|
|
|
|
n := float64(len(points))
|
|
|
|
// Use time relative to first point for numerical stability
|
|
baseTime := points[0].Timestamp
|
|
|
|
var sumX, sumY, sumXY, sumX2, sumY2 float64
|
|
for _, p := range points {
|
|
x := p.Timestamp.Sub(baseTime).Seconds() // seconds since start
|
|
y := p.Value
|
|
|
|
sumX += x
|
|
sumY += y
|
|
sumXY += x * y
|
|
sumX2 += x * x
|
|
sumY2 += y * y
|
|
}
|
|
|
|
// Calculate slope and intercept using least squares
|
|
denominator := n*sumX2 - sumX*sumX
|
|
if math.Abs(denominator) < 1e-10 {
|
|
// All x values are the same (no time span)
|
|
return LinearRegressionResult{R2: 0}
|
|
}
|
|
|
|
slope := (n*sumXY - sumX*sumY) / denominator
|
|
intercept := (sumY - slope*sumX) / n
|
|
|
|
// Calculate R² (coefficient of determination)
|
|
meanY := sumY / n
|
|
var ssRes, ssTot float64 // Sum of squares residual and total
|
|
for _, p := range points {
|
|
x := p.Timestamp.Sub(baseTime).Seconds()
|
|
yPred := slope*x + intercept
|
|
ssRes += (p.Value - yPred) * (p.Value - yPred)
|
|
ssTot += (p.Value - meanY) * (p.Value - meanY)
|
|
}
|
|
|
|
r2 := 0.0
|
|
if ssTot > 0 {
|
|
r2 = 1 - (ssRes / ssTot)
|
|
}
|
|
// Clamp R² to [0, 1] (can be negative for very bad fits)
|
|
if r2 < 0 {
|
|
r2 = 0
|
|
}
|
|
|
|
return LinearRegressionResult{
|
|
Slope: slope,
|
|
Intercept: intercept,
|
|
R2: r2,
|
|
}
|
|
}
|
|
|
|
// classifyTrend determines the trend direction based on slope and statistics.
|
|
// We normalize the slope relative to the metric's magnitude to avoid
|
|
// false positives on high-value metrics.
|
|
func classifyTrend(slopePerSecond, mean, stdDev float64) TrendDirection {
|
|
// If there's no significant variation, it's stable
|
|
if stdDev < 0.01 && math.Abs(slopePerSecond) < 1e-10 {
|
|
return TrendStable
|
|
}
|
|
|
|
// If standard deviation is high relative to mean, it's volatile
|
|
if mean > 0 && stdDev/mean > 0.3 {
|
|
return TrendVolatile
|
|
}
|
|
|
|
// Convert slope to hourly rate for easier reasoning
|
|
hourlyRate := slopePerSecond * 3600
|
|
|
|
// Determine significance threshold based on the metric's scale
|
|
// For percentage metrics (0-100), we care about ~0.1% per hour (~2.4% per day)
|
|
// This catches slow-growing issues before they become critical
|
|
// For absolute metrics, we care about ~0.5% of mean per hour
|
|
threshold := 0.1 // Default threshold for percentage metrics
|
|
if mean > 100 {
|
|
// For larger absolute values, use relative threshold
|
|
threshold = mean * 0.005
|
|
}
|
|
|
|
// Check if the hourly change is significant
|
|
if hourlyRate > threshold {
|
|
return TrendGrowing
|
|
}
|
|
if hourlyRate < -threshold {
|
|
return TrendDeclining
|
|
}
|
|
|
|
return TrendStable
|
|
}
|
|
|
|
// ComputePercentiles calculates percentile values from a sorted slice of points
|
|
func ComputePercentiles(points []MetricPoint, percentiles ...int) map[int]float64 {
|
|
result := make(map[int]float64)
|
|
if len(points) == 0 {
|
|
return result
|
|
}
|
|
|
|
// Extract values and sort
|
|
values := make([]float64, len(points))
|
|
for i, p := range points {
|
|
values[i] = p.Value
|
|
}
|
|
sort.Float64s(values)
|
|
|
|
for _, p := range percentiles {
|
|
if p < 0 || p > 100 {
|
|
continue
|
|
}
|
|
|
|
// Calculate index for percentile
|
|
idx := float64(p) / 100.0 * float64(len(values)-1)
|
|
lower := int(math.Floor(idx))
|
|
upper := int(math.Ceil(idx))
|
|
|
|
if lower >= len(values) {
|
|
lower = len(values) - 1
|
|
}
|
|
if upper >= len(values) {
|
|
upper = len(values) - 1
|
|
}
|
|
|
|
if lower == upper {
|
|
result[p] = values[lower]
|
|
} else {
|
|
// Linear interpolation between adjacent values
|
|
frac := idx - float64(lower)
|
|
result[p] = values[lower]*(1-frac) + values[upper]*frac
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// TrendSummary generates a human-readable summary of a trend
|
|
func TrendSummary(t Trend) string {
|
|
if t.DataPoints < 2 {
|
|
return "insufficient data"
|
|
}
|
|
|
|
directionStr := ""
|
|
switch t.Direction {
|
|
case TrendGrowing:
|
|
directionStr = "growing"
|
|
case TrendDeclining:
|
|
directionStr = "declining"
|
|
case TrendVolatile:
|
|
directionStr = "volatile"
|
|
case TrendStable:
|
|
directionStr = "stable"
|
|
}
|
|
|
|
// Format rate based on magnitude
|
|
rateStr := ""
|
|
if t.Direction == TrendGrowing || t.Direction == TrendDeclining {
|
|
absRate := math.Abs(t.RatePerDay)
|
|
if absRate > 1 {
|
|
rateStr = formatFloat(absRate, 1) + "/day"
|
|
} else {
|
|
rateStr = formatFloat(math.Abs(t.RatePerHour), 2) + "/hr"
|
|
}
|
|
}
|
|
|
|
if rateStr != "" {
|
|
return directionStr + " " + rateStr
|
|
}
|
|
return directionStr
|
|
}
|
|
|
|
// formatFloat formats a float with the given precision, trimming trailing zeros
|
|
func formatFloat(v float64, precision int) string {
|
|
return trimTrailingZeros(floatToString(v, precision))
|
|
}
|
|
|
|
func floatToString(v float64, precision int) string {
|
|
switch precision {
|
|
case 0:
|
|
return intToString(int(math.Round(v)))
|
|
case 1:
|
|
return intToString(int(v)) + "." + intToString(int(math.Round((v-float64(int(v)))*10)))
|
|
case 2:
|
|
return intToString(int(v)) + "." + padLeft(intToString(int(math.Round((v-float64(int(v)))*100))), 2, '0')
|
|
default:
|
|
mult := math.Pow(10, float64(precision))
|
|
return intToString(int(v)) + "." + padLeft(intToString(int(math.Round((v-float64(int(v)))*mult))), precision, '0')
|
|
}
|
|
}
|
|
|
|
func intToString(i int) string {
|
|
if i < 0 {
|
|
return "-" + intToString(-i)
|
|
}
|
|
if i < 10 {
|
|
return string(rune('0' + i))
|
|
}
|
|
return intToString(i/10) + string(rune('0'+i%10))
|
|
}
|
|
|
|
func padLeft(s string, length int, pad rune) string {
|
|
for len(s) < length {
|
|
s = string(pad) + s
|
|
}
|
|
return s
|
|
}
|
|
|
|
func trimTrailingZeros(s string) string {
|
|
if s == "" {
|
|
return s
|
|
}
|
|
// Find decimal point
|
|
dotIdx := -1
|
|
for i, c := range s {
|
|
if c == '.' {
|
|
dotIdx = i
|
|
break
|
|
}
|
|
}
|
|
if dotIdx == -1 {
|
|
return s // No decimal point
|
|
}
|
|
|
|
// Trim trailing zeros after decimal
|
|
end := len(s)
|
|
for end > dotIdx+1 && s[end-1] == '0' {
|
|
end--
|
|
}
|
|
// Also trim decimal if nothing after it
|
|
if end == dotIdx+1 {
|
|
end = dotIdx
|
|
}
|
|
return s[:end]
|
|
}
|