@checkstack/anomaly-common 0.2.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.
package/CHANGELOG.md ADDED
@@ -0,0 +1,41 @@
1
+ # @checkstack/anomaly-common
2
+
3
+ ## 0.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 8d1ef12: ## Anomaly Detection & UI Improvements
8
+
9
+ ### Anomaly Detection Enhancements (Phase 2)
10
+
11
+ - **`@checkstack/anomaly-backend`**: Implemented background baseline analyzer jobs and anomaly trend deviation detection mechanics.
12
+ - **`@checkstack/anomaly-common`**: Added new baseline statistical logic and inference rules.
13
+ - **`@checkstack/anomaly-frontend`**: Added new Anomaly Widget and refactored system detail rendering to be more human-readable.
14
+ - **`@checkstack/dashboard-frontend`**: Refined the global anomaly widget and fixed hardcoded access gating to render appropriately.
15
+ - **`@checkstack/healthcheck-backend`**: Connected executor telemetry to the anomaly pipeline.
16
+ - **`@checkstack/healthcheck-frontend`**: Reconciled baseline display consistency in Drawer and charts.
17
+
18
+ ### Notification Identifiers
19
+
20
+ - **`@checkstack/incident-backend`**: Resolved system IDs to human-readable System Names within Incident notifications to eliminate ID-only alert content.
21
+ - **`@checkstack/maintenance-backend`**: Adopted the same resolution strategy for Maintenance notifications to keep parity.
22
+
23
+ ### UI Experience
24
+
25
+ - **`@checkstack/incident-frontend`**: Fixed the "Back to X" BackLink to properly use `react-router` hook `useNavigate` instead of doing a full application reload.
26
+ - **`@checkstack/healthcheck-frontend`**: Implemented `useNavigate` for seamless SPA back-linking.
27
+ - **`@checkstack/integration-frontend`**: Updated connections and delivery logs links to navigate without hard reloads.
28
+
29
+ - 8d1ef12: Phase 2 of anomaly detection: trend drift detection.
30
+
31
+ The background baseline analyzer now computes a linear regression slope across each field's chronologically-ordered history and runs a `detectDrift` evaluator that catches gradual "creeping degradation" never reaching the 3σ spike threshold. Drifts share the same `anomalies` table as spike anomalies via a new `kind` column (`spike` | `drift`, default `spike`); the existing suspicious → anomaly → recovered lifecycle is reused, ticking at the analyzer's hourly cadence with a default 2-run confirmation window.
32
+
33
+ User-facing additions: a Trend Drift toggle and threshold slider on both the template and assignment anomaly settings panels (with per-field overrides), drift rows in the System Anomaly widget, dashed regression-line overlays on the auto-generated line charts, and a new `ANOMALY_TREND_DETECTED` signal for live UI updates. Plugin authors can disable drift per chartable field via `x-anomaly-drift-enabled: false` or tighten/loosen it via `x-anomaly-drift-threshold`.
34
+
35
+ - 8d1ef12: Added Categorical Anomaly Detection (Dominance Drift) support for non-numeric healthcheck values, and introduced Slider UI components for sensitivity and confirmation window anomaly settings.
36
+
37
+ ### Patch Changes
38
+
39
+ - Updated dependencies [8d1ef12]
40
+ - @checkstack/common@0.7.0
41
+ - @checkstack/signal-common@0.1.10
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "@checkstack/anomaly-common",
3
+ "version": "0.2.0",
4
+ "type": "module",
5
+ "exports": {
6
+ ".": {
7
+ "import": "./src/index.ts"
8
+ }
9
+ },
10
+ "dependencies": {
11
+ "@checkstack/common": "0.6.5",
12
+ "@checkstack/signal-common": "0.1.9",
13
+ "zod": "^4.2.1"
14
+ },
15
+ "devDependencies": {
16
+ "typescript": "^5.7.2",
17
+ "@checkstack/tsconfig": "0.0.5",
18
+ "@checkstack/scripts": "0.1.2"
19
+ },
20
+ "scripts": {
21
+ "typecheck": "tsc --noEmit",
22
+ "lint": "bun run lint:code",
23
+ "lint:code": "eslint . --max-warnings 0"
24
+ },
25
+ "checkstack": {
26
+ "type": "common"
27
+ }
28
+ }
package/src/access.ts ADDED
@@ -0,0 +1,25 @@
1
+ import { accessPair } from "@checkstack/common";
2
+
3
+ export const anomalyAccess = {
4
+ feed: accessPair(
5
+ "anomaly_feed",
6
+ {
7
+ read: {
8
+ description: "View anomaly feed and status badges",
9
+ isDefault: true,
10
+ isPublic: true,
11
+ },
12
+ manage: {
13
+ description: "Manage anomaly configuration and noise floors",
14
+ },
15
+ },
16
+ {
17
+ idParam: "systemId",
18
+ }
19
+ ),
20
+ };
21
+
22
+ export const anomalyAccessRules = [
23
+ anomalyAccess.feed.read,
24
+ anomalyAccess.feed.manage,
25
+ ];
@@ -0,0 +1,94 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { computeMean, computeStdDev, computeLinearRegressionSlope, computeDominance } from "./baseline";
3
+
4
+ describe("Anomaly Engine - Baseline Mathematics", () => {
5
+ describe("computeMean", () => {
6
+ test("computes correct mean for standard array", () => {
7
+ expect(computeMean([2, 4, 6, 8])).toBe(5);
8
+ expect(computeMean([1, 1, 1, 1])).toBe(1);
9
+ });
10
+
11
+ test("returns 0 for empty array", () => {
12
+ expect(computeMean([])).toBe(0);
13
+ });
14
+
15
+ test("handles negative numbers and decimals", () => {
16
+ expect(computeMean([-1.5, 2.5, 0])).toBeCloseTo(0.33333333, 5);
17
+ expect(computeMean([-10, -20, -30])).toBe(-20);
18
+ });
19
+ });
20
+
21
+ describe("computeStdDev", () => {
22
+ test("computes correct population standard deviation", () => {
23
+ // Variance = 5, StdDev = sqrt(5) ≈ 2.236
24
+ expect(computeStdDev([2, 4, 4, 4, 5, 5, 7, 9])).toBeCloseTo(2.0, 5);
25
+
26
+ // Simple 0,2,4 -> mean 2 -> sum(sq(diff)) = 4+0+4 = 8 -> var = 8/3 = 2.666
27
+ // sqrt(2.666) ≈ 1.63299
28
+ expect(computeStdDev([0, 2, 4])).toBeCloseTo(1.63299, 5);
29
+ });
30
+
31
+ test("returns 0 for single item or empty array", () => {
32
+ expect(computeStdDev([5])).toBe(0);
33
+ expect(computeStdDev([])).toBe(0);
34
+ });
35
+
36
+ test("uses provided mean to optimize", () => {
37
+ // 0, 2, 4, mean = 2
38
+ expect(computeStdDev([0, 2, 4], 2)).toBeCloseTo(1.63299, 5);
39
+ });
40
+ });
41
+
42
+ describe("computeLinearRegressionSlope", () => {
43
+ test("computes correct slope for perfectly linear data", () => {
44
+ expect(computeLinearRegressionSlope([0, 2, 4, 6, 8])).toBe(2);
45
+ expect(computeLinearRegressionSlope([10, 8, 6, 4])).toBe(-2);
46
+ expect(computeLinearRegressionSlope([5, 5, 5, 5])).toBe(0);
47
+ });
48
+
49
+ test("computes correct slope for noisy data", () => {
50
+ const values = [1, 3, 2, 4, 5];
51
+ // x: 0,1,2,3,4. mean x: 2. y: 1,3,2,4,5. mean y: 3
52
+ // cov(x,y)/var(x) -> 0.9
53
+ expect(computeLinearRegressionSlope(values)).toBeCloseTo(0.9, 5);
54
+ });
55
+
56
+ test("returns 0 for insufficient data", () => {
57
+ expect(computeLinearRegressionSlope([5])).toBe(0);
58
+ expect(computeLinearRegressionSlope([])).toBe(0);
59
+ });
60
+ });
61
+
62
+ describe("computeDominance", () => {
63
+ test("computes correct dominance ratio and value", () => {
64
+ const result = computeDominance(["success", "success", "error", "success", "timeout"]);
65
+ expect(result.dominantValue).toBe("success");
66
+ expect(result.dominantRatio).toBe(0.6); // 3 / 5
67
+ });
68
+
69
+ test("handles boolean values", () => {
70
+ const result = computeDominance([true, true, false]);
71
+ expect(result.dominantValue).toBe(true);
72
+ expect(result.dominantRatio).toBeCloseTo(0.6667, 4);
73
+ });
74
+
75
+ test("handles numeric values", () => {
76
+ const result = computeDominance([200, 200, 404, 500, 200]);
77
+ expect(result.dominantValue).toBe(200);
78
+ expect(result.dominantRatio).toBe(0.6);
79
+ });
80
+
81
+ test("returns empty object for empty array", () => {
82
+ const result = computeDominance([]);
83
+ expect(result.dominantValue).toBeUndefined();
84
+ expect(result.dominantRatio).toBeUndefined();
85
+ });
86
+
87
+ test("returns first dominant value if tied", () => {
88
+ const result = computeDominance(["a", "b", "a", "b"]);
89
+ // "a" was reached count=2 first
90
+ expect(result.dominantValue).toBe("a");
91
+ expect(result.dominantRatio).toBe(0.5);
92
+ });
93
+ });
94
+ });
@@ -0,0 +1,56 @@
1
+ export function computeMean(values: number[]): number {
2
+ if (values.length === 0) return 0;
3
+ const sum = values.reduce((a, b) => a + b, 0);
4
+ return sum / values.length;
5
+ }
6
+
7
+ export function computeStdDev(values: number[], mean?: number): number {
8
+ if (values.length <= 1) return 0;
9
+ const m = mean ?? computeMean(values);
10
+ const variance = values.reduce((sq, n) => sq + Math.pow(n - m, 2), 0) / values.length;
11
+ return Math.sqrt(variance);
12
+ }
13
+
14
+ export function computeLinearRegressionSlope(values: number[]): number {
15
+ if (values.length <= 1) return 0;
16
+ // x = index, y = value
17
+ const n = values.length;
18
+ let sumX = 0;
19
+ let sumY = 0;
20
+ let sumXY = 0;
21
+ let sumXX = 0;
22
+
23
+ for (let i = 0; i < n; i++) {
24
+ sumX += i;
25
+ sumY += values[i];
26
+ sumXY += i * values[i];
27
+ sumXX += i * i;
28
+ }
29
+
30
+ const denominator = (n * sumXX - sumX * sumX);
31
+ if (denominator === 0) return 0;
32
+
33
+ return (n * sumXY - sumX * sumY) / denominator;
34
+ }
35
+
36
+ export function computeDominance(values: (string | boolean | number)[]): { dominantValue?: string | boolean | number, dominantRatio?: number } {
37
+ if (values.length === 0) return {};
38
+
39
+ const counts = new Map<string | boolean | number, number>();
40
+ let maxCount = 0;
41
+ let dominantValue: string | boolean | number | undefined = undefined;
42
+
43
+ for (const v of values) {
44
+ const count = (counts.get(v) || 0) + 1;
45
+ counts.set(v, count);
46
+ if (count > maxCount) {
47
+ maxCount = count;
48
+ dominantValue = v;
49
+ }
50
+ }
51
+
52
+ return {
53
+ dominantValue,
54
+ dominantRatio: maxCount / values.length,
55
+ };
56
+ }
@@ -0,0 +1,211 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { resolveEffectiveConfig } from "./config";
3
+ import type { AnomalySettings } from "../schema";
4
+
5
+ describe("Anomaly Engine - Config Override Mechanism", () => {
6
+ const defaultTemplate: AnomalySettings = {
7
+ enabled: true,
8
+ sensitivity: 1,
9
+ confirmationWindow: 3,
10
+ baselineWindow: "7d",
11
+ notify: true,
12
+ driftEnabled: true,
13
+ driftThreshold: 2,
14
+ fieldOverrides: {},
15
+ };
16
+
17
+ test("uses defaults when nothing is provided", () => {
18
+ const result = resolveEffectiveConfig("some.field");
19
+ expect(result.enabled).toBe(true);
20
+ expect(result.sensitivity).toBe(1);
21
+ expect(result.confirmationWindow).toBe(3);
22
+ expect(result.direction).toBeUndefined();
23
+ expect(result.driftEnabled).toBe(true);
24
+ expect(result.driftThreshold).toBe(2);
25
+ });
26
+
27
+ test("uses template configuration as base", () => {
28
+ const template: AnomalySettings = {
29
+ ...defaultTemplate,
30
+ enabled: false,
31
+ sensitivity: 2,
32
+ confirmationWindow: 5,
33
+ };
34
+ const result = resolveEffectiveConfig("some.field", template);
35
+ expect(result.enabled).toBe(false);
36
+ expect(result.sensitivity).toBe(2);
37
+ expect(result.confirmationWindow).toBe(5);
38
+ });
39
+
40
+ test("assignment overrides template global settings", () => {
41
+ const template: AnomalySettings = {
42
+ ...defaultTemplate,
43
+ sensitivity: 1,
44
+ confirmationWindow: 3,
45
+ };
46
+ const assignment: Partial<AnomalySettings> = {
47
+ sensitivity: 3,
48
+ confirmationWindow: 1,
49
+ };
50
+ const result = resolveEffectiveConfig("some.field", template, assignment);
51
+ expect(result.sensitivity).toBe(3);
52
+ expect(result.confirmationWindow).toBe(1);
53
+ expect(result.enabled).toBe(true); // fallbacks to template
54
+ });
55
+
56
+ test("template field override overrides template global", () => {
57
+ const template: AnomalySettings = {
58
+ ...defaultTemplate,
59
+ sensitivity: 1,
60
+ fieldOverrides: {
61
+ "some.field": {
62
+ sensitivity: 5,
63
+ },
64
+ },
65
+ };
66
+ const result = resolveEffectiveConfig("some.field", template);
67
+ expect(result.sensitivity).toBe(5);
68
+ });
69
+
70
+ test("assignment field override takes absolute precedence", () => {
71
+ const template: AnomalySettings = {
72
+ ...defaultTemplate,
73
+ sensitivity: 1,
74
+ fieldOverrides: {
75
+ "some.field": {
76
+ sensitivity: 5, // template field level
77
+ },
78
+ },
79
+ };
80
+ const assignment: Partial<AnomalySettings> = {
81
+ sensitivity: 2, // assignment global level
82
+ fieldOverrides: {
83
+ "some.field": {
84
+ sensitivity: 10, // assignment field level
85
+ },
86
+ },
87
+ };
88
+ const result = resolveEffectiveConfig("some.field", template, assignment);
89
+ expect(result.sensitivity).toBe(10);
90
+ });
91
+
92
+ test("template field override is not overridden by assignment global", () => {
93
+ // This is intentional: field-level overrides (from any layer) represent
94
+ // more specific intent and take precedence over global overrides.
95
+ // See resolveEffectiveConfig JSDoc for the full resolution order.
96
+ const template: AnomalySettings = {
97
+ ...defaultTemplate,
98
+ fieldOverrides: {
99
+ "some.field": {
100
+ enabled: false,
101
+ },
102
+ },
103
+ };
104
+ const assignment: Partial<AnomalySettings> = {
105
+ enabled: true,
106
+ };
107
+ const result = resolveEffectiveConfig("some.field", template, assignment);
108
+
109
+ // fieldConfig exists (template field: { enabled: false })
110
+ // So fieldConfig.enabled is false — field-level is more specific than assignment global.
111
+ expect(result.enabled).toBe(false);
112
+ });
113
+
114
+ test("template field override sets direction", () => {
115
+ const template: AnomalySettings = {
116
+ ...defaultTemplate,
117
+ fieldOverrides: {
118
+ "some.field": {
119
+ direction: "higher-is-better",
120
+ },
121
+ },
122
+ };
123
+ const result = resolveEffectiveConfig("some.field", template);
124
+ expect(result.direction).toBe("higher-is-better");
125
+ });
126
+
127
+ test("assignment field override direction beats template field direction", () => {
128
+ const template: AnomalySettings = {
129
+ ...defaultTemplate,
130
+ fieldOverrides: {
131
+ "some.field": { direction: "lower-is-better" },
132
+ },
133
+ };
134
+ const assignment: Partial<AnomalySettings> = {
135
+ fieldOverrides: {
136
+ "some.field": { direction: "deviation" },
137
+ },
138
+ };
139
+ const result = resolveEffectiveConfig("some.field", template, assignment);
140
+ expect(result.direction).toBe("deviation");
141
+ });
142
+
143
+ test("direction is undefined when no override sets it (caller falls back to schema)", () => {
144
+ const result = resolveEffectiveConfig("some.field", defaultTemplate);
145
+ expect(result.direction).toBeUndefined();
146
+ });
147
+
148
+ test("preserves explicit falsy values", () => {
149
+ const template: AnomalySettings = {
150
+ ...defaultTemplate,
151
+ sensitivity: 2,
152
+ };
153
+ const assignment: Partial<AnomalySettings> = {
154
+ sensitivity: 0,
155
+ };
156
+ const result = resolveEffectiveConfig("some.field", template, assignment);
157
+ expect(result.sensitivity).toBe(0);
158
+ });
159
+
160
+ describe("drift settings", () => {
161
+ test("template driftEnabled is honored", () => {
162
+ const template: AnomalySettings = {
163
+ ...defaultTemplate,
164
+ driftEnabled: false,
165
+ };
166
+ const result = resolveEffectiveConfig("some.field", template);
167
+ expect(result.driftEnabled).toBe(false);
168
+ });
169
+
170
+ test("assignment driftEnabled overrides template", () => {
171
+ const template: AnomalySettings = {
172
+ ...defaultTemplate,
173
+ driftEnabled: true,
174
+ };
175
+ const assignment: Partial<AnomalySettings> = { driftEnabled: false };
176
+ const result = resolveEffectiveConfig("some.field", template, assignment);
177
+ expect(result.driftEnabled).toBe(false);
178
+ });
179
+
180
+ test("field-level driftEnabled wins over assignment global", () => {
181
+ const template: AnomalySettings = {
182
+ ...defaultTemplate,
183
+ fieldOverrides: {
184
+ "some.field": { driftEnabled: false },
185
+ },
186
+ };
187
+ const assignment: Partial<AnomalySettings> = { driftEnabled: true };
188
+ const result = resolveEffectiveConfig("some.field", template, assignment);
189
+ expect(result.driftEnabled).toBe(false);
190
+ });
191
+
192
+ test("driftThreshold cascades through layers", () => {
193
+ const template: AnomalySettings = { ...defaultTemplate, driftThreshold: 3 };
194
+ const assignment: Partial<AnomalySettings> = { driftThreshold: 1.5 };
195
+ const result = resolveEffectiveConfig("some.field", template, assignment);
196
+ expect(result.driftThreshold).toBe(1.5);
197
+ });
198
+
199
+ test("field-level driftThreshold wins over assignment global", () => {
200
+ const template: AnomalySettings = {
201
+ ...defaultTemplate,
202
+ fieldOverrides: {
203
+ "some.field": { driftThreshold: 4 },
204
+ },
205
+ };
206
+ const assignment: Partial<AnomalySettings> = { driftThreshold: 1.5 };
207
+ const result = resolveEffectiveConfig("some.field", template, assignment);
208
+ expect(result.driftThreshold).toBe(4);
209
+ });
210
+ });
211
+ });
@@ -0,0 +1,71 @@
1
+ import type { AnomalySettings, AnomalyDirection } from "../schema";
2
+
3
+ export interface EffectiveConfig {
4
+ enabled: boolean;
5
+ sensitivity: number;
6
+ confirmationWindow: number;
7
+ direction?: AnomalyDirection;
8
+ driftEnabled: boolean;
9
+ driftThreshold: number;
10
+ }
11
+
12
+ /**
13
+ * Resolves the effective anomaly detection config for a specific field path
14
+ * using the Three-Layer Override Model.
15
+ *
16
+ * Resolution order (highest to lowest precedence):
17
+ * 1. Assignment field override (most specific — user override for a specific field on a specific system)
18
+ * 2. Template field override (plugin developer annotation for a specific field)
19
+ * 3. Assignment global (user override for the entire system assignment)
20
+ * 4. Template global (plugin developer default for the entire health check)
21
+ * 5. Engine defaults (hardcoded fallback values)
22
+ *
23
+ * Note: Field-level overrides always win over global overrides because they
24
+ * represent more specific intent. If a template marks a field as `enabled: false`,
25
+ * only an assignment-level field override can re-enable it — the assignment global
26
+ * `enabled: true` won't override it, since the template field config is more specific.
27
+ */
28
+ export function resolveEffectiveConfig(
29
+ path: string,
30
+ templateConfig?: AnomalySettings,
31
+ assignmentConfig?: Partial<AnomalySettings>
32
+ ): EffectiveConfig {
33
+ const fieldConfig = assignmentConfig?.fieldOverrides?.[path] ?? templateConfig?.fieldOverrides?.[path];
34
+
35
+ const enabled = fieldConfig?.enabled
36
+ ?? assignmentConfig?.enabled
37
+ ?? templateConfig?.enabled
38
+ ?? true;
39
+
40
+ const sensitivity = fieldConfig?.sensitivity
41
+ ?? assignmentConfig?.sensitivity
42
+ ?? templateConfig?.sensitivity
43
+ ?? 1;
44
+
45
+ const confirmationWindow = fieldConfig?.confirmationWindow
46
+ ?? assignmentConfig?.confirmationWindow
47
+ ?? templateConfig?.confirmationWindow
48
+ ?? 3;
49
+
50
+ const direction = fieldConfig?.direction;
51
+
52
+ const driftEnabled = fieldConfig?.driftEnabled
53
+ ?? assignmentConfig?.driftEnabled
54
+ ?? templateConfig?.driftEnabled
55
+ ?? true;
56
+
57
+ const driftThreshold = fieldConfig?.driftThreshold
58
+ ?? assignmentConfig?.driftThreshold
59
+ ?? templateConfig?.driftThreshold
60
+ ?? 2;
61
+
62
+ return {
63
+ enabled,
64
+ sensitivity,
65
+ confirmationWindow,
66
+ direction,
67
+ driftEnabled,
68
+ driftThreshold,
69
+ };
70
+ }
71
+
@@ -0,0 +1,205 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { detectDrift } from "./drift";
3
+
4
+ describe("Anomaly Engine - Drift Detection", () => {
5
+ describe("trigger condition |slope × n| > threshold × σ × sensitivity", () => {
6
+ test("does not trigger when projected change is below threshold", () => {
7
+ // slope * n = 0.5 * 100 = 50; threshold * σ * sensitivity = 2 * 30 * 1 = 60
8
+ const result = detectDrift({
9
+ slope: 0.5,
10
+ stdDev: 30,
11
+ sampleCount: 100,
12
+ direction: "deviation",
13
+ sensitivity: 1,
14
+ threshold: 2,
15
+ });
16
+ expect(result.drifting).toBe(false);
17
+ expect(result.projectedChange).toBe(50);
18
+ });
19
+
20
+ test("triggers when projected change exceeds threshold", () => {
21
+ // slope * n = 1 * 100 = 100; threshold * σ * sensitivity = 2 * 30 * 1 = 60
22
+ const result = detectDrift({
23
+ slope: 1,
24
+ stdDev: 30,
25
+ sampleCount: 100,
26
+ direction: "deviation",
27
+ sensitivity: 1,
28
+ threshold: 2,
29
+ });
30
+ expect(result.drifting).toBe(true);
31
+ expect(result.projectedChange).toBe(100);
32
+ expect(result.deviationSigmas).toBeCloseTo(100 / 30, 4);
33
+ expect(result.driftDirection).toBe("above");
34
+ });
35
+
36
+ test("uses absolute value of slope so negative drifts also count", () => {
37
+ const result = detectDrift({
38
+ slope: -1,
39
+ stdDev: 30,
40
+ sampleCount: 100,
41
+ direction: "deviation",
42
+ sensitivity: 1,
43
+ threshold: 2,
44
+ });
45
+ expect(result.drifting).toBe(true);
46
+ expect(result.driftDirection).toBe("below");
47
+ });
48
+ });
49
+
50
+ describe("direction filtering", () => {
51
+ test("lower-is-better only flags positive slopes (metric getting worse)", () => {
52
+ const rising = detectDrift({
53
+ slope: 1,
54
+ stdDev: 30,
55
+ sampleCount: 100,
56
+ direction: "lower-is-better",
57
+ sensitivity: 1,
58
+ threshold: 2,
59
+ });
60
+ expect(rising.drifting).toBe(true);
61
+
62
+ const falling = detectDrift({
63
+ slope: -1,
64
+ stdDev: 30,
65
+ sampleCount: 100,
66
+ direction: "lower-is-better",
67
+ sensitivity: 1,
68
+ threshold: 2,
69
+ });
70
+ expect(falling.drifting).toBe(false);
71
+ });
72
+
73
+ test("higher-is-better only flags negative slopes", () => {
74
+ const falling = detectDrift({
75
+ slope: -1,
76
+ stdDev: 30,
77
+ sampleCount: 100,
78
+ direction: "higher-is-better",
79
+ sensitivity: 1,
80
+ threshold: 2,
81
+ });
82
+ expect(falling.drifting).toBe(true);
83
+
84
+ const rising = detectDrift({
85
+ slope: 1,
86
+ stdDev: 30,
87
+ sampleCount: 100,
88
+ direction: "higher-is-better",
89
+ sensitivity: 1,
90
+ threshold: 2,
91
+ });
92
+ expect(rising.drifting).toBe(false);
93
+ });
94
+
95
+ test("dominance never triggers drift", () => {
96
+ const result = detectDrift({
97
+ slope: 100,
98
+ stdDev: 1,
99
+ sampleCount: 100,
100
+ direction: "dominance",
101
+ sensitivity: 1,
102
+ threshold: 2,
103
+ });
104
+ expect(result.drifting).toBe(false);
105
+ });
106
+ });
107
+
108
+ describe("sensitivity scaling", () => {
109
+ test("higher sensitivity widens the band — fewer detections", () => {
110
+ // Borderline case: slope*n = 60, threshold*σ = 60. With sensitivity 2.0 the band doubles.
111
+ const baseline = detectDrift({
112
+ slope: 1,
113
+ stdDev: 30,
114
+ sampleCount: 100,
115
+ direction: "deviation",
116
+ sensitivity: 1,
117
+ threshold: 2,
118
+ });
119
+ expect(baseline.drifting).toBe(true);
120
+
121
+ const desensitized = detectDrift({
122
+ slope: 1,
123
+ stdDev: 30,
124
+ sampleCount: 100,
125
+ direction: "deviation",
126
+ sensitivity: 2,
127
+ threshold: 2,
128
+ });
129
+ expect(desensitized.drifting).toBe(false);
130
+ });
131
+
132
+ test("lower sensitivity tightens the band — more detections", () => {
133
+ const dormant = detectDrift({
134
+ slope: 0.5,
135
+ stdDev: 30,
136
+ sampleCount: 100,
137
+ direction: "deviation",
138
+ sensitivity: 1,
139
+ threshold: 2,
140
+ });
141
+ expect(dormant.drifting).toBe(false);
142
+
143
+ const sensitive = detectDrift({
144
+ slope: 0.5,
145
+ stdDev: 30,
146
+ sampleCount: 100,
147
+ direction: "deviation",
148
+ sensitivity: 0.5,
149
+ threshold: 2,
150
+ });
151
+ expect(sensitive.drifting).toBe(true);
152
+ });
153
+ });
154
+
155
+ describe("degenerate inputs", () => {
156
+ test("zero stdDev with non-zero slope still drifts (any movement is anomalous)", () => {
157
+ const result = detectDrift({
158
+ slope: 1,
159
+ stdDev: 0,
160
+ sampleCount: 100,
161
+ direction: "deviation",
162
+ sensitivity: 1,
163
+ threshold: 2,
164
+ });
165
+ expect(result.drifting).toBe(true);
166
+ expect(result.deviationSigmas).toBe(Number.POSITIVE_INFINITY);
167
+ });
168
+
169
+ test("zero slope never drifts", () => {
170
+ const result = detectDrift({
171
+ slope: 0,
172
+ stdDev: 30,
173
+ sampleCount: 100,
174
+ direction: "deviation",
175
+ sensitivity: 1,
176
+ threshold: 2,
177
+ });
178
+ expect(result.drifting).toBe(false);
179
+ });
180
+
181
+ test("zero sampleCount never drifts", () => {
182
+ const result = detectDrift({
183
+ slope: 1,
184
+ stdDev: 30,
185
+ sampleCount: 0,
186
+ direction: "deviation",
187
+ sensitivity: 1,
188
+ threshold: 2,
189
+ });
190
+ expect(result.drifting).toBe(false);
191
+ });
192
+
193
+ test("zero stdDev with zero slope does not drift", () => {
194
+ const result = detectDrift({
195
+ slope: 0,
196
+ stdDev: 0,
197
+ sampleCount: 100,
198
+ direction: "deviation",
199
+ sensitivity: 1,
200
+ threshold: 2,
201
+ });
202
+ expect(result.drifting).toBe(false);
203
+ });
204
+ });
205
+ });
@@ -0,0 +1,80 @@
1
+ import type { AnomalyDirection } from "../schema";
2
+
3
+ export interface DriftDetectionInput {
4
+ /** Linear regression slope across the chronologically-ordered baseline window. */
5
+ slope: number;
6
+ /** Population standard deviation of the same window. */
7
+ stdDev: number;
8
+ /** Number of samples in the window. */
9
+ sampleCount: number;
10
+ /** Schema-declared direction for the field. */
11
+ direction: AnomalyDirection;
12
+ /** Sensitivity multiplier (matches the spike-detection slider). */
13
+ sensitivity: number;
14
+ /** Sigma multiplier on the drift trigger band. Default 2 in callers. */
15
+ threshold: number;
16
+ }
17
+
18
+ export interface DriftDetectionResult {
19
+ drifting: boolean;
20
+ /** slope × sampleCount — the projected change over the baseline window. */
21
+ projectedChange: number;
22
+ /** projectedChange / stdDev — how many σ the trend has shifted the baseline. */
23
+ deviationSigmas: number;
24
+ /** Which side of the baseline the drift is moving toward. */
25
+ driftDirection: "above" | "below";
26
+ }
27
+
28
+ /**
29
+ * Pure drift detection.
30
+ *
31
+ * Triggers when |slope × sampleCount| > threshold × σ × sensitivity, i.e. when
32
+ * the regression projects the metric to walk by more than `threshold` standard
33
+ * deviations across the baseline window. Filtered by direction:
34
+ * - lower-is-better: only positive slope (worsening)
35
+ * - higher-is-better: only negative slope (worsening)
36
+ * - deviation: either
37
+ * - dominance: never (categorical fields don't drift continuously)
38
+ *
39
+ * Returns deviationSigmas = ∞ when stdDev is 0 and slope is non-zero, since any
40
+ * movement on a previously-constant metric is by definition outside the noise.
41
+ */
42
+ export function detectDrift({
43
+ slope,
44
+ stdDev,
45
+ sampleCount,
46
+ direction,
47
+ sensitivity,
48
+ threshold,
49
+ }: DriftDetectionInput): DriftDetectionResult {
50
+ const projectedChange = slope * sampleCount;
51
+ const driftDirection: "above" | "below" = slope >= 0 ? "above" : "below";
52
+
53
+ const deviationSigmas =
54
+ stdDev === 0
55
+ ? slope === 0
56
+ ? 0
57
+ : Number.POSITIVE_INFINITY
58
+ : Math.abs(projectedChange) / stdDev;
59
+
60
+ if (sampleCount === 0 || slope === 0) {
61
+ return { drifting: false, projectedChange, deviationSigmas, driftDirection };
62
+ }
63
+
64
+ if (direction === "dominance") {
65
+ return { drifting: false, projectedChange, deviationSigmas, driftDirection };
66
+ }
67
+
68
+ if (direction === "lower-is-better" && slope < 0) {
69
+ return { drifting: false, projectedChange, deviationSigmas, driftDirection };
70
+ }
71
+
72
+ if (direction === "higher-is-better" && slope > 0) {
73
+ return { drifting: false, projectedChange, deviationSigmas, driftDirection };
74
+ }
75
+
76
+ const triggerBand = threshold * stdDev * sensitivity;
77
+ const drifting = Math.abs(projectedChange) > triggerBand;
78
+
79
+ return { drifting, projectedChange, deviationSigmas, driftDirection };
80
+ }
@@ -0,0 +1,108 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { computeThresholds, isAnomalous, isCategoricalAnomalous } from "./thresholds";
3
+
4
+ describe("Anomaly Engine - Thresholds", () => {
5
+ describe("computeThresholds", () => {
6
+ test("higher-is-better generates only lowerTrigger", () => {
7
+ // e.g. success rate. Mean 90, stdDev 5, sensitivity 1 -> margin 15
8
+ // lowerTrigger = 90 - 15 = 75
9
+ const thresholds = computeThresholds(90, 5, "higher-is-better", 1);
10
+ expect(thresholds.lowerTrigger).toBe(75);
11
+ expect(thresholds.upperTrigger).toBeUndefined();
12
+ });
13
+
14
+ test("lower-is-better generates only upperTrigger", () => {
15
+ // e.g. latency. Mean 100, stdDev 20, sensitivity 1 -> margin 60
16
+ // upperTrigger = 100 + 60 = 160
17
+ const thresholds = computeThresholds(100, 20, "lower-is-better", 1);
18
+ expect(thresholds.upperTrigger).toBe(160);
19
+ expect(thresholds.lowerTrigger).toBeUndefined();
20
+ });
21
+
22
+ test("deviation generates both triggers", () => {
23
+ // e.g. queue size. Mean 50, stdDev 10, sensitivity 1 -> margin 30
24
+ // triggers at 20 and 80
25
+ const thresholds = computeThresholds(50, 10, "deviation", 1);
26
+ expect(thresholds.lowerTrigger).toBe(20);
27
+ expect(thresholds.upperTrigger).toBe(80);
28
+ });
29
+
30
+ test("scales margin with sensitivity multiplier", () => {
31
+ // deviation, sensitivity 0.5 (tighter bounds)
32
+ // margin = 3 * 10 * 0.5 = 15 -> triggers at 35 and 65
33
+ const strictThresholds = computeThresholds(50, 10, "deviation", 0.5);
34
+ expect(strictThresholds.lowerTrigger).toBe(35);
35
+ expect(strictThresholds.upperTrigger).toBe(65);
36
+
37
+ // deviation, sensitivity 2 (looser bounds)
38
+ // margin = 3 * 10 * 2 = 60 -> triggers at -10 and 110
39
+ const looseThresholds = computeThresholds(50, 10, "deviation", 2);
40
+ expect(looseThresholds.lowerTrigger).toBe(-10);
41
+ expect(looseThresholds.upperTrigger).toBe(110);
42
+ });
43
+ });
44
+
45
+ describe("isAnomalous", () => {
46
+ test("detects lower anomalies", () => {
47
+ const thresholds = { lowerTrigger: 50 };
48
+ expect(isAnomalous(40, thresholds)).toBe(true);
49
+ expect(isAnomalous(50, thresholds)).toBe(false); // Exactly at threshold is not anomalous
50
+ expect(isAnomalous(60, thresholds)).toBe(false);
51
+ });
52
+
53
+ test("detects upper anomalies", () => {
54
+ const thresholds = { upperTrigger: 100 };
55
+ expect(isAnomalous(110, thresholds)).toBe(true);
56
+ expect(isAnomalous(100, thresholds)).toBe(false);
57
+ expect(isAnomalous(90, thresholds)).toBe(false);
58
+ });
59
+
60
+ test("detects bidirectional anomalies", () => {
61
+ const thresholds = { lowerTrigger: 20, upperTrigger: 80 };
62
+ expect(isAnomalous(10, thresholds)).toBe(true);
63
+ expect(isAnomalous(50, thresholds)).toBe(false);
64
+ expect(isAnomalous(90, thresholds)).toBe(true);
65
+ });
66
+
67
+ test("handles undefined thresholds gracefully", () => {
68
+ const thresholds = {};
69
+ expect(isAnomalous(1000, thresholds)).toBe(false);
70
+ expect(isAnomalous(-1000, thresholds)).toBe(false);
71
+ });
72
+ });
73
+
74
+ describe("isCategoricalAnomalous", () => {
75
+ test("handles undefined baselines safely", () => {
76
+ expect(isCategoricalAnomalous(200, undefined, undefined, 1)).toBe(false);
77
+ expect(isCategoricalAnomalous(200, 200, undefined, 1)).toBe(false);
78
+ expect(isCategoricalAnomalous(200, undefined, 0.9, 1)).toBe(false);
79
+ });
80
+
81
+ test("triggers if value deviates and ratio is sufficiently high (sensitivity = 1)", () => {
82
+ // ratio 0.95 > 0.9, deviates
83
+ expect(isCategoricalAnomalous(500, 200, 0.95, 1)).toBe(true);
84
+ // ratio 0.95 > 0.9, no deviation
85
+ expect(isCategoricalAnomalous(200, 200, 0.95, 1)).toBe(false);
86
+ });
87
+
88
+ test("ignores deviation if baseline ratio is too noisy (sensitivity = 1)", () => {
89
+ // ratio 0.85 < 0.9
90
+ expect(isCategoricalAnomalous(500, 200, 0.85, 1)).toBe(false);
91
+ });
92
+
93
+ test("scales dominance threshold with sensitivity", () => {
94
+ // High sensitivity (0.5) -> tighter bounds, more alerts.
95
+ // Required ratio = 0.9 * 0.5 = 0.45.
96
+ // Even if baseline ratio is 0.50 (very noisy), it still triggers an alert!
97
+ expect(isCategoricalAnomalous(500, 200, 0.50, 0.5)).toBe(true);
98
+
99
+ // Low sensitivity (2.0) -> looser bounds, fewer alerts.
100
+ // Required ratio = 0.9 * 2.0 = 1.8 -> clamped to 0.99.
101
+ // If baseline ratio is 0.95 (pretty stable but not 99%), it does NOT alert.
102
+ expect(isCategoricalAnomalous(500, 200, 0.95, 2.0)).toBe(false);
103
+
104
+ // If baseline ratio is 0.995 (extremely stable), it WILL alert even at sensitivity 2.0.
105
+ expect(isCategoricalAnomalous(500, 200, 0.995, 2.0)).toBe(true);
106
+ });
107
+ });
108
+ });
@@ -0,0 +1,72 @@
1
+ import type { AnomalyDirection } from "../schema";
2
+
3
+ export interface Thresholds {
4
+ lowerTrigger?: number;
5
+ upperTrigger?: number;
6
+ }
7
+
8
+ export function computeThresholds(
9
+ mean: number,
10
+ stdDev: number,
11
+ direction: AnomalyDirection,
12
+ sensitivity: number = 1
13
+ ): Thresholds {
14
+ const margin = 3 * stdDev * sensitivity;
15
+
16
+ switch (direction) {
17
+ case "higher-is-better": {
18
+ return {
19
+ lowerTrigger: mean - margin,
20
+ // No upper trigger, values can go as high as they want
21
+ };
22
+ }
23
+ case "lower-is-better": {
24
+ return {
25
+ upperTrigger: mean + margin,
26
+ // No lower trigger, getting faster/better is not an anomaly
27
+ };
28
+ }
29
+ case "deviation": {
30
+ return {
31
+ lowerTrigger: mean - margin,
32
+ upperTrigger: mean + margin,
33
+ };
34
+ }
35
+ default: {
36
+ return {};
37
+ }
38
+ }
39
+ }
40
+
41
+ export function isAnomalous(
42
+ value: number,
43
+ thresholds: Thresholds
44
+ ): boolean {
45
+ if (thresholds.lowerTrigger !== undefined && value < thresholds.lowerTrigger) {
46
+ return true;
47
+ }
48
+ if (thresholds.upperTrigger !== undefined && value > thresholds.upperTrigger) {
49
+ return true;
50
+ }
51
+ return false;
52
+ }
53
+
54
+ export function isCategoricalAnomalous(
55
+ value: string | boolean | number,
56
+ dominantValue: string | boolean | number | undefined,
57
+ dominantRatio: number | undefined,
58
+ sensitivity: number = 1
59
+ ): boolean {
60
+ if (dominantValue === undefined || dominantRatio === undefined) return false;
61
+
62
+ // Sensitivity scaling for categorical fields limits false positives.
63
+ // Base required dominance is 90% stability.
64
+ // Lower sensitivity values (e.g., 0.5) mean "tighter bounds, more alerts".
65
+ // Therefore, we lower the required ratio so we alert even if the baseline is noisier.
66
+ // Higher sensitivity values (e.g., 2.0) mean "looser bounds, fewer alerts".
67
+ // Therefore, we raise the required ratio so we only alert if the baseline was extremely stable.
68
+ const requiredRatio = Math.min(0.99, 0.9 * sensitivity);
69
+ if (dominantRatio < requiredRatio) return false;
70
+
71
+ return String(value) !== String(dominantValue);
72
+ }
package/src/index.ts ADDED
@@ -0,0 +1,42 @@
1
+ export * from "./schema";
2
+ export * from "./engine/baseline";
3
+ export * from "./engine/thresholds";
4
+ export * from "./engine/config";
5
+ export * from "./engine/drift";
6
+ export * from "./access";
7
+ export * from "./rpc-contract";
8
+ export * from "./plugin-metadata";
9
+
10
+ import { createSignal } from "@checkstack/signal-common";
11
+ import { z } from "zod";
12
+ import { AnomalyStateSchema } from "./schema";
13
+
14
+ export const ANOMALY_STATE_CHANGED = createSignal(
15
+ "anomaly.state_changed",
16
+ z.object({
17
+ systemId: z.string(),
18
+ anomalyId: z.string(),
19
+ newState: AnomalyStateSchema,
20
+ })
21
+ );
22
+
23
+ export const ANOMALY_BASELINE_UPDATED = createSignal(
24
+ "anomaly.baseline_updated",
25
+ z.object({
26
+ systemId: z.string(),
27
+ configurationId: z.string(),
28
+ fieldPath: z.string(),
29
+ mean: z.number(),
30
+ stdDev: z.number(),
31
+ sampleCount: z.number(),
32
+ })
33
+ );
34
+
35
+ export const ANOMALY_TREND_DETECTED = createSignal(
36
+ "anomaly.trend_detected",
37
+ z.object({
38
+ systemId: z.string(),
39
+ anomalyId: z.string(),
40
+ fieldPath: z.string(),
41
+ })
42
+ );
@@ -0,0 +1,5 @@
1
+ import { definePluginMetadata } from "@checkstack/common";
2
+
3
+ export const pluginMetadata = definePluginMetadata({
4
+ pluginId: "anomaly",
5
+ });
@@ -0,0 +1,158 @@
1
+ import { createClientDefinition, proc } from "@checkstack/common";
2
+ import { z } from "zod";
3
+ import { pluginMetadata } from "./plugin-metadata";
4
+ import { AnomalyStateSchema, AnomalySettingsSchema, AnomalyFieldConfigSchema, AnomalyKindSchema } from "./schema";
5
+ import { anomalyAccess } from "./access";
6
+
7
+ export const AnomalyDtoSchema = z.object({
8
+ id: z.string(),
9
+ systemId: z.string(),
10
+ configurationId: z.string(),
11
+ fieldPath: z.string(),
12
+ kind: AnomalyKindSchema,
13
+ state: AnomalyStateSchema,
14
+ direction: z.enum(["above", "below", "changed"]),
15
+ baselineValue: z.number().nullable(),
16
+ baselineStdDev: z.number().nullable(),
17
+ observedValue: z.string(),
18
+ deviation: z.number(),
19
+ suspiciousRunCount: z.number(),
20
+ confirmationThreshold: z.number(),
21
+ startedAt: z.string(),
22
+ confirmedAt: z.string().nullable(),
23
+ recoveredAt: z.string().nullable(),
24
+ metadata: z.record(z.string(), z.unknown()).nullable(),
25
+ });
26
+
27
+ export type AnomalyDto = z.infer<typeof AnomalyDtoSchema>;
28
+
29
+ export const AnomalyBaselineDtoSchema = z.object({
30
+ id: z.string(),
31
+ systemId: z.string(),
32
+ configurationId: z.string(),
33
+ fieldPath: z.string(),
34
+ mean: z.number(),
35
+ stdDev: z.number(),
36
+ trendSlope: z.number(),
37
+ sampleCount: z.number(),
38
+ computedAt: z.string(),
39
+ dominantValue: z.string().nullable(),
40
+ dominantRatio: z.number().nullable(),
41
+ });
42
+
43
+ export type AnomalyBaselineDto = z.infer<typeof AnomalyBaselineDtoSchema>;
44
+
45
+ /**
46
+ * Schema for a VersionedRecord wrapper used in RPC transport.
47
+ * Wraps the data with version metadata for backward-compatible schema evolution.
48
+ */
49
+ const VersionedAnomalySettingsSchema = z.object({
50
+ version: z.number(),
51
+ data: AnomalySettingsSchema,
52
+ migratedAt: z.date().optional(),
53
+ originalVersion: z.number().optional(),
54
+ });
55
+
56
+ /**
57
+ * Partial settings schema for assignment-level overrides.
58
+ * Only includes fields that the user explicitly sets.
59
+ */
60
+ const PartialAnomalySettingsSchema = z.object({
61
+ enabled: z.boolean().optional(),
62
+ sensitivity: z.number().optional(),
63
+ confirmationWindow: z.number().int().optional(),
64
+ baselineWindow: z.string().optional(),
65
+ notify: z.boolean().optional(),
66
+ driftEnabled: z.boolean().optional(),
67
+ driftThreshold: z.number().optional(),
68
+ fieldOverrides: z.record(z.string(), AnomalyFieldConfigSchema).optional(),
69
+ });
70
+
71
+ const VersionedPartialAnomalySettingsSchema = z.object({
72
+ version: z.number(),
73
+ data: PartialAnomalySettingsSchema,
74
+ migratedAt: z.date().optional(),
75
+ originalVersion: z.number().optional(),
76
+ });
77
+
78
+ export const anomalyContract = {
79
+ getAnomalies: proc({
80
+ operationType: "query",
81
+ userType: "public",
82
+ access: [anomalyAccess.feed.read],
83
+ instanceAccess: { idParam: "systemId" },
84
+ })
85
+ .input(z.object({
86
+ systemId: z.string().optional(),
87
+ configurationId: z.string().optional(),
88
+ state: AnomalyStateSchema.optional(),
89
+ kind: AnomalyKindSchema.optional(),
90
+ limit: z.number().optional().default(50),
91
+ }))
92
+ .output(z.array(AnomalyDtoSchema)),
93
+
94
+ getAnomalyBaselines: proc({
95
+ operationType: "query",
96
+ userType: "public",
97
+ access: [anomalyAccess.feed.read],
98
+ instanceAccess: { idParam: "systemId" },
99
+ })
100
+ .input(z.object({
101
+ systemId: z.string(),
102
+ configurationId: z.string(),
103
+ }))
104
+ .output(z.array(AnomalyBaselineDtoSchema)),
105
+
106
+ getAnomalyConfig: proc({
107
+ operationType: "query",
108
+ userType: "authenticated",
109
+ access: [anomalyAccess.feed.manage],
110
+ })
111
+ .input(z.object({
112
+ configurationId: z.string(),
113
+ }))
114
+ .output(VersionedAnomalySettingsSchema),
115
+
116
+ updateAnomalyConfig: proc({
117
+ operationType: "mutation",
118
+ userType: "authenticated",
119
+ access: [anomalyAccess.feed.manage],
120
+ })
121
+ .input(z.object({
122
+ configurationId: z.string(),
123
+ config: AnomalySettingsSchema,
124
+ }))
125
+ .output(VersionedAnomalySettingsSchema),
126
+
127
+ getAnomalyAssignmentConfig: proc({
128
+ operationType: "query",
129
+ userType: "authenticated",
130
+ access: [anomalyAccess.feed.manage],
131
+ instanceAccess: { idParam: "systemId" },
132
+ })
133
+ .input(z.object({
134
+ systemId: z.string(),
135
+ configurationId: z.string(),
136
+ }))
137
+ .output(VersionedPartialAnomalySettingsSchema.nullable()),
138
+
139
+ updateAnomalyAssignmentConfig: proc({
140
+ operationType: "mutation",
141
+ userType: "authenticated",
142
+ access: [anomalyAccess.feed.manage],
143
+ instanceAccess: { idParam: "systemId" },
144
+ })
145
+ .input(z.object({
146
+ systemId: z.string(),
147
+ configurationId: z.string(),
148
+ config: PartialAnomalySettingsSchema,
149
+ }))
150
+ .output(VersionedPartialAnomalySettingsSchema),
151
+ };
152
+
153
+ export type AnomalyContract = typeof anomalyContract;
154
+
155
+ export const AnomalyApi = createClientDefinition(
156
+ anomalyContract,
157
+ pluginMetadata
158
+ );
package/src/schema.ts ADDED
@@ -0,0 +1,60 @@
1
+ import { z } from "zod";
2
+
3
+ export const AnomalyDirectionSchema = z.enum([
4
+ "higher-is-better",
5
+ "lower-is-better",
6
+ "deviation",
7
+ "dominance",
8
+ ]);
9
+ export type AnomalyDirection = z.infer<typeof AnomalyDirectionSchema>;
10
+
11
+ export const AnomalyStateSchema = z.enum([
12
+ "suspicious",
13
+ "anomaly",
14
+ "recovered",
15
+ ]);
16
+ export type AnomalyState = z.infer<typeof AnomalyStateSchema>;
17
+
18
+ export const AnomalyKindSchema = z.enum(["spike", "drift"]);
19
+ export type AnomalyKind = z.infer<typeof AnomalyKindSchema>;
20
+
21
+ export const FieldBaselineSchema = z.object({
22
+ mean: z.number(),
23
+ stdDev: z.number(),
24
+ trendSlope: z.number(),
25
+ sampleCount: z.number(),
26
+ computedAt: z.string(), // ISO timestamp
27
+ dominantValue: z.union([z.string(), z.boolean(), z.number()]).optional(),
28
+ dominantRatio: z.number().optional(),
29
+ });
30
+ export type FieldBaseline = z.infer<typeof FieldBaselineSchema>;
31
+
32
+ export const AnomalyMetadataSchema = z
33
+ .object({
34
+ trendData: z.record(z.string(), z.unknown()).optional(),
35
+ relatedAnomalies: z.array(z.string()).optional(), // UUIDs
36
+ })
37
+ .catchall(z.unknown());
38
+ export type AnomalyMetadata = z.infer<typeof AnomalyMetadataSchema>;
39
+
40
+ export const AnomalyFieldConfigSchema = z.object({
41
+ enabled: z.boolean().optional(),
42
+ sensitivity: z.number().optional(),
43
+ confirmationWindow: z.number().int().optional(),
44
+ direction: AnomalyDirectionSchema.optional(),
45
+ driftEnabled: z.boolean().optional(),
46
+ driftThreshold: z.number().optional(),
47
+ });
48
+ export type AnomalyFieldConfig = z.infer<typeof AnomalyFieldConfigSchema>;
49
+
50
+ export const AnomalySettingsSchema = z.object({
51
+ enabled: z.boolean().default(true),
52
+ sensitivity: z.number().default(1),
53
+ confirmationWindow: z.number().int().default(3),
54
+ baselineWindow: z.string().default("7d"),
55
+ notify: z.boolean().default(true),
56
+ driftEnabled: z.boolean().default(true),
57
+ driftThreshold: z.number().default(2),
58
+ fieldOverrides: z.record(z.string(), AnomalyFieldConfigSchema).optional(),
59
+ });
60
+ export type AnomalySettings = z.infer<typeof AnomalySettingsSchema>;
package/tsconfig.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "extends": "@checkstack/tsconfig/common.json",
3
+ "include": [
4
+ "src"
5
+ ]
6
+ }