Pulse/internal/ai/context/trends.go
rcourtman 96af101c98 feat(ai): Add enriched context with historical trends and predictions
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.
2025-12-12 09:45:57 +00:00

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]
}