@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 +68 -0
- package/package.json +7 -7
- package/src/engine/config.test.ts +93 -142
- package/src/engine/config.ts +73 -33
- package/src/engine/drift.test.ts +87 -0
- package/src/engine/drift.ts +34 -3
- package/src/engine/thresholds.test.ts +87 -11
- package/src/engine/thresholds.ts +41 -11
- package/src/schema.ts +2 -4
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
|
|
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.
|
|
13
|
-
"@checkstack/catalog-common": "2.0.
|
|
14
|
-
"@checkstack/notification-common": "1.0.
|
|
15
|
-
"@checkstack/signal-common": "0.2.
|
|
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.
|
|
21
|
-
"@checkstack/scripts": "0.
|
|
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("
|
|
28
|
-
const template: AnomalySettings = {
|
|
29
|
-
|
|
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
|
-
|
|
41
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
};
|
|
46
|
-
const assignment: Partial<AnomalySettings> = {
|
|
47
|
-
sensitivity: 3,
|
|
48
|
-
confirmationWindow: 1,
|
|
37
|
+
enabled: true,
|
|
38
|
+
fieldOverrides: { "some.field": { enabled: false } },
|
|
49
39
|
};
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
|
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:
|
|
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(
|
|
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
|
|
64
|
+
test("assignment field override beats template field override", () => {
|
|
71
65
|
const template: AnomalySettings = {
|
|
72
66
|
...defaultTemplate,
|
|
73
|
-
sensitivity:
|
|
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
|
-
|
|
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(
|
|
73
|
+
expect(result.sensitivity).toBe(0.5);
|
|
90
74
|
});
|
|
91
75
|
|
|
92
|
-
test("
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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("
|
|
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(
|
|
124
|
-
|
|
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("
|
|
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
|
-
|
|
140
|
-
|
|
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
|
-
|
|
145
|
-
|
|
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:
|
|
152
|
-
};
|
|
153
|
-
const assignment: Partial<AnomalySettings> = {
|
|
154
|
-
sensitivity: 0,
|
|
131
|
+
fieldOverrides: { "some.field": { sensitivity: 0 } },
|
|
155
132
|
};
|
|
156
|
-
|
|
157
|
-
expect(result.sensitivity).toBe(0);
|
|
133
|
+
expect(resolveEffectiveConfig("some.field", template).sensitivity).toBe(0);
|
|
158
134
|
});
|
|
159
135
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
});
|
package/src/engine/config.ts
CHANGED
|
@@ -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
|
-
*
|
|
14
|
-
*
|
|
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
|
|
18
|
-
* 2. Template field override
|
|
19
|
-
* 3.
|
|
20
|
-
* 4.
|
|
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
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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 =
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
?? assignmentConfig?.enabled
|
|
37
|
-
?? templateConfig?.enabled
|
|
38
|
-
?? true;
|
|
63
|
+
const fieldConfig =
|
|
64
|
+
assignmentConfig?.fieldOverrides?.[path] ??
|
|
65
|
+
templateConfig?.fieldOverrides?.[path];
|
|
39
66
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
|
46
|
-
??
|
|
47
|
-
??
|
|
48
|
-
|
|
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
|
-
??
|
|
54
|
-
??
|
|
55
|
-
?? true;
|
|
86
|
+
?? schemaDefaults?.driftEnabled
|
|
87
|
+
?? ENGINE_DEFAULTS.driftEnabled;
|
|
56
88
|
|
|
57
89
|
const driftThreshold = fieldConfig?.driftThreshold
|
|
58
|
-
??
|
|
59
|
-
??
|
|
60
|
-
|
|
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
|
-
|
package/src/engine/drift.test.ts
CHANGED
|
@@ -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
|
});
|
package/src/engine/drift.ts
CHANGED
|
@@ -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
|
-
:
|
|
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
|
|
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
|
|
package/src/engine/thresholds.ts
CHANGED
|
@@ -38,17 +38,47 @@ export function computeThresholds(
|
|
|
38
38
|
}
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
export
|
|
42
|
-
value: number
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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>;
|