UNPKG

aiwg

Version:

Cognitive architecture for AI-augmented software development with structured memory, ensemble validation, and closed-loop correction. FAIR-aligned artifacts, 84% cost reduction via human-in-the-loop, standards adopted by 100+ organizations.

455 lines (393 loc) 13 kB
/** * TrendAnalyzer - Statistical analysis for NFR trend detection * * Provides statistical methods for analyzing time-series data, detecting * trends, outliers, change points, and forecasting future values. * * Features: * - Moving average calculation (simple and exponential) * - Outlier detection (IQR and Z-score methods) * - Linear regression for trend lines * - Simple forecasting using trend extrapolation * - Change point detection using statistical tests * * @module testing/trend-analyzer */ /** * Time-series data point */ export interface TimeSeriesPoint { /** Timestamp (Unix milliseconds) */ timestamp: number; /** Measured value */ value: number; } /** * Time-series dataset */ export type TimeSeries = TimeSeriesPoint[]; /** * Linear trend line (y = mx + b) */ export interface TrendLine { /** Slope (change per unit time) */ slope: number; /** Y-intercept */ intercept: number; /** R-squared value (goodness of fit, 0-1) */ rSquared: number; } /** * Forecast result with confidence */ export interface ForecastResult { /** Predicted value */ value: number; /** Lower bound of prediction interval */ lowerBound: number; /** Upper bound of prediction interval */ upperBound: number; /** Confidence level (0-1) */ confidence: number; } /** * Change point detection result */ export interface ChangePoint { /** Index in time series where change occurred */ index: number; /** Timestamp of change point */ timestamp: number; /** Mean before change */ meanBefore: number; /** Mean after change */ meanAfter: number; /** Statistical significance (p-value) */ significance: number; } /** * Outlier detection method */ export type OutlierMethod = 'iqr' | 'zscore'; /** * TrendAnalyzer - Statistical analysis for NFR trends * * @example * ```typescript * const analyzer = new TrendAnalyzer(); * * // Calculate moving average * const samples = [10, 12, 11, 13, 15, 14, 16]; * const smoothed = analyzer.calculateMovingAverage(samples, 3); * * // Detect outliers * const outliers = analyzer.detectOutliers(samples, 'iqr'); * * // Fit trend line * const timeSeries = samples.map((v, i) => ({ timestamp: i * 1000, value: v })); * const trend = analyzer.fitTrendLine(timeSeries); * console.log(`Trend: ${trend.slope > 0 ? 'increasing' : 'decreasing'}`); * ``` */ export class TrendAnalyzer { /** * Calculate simple moving average * * @param samples - Array of values * @param windowSize - Size of moving window * @returns Array of moving averages (same length as input, padded with original values) * @throws {Error} If windowSize is invalid */ calculateMovingAverage(samples: number[], windowSize: number): number[] { if (windowSize <= 0) { throw new Error('Window size must be positive'); } if (windowSize > samples.length) { throw new Error('Window size cannot exceed sample count'); } const result: number[] = []; for (let i = 0; i < samples.length; i++) { if (i < windowSize - 1) { // Not enough data for full window, use available data const window = samples.slice(0, i + 1); const avg = window.reduce((sum, val) => sum + val, 0) / window.length; result.push(avg); } else { // Full window available const window = samples.slice(i - windowSize + 1, i + 1); const avg = window.reduce((sum, val) => sum + val, 0) / window.length; result.push(avg); } } return result; } /** * Calculate exponential moving average * * @param samples - Array of values * @param alpha - Smoothing factor (0-1, higher = more weight on recent values) * @returns Array of exponential moving averages * @throws {Error} If alpha is out of range */ calculateExponentialMovingAverage(samples: number[], alpha: number = 0.3): number[] { if (alpha <= 0 || alpha > 1) { throw new Error('Alpha must be between 0 and 1'); } if (samples.length === 0) { return []; } const result: number[] = [samples[0]]; // Start with first value for (let i = 1; i < samples.length; i++) { const ema = alpha * samples[i] + (1 - alpha) * result[i - 1]; result.push(ema); } return result; } /** * Detect outliers using IQR or Z-score method * * @param samples - Array of values * @param method - Detection method ('iqr' or 'zscore') * @returns Boolean array indicating outliers (true = outlier) * @throws {Error} If method is invalid */ detectOutliers(samples: number[], method: OutlierMethod = 'iqr'): boolean[] { if (samples.length === 0) { return []; } if (method === 'iqr') { return this.detectOutliersIQR(samples); } else if (method === 'zscore') { return this.detectOutliersZScore(samples); } else { throw new Error(`Invalid outlier detection method: ${method}`); } } /** * Fit linear trend line to time series data * * Uses least squares regression to find best-fit line. * * @param data - Time series data points * @returns Trend line parameters and R-squared * @throws {Error} If insufficient data points */ fitTrendLine(data: TimeSeries): TrendLine { if (data.length < 2) { throw new Error('Need at least 2 data points to fit trend line'); } // Extract x (timestamps) and y (values) const x = data.map(p => p.timestamp); const y = data.map(p => p.value); // Normalize timestamps to avoid floating point issues const xMin = Math.min(...x); const xNorm = x.map(t => t - xMin); // Calculate means const xMean = xNorm.reduce((sum, val) => sum + val, 0) / xNorm.length; const yMean = y.reduce((sum, val) => sum + val, 0) / y.length; // Calculate slope and intercept using least squares let numerator = 0; let denominator = 0; for (let i = 0; i < xNorm.length; i++) { numerator += (xNorm[i] - xMean) * (y[i] - yMean); denominator += Math.pow(xNorm[i] - xMean, 2); } const slope = denominator === 0 ? 0 : numerator / denominator; const intercept = yMean - slope * xMean; // Calculate R-squared let ssRes = 0; // Sum of squared residuals let ssTot = 0; // Total sum of squares for (let i = 0; i < xNorm.length; i++) { const predicted = slope * xNorm[i] + intercept; ssRes += Math.pow(y[i] - predicted, 2); ssTot += Math.pow(y[i] - yMean, 2); } const rSquared = ssTot === 0 ? 1 : 1 - (ssRes / ssTot); return { slope, intercept: intercept - slope * xMin, // Adjust for denormalization rSquared: Math.max(0, Math.min(1, rSquared)), // Clamp to [0, 1] }; } /** * Forecast future value using trend extrapolation * * Uses linear regression trend line to predict future value. * * @param data - Historical time series data * @param horizon - Number of time units into future (milliseconds) * @returns Forecast with prediction interval * @throws {Error} If insufficient data or invalid horizon */ forecastValue(data: TimeSeries, horizon: number): ForecastResult { if (data.length < 2) { throw new Error('Need at least 2 data points to forecast'); } if (horizon < 0) { throw new Error('Horizon must be non-negative'); } const trend = this.fitTrendLine(data); const lastTimestamp = data[data.length - 1].timestamp; const futureTimestamp = lastTimestamp + horizon; // Calculate predicted value const predictedValue = trend.slope * futureTimestamp + trend.intercept; // Calculate prediction interval using residual standard error const values = data.map(p => p.value); const timestamps = data.map(p => p.timestamp); const residuals: number[] = []; for (let i = 0; i < data.length; i++) { const predicted = trend.slope * timestamps[i] + trend.intercept; residuals.push(values[i] - predicted); } const residualStdDev = this.calculateStdDev(residuals); // Use 95% confidence (1.96 standard deviations) const margin = 1.96 * residualStdDev; return { value: predictedValue, lowerBound: predictedValue - margin, upperBound: predictedValue + margin, confidence: 0.95, }; } /** * Detect change point in time series * * Uses a sliding window approach to find significant shifts in mean. * * @param data - Time series data * @param minSegmentSize - Minimum size of segments before/after change point * @returns Change point if detected, null otherwise */ detectChangePoint(data: TimeSeries, minSegmentSize: number = 5): ChangePoint | null { if (data.length < minSegmentSize * 2) { return null; // Not enough data } let maxSignificance = 0; let bestChangePoint: ChangePoint | null = null; // Try each possible split point for (let i = minSegmentSize; i < data.length - minSegmentSize; i++) { const before = data.slice(0, i).map(p => p.value); const after = data.slice(i).map(p => p.value); const meanBefore = this.calculateMean(before); const meanAfter = this.calculateMean(after); // Calculate t-statistic for difference in means const stdBefore = this.calculateStdDev(before); const stdAfter = this.calculateStdDev(after); const pooledStd = Math.sqrt( (Math.pow(stdBefore, 2) / before.length) + (Math.pow(stdAfter, 2) / after.length) ); if (pooledStd === 0) { // Handle constant values - use simple mean difference const tStat = Math.abs(meanAfter - meanBefore) * 10; // Scale for detection if (tStat > maxSignificance) { maxSignificance = tStat; bestChangePoint = { index: i, timestamp: data[i].timestamp, meanBefore, meanAfter, significance: 0.01, // Highly significant }; } continue; } const tStat = Math.abs(meanAfter - meanBefore) / pooledStd; // Convert t-statistic to rough p-value significance // (simplified, not exact) const significance = Math.min(1, 1 / (1 + tStat)); if (tStat > maxSignificance) { maxSignificance = tStat; bestChangePoint = { index: i, timestamp: data[i].timestamp, meanBefore, meanAfter, significance, }; } } // Only return if change is significant (t-stat > 2.0 roughly p < 0.05) if (maxSignificance > 2.0 && bestChangePoint) { return bestChangePoint; } return null; } /** * Calculate standard deviation of samples * * @param samples - Array of values * @returns Standard deviation */ private calculateStdDev(samples: number[]): number { if (samples.length <= 1) { return 0; } const mean = this.calculateMean(samples); const variance = samples.reduce((sum, val) => sum + Math.pow(val - mean, 2), 0) / (samples.length - 1); return Math.sqrt(variance); } /** * Calculate mean of samples * * @param samples - Array of values * @returns Mean value */ private calculateMean(samples: number[]): number { if (samples.length === 0) { return 0; } return samples.reduce((sum, val) => sum + val, 0) / samples.length; } /** * Calculate percentile from sorted values * * @param sortedValues - Pre-sorted array of values * @param percentile - Percentile to calculate (0-100) * @returns Percentile value */ private calculatePercentile(sortedValues: number[], percentile: number): number { if (sortedValues.length === 0) { return 0; } const index = (percentile / 100) * (sortedValues.length - 1); if (Number.isInteger(index)) { return sortedValues[index]; } const lower = Math.floor(index); const upper = Math.ceil(index); const weight = index - lower; return sortedValues[lower] * (1 - weight) + sortedValues[upper] * weight; } /** * Detect outliers using IQR method * * @private */ private detectOutliersIQR(samples: number[]): boolean[] { const sorted = [...samples].sort((a, b) => a - b); const q1 = this.calculatePercentile(sorted, 25); const q3 = this.calculatePercentile(sorted, 75); const iqr = q3 - q1; const lowerBound = q1 - 1.5 * iqr; const upperBound = q3 + 1.5 * iqr; return samples.map(value => value < lowerBound || value > upperBound); } /** * Detect outliers using Z-score method * * @private */ private detectOutliersZScore(samples: number[], threshold: number = 3): boolean[] { const mean = this.calculateMean(samples); const stdDev = this.calculateStdDev(samples); if (stdDev === 0) { // No variance, no outliers return samples.map(() => false); } return samples.map(value => { const zScore = Math.abs((value - mean) / stdDev); return zScore > threshold; }); } }