@anyshift/mcp-proxy 0.3.6 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,217 @@
1
+ import { describe, it, expect } from '@jest/globals';
2
+ import { mean, median, standardDeviation, movingAverage, quantile, rollingQuantiles, } from '../../timeseries/algorithms/stats.js';
3
+ import { detectMadAnomalies } from '../../timeseries/algorithms/mad.js';
4
+ import { detectThresholdAnomalies } from '../../timeseries/algorithms/threshold.js';
5
+ import { detectRollingQuantileAnomalies } from '../../timeseries/algorithms/rolling-quantile.js';
6
+ import { detectMovingAverageAnomalies } from '../../timeseries/algorithms/moving-average.js';
7
+ describe('Statistical Functions', () => {
8
+ describe('mean', () => {
9
+ it('calculates mean correctly', () => {
10
+ expect(mean([1, 2, 3, 4, 5])).toBe(3);
11
+ expect(mean([10, 20, 30])).toBe(20);
12
+ expect(mean([])).toBe(0);
13
+ });
14
+ });
15
+ describe('median', () => {
16
+ it('calculates median for odd-length arrays', () => {
17
+ expect(median([1, 2, 3, 4, 5])).toBe(3);
18
+ expect(median([5, 1, 3])).toBe(3);
19
+ });
20
+ it('calculates median for even-length arrays', () => {
21
+ expect(median([1, 2, 3, 4])).toBe(2.5);
22
+ expect(median([1, 2, 3, 4, 5, 6])).toBe(3.5);
23
+ });
24
+ it('returns 0 for empty arrays', () => {
25
+ expect(median([])).toBe(0);
26
+ });
27
+ });
28
+ describe('standardDeviation', () => {
29
+ it('calculates standard deviation correctly', () => {
30
+ const values = [2, 4, 4, 4, 5, 5, 7, 9];
31
+ const std = standardDeviation(values);
32
+ expect(std).toBeCloseTo(2, 1);
33
+ });
34
+ it('returns 0 for arrays with less than 2 elements', () => {
35
+ expect(standardDeviation([1])).toBe(0);
36
+ expect(standardDeviation([])).toBe(0);
37
+ });
38
+ });
39
+ describe('movingAverage', () => {
40
+ it('calculates moving average correctly', () => {
41
+ const values = [1, 2, 3, 4, 5];
42
+ const ma = movingAverage(values, 3);
43
+ expect(ma[0]).toBeNaN();
44
+ expect(ma[1]).toBeNaN();
45
+ expect(ma[2]).toBe(2); // (1+2+3)/3
46
+ expect(ma[3]).toBe(3); // (2+3+4)/3
47
+ expect(ma[4]).toBe(4); // (3+4+5)/3
48
+ });
49
+ });
50
+ describe('quantile', () => {
51
+ it('calculates quantiles correctly', () => {
52
+ const values = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
53
+ expect(quantile(values, 0)).toBe(1);
54
+ expect(quantile(values, 0.5)).toBe(5.5);
55
+ expect(quantile(values, 1)).toBe(10);
56
+ expect(quantile(values, 0.25)).toBeCloseTo(3.25, 1);
57
+ expect(quantile(values, 0.75)).toBeCloseTo(7.75, 1);
58
+ });
59
+ it('handles edge cases', () => {
60
+ expect(quantile([], 0.5)).toBe(0);
61
+ expect(quantile([5], 0.5)).toBe(5);
62
+ });
63
+ });
64
+ describe('rollingQuantiles', () => {
65
+ it('calculates rolling quantiles correctly', () => {
66
+ const values = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
67
+ const { lower, upper } = rollingQuantiles(values, 5, 0.1, 0.9);
68
+ // First 4 values should be NaN (not enough window data)
69
+ expect(lower[0]).toBeNaN();
70
+ expect(lower[3]).toBeNaN();
71
+ expect(upper[0]).toBeNaN();
72
+ expect(upper[3]).toBeNaN();
73
+ // From index 4 onwards, we should have valid quantiles
74
+ expect(lower[4]).toBeDefined();
75
+ expect(upper[4]).toBeDefined();
76
+ expect(lower[4]).toBeLessThan(upper[4]);
77
+ });
78
+ });
79
+ });
80
+ describe('Anomaly Detection Algorithms', () => {
81
+ const createDataPoints = (values) => values.map((value, index) => ({ index, value }));
82
+ const createDataPointsWithTimestamp = (values, baseTimestamp) => values.map((value, index) => ({
83
+ index,
84
+ value,
85
+ timestamp: baseTimestamp + index * 60 // 1 minute apart
86
+ }));
87
+ describe('Rolling Quantile Detection', () => {
88
+ it('detects outliers using rolling quantiles', () => {
89
+ // Values around 10, then a spike to 100
90
+ const values = [10, 11, 10, 12, 11, 10, 11, 10, 100, 11, 10];
91
+ const data = createDataPoints(values);
92
+ const anomalies = detectRollingQuantileAnomalies(data, {
93
+ window_size: 5,
94
+ lower_quantile: 0.05,
95
+ upper_quantile: 0.95,
96
+ });
97
+ expect(anomalies.length).toBeGreaterThan(0);
98
+ expect(anomalies.some(a => a.value === 100)).toBe(true);
99
+ });
100
+ it('returns no anomalies for uniform data', () => {
101
+ const data = createDataPoints([5, 5, 5, 5, 5, 5, 5, 5]);
102
+ const anomalies = detectRollingQuantileAnomalies(data, {
103
+ window_size: 3,
104
+ });
105
+ expect(anomalies.length).toBe(0);
106
+ });
107
+ it('throws error for invalid window size', () => {
108
+ const data = createDataPoints([1, 2, 3]);
109
+ expect(() => detectRollingQuantileAnomalies(data, { window_size: 0 })).toThrow('window_size must be greater than 0');
110
+ });
111
+ it('throws error when window is larger than data', () => {
112
+ const data = createDataPoints([1, 2, 3]);
113
+ expect(() => detectRollingQuantileAnomalies(data, { window_size: 10 })).toThrow('window_size (10) cannot be larger than data length (3)');
114
+ });
115
+ it('throws error for invalid quantile values', () => {
116
+ const data = createDataPoints([1, 2, 3, 4, 5]);
117
+ expect(() => detectRollingQuantileAnomalies(data, {
118
+ window_size: 3,
119
+ lower_quantile: 0.9,
120
+ upper_quantile: 0.1,
121
+ })).toThrow('lower_quantile must be less than upper_quantile');
122
+ });
123
+ });
124
+ describe('MAD Detection', () => {
125
+ it('detects outliers using MAD', () => {
126
+ const values = [10, 11, 10, 12, 11, 10, 11, 100];
127
+ const data = createDataPoints(values);
128
+ const anomalies = detectMadAnomalies(data, { threshold: 2 });
129
+ expect(anomalies.length).toBeGreaterThan(0);
130
+ expect(anomalies.some(a => a.value === 100)).toBe(true);
131
+ });
132
+ it('returns no anomalies for uniform data', () => {
133
+ const data = createDataPoints([5, 5, 5, 5, 5]);
134
+ const anomalies = detectMadAnomalies(data);
135
+ expect(anomalies.length).toBe(0);
136
+ });
137
+ });
138
+ describe('Threshold Detection', () => {
139
+ it('detects values crossing upper thresholds', () => {
140
+ const data = createDataPoints([10, 20, 30, 40, 50]);
141
+ const anomalies = detectThresholdAnomalies(data, {
142
+ thresholds: [
143
+ { name: 'warning', value: 35, direction: 'upper' },
144
+ { name: 'critical', value: 45, direction: 'upper' },
145
+ ],
146
+ });
147
+ expect(anomalies.length).toBe(2);
148
+ expect(anomalies[0].value).toBe(40);
149
+ expect(anomalies[0].crossed_thresholds).toContain('warning');
150
+ expect(anomalies[1].value).toBe(50);
151
+ expect(anomalies[1].crossed_thresholds).toContain('warning');
152
+ expect(anomalies[1].crossed_thresholds).toContain('critical');
153
+ });
154
+ it('detects values crossing lower thresholds', () => {
155
+ const data = createDataPoints([50, 40, 30, 20, 10]);
156
+ const anomalies = detectThresholdAnomalies(data, {
157
+ thresholds: [
158
+ { name: 'min_healthy', value: 25, direction: 'lower' },
159
+ ],
160
+ });
161
+ expect(anomalies.length).toBe(2);
162
+ expect(anomalies[0].value).toBe(20);
163
+ expect(anomalies[1].value).toBe(10);
164
+ });
165
+ it('throws error when no thresholds provided', () => {
166
+ const data = createDataPoints([10, 20, 30]);
167
+ expect(() => detectThresholdAnomalies(data, { thresholds: [] })).toThrow('At least one threshold must be specified');
168
+ });
169
+ });
170
+ describe('Moving Average Detection', () => {
171
+ it('detects deviations from moving average', () => {
172
+ const values = [10, 10, 10, 10, 10, 500, 10, 10];
173
+ const data = createDataPoints(values);
174
+ const anomalies = detectMovingAverageAnomalies(data, {
175
+ window_size: 3,
176
+ threshold: 1,
177
+ });
178
+ expect(anomalies.length).toBeGreaterThan(0);
179
+ });
180
+ it('throws error for invalid window size', () => {
181
+ const data = createDataPoints([1, 2, 3]);
182
+ expect(() => detectMovingAverageAnomalies(data, { window_size: 0 })).toThrow('window_size must be greater than 0');
183
+ });
184
+ it('throws error when window is larger than data', () => {
185
+ const data = createDataPoints([1, 2, 3]);
186
+ expect(() => detectMovingAverageAnomalies(data, { window_size: 10 })).toThrow('window_size (10) cannot be larger than data length (3)');
187
+ });
188
+ });
189
+ });
190
+ describe('Anomaly Pooling', () => {
191
+ // Test pooling by importing the handler and running full detection
192
+ // This tests the integrated pooling behavior
193
+ const createDataPoints = (values) => values.map((value, index) => ({ index, value }));
194
+ it('pools consecutive anomalies together', () => {
195
+ // Create data where indices 3,4,5 are all above threshold
196
+ const data = createDataPoints([10, 10, 10, 100, 110, 105, 10, 10]);
197
+ const anomalies = detectThresholdAnomalies(data, {
198
+ thresholds: [{ name: 'high', value: 50, direction: 'upper' }],
199
+ });
200
+ // Should have 3 consecutive anomalies at indices 3, 4, 5
201
+ expect(anomalies.length).toBe(3);
202
+ expect(anomalies[0].index).toBe(3);
203
+ expect(anomalies[1].index).toBe(4);
204
+ expect(anomalies[2].index).toBe(5);
205
+ });
206
+ it('keeps separate pools for non-consecutive anomalies', () => {
207
+ // Create data where indices 1 and 5 are anomalies (not consecutive)
208
+ const data = createDataPoints([10, 100, 10, 10, 10, 100, 10]);
209
+ const anomalies = detectThresholdAnomalies(data, {
210
+ thresholds: [{ name: 'high', value: 50, direction: 'upper' }],
211
+ });
212
+ // Should have 2 separate anomalies at indices 1 and 5
213
+ expect(anomalies.length).toBe(2);
214
+ expect(anomalies[0].index).toBe(1);
215
+ expect(anomalies[1].index).toBe(5);
216
+ });
217
+ });
@@ -275,7 +275,7 @@ export async function handleToolResponse(config, toolName, args, responseData) {
275
275
  // Generate tool_id for all responses
276
276
  const tool_id = generateToolId(toolName, args, config.toolAbbreviations);
277
277
  // Some tools should always return directly to AI (never write to file)
278
- if (toolName === 'execute_jq_query' || toolName === 'get_label_schema') {
278
+ if (toolName === 'execute_jq_query' || toolName === 'get_label_schema' || toolName === 'detect_timeseries_anomalies') {
279
279
  const { contentToWrite, parsedForSchema } = extractContentForFile(responseData);
280
280
  return {
281
281
  tool_id,
package/dist/index.js CHANGED
@@ -20,6 +20,7 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
20
20
  import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
21
21
  import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';
22
22
  import { createJqTool } from './jq/index.js';
23
+ import { createTimeseriesTool } from './timeseries/index.js';
23
24
  import { truncateResponseIfNeeded } from './truncation/index.js';
24
25
  import { createFileWriter } from './fileWriter/index.js';
25
26
  import { generateToolId } from './utils/filename.js';
@@ -156,6 +157,12 @@ const ENABLE_JQ = process.env.MCP_PROXY_ENABLE_JQ !== 'false'; // default true
156
157
  * Timeout in milliseconds for JQ query execution
157
158
  */
158
159
  const JQ_TIMEOUT_MS = parseInt(process.env.MCP_PROXY_JQ_TIMEOUT_MS || '30000');
160
+ /**
161
+ * MCP_PROXY_ENABLE_TIMESERIES (OPTIONAL, default: true)
162
+ * Enable the time series anomaly detection tool
163
+ * This tool allows detecting anomalies in time series data extracted from JSON files
164
+ */
165
+ const ENABLE_TIMESERIES = process.env.MCP_PROXY_ENABLE_TIMESERIES !== 'false'; // default true
159
166
  /**
160
167
  * MCP_PROXY_SCHEMA_MAX_DEPTH (OPTIONAL, default: 3)
161
168
  * Maximum depth to traverse when generating query-assist schemas
@@ -243,6 +250,7 @@ if (ENABLE_LOGGING) {
243
250
  console.debug(` Min chars for write: ${MIN_CHARS_FOR_WRITE}`);
244
251
  }
245
252
  console.debug(` JQ tool enabled: ${ENABLE_JQ}`);
253
+ console.debug(` Timeseries tool enabled: ${ENABLE_TIMESERIES}`);
246
254
  if (CHILD_COMMAND) {
247
255
  console.debug(` Pass-through env vars: ${Object.keys(childEnv).length}`);
248
256
  }
@@ -327,6 +335,14 @@ async function main() {
327
335
  timeoutMs: JQ_TIMEOUT_MS
328
336
  });
329
337
  }
338
+ // Timeseries anomaly detection tool configuration
339
+ let timeseriesTool = null;
340
+ if (ENABLE_TIMESERIES) {
341
+ timeseriesTool = createTimeseriesTool({
342
+ allowedPaths: [process.cwd(), OUTPUT_PATH].filter(Boolean),
343
+ timeoutMs: JQ_TIMEOUT_MS // Uses same timeout as JQ since it runs jq internally
344
+ });
345
+ }
330
346
  // ------------------------------------------------------------------------
331
347
  // 5. REGISTER ALL TOOLS (CHILD + PROXY) WITH DESCRIPTION INJECTION
332
348
  // ------------------------------------------------------------------------
@@ -334,9 +350,11 @@ async function main() {
334
350
  const enhancedChildTools = childToolsResponse.tools.map(injectProxyParams);
335
351
  const allTools = [
336
352
  ...enhancedChildTools,
337
- ...(jqTool ? [jqTool.toolDefinition] : []) // JQ tool already has description param
353
+ ...(jqTool ? [jqTool.toolDefinition] : []), // JQ tool already has description param
354
+ ...(timeseriesTool ? [timeseriesTool.toolDefinition] : []) // Timeseries tool already has description param
338
355
  ];
339
- console.debug(`[mcp-proxy] Exposing ${allTools.length} tools total (${childToolsResponse.tools.length} from child${jqTool ? ' + 1 JQ' : ''})`);
356
+ const proxyToolCount = (jqTool ? 1 : 0) + (timeseriesTool ? 1 : 0);
357
+ console.debug(`[mcp-proxy] Exposing ${allTools.length} tools total (${childToolsResponse.tools.length} from child + ${proxyToolCount} proxy tools)`);
340
358
  // ------------------------------------------------------------------------
341
359
  // 6. HANDLE TOOL LIST REQUESTS
342
360
  // ------------------------------------------------------------------------
@@ -379,6 +397,39 @@ async function main() {
379
397
  isError: result.isError
380
398
  };
381
399
  }
400
+ // Handle Timeseries anomaly detection tool locally (if enabled)
401
+ if (toolName === 'detect_timeseries_anomalies' && timeseriesTool) {
402
+ if (ENABLE_LOGGING) {
403
+ console.debug('[mcp-proxy] Executing Timeseries anomaly detection tool locally');
404
+ }
405
+ result = await timeseriesTool.handler({
406
+ params: { arguments: toolArgs }
407
+ });
408
+ // Timeseries tool returns directly, wrap in unified format
409
+ const tool_id = generateToolId(toolName, toolArgs, fileWriterConfig.toolAbbreviations);
410
+ let outputContent = null;
411
+ if (result.content?.[0]?.text) {
412
+ try {
413
+ outputContent = JSON.parse(result.content[0].text);
414
+ }
415
+ catch {
416
+ // If JSON parsing fails, use the raw text
417
+ outputContent = result.content[0].text;
418
+ }
419
+ }
420
+ const unifiedResponse = {
421
+ tool_id,
422
+ wroteToFile: false,
423
+ outputContent
424
+ };
425
+ return {
426
+ content: [{
427
+ type: 'text',
428
+ text: JSON.stringify(unifiedResponse, null, 2)
429
+ }],
430
+ isError: result.isError
431
+ };
432
+ }
382
433
  // Forward all other tools to child MCP (if child exists)
383
434
  if (!childClient) {
384
435
  const tool_id = generateToolId(toolName, toolArgs, fileWriterConfig.toolAbbreviations);
@@ -1,7 +1,7 @@
1
- import { spawn } from 'child_process';
2
1
  import { existsSync } from 'fs';
3
2
  import path from 'path';
4
3
  import { validatePathWithinAllowedDirs } from '../utils/pathValidation.js';
4
+ import { validateJqQuery, runJq } from '../utils/jq.js';
5
5
  // Default timeout for JQ execution
6
6
  const DEFAULT_TIMEOUT_MS = 30000;
7
7
  /**
@@ -17,19 +17,7 @@ export async function executeJqQuery(config, jqQuery, filePath) {
17
17
  throw new Error('jq_query and file_path are required');
18
18
  }
19
19
  // Sanitize jq query to prevent environment variable access
20
- const dangerousPatterns = [
21
- /\$ENV/i, // $ENV variable access
22
- /env\./i, // env.VARIABLE access
23
- /@env/i, // @env function
24
- /\.env\[/i, // .env["VARIABLE"] access
25
- /getenv/i, // getenv function
26
- /\$__loc__/i, // location info that might leak paths
27
- /input_filename/i, // input filename access
28
- ];
29
- const isDangerous = dangerousPatterns.some((pattern) => pattern.test(jqQuery));
30
- if (isDangerous) {
31
- throw new Error('The jq query contains patterns that could access environment variables or system information. Please use a different query.');
32
- }
20
+ validateJqQuery(jqQuery);
33
21
  // Validate file path
34
22
  if (!path.isAbsolute(filePath)) {
35
23
  throw new Error(`File path must be an absolute path starting with "/": ${filePath}`);
@@ -46,46 +34,13 @@ export async function executeJqQuery(config, jqQuery, filePath) {
46
34
  validatePathWithinAllowedDirs(filePath, config.allowedPaths);
47
35
  // Execute jq query
48
36
  const timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
49
- return new Promise((resolve, reject) => {
50
- const jqProcess = spawn('jq', [jqQuery, filePath], {
51
- stdio: ['pipe', 'pipe', 'pipe'],
52
- timeout: timeoutMs,
53
- });
54
- let stdout = '';
55
- let stderr = '';
56
- jqProcess.stdout.on('data', (data) => {
57
- stdout += data.toString();
58
- });
59
- jqProcess.stderr.on('data', (data) => {
60
- stderr += data.toString();
61
- });
62
- jqProcess.on('close', (code) => {
63
- if (code === 0) {
64
- // Success - return clean response directly to AI
65
- const responseText = stdout.trim();
66
- resolve({
67
- content: [
68
- {
69
- type: 'text',
70
- text: responseText,
71
- },
72
- ],
73
- });
74
- }
75
- else {
76
- // Error
77
- reject(new Error(`jq command failed with exit code ${code}: ${stderr.trim()}`));
78
- }
79
- });
80
- jqProcess.on('error', (error) => {
81
- reject(new Error(`Failed to execute jq command: ${error.message}`));
82
- });
83
- // Handle timeout
84
- setTimeout(() => {
85
- if (!jqProcess.killed) {
86
- jqProcess.kill('SIGTERM');
87
- reject(new Error(`jq command timed out after ${timeoutMs}ms`));
88
- }
89
- }, timeoutMs);
90
- });
37
+ const responseText = await runJq(jqQuery, filePath, timeoutMs);
38
+ return {
39
+ content: [
40
+ {
41
+ type: 'text',
42
+ text: responseText,
43
+ },
44
+ ],
45
+ };
91
46
  }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Anomaly detection algorithms index
3
+ */
4
+ export * from './stats.js';
5
+ export { detectMadAnomalies } from './mad.js';
6
+ export { detectMovingAverageAnomalies } from './moving-average.js';
7
+ export { detectThresholdAnomalies } from './threshold.js';
8
+ export { detectRollingQuantileAnomalies } from './rolling-quantile.js';
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Anomaly detection algorithms index
3
+ */
4
+ export * from './stats.js';
5
+ export { detectMadAnomalies } from './mad.js';
6
+ export { detectMovingAverageAnomalies } from './moving-average.js';
7
+ export { detectThresholdAnomalies } from './threshold.js';
8
+ export { detectRollingQuantileAnomalies } from './rolling-quantile.js';
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Median Absolute Deviation (MAD) based anomaly detection
3
+ *
4
+ * Uses modified Z-scores based on median and MAD instead of mean and std dev.
5
+ * Most robust to outliers, excellent for non-normal distributions.
6
+ */
7
+ import type { DataPoint, Anomaly, MadParams } from '../types.js';
8
+ /**
9
+ * Detect anomalies using MAD method
10
+ *
11
+ * @param data - Array of data points
12
+ * @param params - MAD parameters
13
+ * @returns Array of detected anomalies
14
+ */
15
+ export declare function detectMadAnomalies(data: DataPoint[], params?: MadParams): Anomaly[];
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Median Absolute Deviation (MAD) based anomaly detection
3
+ *
4
+ * Uses modified Z-scores based on median and MAD instead of mean and std dev.
5
+ * Most robust to outliers, excellent for non-normal distributions.
6
+ */
7
+ import { median, medianAbsoluteDeviation, modifiedZScore } from './stats.js';
8
+ const DEFAULT_THRESHOLD = 3;
9
+ /**
10
+ * Detect anomalies using MAD method
11
+ *
12
+ * @param data - Array of data points
13
+ * @param params - MAD parameters
14
+ * @returns Array of detected anomalies
15
+ */
16
+ export function detectMadAnomalies(data, params = {}) {
17
+ const threshold = params.threshold ?? DEFAULT_THRESHOLD;
18
+ const values = data.map((d) => d.value);
19
+ const med = median(values);
20
+ const mad = medianAbsoluteDeviation(values);
21
+ if (mad === 0) {
22
+ // All values are at the median, no anomalies possible
23
+ return [];
24
+ }
25
+ const anomalies = [];
26
+ for (const point of data) {
27
+ const modZ = modifiedZScore(point.value, med, mad);
28
+ const absModZ = Math.abs(modZ);
29
+ if (absModZ > threshold) {
30
+ anomalies.push({
31
+ index: point.index,
32
+ timestamp: point.timestamp,
33
+ value: point.value,
34
+ reason: `Modified Z-score ${modZ.toFixed(2)} exceeds threshold ±${threshold}`,
35
+ metadata: {
36
+ modified_zscore: modZ,
37
+ median: med,
38
+ mad,
39
+ },
40
+ });
41
+ }
42
+ }
43
+ return anomalies;
44
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Moving Average based anomaly detection
3
+ *
4
+ * Detects points that deviate significantly from a rolling moving average.
5
+ * Good for detecting sudden changes in time series with trends.
6
+ */
7
+ import type { DataPoint, Anomaly, MovingAverageParams } from '../types.js';
8
+ /**
9
+ * Detect anomalies using Moving Average method
10
+ *
11
+ * @param data - Array of data points (should be in time order)
12
+ * @param params - Moving Average parameters
13
+ * @returns Array of detected anomalies
14
+ */
15
+ export declare function detectMovingAverageAnomalies(data: DataPoint[], params: MovingAverageParams): Anomaly[];
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Moving Average based anomaly detection
3
+ *
4
+ * Detects points that deviate significantly from a rolling moving average.
5
+ * Good for detecting sudden changes in time series with trends.
6
+ */
7
+ import { movingAverage, movingStandardDeviation } from './stats.js';
8
+ const DEFAULT_THRESHOLD = 2;
9
+ /**
10
+ * Detect anomalies using Moving Average method
11
+ *
12
+ * @param data - Array of data points (should be in time order)
13
+ * @param params - Moving Average parameters
14
+ * @returns Array of detected anomalies
15
+ */
16
+ export function detectMovingAverageAnomalies(data, params) {
17
+ const { window_size: windowSize, threshold = DEFAULT_THRESHOLD } = params;
18
+ if (windowSize <= 0) {
19
+ throw new Error('window_size must be greater than 0');
20
+ }
21
+ if (windowSize > data.length) {
22
+ throw new Error(`window_size (${windowSize}) cannot be larger than data length (${data.length})`);
23
+ }
24
+ const values = data.map((d) => d.value);
25
+ const ma = movingAverage(values, windowSize);
26
+ const mstd = movingStandardDeviation(values, windowSize);
27
+ const anomalies = [];
28
+ for (let i = 0; i < data.length; i++) {
29
+ // Skip points where we don't have enough data for the window
30
+ if (isNaN(ma[i]) || isNaN(mstd[i])) {
31
+ continue;
32
+ }
33
+ const point = data[i];
34
+ const expectedValue = ma[i];
35
+ const stdDev = mstd[i];
36
+ // If standard deviation is 0, all values in window are the same
37
+ if (stdDev === 0) {
38
+ // Only flag as anomaly if current value differs from the constant
39
+ if (point.value !== expectedValue) {
40
+ anomalies.push({
41
+ index: point.index,
42
+ timestamp: point.timestamp,
43
+ value: point.value,
44
+ reason: `Value ${point.value} differs from constant window value ${expectedValue}`,
45
+ metadata: {
46
+ moving_average: expectedValue,
47
+ moving_std_dev: stdDev,
48
+ window_size: windowSize,
49
+ },
50
+ });
51
+ }
52
+ continue;
53
+ }
54
+ const deviation = Math.abs(point.value - expectedValue);
55
+ const deviationScore = deviation / stdDev;
56
+ if (deviationScore > threshold) {
57
+ anomalies.push({
58
+ index: point.index,
59
+ timestamp: point.timestamp,
60
+ value: point.value,
61
+ reason: `Value deviates ${deviationScore.toFixed(2)} std devs from moving average (expected: ${expectedValue.toFixed(2)}, threshold: ${threshold})`,
62
+ metadata: {
63
+ moving_average: expectedValue,
64
+ moving_std_dev: stdDev,
65
+ deviation_score: deviationScore,
66
+ window_size: windowSize,
67
+ },
68
+ });
69
+ }
70
+ }
71
+ return anomalies;
72
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Rolling Quantile based anomaly detection (similar to Datadog's Basic algorithm)
3
+ *
4
+ * Uses a sliding window to compute rolling percentiles, then flags points
5
+ * outside the expected range. This is non-parametric and works well for
6
+ * any distribution without assuming normality.
7
+ */
8
+ import type { DataPoint, Anomaly, RollingQuantileParams } from '../types.js';
9
+ /**
10
+ * Detect anomalies using Rolling Quantile method
11
+ *
12
+ * @param data - Array of data points (should be in time order)
13
+ * @param params - Rolling Quantile parameters
14
+ * @returns Array of detected anomalies
15
+ */
16
+ export declare function detectRollingQuantileAnomalies(data: DataPoint[], params: RollingQuantileParams): Anomaly[];