@checkstack/anomaly-common 1.0.1 → 1.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 CHANGED
@@ -1,5 +1,133 @@
1
1
  # @checkstack/anomaly-common
2
2
 
3
+ ## 1.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 9016526: Add a `/rest/:pluginId/*` HTTP mount that serves every plugin's oRPC contract
8
+ through the REST/OpenAPI shape described by `/api/openapi.json`. Queries are
9
+ `GET` with query parameters, mutations are `POST` with the input as the raw
10
+ JSON body. The existing `/api/:pluginId/*` mount continues to serve oRPC's
11
+ native wire protocol unchanged, so existing clients are not affected.
12
+
13
+ The OpenAPI spec at `/api/openapi.json` now reflects the real mount: every
14
+ `paths` entry is prefixed with `/rest` instead of `/api`.
15
+
16
+ Also fixes a SPA-fallback bug: the backend's `/api-docs` route previously
17
+ returned 404 on production deployments because the static-file middleware
18
+ skipped any path starting with `/api`, capturing `/api-docs` along with real
19
+ API routes. The skip now requires a trailing slash (`/api/`, `/rest/`).
20
+
21
+ Required access rules are now visible in the API Docs UI. The OpenAPI spec
22
+ generator was reading a non-existent `accessRules` field on procedure
23
+ metadata; the real field is `access: AccessRule[]`. Each procedure's access
24
+ rules are now flattened to fully-qualified IDs (e.g. `catalog.system.read`)
25
+ and emitted under `x-orpc-meta.accessRules`, which the existing
26
+ `Required Access Rules` section in the docs UI already knew how to render.
27
+
28
+ The API Docs schema renderer now handles record types (zod `z.record`),
29
+ `$ref`s into `components.schemas`, `oneOf`/`anyOf`/`allOf`, nullable union
30
+ types (`type: ["string", "null"]`), and `format` qualifiers. Previously
31
+ record outputs like `{ statuses: object }` masked the actual value type;
32
+ they now render as `{ [key]: <ResolvedType> { ... } }` with the inner
33
+ schema expanded, capped at 12 levels with cycle detection.
34
+
35
+ **REST method conventions.** `proc()` now defaults to `GET` for queries and
36
+ `POST` for mutations on the `/rest` mount, using bracket-notation query
37
+ params (`?filter[status]=active&ids[0]=a`) for GET inputs. Existing
38
+ procedures were updated to follow REST semantics:
39
+
40
+ - `update*` mutations → `PATCH`
41
+ - `delete*` / `remove*` mutations → `DELETE`
42
+ - `getBulk*` queries and any query taking a large array input → `POST`
43
+ (because `@orpc/openapi@1.13.x` has no GET→POST URL-length fallback)
44
+
45
+ GET endpoints require an `object` input — bare scalars like
46
+ `.input(z.string())` are not valid on GET. `getSystemConfigurations` was
47
+ refactored from `.input(z.string())` to `.input(z.object({ systemId: ... }))`
48
+ to fit the GET shape; the only call-site update was the in-process router
49
+ unpacking `input.systemId` instead of passing `input` directly.
50
+
51
+ The API Docs UI now renders query parameters (path/query/header/cookie) in a
52
+ dedicated table for GET endpoints, and the fetch example shows them in the
53
+ URL with `<required>` / `<optional>` placeholders.
54
+
55
+ ### Patch Changes
56
+
57
+ - Updated dependencies [9016526]
58
+ - @checkstack/common@0.10.0
59
+ - @checkstack/catalog-common@2.2.0
60
+ - @checkstack/notification-common@1.1.0
61
+ - @checkstack/signal-common@0.2.3
62
+
63
+ ## 1.1.0
64
+
65
+ ### Minor Changes
66
+
67
+ - 42abfff: Remove global anomaly settings — configuration is now field-only.
68
+
69
+ `AnomalySettings` (template- and assignment-level) no longer carries
70
+ `sensitivity`, `confirmationWindow`, `driftEnabled`, or `driftThreshold`.
71
+ These were duplicating the per-field configuration path with awkward
72
+ cascade semantics, and a single global multiplier was meaningless across
73
+ fields with different units (ms, %, counts).
74
+
75
+ The schema retains only the truly global concerns:
76
+
77
+ - `enabled` — master kill switch for the assignment
78
+ - `baselineWindow` — there is one history per system, not per field
79
+ - `notify` — one notification preference per assignment
80
+ - `fieldOverrides` — per-field configuration (where everything else now lives)
81
+
82
+ `resolveEffectiveConfig` collapses to two layers: field override → schema
83
+ default → engine fallback constant. The plugin-author defaults set via
84
+ `x-anomaly-*` annotations now drive sensitivity/window/drift across the
85
+ detector and drift evaluator (previously only floors were threaded
86
+ through the schema layer).
87
+
88
+ **Breaking changes:**
89
+
90
+ - Any global `sensitivity`/`confirmationWindow`/`driftEnabled`/
91
+ `driftThreshold` values previously stored in `anomaly_configurations`
92
+ or `anomaly_assignments` are silently stripped on parse. Users who
93
+ customized these globals will revert to the plugin's tuned per-field
94
+ defaults; if they want to keep those values they must re-apply them
95
+ per field in the new UI.
96
+ - `AnomalySettingsForm` no longer renders the global sliders. The form
97
+ now shows: enable toggle, baseline window selector, notify toggle,
98
+ field overrides editor.
99
+ - `AnomalyFieldOverridesEditor` props `defaultSensitivity`,
100
+ `defaultConfirmationWindow`, `defaultDriftEnabled`, `defaultDriftThreshold`
101
+ are removed. Engine fallbacks (1.0, 3, true, 2) are now hard-coded
102
+ internal constants used only when neither field override nor schema
103
+ default is set.
104
+ - The GitOps `System.anomaly` entry schema (in `anomaly-gitops-kinds`)
105
+ drops `sensitivity`, `confirmationWindow`, `driftEnabled`, and
106
+ `driftThreshold` to match the new `AnomalySettings` shape. YAML files
107
+ declaring those fields will be rejected at parse time — operators
108
+ must move per-field tuning into `fieldOverrides`.
109
+
110
+ This change makes the override model trivial to explain ("plugin defaults,
111
+ overridden per field") and removes a class of confusing "where did this
112
+ threshold come from?" questions.
113
+
114
+ - 42abfff: Add practical-significance floors to anomaly detection.
115
+
116
+ 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.
117
+
118
+ 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.
119
+
120
+ 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.
121
+
122
+ ### Patch Changes
123
+
124
+ - Updated dependencies [42abfff]
125
+ - Updated dependencies [1ef2e79]
126
+ - @checkstack/common@0.9.0
127
+ - @checkstack/catalog-common@2.1.0
128
+ - @checkstack/notification-common@1.0.2
129
+ - @checkstack/signal-common@0.2.2
130
+
3
131
  ## 1.0.1
4
132
 
5
133
  ### 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.2.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.9.0",
13
+ "@checkstack/catalog-common": "2.1.0",
14
+ "@checkstack/notification-common": "1.0.2",
15
+ "@checkstack/signal-common": "0.2.2",
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.1"
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".
@@ -132,6 +132,7 @@ export const anomalyContract = {
132
132
  userType: "authenticated",
133
133
  access: [anomalyAccess.feed.manage],
134
134
  })
135
+ .route({ method: "PATCH" })
135
136
  .input(z.object({
136
137
  configurationId: z.string(),
137
138
  config: AnomalySettingsSchema,
@@ -156,6 +157,7 @@ export const anomalyContract = {
156
157
  access: [anomalyAccess.feed.manage],
157
158
  instanceAccess: { idParam: "systemId" },
158
159
  })
160
+ .route({ method: "PATCH" })
159
161
  .input(z.object({
160
162
  systemId: z.string(),
161
163
  configurationId: z.string(),
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>;