@checkstack/anomaly-common 1.0.1 → 1.1.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 CHANGED
@@ -1,5 +1,73 @@
1
1
  # @checkstack/anomaly-common
2
2
 
3
+ ## 1.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 42abfff: Remove global anomaly settings — configuration is now field-only.
8
+
9
+ `AnomalySettings` (template- and assignment-level) no longer carries
10
+ `sensitivity`, `confirmationWindow`, `driftEnabled`, or `driftThreshold`.
11
+ These were duplicating the per-field configuration path with awkward
12
+ cascade semantics, and a single global multiplier was meaningless across
13
+ fields with different units (ms, %, counts).
14
+
15
+ The schema retains only the truly global concerns:
16
+
17
+ - `enabled` — master kill switch for the assignment
18
+ - `baselineWindow` — there is one history per system, not per field
19
+ - `notify` — one notification preference per assignment
20
+ - `fieldOverrides` — per-field configuration (where everything else now lives)
21
+
22
+ `resolveEffectiveConfig` collapses to two layers: field override → schema
23
+ default → engine fallback constant. The plugin-author defaults set via
24
+ `x-anomaly-*` annotations now drive sensitivity/window/drift across the
25
+ detector and drift evaluator (previously only floors were threaded
26
+ through the schema layer).
27
+
28
+ **Breaking changes:**
29
+
30
+ - Any global `sensitivity`/`confirmationWindow`/`driftEnabled`/
31
+ `driftThreshold` values previously stored in `anomaly_configurations`
32
+ or `anomaly_assignments` are silently stripped on parse. Users who
33
+ customized these globals will revert to the plugin's tuned per-field
34
+ defaults; if they want to keep those values they must re-apply them
35
+ per field in the new UI.
36
+ - `AnomalySettingsForm` no longer renders the global sliders. The form
37
+ now shows: enable toggle, baseline window selector, notify toggle,
38
+ field overrides editor.
39
+ - `AnomalyFieldOverridesEditor` props `defaultSensitivity`,
40
+ `defaultConfirmationWindow`, `defaultDriftEnabled`, `defaultDriftThreshold`
41
+ are removed. Engine fallbacks (1.0, 3, true, 2) are now hard-coded
42
+ internal constants used only when neither field override nor schema
43
+ default is set.
44
+ - The GitOps `System.anomaly` entry schema (in `anomaly-gitops-kinds`)
45
+ drops `sensitivity`, `confirmationWindow`, `driftEnabled`, and
46
+ `driftThreshold` to match the new `AnomalySettings` shape. YAML files
47
+ declaring those fields will be rejected at parse time — operators
48
+ must move per-field tuning into `fieldOverrides`.
49
+
50
+ This change makes the override model trivial to explain ("plugin defaults,
51
+ overridden per field") and removes a class of confusing "where did this
52
+ threshold come from?" questions.
53
+
54
+ - 42abfff: Add practical-significance floors to anomaly detection.
55
+
56
+ Two new schema annotations — `x-anomaly-min-absolute-delta` and `x-anomaly-min-relative-delta` — let plugin authors and operators suppress alerts whose statistical deviation is large but practical impact is negligible. Both floors must clear in addition to the existing μ ± Nσ trigger; defaults are 0 (disabled) so existing behaviour is unchanged.
57
+
58
+ This is the fix for cases like a 6 ms latency baseline whose σ ≈ 1 ms causes routine 20 ms blips to fire as anomalies despite Δ=14 ms being operationally irrelevant. With `min-absolute-delta: 50` and `min-relative-delta: 0.5`, those blips stay silent while a 6 ms → 200 ms spike still fires.
59
+
60
+ Built-in plugins ship with sensible defaults applied to every per-run field: 50 ms + 50 % for ms-unit fields, 5 percentage points for `%`-unit fields, 1 + 25 % for counter fields, 1 GB + 5 % for disk fields, 50 MB + 10 % for memory fields, 1 day for TLS expiry, 0.5 + 25 % for load average, 1 + 5 % for Minecraft TPS. Operators can override per-system or per-field via the assignment UI.
61
+
62
+ ### Patch Changes
63
+
64
+ - Updated dependencies [42abfff]
65
+ - Updated dependencies [1ef2e79]
66
+ - @checkstack/common@0.9.0
67
+ - @checkstack/catalog-common@2.1.0
68
+ - @checkstack/notification-common@1.0.2
69
+ - @checkstack/signal-common@0.2.2
70
+
3
71
  ## 1.0.1
4
72
 
5
73
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/anomaly-common",
3
- "version": "1.0.1",
3
+ "version": "1.1.0",
4
4
  "license": "Elastic-2.0",
5
5
  "type": "module",
6
6
  "exports": {
@@ -9,16 +9,16 @@
9
9
  }
10
10
  },
11
11
  "dependencies": {
12
- "@checkstack/common": "0.7.0",
13
- "@checkstack/catalog-common": "2.0.0",
14
- "@checkstack/notification-common": "1.0.0",
15
- "@checkstack/signal-common": "0.2.0",
12
+ "@checkstack/common": "0.8.0",
13
+ "@checkstack/catalog-common": "2.0.1",
14
+ "@checkstack/notification-common": "1.0.1",
15
+ "@checkstack/signal-common": "0.2.1",
16
16
  "zod": "^4.2.1"
17
17
  },
18
18
  "devDependencies": {
19
19
  "typescript": "^5.7.2",
20
- "@checkstack/tsconfig": "0.0.6",
21
- "@checkstack/scripts": "0.1.2"
20
+ "@checkstack/tsconfig": "0.0.7",
21
+ "@checkstack/scripts": "0.3.0"
22
22
  },
23
23
  "scripts": {
24
24
  "typecheck": "tsgo -b",
@@ -5,16 +5,12 @@ import type { AnomalySettings } from "../schema";
5
5
  describe("Anomaly Engine - Config Override Mechanism", () => {
6
6
  const defaultTemplate: AnomalySettings = {
7
7
  enabled: true,
8
- sensitivity: 1,
9
- confirmationWindow: 3,
10
8
  baselineWindow: "7d",
11
9
  notify: true,
12
- driftEnabled: true,
13
- driftThreshold: 2,
14
10
  fieldOverrides: {},
15
11
  };
16
12
 
17
- test("uses defaults when nothing is provided", () => {
13
+ test("uses engine defaults when nothing is provided", () => {
18
14
  const result = resolveEffectiveConfig("some.field");
19
15
  expect(result.enabled).toBe(true);
20
16
  expect(result.sensitivity).toBe(1);
@@ -22,190 +18,145 @@ describe("Anomaly Engine - Config Override Mechanism", () => {
22
18
  expect(result.direction).toBeUndefined();
23
19
  expect(result.driftEnabled).toBe(true);
24
20
  expect(result.driftThreshold).toBe(2);
21
+ expect(result.minAbsoluteDelta).toBe(0);
22
+ expect(result.minRelativeDelta).toBe(0);
25
23
  });
26
24
 
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
- });
25
+ test("master enabled toggle cascades from template to assignment to field", () => {
26
+ const template: AnomalySettings = { ...defaultTemplate, enabled: false };
27
+ expect(resolveEffectiveConfig("some.field", template).enabled).toBe(false);
39
28
 
40
- test("assignment overrides template global settings", () => {
41
- const template: AnomalySettings = {
29
+ const assignment: Partial<AnomalySettings> = { enabled: true };
30
+ expect(
31
+ resolveEffectiveConfig("some.field", template, assignment).enabled,
32
+ ).toBe(true);
33
+
34
+ // Field override beats both globals.
35
+ const templateWithFieldOff: AnomalySettings = {
42
36
  ...defaultTemplate,
43
- sensitivity: 1,
44
- confirmationWindow: 3,
45
- };
46
- const assignment: Partial<AnomalySettings> = {
47
- sensitivity: 3,
48
- confirmationWindow: 1,
37
+ enabled: true,
38
+ fieldOverrides: { "some.field": { enabled: false } },
49
39
  };
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
40
+ expect(
41
+ resolveEffectiveConfig("some.field", templateWithFieldOff).enabled,
42
+ ).toBe(false);
54
43
  });
55
44
 
56
- test("template field override overrides template global", () => {
45
+ test("template field override drives sensitivity / confirmation / drift", () => {
57
46
  const template: AnomalySettings = {
58
47
  ...defaultTemplate,
59
- sensitivity: 1,
60
48
  fieldOverrides: {
61
49
  "some.field": {
62
- sensitivity: 5,
50
+ sensitivity: 2,
51
+ confirmationWindow: 5,
52
+ driftEnabled: false,
53
+ driftThreshold: 4,
63
54
  },
64
55
  },
65
56
  };
66
57
  const result = resolveEffectiveConfig("some.field", template);
67
- expect(result.sensitivity).toBe(5);
58
+ expect(result.sensitivity).toBe(2);
59
+ expect(result.confirmationWindow).toBe(5);
60
+ expect(result.driftEnabled).toBe(false);
61
+ expect(result.driftThreshold).toBe(4);
68
62
  });
69
63
 
70
- test("assignment field override takes absolute precedence", () => {
64
+ test("assignment field override beats template field override", () => {
71
65
  const template: AnomalySettings = {
72
66
  ...defaultTemplate,
73
- sensitivity: 1,
74
- fieldOverrides: {
75
- "some.field": {
76
- sensitivity: 5, // template field level
77
- },
78
- },
67
+ fieldOverrides: { "some.field": { sensitivity: 2 } },
79
68
  };
80
69
  const assignment: Partial<AnomalySettings> = {
81
- sensitivity: 2, // assignment global level
82
- fieldOverrides: {
83
- "some.field": {
84
- sensitivity: 10, // assignment field level
85
- },
86
- },
70
+ fieldOverrides: { "some.field": { sensitivity: 0.5 } },
87
71
  };
88
72
  const result = resolveEffectiveConfig("some.field", template, assignment);
89
- expect(result.sensitivity).toBe(10);
73
+ expect(result.sensitivity).toBe(0.5);
90
74
  });
91
75
 
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
- },
76
+ test("schema defaults fill in when no field override is set", () => {
77
+ const result = resolveEffectiveConfig(
78
+ "some.field",
79
+ undefined,
80
+ undefined,
81
+ {
82
+ sensitivity: 1.5,
83
+ confirmationWindow: 5,
84
+ driftEnabled: false,
85
+ driftThreshold: 3,
86
+ minAbsoluteDelta: 50,
87
+ minRelativeDelta: 0.5,
102
88
  },
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);
89
+ );
90
+ expect(result.sensitivity).toBe(1.5);
91
+ expect(result.confirmationWindow).toBe(5);
92
+ expect(result.driftEnabled).toBe(false);
93
+ expect(result.driftThreshold).toBe(3);
94
+ expect(result.minAbsoluteDelta).toBe(50);
95
+ expect(result.minRelativeDelta).toBe(0.5);
112
96
  });
113
97
 
114
- test("template field override sets direction", () => {
98
+ test("field override beats schema defaults", () => {
115
99
  const template: AnomalySettings = {
116
100
  ...defaultTemplate,
117
- fieldOverrides: {
118
- "some.field": {
119
- direction: "higher-is-better",
120
- },
121
- },
101
+ fieldOverrides: { "some.field": { sensitivity: 0.7 } },
122
102
  };
123
- const result = resolveEffectiveConfig("some.field", template);
124
- expect(result.direction).toBe("higher-is-better");
103
+ const result = resolveEffectiveConfig(
104
+ "some.field",
105
+ template,
106
+ undefined,
107
+ { sensitivity: 1.5 },
108
+ );
109
+ expect(result.sensitivity).toBe(0.7);
125
110
  });
126
111
 
127
- test("assignment field override direction beats template field direction", () => {
112
+ test("template field override sets direction", () => {
128
113
  const template: AnomalySettings = {
129
114
  ...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
- },
115
+ fieldOverrides: { "some.field": { direction: "higher-is-better" } },
138
116
  };
139
- const result = resolveEffectiveConfig("some.field", template, assignment);
140
- expect(result.direction).toBe("deviation");
117
+ expect(resolveEffectiveConfig("some.field", template).direction).toBe(
118
+ "higher-is-better",
119
+ );
141
120
  });
142
121
 
143
122
  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();
123
+ expect(
124
+ resolveEffectiveConfig("some.field", defaultTemplate).direction,
125
+ ).toBeUndefined();
146
126
  });
147
127
 
148
128
  test("preserves explicit falsy values", () => {
149
129
  const template: AnomalySettings = {
150
130
  ...defaultTemplate,
151
- sensitivity: 2,
152
- };
153
- const assignment: Partial<AnomalySettings> = {
154
- sensitivity: 0,
131
+ fieldOverrides: { "some.field": { sensitivity: 0 } },
155
132
  };
156
- const result = resolveEffectiveConfig("some.field", template, assignment);
157
- expect(result.sensitivity).toBe(0);
133
+ expect(resolveEffectiveConfig("some.field", template).sensitivity).toBe(0);
158
134
  });
159
135
 
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
- });
136
+ test("explicit 0 floor from field override re-enables noise (overrides schema default)", () => {
137
+ const template: AnomalySettings = {
138
+ ...defaultTemplate,
139
+ fieldOverrides: { "some.field": { minAbsoluteDelta: 0 } },
140
+ };
141
+ const result = resolveEffectiveConfig(
142
+ "some.field",
143
+ template,
144
+ undefined,
145
+ { minAbsoluteDelta: 50 },
146
+ );
147
+ expect(result.minAbsoluteDelta).toBe(0);
148
+ });
198
149
 
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
- });
150
+ test("globals other than enabled are ignored — only fieldOverrides shape detection", () => {
151
+ // The schema doesn't accept sensitivity/confirmationWindow/drift* at the
152
+ // global level any more, so even if a stale config carries them they
153
+ // would be stripped by Zod parse. This test asserts that the resolver
154
+ // doesn't accidentally read them off the typed AnomalySettings shape.
155
+ const template: AnomalySettings = { ...defaultTemplate };
156
+ const result = resolveEffectiveConfig("some.field", template);
157
+ expect(result.sensitivity).toBe(1);
158
+ expect(result.confirmationWindow).toBe(3);
159
+ expect(result.driftEnabled).toBe(true);
160
+ expect(result.driftThreshold).toBe(2);
210
161
  });
211
162
  });
@@ -7,57 +7,96 @@ export interface EffectiveConfig {
7
7
  direction?: AnomalyDirection;
8
8
  driftEnabled: boolean;
9
9
  driftThreshold: number;
10
+ /** Practical-significance floor on absolute deviation (default 0). */
11
+ minAbsoluteDelta: number;
12
+ /** Practical-significance floor on relative deviation (default 0). */
13
+ minRelativeDelta: number;
10
14
  }
11
15
 
12
16
  /**
13
- * Resolves the effective anomaly detection config for a specific field path
14
- * using the Three-Layer Override Model.
17
+ * Per-field schema-declared anomaly defaults. These are the plugin author's
18
+ * tuned defaults — the layer beneath user-supplied field overrides.
19
+ */
20
+ export interface SchemaDefaults {
21
+ sensitivity?: number;
22
+ confirmationWindow?: number;
23
+ driftEnabled?: boolean;
24
+ driftThreshold?: number;
25
+ minAbsoluteDelta?: number;
26
+ minRelativeDelta?: number;
27
+ }
28
+
29
+ /** Engine fallback constants — used only when neither the user nor the schema set a value. */
30
+ const ENGINE_DEFAULTS = {
31
+ enabled: true,
32
+ sensitivity: 1,
33
+ confirmationWindow: 3,
34
+ driftEnabled: true,
35
+ driftThreshold: 2,
36
+ minAbsoluteDelta: 0,
37
+ minRelativeDelta: 0,
38
+ } as const;
39
+
40
+ /**
41
+ * Resolves the effective anomaly detection config for a specific field path.
15
42
  *
16
43
  * 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)
44
+ * 1. Assignment field override
45
+ * 2. Template field override
46
+ * 3. Schema annotation (plugin-author default)
47
+ * 4. Engine fallback constant
22
48
  *
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.
49
+ * Globals were removed in favour of per-field configuration only. Plugin
50
+ * defaults are tuned per-field with awareness of each metric's unit and
51
+ * nature, so a single global multiplier across heterogeneous fields would
52
+ * be meaningless. Templates and assignments influence detection through
53
+ * `fieldOverrides`; the only true global on `AnomalySettings` is the master
54
+ * `enabled` toggle (and the `baselineWindow` / `notify` knobs which are
55
+ * inherently scoped to the whole assignment).
27
56
  */
28
57
  export function resolveEffectiveConfig(
29
58
  path: string,
30
59
  templateConfig?: AnomalySettings,
31
- assignmentConfig?: Partial<AnomalySettings>
60
+ assignmentConfig?: Partial<AnomalySettings>,
61
+ schemaDefaults?: SchemaDefaults
32
62
  ): EffectiveConfig {
33
- const fieldConfig = assignmentConfig?.fieldOverrides?.[path] ?? templateConfig?.fieldOverrides?.[path];
34
-
35
- const enabled = fieldConfig?.enabled
36
- ?? assignmentConfig?.enabled
37
- ?? templateConfig?.enabled
38
- ?? true;
63
+ const fieldConfig =
64
+ assignmentConfig?.fieldOverrides?.[path] ??
65
+ templateConfig?.fieldOverrides?.[path];
39
66
 
40
- const sensitivity = fieldConfig?.sensitivity
41
- ?? assignmentConfig?.sensitivity
42
- ?? templateConfig?.sensitivity
43
- ?? 1;
67
+ // The master toggle remains a global — it's the kill switch for the
68
+ // entire assignment, not a per-field setting. A field override of
69
+ // `enabled: false` can still locally opt out.
70
+ const enabled = fieldConfig?.enabled
71
+ ?? assignmentConfig?.enabled
72
+ ?? templateConfig?.enabled
73
+ ?? ENGINE_DEFAULTS.enabled;
44
74
 
45
- const confirmationWindow = fieldConfig?.confirmationWindow
46
- ?? assignmentConfig?.confirmationWindow
47
- ?? templateConfig?.confirmationWindow
48
- ?? 3;
75
+ const sensitivity = fieldConfig?.sensitivity
76
+ ?? schemaDefaults?.sensitivity
77
+ ?? ENGINE_DEFAULTS.sensitivity;
78
+
79
+ const confirmationWindow = fieldConfig?.confirmationWindow
80
+ ?? schemaDefaults?.confirmationWindow
81
+ ?? ENGINE_DEFAULTS.confirmationWindow;
49
82
 
50
83
  const direction = fieldConfig?.direction;
51
84
 
52
85
  const driftEnabled = fieldConfig?.driftEnabled
53
- ?? assignmentConfig?.driftEnabled
54
- ?? templateConfig?.driftEnabled
55
- ?? true;
86
+ ?? schemaDefaults?.driftEnabled
87
+ ?? ENGINE_DEFAULTS.driftEnabled;
56
88
 
57
89
  const driftThreshold = fieldConfig?.driftThreshold
58
- ?? assignmentConfig?.driftThreshold
59
- ?? templateConfig?.driftThreshold
60
- ?? 2;
90
+ ?? schemaDefaults?.driftThreshold
91
+ ?? ENGINE_DEFAULTS.driftThreshold;
92
+
93
+ const minAbsoluteDelta = fieldConfig?.minAbsoluteDelta
94
+ ?? schemaDefaults?.minAbsoluteDelta
95
+ ?? ENGINE_DEFAULTS.minAbsoluteDelta;
96
+
97
+ const minRelativeDelta = fieldConfig?.minRelativeDelta
98
+ ?? schemaDefaults?.minRelativeDelta
99
+ ?? ENGINE_DEFAULTS.minRelativeDelta;
61
100
 
62
101
  return {
63
102
  enabled,
@@ -66,6 +105,7 @@ export function resolveEffectiveConfig(
66
105
  direction,
67
106
  driftEnabled,
68
107
  driftThreshold,
108
+ minAbsoluteDelta,
109
+ minRelativeDelta,
69
110
  };
70
111
  }
71
-
@@ -202,4 +202,91 @@ describe("Anomaly Engine - Drift Detection", () => {
202
202
  expect(result.drifting).toBe(false);
203
203
  });
204
204
  });
205
+
206
+ describe("practical-significance floors", () => {
207
+ test("absolute floor suppresses drift below the floor", () => {
208
+ // slope*n = 100, statistical band = 60 → would normally drift.
209
+ // But projected change 100 < absolute floor 200 → no drift.
210
+ const suppressed = detectDrift({
211
+ slope: 1,
212
+ stdDev: 30,
213
+ sampleCount: 100,
214
+ direction: "deviation",
215
+ sensitivity: 1,
216
+ threshold: 2,
217
+ minAbsoluteDelta: 200,
218
+ });
219
+ expect(suppressed.drifting).toBe(false);
220
+
221
+ const passes = detectDrift({
222
+ slope: 1,
223
+ stdDev: 30,
224
+ sampleCount: 100,
225
+ direction: "deviation",
226
+ sensitivity: 1,
227
+ threshold: 2,
228
+ minAbsoluteDelta: 50,
229
+ });
230
+ expect(passes.drifting).toBe(true);
231
+ });
232
+
233
+ test("relative floor suppresses drift below the proportional floor", () => {
234
+ // mean=1000, projected change=100 → relative=0.1 (10%).
235
+ // With minRelativeDelta=0.5 (50%), drift suppressed.
236
+ const suppressed = detectDrift({
237
+ slope: 1,
238
+ stdDev: 30,
239
+ sampleCount: 100,
240
+ direction: "deviation",
241
+ sensitivity: 1,
242
+ threshold: 2,
243
+ mean: 1000,
244
+ minRelativeDelta: 0.5,
245
+ });
246
+ expect(suppressed.drifting).toBe(false);
247
+
248
+ // mean=100, same projected change=100 → relative=1.0 → fires.
249
+ const fires = detectDrift({
250
+ slope: 1,
251
+ stdDev: 30,
252
+ sampleCount: 100,
253
+ direction: "deviation",
254
+ sensitivity: 1,
255
+ threshold: 2,
256
+ mean: 100,
257
+ minRelativeDelta: 0.5,
258
+ });
259
+ expect(fires.drifting).toBe(true);
260
+ });
261
+
262
+ test("relative floor without mean is a no-op", () => {
263
+ // No mean provided → relative floor cannot be evaluated, so it doesn't suppress.
264
+ const result = detectDrift({
265
+ slope: 1,
266
+ stdDev: 30,
267
+ sampleCount: 100,
268
+ direction: "deviation",
269
+ sensitivity: 1,
270
+ threshold: 2,
271
+ minRelativeDelta: 0.99,
272
+ });
273
+ expect(result.drifting).toBe(true);
274
+ });
275
+
276
+ test("zero stdDev still respects floors", () => {
277
+ // Without floors, this drifts (any movement on constant metric).
278
+ // With absolute floor above projected change, it's suppressed.
279
+ const result = detectDrift({
280
+ slope: 0.001,
281
+ stdDev: 0,
282
+ sampleCount: 100,
283
+ direction: "deviation",
284
+ sensitivity: 1,
285
+ threshold: 2,
286
+ minAbsoluteDelta: 1,
287
+ });
288
+ expect(result.drifting).toBe(false);
289
+ expect(result.deviationSigmas).toBe(Number.POSITIVE_INFINITY);
290
+ });
291
+ });
205
292
  });
@@ -13,6 +13,19 @@ export interface DriftDetectionInput {
13
13
  sensitivity: number;
14
14
  /** Sigma multiplier on the drift trigger band. Default 2 in callers. */
15
15
  threshold: number;
16
+ /** Baseline mean — required when applying the relative floor. */
17
+ mean?: number;
18
+ /**
19
+ * Floor on |slope × sampleCount| (projected absolute change). The
20
+ * statistical drift trigger must be exceeded *and* the projected change
21
+ * must be ≥ this floor. Default 0 (disabled).
22
+ */
23
+ minAbsoluteDelta?: number;
24
+ /**
25
+ * Floor on |slope × sampleCount| / max(|μ|, ε) (projected relative
26
+ * change), expressed as a fraction. Default 0 (disabled).
27
+ */
28
+ minRelativeDelta?: number;
16
29
  }
17
30
 
18
31
  export interface DriftDetectionResult {
@@ -46,8 +59,12 @@ export function detectDrift({
46
59
  direction,
47
60
  sensitivity,
48
61
  threshold,
62
+ mean,
63
+ minAbsoluteDelta = 0,
64
+ minRelativeDelta = 0,
49
65
  }: DriftDetectionInput): DriftDetectionResult {
50
66
  const projectedChange = slope * sampleCount;
67
+ const absChange = Math.abs(projectedChange);
51
68
  const driftDirection: "above" | "below" = slope >= 0 ? "above" : "below";
52
69
 
53
70
  const deviationSigmas =
@@ -55,7 +72,7 @@ export function detectDrift({
55
72
  ? slope === 0
56
73
  ? 0
57
74
  : Number.POSITIVE_INFINITY
58
- : Math.abs(projectedChange) / stdDev;
75
+ : absChange / stdDev;
59
76
 
60
77
  if (sampleCount === 0 || slope === 0) {
61
78
  return { drifting: false, projectedChange, deviationSigmas, driftDirection };
@@ -74,7 +91,21 @@ export function detectDrift({
74
91
  }
75
92
 
76
93
  const triggerBand = threshold * stdDev * sensitivity;
77
- const drifting = Math.abs(projectedChange) > triggerBand;
94
+ const exceedsStatistical = absChange > triggerBand;
95
+ if (!exceedsStatistical) {
96
+ return { drifting: false, projectedChange, deviationSigmas, driftDirection };
97
+ }
98
+
99
+ if (absChange < minAbsoluteDelta) {
100
+ return { drifting: false, projectedChange, deviationSigmas, driftDirection };
101
+ }
102
+
103
+ if (minRelativeDelta > 0 && mean !== undefined) {
104
+ const denominator = Math.max(Math.abs(mean), Number.EPSILON);
105
+ if (absChange / denominator < minRelativeDelta) {
106
+ return { drifting: false, projectedChange, deviationSigmas, driftDirection };
107
+ }
108
+ }
78
109
 
79
- return { drifting, projectedChange, deviationSigmas, driftDirection };
110
+ return { drifting: true, projectedChange, deviationSigmas, driftDirection };
80
111
  }
@@ -45,29 +45,105 @@ describe("Anomaly Engine - Thresholds", () => {
45
45
  describe("isAnomalous", () => {
46
46
  test("detects lower anomalies", () => {
47
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);
48
+ expect(isAnomalous({ value: 40, mean: 60, thresholds })).toBe(true);
49
+ expect(isAnomalous({ value: 50, mean: 60, thresholds })).toBe(false); // Exactly at threshold is not anomalous
50
+ expect(isAnomalous({ value: 60, mean: 60, thresholds })).toBe(false);
51
51
  });
52
52
 
53
53
  test("detects upper anomalies", () => {
54
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);
55
+ expect(isAnomalous({ value: 110, mean: 80, thresholds })).toBe(true);
56
+ expect(isAnomalous({ value: 100, mean: 80, thresholds })).toBe(false);
57
+ expect(isAnomalous({ value: 90, mean: 80, thresholds })).toBe(false);
58
58
  });
59
59
 
60
60
  test("detects bidirectional anomalies", () => {
61
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);
62
+ expect(isAnomalous({ value: 10, mean: 50, thresholds })).toBe(true);
63
+ expect(isAnomalous({ value: 50, mean: 50, thresholds })).toBe(false);
64
+ expect(isAnomalous({ value: 90, mean: 50, thresholds })).toBe(true);
65
65
  });
66
66
 
67
67
  test("handles undefined thresholds gracefully", () => {
68
68
  const thresholds = {};
69
- expect(isAnomalous(1000, thresholds)).toBe(false);
70
- expect(isAnomalous(-1000, thresholds)).toBe(false);
69
+ expect(isAnomalous({ value: 1000, mean: 0, thresholds })).toBe(false);
70
+ expect(isAnomalous({ value: -1000, mean: 0, thresholds })).toBe(false);
71
+ });
72
+
73
+ test("absolute floor suppresses statistically-anomalous but practically-tiny swings", () => {
74
+ // 6ms baseline, σ=1 → upperTrigger ≈ 9. Value 20 crosses statistical trigger
75
+ // but Δ=14ms is below the 50ms practical-significance floor → no fire.
76
+ const thresholds = computeThresholds(6, 1, "lower-is-better", 1);
77
+ expect(
78
+ isAnomalous({ value: 20, mean: 6, thresholds, minAbsoluteDelta: 50 }),
79
+ ).toBe(false);
80
+ // Same baseline, value 200ms (Δ=194ms) → fires.
81
+ expect(
82
+ isAnomalous({ value: 200, mean: 6, thresholds, minAbsoluteDelta: 50 }),
83
+ ).toBe(true);
84
+ });
85
+
86
+ test("relative floor suppresses small proportional changes", () => {
87
+ // 1000ms baseline, σ=10 → upperTrigger=1030. Value 1100 crosses statistically.
88
+ // Δ=100, Δ/μ=0.1. With minRelativeDelta=0.5 (50%), still doesn't fire.
89
+ const thresholds = computeThresholds(1000, 10, "lower-is-better", 1);
90
+ expect(
91
+ isAnomalous({ value: 1100, mean: 1000, thresholds, minRelativeDelta: 0.5 }),
92
+ ).toBe(false);
93
+ // Value 2000, Δ/μ=1.0 → fires.
94
+ expect(
95
+ isAnomalous({ value: 2000, mean: 1000, thresholds, minRelativeDelta: 0.5 }),
96
+ ).toBe(true);
97
+ });
98
+
99
+ test("both floors must clear when both are set", () => {
100
+ const thresholds = computeThresholds(100, 5, "lower-is-better", 1);
101
+ // Δ=20 passes minAbsoluteDelta=10 but Δ/μ=0.2 fails minRelativeDelta=0.5
102
+ expect(
103
+ isAnomalous({
104
+ value: 120,
105
+ mean: 100,
106
+ thresholds,
107
+ minAbsoluteDelta: 10,
108
+ minRelativeDelta: 0.5,
109
+ }),
110
+ ).toBe(false);
111
+ // Δ=80 passes both floors
112
+ expect(
113
+ isAnomalous({
114
+ value: 180,
115
+ mean: 100,
116
+ thresholds,
117
+ minAbsoluteDelta: 10,
118
+ minRelativeDelta: 0.5,
119
+ }),
120
+ ).toBe(true);
121
+ });
122
+
123
+ test("floors of 0 are no-ops (backward compatible)", () => {
124
+ const thresholds = { upperTrigger: 100 };
125
+ expect(
126
+ isAnomalous({
127
+ value: 101,
128
+ mean: 50,
129
+ thresholds,
130
+ minAbsoluteDelta: 0,
131
+ minRelativeDelta: 0,
132
+ }),
133
+ ).toBe(true);
134
+ });
135
+
136
+ test("relative floor handles mean ≈ 0 without dividing by zero", () => {
137
+ const thresholds = { upperTrigger: 0.01 };
138
+ // mean=0, value=10, Δ=10. Math.max(0, ε)=ε ≈ 2.2e-16. Δ/ε = 4.5e16 → passes.
139
+ expect(
140
+ isAnomalous({
141
+ value: 10,
142
+ mean: 0,
143
+ thresholds,
144
+ minRelativeDelta: 0.5,
145
+ }),
146
+ ).toBe(true);
71
147
  });
72
148
  });
73
149
 
@@ -38,17 +38,47 @@ export function computeThresholds(
38
38
  }
39
39
  }
40
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;
41
+ export interface AnomalyCheckInput {
42
+ value: number;
43
+ mean: number;
44
+ thresholds: Thresholds;
45
+ /**
46
+ * Floor on |value − μ|. The statistical trigger must be exceeded *and*
47
+ * the absolute deviation must be ≥ this floor for the value to count as
48
+ * anomalous. Default 0 (disabled).
49
+ */
50
+ minAbsoluteDelta?: number;
51
+ /**
52
+ * Floor on |value − μ| / max(|μ|, ε), expressed as a fraction. The
53
+ * statistical trigger must be exceeded *and* the relative deviation must
54
+ * be ≥ this floor for the value to count as anomalous. Default 0
55
+ * (disabled).
56
+ */
57
+ minRelativeDelta?: number;
58
+ }
59
+
60
+ export function isAnomalous({
61
+ value,
62
+ mean,
63
+ thresholds,
64
+ minAbsoluteDelta = 0,
65
+ minRelativeDelta = 0,
66
+ }: AnomalyCheckInput): boolean {
67
+ const exceedsStatistical =
68
+ (thresholds.lowerTrigger !== undefined && value < thresholds.lowerTrigger) ||
69
+ (thresholds.upperTrigger !== undefined && value > thresholds.upperTrigger);
70
+
71
+ if (!exceedsStatistical) return false;
72
+
73
+ const absoluteDelta = Math.abs(value - mean);
74
+ if (absoluteDelta < minAbsoluteDelta) return false;
75
+
76
+ if (minRelativeDelta > 0) {
77
+ const denominator = Math.max(Math.abs(mean), Number.EPSILON);
78
+ if (absoluteDelta / denominator < minRelativeDelta) return false;
50
79
  }
51
- return false;
80
+
81
+ return true;
52
82
  }
53
83
 
54
84
  export function isCategoricalAnomalous(
@@ -58,7 +88,7 @@ export function isCategoricalAnomalous(
58
88
  sensitivity: number = 1
59
89
  ): boolean {
60
90
  if (dominantValue === undefined || dominantRatio === undefined) return false;
61
-
91
+
62
92
  // Sensitivity scaling for categorical fields limits false positives.
63
93
  // Base required dominance is 90% stability.
64
94
  // Lower sensitivity values (e.g., 0.5) mean "tighter bounds, more alerts".
package/src/schema.ts CHANGED
@@ -44,17 +44,15 @@ export const AnomalyFieldConfigSchema = z.object({
44
44
  direction: AnomalyDirectionSchema.optional(),
45
45
  driftEnabled: z.boolean().optional(),
46
46
  driftThreshold: z.number().optional(),
47
+ minAbsoluteDelta: z.number().nonnegative().optional(),
48
+ minRelativeDelta: z.number().nonnegative().optional(),
47
49
  });
48
50
  export type AnomalyFieldConfig = z.infer<typeof AnomalyFieldConfigSchema>;
49
51
 
50
52
  export const AnomalySettingsSchema = z.object({
51
53
  enabled: z.boolean().default(true),
52
- sensitivity: z.number().default(1),
53
- confirmationWindow: z.number().int().default(3),
54
54
  baselineWindow: z.string().default("7d"),
55
55
  notify: z.boolean().default(true),
56
- driftEnabled: z.boolean().default(true),
57
- driftThreshold: z.number().default(2),
58
56
  fieldOverrides: z.record(z.string(), AnomalyFieldConfigSchema).optional(),
59
57
  });
60
58
  export type AnomalySettings = z.infer<typeof AnomalySettingsSchema>;