Add ML unit tests (#22043)
* Add ML unit tests - Added comprehensive unit tests for machine learning features (diff, smooth, lag, kmeans, anomaly detection). * Add conditional ML unittest handling and improve preprocessing test equivalence comments * Address review comments * Address review comments - P2
Stelios Fragkakis committed
Mar 26, 2026 at 09:12 UTC
6c5f54409a1299425b85ef6fe9af550f7f07ec20
4 files changed
+770
-1
CMakeLists.txt
+1
@@ -1524,6 +1524,7 @@ if(ENABLE_ML)
1524
src/ml/ml_private.h
1525
src/ml/ml_public.h
1526
src/ml/ml_public.cc
1527
+ src/ml/ml-unittest.cc
1528
)
1529
1530
if(NOT ENABLE_MIMALLOC)
src/daemon/main.c
+16
-1
@@ -225,6 +225,9 @@ int health_config_unittest(void);
225
int utf8_sanitizer_unittest(void);
226
int yaml_unittest(void);
227
int json_c_parser_unittest(void);
228
+#ifdef ENABLE_ML
229
+int ml_unittest(void);
230
+#endif
231
bool netdata_random_session_id_generate(void);
232
233
#ifdef OS_WINDOWS
@@ -391,7 +394,19 @@ int netdata_main(int argc, char **argv) {
394
fprintf(stderr, "\n\nYAML TESTS PASSED\n\n");
395
return 0;
396
}
394
-
397
+
398
+ if(strcmp(optarg, "mltest") == 0) {
399
+#ifdef ENABLE_ML
400
+ unittest_running = true;
401
+ if (ml_unittest()) return 1;
402
+ fprintf(stderr, "\n\nML TESTS PASSED\n\n");
403
+ return 0;
404
+#else
405
+ fprintf(stderr, "ML support is disabled in this build.\n");
406
+ return 1;
407
+#endif
408
+ }
409
+
410
if(strcmp(optarg, "unittest") == 0) {
411
unittest_running = true;
412
src/ml/ml-unittest.cc
new
+751
@@ -0,0 +1,751 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+#include "ml_config.h"
4
+#include "ml_features.h"
5
+#include "ml_kmeans.h"
6
+
7
+#include <algorithm>
8
+#include <cmath>
9
+#include <cstdio>
10
+#include <cstring>
11
+#include <vector>
12
+
13
+static constexpr double ML_PI = 3.14159265358979323846;
14
+
15
+static int tests_run = 0;
16
+static int tests_failed = 0;
17
+
18
+#define ML_TEST_ASSERT(cond, msg) do { \
19
+ tests_run++; \
20
+ if (!(cond)) { \
21
+ fprintf(stderr, " FAIL: %s (line %d)\n", msg, __LINE__); \
22
+ tests_failed++; \
23
+ } \
24
+} while (0)
25
+
26
+#define ML_TEST_ASSERT_DOUBLE_EQ(a, b, eps, msg) do { \
27
+ tests_run++; \
28
+ if (std::fabs((a) - (b)) > (eps)) { \
29
+ fprintf(stderr, " FAIL: %s (line %d): expected %.10f, got %.10f\n", msg, __LINE__, (double)(b), (double)(a)); \
30
+ tests_failed++; \
31
+ } \
32
+} while (0)
33
+
34
+// Test: diff transform with diff_n=1
35
+// Input: [1, 3, 6, 10, 15, 9, 9, 9, 9]
36
+// Expected diff (high - low with lag 1): [2, 3, 4, 5, -6, 0, 0, 0]
37
+// (last element zeroed)
38
+static void test_features_diff()
39
+{
40
+ fprintf(stderr, " test_features_diff...\n");
41
+
42
+ const size_t n = 9;
43
+ calculated_number_t src[16] = {1, 3, 6, 10, 15, 9, 9, 9, 9};
44
+ calculated_number_t dst[16] = {0};
45
+
46
+ std::vector<DSample> pf;
47
+ ml_features_t features = {
48
+ 1, 1, 1, // diff_n=1, smooth_n=1, lag_n=1
49
+ dst, n, src, n,
50
+ pf
51
+ };
52
+
53
+ // ml_features_preprocess calls diff, smooth, lag in sequence.
54
+ // To test diff alone, we use preprocess with smooth_n=1 (no-op smooth)
55
+ // and lag_n=1, sampling_ratio=1.0.
56
+ // After diff with diff_n=1:
57
+ // src[0..7] = [2, 3, 4, 5, -6, 0, 0, 0], src[8] = 0
58
+ // After smooth with smooth_n=1 (no-op):
59
+ // unchanged, but last smooth_n=1 elements zeroed: src[8]=0 (already 0)
60
+ // After lag with lag_n=1:
61
+ // feature vectors: [src[i], src[i+1]] for i in 0..6
62
+ // n_vectors = 9 - 1 - 1 + 1 - 1 = 7
63
+
64
+ ml_features_preprocess(&features, 1.0);
65
+
66
+ ML_TEST_ASSERT(pf.size() == 7, "lag should produce 7 feature vectors");
67
+ if (pf.size() >= 1) {
68
+ // First feature vector should be [2, 3]
69
+ ML_TEST_ASSERT_DOUBLE_EQ(pf[0](0), 2.0, 1e-9, "pf[0](0) == 2.0");
70
+ ML_TEST_ASSERT_DOUBLE_EQ(pf[0](1), 3.0, 1e-9, "pf[0](1) == 3.0");
71
+ }
72
+ if (pf.size() >= 4) {
73
+ // Fourth feature vector should be [5, -6]
74
+ ML_TEST_ASSERT_DOUBLE_EQ(pf[3](0), 5.0, 1e-9, "pf[3](0) == 5.0");
75
+ ML_TEST_ASSERT_DOUBLE_EQ(pf[3](1), -6.0, 1e-9, "pf[3](1) == -6.0");
76
+ }
77
+}
78
+
79
+// Test: diff_n=0 means no differencing
80
+static void test_features_no_diff()
81
+{
82
+ fprintf(stderr, " test_features_no_diff...\n");
83
+
84
+ const size_t n = 6;
85
+ calculated_number_t src[16] = {10, 20, 30, 40, 50, 60};
86
+ calculated_number_t dst[16] = {0};
87
+
88
+ std::vector<DSample> pf;
89
+ ml_features_t features = {
90
+ 0, 1, 1, // diff_n=0, smooth_n=1, lag_n=1
91
+ dst, n, src, n,
92
+ pf
93
+ };
94
+
95
+ ml_features_preprocess(&features, 1.0);
96
+
97
+ // With diff_n=0, smooth_n=1 (no-op), lag_n=1:
98
+ // n_vectors = 6 - 0 - 1 + 1 - 1 = 5
99
+ // But smooth zeros last smooth_n=1 elements, so src[5]=0
100
+ // Feature vectors: [src[i], src[i+1]] for i in 0..3
101
+ // Wait: n = src_n - diff_n - smooth_n + 1 - lag_n = 6 - 0 - 1 + 1 - 1 = 5
102
+ ML_TEST_ASSERT(pf.size() == 5, "no-diff should produce 5 feature vectors");
103
+
104
+ if (pf.size() >= 1) {
105
+ ML_TEST_ASSERT_DOUBLE_EQ(pf[0](0), 10.0, 1e-9, "pf[0](0) == 10.0");
106
+ ML_TEST_ASSERT_DOUBLE_EQ(pf[0](1), 20.0, 1e-9, "pf[0](1) == 20.0");
107
+ }
108
+}
109
+
110
+// Test: smoothing with smooth_n=3
111
+static void test_features_smooth()
112
+{
113
+ fprintf(stderr, " test_features_smooth...\n");
114
+
115
+ const size_t n = 9;
116
+ // Use a simple sequence: 1,2,3,4,5,6,7,8,9
117
+ calculated_number_t src[16] = {1, 2, 3, 4, 5, 6, 7, 8, 9};
118
+ calculated_number_t dst[16] = {0};
119
+
120
+ std::vector<DSample> pf;
121
+ ml_features_t features = {
122
+ 0, 3, 1, // diff_n=0, smooth_n=3, lag_n=1
123
+ dst, n, src, n,
124
+ pf
125
+ };
126
+
127
+ ml_features_preprocess(&features, 1.0);
128
+
129
+ // With diff_n=0: no diff
130
+ // Smooth with smooth_n=3, operating on src_n - diff_n = 9 elements:
131
+ // Moving average: src[0] = (1+2+3)/3=2, src[1]=(2+3+4)/3=3, ..., src[6]=(7+8+9)/3=8
132
+ // Last smooth_n=3 elements zeroed: src[6..8]=0
133
+ // So smoothed: [2, 3, 4, 5, 6, 7, 0, 0, 0]
134
+ // Wait, the smooth zeros the LAST smooth_n elements from the end of src.
135
+ // src[(src_n-1)-0]=src[8]=0, src[(src_n-1)-1]=src[7]=0, src[(src_n-1)-2]=src[6]=0
136
+ // So the smoothed region is src[0..5] = [2, 3, 4, 5, 6, 7]
137
+ // Lag with lag_n=1:
138
+ // n_vectors = 9 - 0 - 3 + 1 - 1 = 6
139
+ // Vectors: [src[i], src[i+1]] for i in 0..5
140
+
141
+ ML_TEST_ASSERT(pf.size() == 6, "smooth should produce 6 feature vectors");
142
+
143
+ if (pf.size() >= 1) {
144
+ ML_TEST_ASSERT_DOUBLE_EQ(pf[0](0), 2.0, 1e-9, "pf[0](0) == 2.0 (smoothed)");
145
+ ML_TEST_ASSERT_DOUBLE_EQ(pf[0](1), 3.0, 1e-9, "pf[0](1) == 3.0 (smoothed)");
146
+ }
147
+ if (pf.size() >= 6) {
148
+ ML_TEST_ASSERT_DOUBLE_EQ(pf[5](0), 7.0, 1e-9, "pf[5](0) == 7.0 (smoothed)");
149
+ ML_TEST_ASSERT_DOUBLE_EQ(pf[5](1), 0.0, 1e-9, "pf[5](1) == 0.0 (zeroed by smooth)");
150
+ }
151
+}
152
+
153
+// Test: full pipeline with default-like params (diff_n=1, smooth_n=3, lag_n=5)
154
+// Validates the feature vector shape and that a round-trip through
155
+// train + score produces sensible anomaly scores.
156
+static void test_full_pipeline()
157
+{
158
+ fprintf(stderr, " test_full_pipeline...\n");
159
+
160
+ // diff_n=1, smooth_n=3, lag_n=5 => n = 1 + 3 + 5 = 9
161
+ const size_t diff_n = 1;
162
+ const size_t smooth_n = 3;
163
+ const size_t lag_n = 5;
164
+ const size_t n = diff_n + smooth_n + lag_n;
165
+
166
+ // Generate a "normal" pattern: sine wave with some noise-like variation
167
+ const size_t num_samples = 100;
168
+ calculated_number_t normal_data[100];
169
+ for (size_t i = 0; i < num_samples; i++)
170
+ normal_data[i] = 50.0 + 20.0 * std::sin(2.0 * ML_PI * (double)i / 25.0);
171
+
172
+ // Simulate prediction: slide a window of size n over the data,
173
+ // preprocess each window, collect the first feature vector.
174
+ std::vector<DSample> all_features;
175
+
176
+ for (size_t start = 0; start + n <= num_samples; start++) {
177
+ calculated_number_t src[128];
178
+ calculated_number_t dst[128];
179
+
180
+ memset(src, 0, sizeof(src));
181
+ memcpy(src, &normal_data[start], n * sizeof(calculated_number_t));
182
+ memcpy(dst, src, n * sizeof(calculated_number_t));
183
+
184
+ std::vector<DSample> pf;
185
+ ml_features_t features = {
186
+ diff_n, smooth_n, lag_n,
187
+ dst, n, src, n,
188
+ pf
189
+ };
190
+ ml_features_preprocess(&features, 1.0);
191
+
192
+ // With these params:
193
+ // n_vectors = n - diff_n - smooth_n + 1 - lag_n = 9 - 1 - 3 + 1 - 5 = 1
194
+ ML_TEST_ASSERT(pf.size() == 1, "prediction window should produce exactly 1 feature vector");
195
+
196
+ if (pf.size() >= 1)
197
+ all_features.push_back(pf[0]);
198
+ }
199
+
200
+ ML_TEST_ASSERT(all_features.size() > 2, "should have enough features for kmeans");
201
+
202
+ // Each feature vector should have lag_n + 1 = 6 elements
203
+ if (all_features.size() > 0) {
204
+ ML_TEST_ASSERT(all_features[0].size() == (long)(lag_n + 1),
205
+ "feature vector should have lag_n+1 elements");
206
+ }
207
+
208
+ // Train a kmeans model on the normal data
209
+ std::vector<DSample> training_features = all_features;
210
+ ml_features_t train_ft = {
211
+ diff_n, smooth_n, lag_n,
212
+ nullptr, 0, nullptr, 0,
213
+ training_features
214
+ };
215
+
216
+ ml_kmeans_t kmeans;
217
+ ml_kmeans_init(&kmeans);
218
+ ml_kmeans_train(&kmeans, &train_ft, 1000, 0, 100);
219
+
220
+ ML_TEST_ASSERT(kmeans.cluster_centers.size() == 2, "kmeans should have 2 cluster centers");
221
+ ML_TEST_ASSERT(kmeans.min_dist < kmeans.max_dist, "min_dist < max_dist after training");
222
+
223
+ // Score all training samples — the best score must be 0 (the sample at min_dist)
224
+ // and all training scores must be in [0, 100].
225
+ ml_kmeans_inlined_t inlined_km(kmeans);
226
+ calculated_number_t best_normal_score = 100.0;
227
+ for (size_t i = 0; i < training_features.size(); i++) {
228
+ calculated_number_t s = ml_kmeans_anomaly_score(&inlined_km, training_features[i]);
229
+ ML_TEST_ASSERT(!std::isnan(s), "training sample score should not be NaN");
230
+ ML_TEST_ASSERT(s >= 0.0 && s <= 100.0, "training sample score should be in [0, 100]");
231
+ if (s < best_normal_score)
232
+ best_normal_score = s;
233
+ }
234
+ calculated_number_t normal_score = best_normal_score;
235
+ ML_TEST_ASSERT_DOUBLE_EQ(normal_score, 0.0, 1e-6, "best training sample should score ~0");
236
+
237
+ // Create an anomalous sample: extreme spike values unlike the sine wave
238
+ calculated_number_t anomalous_window[9] = {50, 50, 50, 500, 50, 50, 50, 500, 50};
239
+ {
240
+ calculated_number_t src[128], dst[128];
241
+ memset(src, 0, sizeof(src));
242
+ memcpy(src, anomalous_window, n * sizeof(calculated_number_t));
243
+ memcpy(dst, src, n * sizeof(calculated_number_t));
244
+
245
+ std::vector<DSample> pf;
246
+ ml_features_t features = {
247
+ diff_n, smooth_n, lag_n,
248
+ dst, n, src, n,
249
+ pf
250
+ };
251
+ ml_features_preprocess(&features, 1.0);
252
+
253
+ if (pf.size() >= 1) {
254
+ calculated_number_t anomaly_score = ml_kmeans_anomaly_score(&inlined_km, pf[0]);
255
+ ML_TEST_ASSERT(!std::isnan(anomaly_score), "anomaly score should not be NaN");
256
+ ML_TEST_ASSERT(anomaly_score > normal_score, "anomalous data should score higher than normal");
257
+ }
258
+ }
259
+}
260
+
261
+// Test: kmeans anomaly_score edge cases
262
+static void test_kmeans_scoring()
263
+{
264
+ fprintf(stderr, " test_kmeans_scoring...\n");
265
+
266
+ // Build a simple model with known cluster centers
267
+ ml_kmeans_inlined_t km;
268
+ km.cluster_centers[0].set_size(6);
269
+ km.cluster_centers[1].set_size(6);
270
+ for (int i = 0; i < 6; i++) {
271
+ km.cluster_centers[0](i) = 0.0;
272
+ km.cluster_centers[1](i) = 10.0;
273
+ }
274
+ km.min_dist = 1.0;
275
+ km.max_dist = 10.0;
276
+ km.after = 0;
277
+ km.before = 100;
278
+
279
+ // Sample at cluster center 0 — should score low
280
+ DSample at_center0;
281
+ at_center0.set_size(6);
282
+ for (int i = 0; i < 6; i++)
283
+ at_center0(i) = 0.0;
284
+
285
+ calculated_number_t score0 = ml_kmeans_anomaly_score(&km, at_center0);
286
+ ML_TEST_ASSERT(!std::isnan(score0), "score at center should not be NaN");
287
+
288
+ // Sample at midpoint — should score moderate
289
+ DSample at_mid;
290
+ at_mid.set_size(6);
291
+ for (int i = 0; i < 6; i++)
292
+ at_mid(i) = 5.0;
293
+
294
+ calculated_number_t score_mid = ml_kmeans_anomaly_score(&km, at_mid);
295
+ ML_TEST_ASSERT(!std::isnan(score_mid), "score at midpoint should not be NaN");
296
+
297
+ // Sample far away — should score high (capped at 100)
298
+ DSample far_away;
299
+ far_away.set_size(6);
300
+ for (int i = 0; i < 6; i++)
301
+ far_away(i) = 100.0;
302
+
303
+ calculated_number_t score_far = ml_kmeans_anomaly_score(&km, far_away);
304
+ ML_TEST_ASSERT_DOUBLE_EQ(score_far, 100.0, 1e-9, "far away sample should be capped at 100");
305
+
306
+ // When min_dist == max_dist, score should be 0
307
+ ml_kmeans_inlined_t km_equal;
308
+ km_equal.cluster_centers[0].set_size(6);
309
+ km_equal.cluster_centers[1].set_size(6);
310
+ for (int i = 0; i < 6; i++) {
311
+ km_equal.cluster_centers[0](i) = 5.0;
312
+ km_equal.cluster_centers[1](i) = 5.0;
313
+ }
314
+ km_equal.min_dist = 5.0;
315
+ km_equal.max_dist = 5.0;
316
+
317
+ calculated_number_t score_eq = ml_kmeans_anomaly_score(&km_equal, at_mid);
318
+ ML_TEST_ASSERT_DOUBLE_EQ(score_eq, 0.0, 1e-9, "equal min/max should return 0");
319
+}
320
+
321
+// Test: circular buffer linearization produces the same result as std::rotate
322
+static void test_circular_buffer_equivalence()
323
+{
324
+ fprintf(stderr, " test_circular_buffer_equivalence...\n");
325
+
326
+ const size_t diff_n = 1;
327
+ const size_t smooth_n = 3;
328
+ const size_t lag_n = 5;
329
+ const size_t n = diff_n + smooth_n + lag_n;
330
+
331
+ // Simulate feeding values into the dimension
332
+ calculated_number_t values[] = {
333
+ 1.0, 2.5, 3.7, 4.1, 5.9, 6.3, 7.8, 8.2, 9.0, // fill buffer (n=9 values)
334
+ 10.5, 11.3, 12.8, 13.1, 14.7, 15.2 // 6 more values to rotate
335
+ };
336
+ size_t num_values = sizeof(values) / sizeof(values[0]);
337
+
338
+ // Method 1: std::rotate (master approach)
339
+ std::vector<calculated_number_t> cns_rotate;
340
+ std::vector<DSample> rotate_results;
341
+
342
+ for (size_t i = 0; i < num_values; i++) {
343
+ if (cns_rotate.size() < n) {
344
+ cns_rotate.push_back(values[i]);
345
+ continue;
346
+ }
347
+
348
+ std::rotate(cns_rotate.begin(), cns_rotate.begin() + 1, cns_rotate.end());
349
+ cns_rotate[n - 1] = values[i];
350
+
351
+ calculated_number_t src[128], dst[128];
352
+ memset(src, 0, sizeof(src));
353
+ memcpy(src, cns_rotate.data(), n * sizeof(calculated_number_t));
354
+ memcpy(dst, cns_rotate.data(), n * sizeof(calculated_number_t));
355
+
356
+ std::vector<DSample> pf;
357
+ ml_features_t features = {
358
+ diff_n, smooth_n, lag_n,
359
+ dst, n, src, n,
360
+ pf
361
+ };
362
+ ml_features_preprocess(&features, 1.0);
363
+
364
+ if (pf.size() >= 1)
365
+ rotate_results.push_back(pf[0]);
366
+ }
367
+
368
+ // Method 2: circular buffer (branch approach)
369
+ std::vector<calculated_number_t> cns_circ;
370
+ size_t cns_head = 0;
371
+ std::vector<DSample> circ_results;
372
+
373
+ for (size_t i = 0; i < num_values; i++) {
374
+ if (cns_circ.size() < n) {
375
+ cns_circ.push_back(values[i]);
376
+ continue;
377
+ }
378
+
379
+ cns_circ[cns_head] = values[i];
380
+ cns_head = (cns_head + 1) % n;
381
+
382
+ // Linearize circular buffer
383
+ calculated_number_t src[128], dst[128];
384
+ size_t first_chunk = n - cns_head;
385
+ memcpy(src, cns_circ.data() + cns_head, first_chunk * sizeof(calculated_number_t));
386
+ if (cns_head)
387
+ memcpy(src + first_chunk, cns_circ.data(), cns_head * sizeof(calculated_number_t));
388
+ memcpy(dst, src, n * sizeof(calculated_number_t));
389
+
390
+ std::vector<DSample> pf;
391
+ ml_features_t features = {
392
+ diff_n, smooth_n, lag_n,
393
+ dst, n, src, n,
394
+ pf
395
+ };
396
+ ml_features_preprocess(&features, 1.0);
397
+
398
+ if (pf.size() >= 1)
399
+ circ_results.push_back(pf[0]);
400
+ }
401
+
402
+ ML_TEST_ASSERT(rotate_results.size() == circ_results.size(),
403
+ "both methods should produce same number of results");
404
+
405
+ for (size_t i = 0; i < rotate_results.size() && i < circ_results.size(); i++) {
406
+ for (long j = 0; j < rotate_results[i].size(); j++) {
407
+ char msg[128];
408
+ snprintf(msg, sizeof(msg), "result[%zu](%ld) should match between rotate and circular", i, j);
409
+ ML_TEST_ASSERT_DOUBLE_EQ(rotate_results[i](j), circ_results[i](j), 1e-12, msg);
410
+ }
411
+ }
412
+}
413
+
414
+// Test: ml_features_preprocess with a prediction-sized window produces the same
415
+// feature vector as a manual reimplementation of diff + smooth + extract.
416
+// This validates the preprocessing math and serves as a baseline for verifying
417
+// that any optimized prediction path (e.g. ml_features_preprocess_predict)
418
+// produces identical results.
419
+static void test_preprocess_predict_equivalence()
420
+{
421
+ fprintf(stderr, " test_preprocess_predict_equivalence...\n");
422
+
423
+ struct {
424
+ size_t diff_n, smooth_n, lag_n;
425
+ } param_sets[] = {
426
+ {1, 3, 5}, // default params
427
+ {0, 3, 5}, // no diff
428
+ {1, 1, 5}, // minimal smooth
429
+ {1, 3, 1}, // minimal lag
430
+ {0, 1, 1}, // minimal everything
431
+ {1, 5, 3}, // larger smooth, smaller lag
432
+ };
433
+
434
+ // Various input patterns
435
+ auto fill_sine = [](calculated_number_t *buf, size_t n) {
436
+ for (size_t i = 0; i < n; i++)
437
+ buf[i] = 50.0 + 20.0 * std::sin(2.0 * ML_PI * (double)i / 7.0);
438
+ };
439
+ auto fill_ramp = [](calculated_number_t *buf, size_t n) {
440
+ for (size_t i = 0; i < n; i++)
441
+ buf[i] = 1.0 + 0.7 * (double)i;
442
+ };
443
+ auto fill_spike = [](calculated_number_t *buf, size_t n) {
444
+ for (size_t i = 0; i < n; i++)
445
+ buf[i] = (i == n / 2) ? 500.0 : 10.0;
446
+ };
447
+
448
+ void (*fillers[])(calculated_number_t *, size_t) = {fill_sine, fill_ramp, fill_spike};
449
+ const char *filler_names[] = {"sine", "ramp", "spike"};
450
+
451
+ for (size_t p = 0; p < sizeof(param_sets) / sizeof(param_sets[0]); p++) {
452
+ size_t diff_n = param_sets[p].diff_n;
453
+ size_t smooth_n = param_sets[p].smooth_n;
454
+ size_t lag_n = param_sets[p].lag_n;
455
+ size_t n = diff_n + smooth_n + lag_n;
456
+
457
+ for (size_t f = 0; f < 3; f++) {
458
+ calculated_number_t input[128];
459
+ fillers[f](input, n);
460
+
461
+ // Path 1: ml_features_preprocess (master prediction path)
462
+ calculated_number_t src1[128], dst1[128];
463
+ memset(src1, 0, sizeof(src1));
464
+ memcpy(src1, input, n * sizeof(calculated_number_t));
465
+ memcpy(dst1, src1, n * sizeof(calculated_number_t));
466
+
467
+ std::vector<DSample> pf;
468
+ ml_features_t features1 = {
469
+ diff_n, smooth_n, lag_n,
470
+ dst1, n, src1, n,
471
+ pf
472
+ };
473
+ ml_features_preprocess(&features1, 1.0);
474
+
475
+ // With prediction-sized window: n_vectors = n - diff_n - smooth_n + 1 - lag_n = 1
476
+ char msg[256];
477
+ snprintf(msg, sizeof(msg), "params(%zu,%zu,%zu) %s: preprocess should produce 1 vector",
478
+ diff_n, smooth_n, lag_n, filler_names[f]);
479
+ ML_TEST_ASSERT(pf.size() == 1, msg);
480
+ if (pf.size() != 1) continue;
481
+
482
+ // Path 2: manual extraction matching what ml_features_preprocess_predict does:
483
+ // diff + smooth, then read first lag_n+1 values from src.
484
+ // This validates the logic without depending on the branch function existing.
485
+ calculated_number_t src2[128], dst2[128];
486
+ memset(src2, 0, sizeof(src2));
487
+ memcpy(src2, input, n * sizeof(calculated_number_t));
488
+ memcpy(dst2, src2, n * sizeof(calculated_number_t));
489
+
490
+ // Replicate diff
491
+ if (diff_n > 0) {
492
+ for (size_t idx = 0; idx != (n - diff_n); idx++) {
493
+ size_t high = (n - 1) - idx;
494
+ size_t low = high - diff_n;
495
+ dst2[low] = src2[high] - src2[low];
496
+ }
497
+ memcpy(src2, dst2, (n - diff_n) * sizeof(calculated_number_t));
498
+ for (size_t idx = n - diff_n; idx != n; idx++)
499
+ src2[idx] = 0.0;
500
+ }
501
+
502
+ // Replicate smooth
503
+ {
504
+ calculated_number_t sum = 0.0;
505
+ size_t idx = 0;
506
+ for (; idx != smooth_n - 1; idx++)
507
+ sum += src2[idx];
508
+ for (; idx != (n - diff_n); idx++) {
509
+ sum += src2[idx];
510
+ calculated_number_t prev = src2[idx - (smooth_n - 1)];
511
+ src2[idx - (smooth_n - 1)] = sum / smooth_n;
512
+ sum -= prev;
513
+ }
514
+ for (idx = 0; idx != smooth_n; idx++)
515
+ src2[(n - 1) - idx] = 0.0;
516
+ }
517
+
518
+ // Extract feature: first lag_n+1 values (what preprocess_predict does)
519
+ DSample direct_feature;
520
+ direct_feature.set_size(lag_n + 1);
521
+ for (size_t fi = 0; fi != lag_n + 1; fi++)
522
+ direct_feature(fi) = src2[fi];
523
+
524
+ // Compare: pf[0] from full pipeline must match direct extraction
525
+ for (size_t fi = 0; fi < lag_n + 1; fi++) {
526
+ snprintf(msg, sizeof(msg), "params(%zu,%zu,%zu) %s: feature[%zu] preprocess vs direct",
527
+ diff_n, smooth_n, lag_n, filler_names[f], fi);
528
+ ML_TEST_ASSERT_DOUBLE_EQ(pf[0](fi), direct_feature(fi), 1e-12, msg);
529
+ }
530
+ }
531
+ }
532
+}
533
+
534
+// Test: constant input values produce zero-diff features and don't cause anomalies
535
+static void test_constant_input()
536
+{
537
+ fprintf(stderr, " test_constant_input...\n");
538
+
539
+ const size_t diff_n = 1;
540
+ const size_t smooth_n = 3;
541
+ const size_t lag_n = 5;
542
+ const size_t n = diff_n + smooth_n + lag_n;
543
+
544
+ // All constant values
545
+ calculated_number_t src[16], dst[16];
546
+ for (size_t i = 0; i < n; i++)
547
+ src[i] = 42.0;
548
+ memcpy(dst, src, n * sizeof(calculated_number_t));
549
+
550
+ std::vector<DSample> pf;
551
+ ml_features_t features = {
552
+ diff_n, smooth_n, lag_n,
553
+ dst, n, src, n,
554
+ pf
555
+ };
556
+ ml_features_preprocess(&features, 1.0);
557
+
558
+ ML_TEST_ASSERT(pf.size() == 1, "constant input should produce 1 feature vector");
559
+
560
+ // With diff_n=1 on constant input, all diffs are 0.
561
+ // After smooth, still all 0. Feature vector should be all zeros.
562
+ if (pf.size() >= 1) {
563
+ for (size_t i = 0; i < lag_n + 1; i++) {
564
+ char msg[128];
565
+ snprintf(msg, sizeof(msg), "constant input: feature[%zu] should be 0", i);
566
+ ML_TEST_ASSERT_DOUBLE_EQ(pf[0](i), 0.0, 1e-12, msg);
567
+ }
568
+ }
569
+
570
+ // All-zero feature with diff_n=0 should preserve the constant value
571
+ calculated_number_t src2[16], dst2[16];
572
+ for (size_t i = 0; i < n; i++)
573
+ src2[i] = 42.0;
574
+ memcpy(dst2, src2, n * sizeof(calculated_number_t));
575
+
576
+ std::vector<DSample> pf2;
577
+ ml_features_t features2 = {
578
+ 0, smooth_n, lag_n,
579
+ dst2, n, src2, n,
580
+ pf2
581
+ };
582
+ ml_features_preprocess(&features2, 1.0);
583
+
584
+ // With diff_n=0, smooth on constant values gives the same constant.
585
+ // Feature vector should be all 42.0.
586
+ if (pf2.size() >= 1) {
587
+ for (size_t i = 0; i < lag_n + 1; i++) {
588
+ char msg[128];
589
+ snprintf(msg, sizeof(msg), "constant no-diff: feature[%zu] should be 42", i);
590
+ ML_TEST_ASSERT_DOUBLE_EQ(pf2[0](i), 42.0, 1e-9, msg);
591
+ }
592
+ }
593
+
594
+ // Simulate same_value detection with circular buffer
595
+ // Feed the same value repeatedly — same_value should be true every time
596
+ std::vector<calculated_number_t> cns;
597
+ size_t cns_head = 0;
598
+ bool all_same = true;
599
+
600
+ for (size_t i = 0; i < n + 10; i++) {
601
+ calculated_number_t value = 42.0;
602
+
603
+ if (cns.size() < n) {
604
+ cns.push_back(value);
605
+ continue;
606
+ }
607
+
608
+ size_t newest_idx = (cns_head + n - 1) % n;
609
+ bool same_value = (cns[newest_idx] == value);
610
+ cns[cns_head] = value;
611
+ cns_head = (cns_head + 1) % n;
612
+
613
+ if (!same_value)
614
+ all_same = false;
615
+ }
616
+ ML_TEST_ASSERT(all_same, "constant input should always detect same_value");
617
+
618
+ // Now feed a different value — same_value should be false
619
+ {
620
+ calculated_number_t value = 99.0;
621
+ size_t newest_idx = (cns_head + n - 1) % n;
622
+ bool same_value = (cns[newest_idx] == value);
623
+ ML_TEST_ASSERT(!same_value, "different value should not detect same_value");
624
+ }
625
+}
626
+
627
+// Test: various parameter combinations produce correctly sized outputs
628
+static void test_parameter_combinations()
629
+{
630
+ fprintf(stderr, " test_parameter_combinations...\n");
631
+
632
+ struct {
633
+ size_t diff_n, smooth_n, lag_n;
634
+ size_t expected_vectors; // from a window of size diff_n + smooth_n + lag_n
635
+ } cases[] = {
636
+ // n_vectors = n - diff_n - smooth_n + 1 - lag_n
637
+ // For prediction-sized window (n = diff_n + smooth_n + lag_n):
638
+ // n_vectors = (diff_n + smooth_n + lag_n) - diff_n - smooth_n + 1 - lag_n = 1
639
+ {0, 1, 1, 1},
640
+ {0, 1, 2, 1},
641
+ {0, 1, 3, 1},
642
+ {0, 1, 5, 1},
643
+ {0, 2, 5, 1},
644
+ {0, 3, 5, 1},
645
+ {0, 5, 5, 1},
646
+ {1, 1, 1, 1},
647
+ {1, 1, 5, 1},
648
+ {1, 2, 5, 1},
649
+ {1, 3, 5, 1},
650
+ {1, 5, 5, 1},
651
+ {1, 3, 1, 1},
652
+ {1, 3, 3, 1},
653
+ {1, 5, 3, 1},
654
+ };
655
+
656
+ for (size_t c = 0; c < sizeof(cases) / sizeof(cases[0]); c++) {
657
+ size_t diff_n = cases[c].diff_n;
658
+ size_t smooth_n = cases[c].smooth_n;
659
+ size_t lag_n = cases[c].lag_n;
660
+ size_t n = diff_n + smooth_n + lag_n;
661
+
662
+ // Fill with a sine wave to get non-trivial values
663
+ calculated_number_t src[128], dst[128];
664
+ memset(src, 0, sizeof(src));
665
+ for (size_t i = 0; i < n; i++)
666
+ src[i] = 10.0 + 5.0 * std::sin(2.0 * ML_PI * (double)i / (double)n);
667
+ memcpy(dst, src, n * sizeof(calculated_number_t));
668
+
669
+ std::vector<DSample> pf;
670
+ ml_features_t features = {
671
+ diff_n, smooth_n, lag_n,
672
+ dst, n, src, n,
673
+ pf
674
+ };
675
+ ml_features_preprocess(&features, 1.0);
676
+
677
+ char msg[256];
678
+ snprintf(msg, sizeof(msg), "params(%zu,%zu,%zu): expected %zu vectors, got %zu",
679
+ diff_n, smooth_n, lag_n, cases[c].expected_vectors, pf.size());
680
+ ML_TEST_ASSERT(pf.size() == cases[c].expected_vectors, msg);
681
+
682
+ // Verify feature vector is a 6x1 matrix (DSample is fixed-size)
683
+ if (pf.size() >= 1) {
684
+ snprintf(msg, sizeof(msg), "params(%zu,%zu,%zu): DSample should be 6 elements",
685
+ diff_n, smooth_n, lag_n);
686
+ ML_TEST_ASSERT(pf[0].size() == 6, msg);
687
+
688
+ // Verify no NaN/Inf in the lag_n+1 active feature elements
689
+ bool has_nan_inf = false;
690
+ for (size_t fi = 0; fi < lag_n + 1; fi++) {
691
+ if (std::isnan(pf[0](fi)) || std::isinf(pf[0](fi)))
692
+ has_nan_inf = true;
693
+ }
694
+ snprintf(msg, sizeof(msg), "params(%zu,%zu,%zu): no NaN/Inf in features",
695
+ diff_n, smooth_n, lag_n);
696
+ ML_TEST_ASSERT(!has_nan_inf, msg);
697
+ }
698
+
699
+ // With a larger window, verify we get more vectors
700
+ size_t large_n = n + 10;
701
+ calculated_number_t src_large[128], dst_large[128];
702
+ memset(src_large, 0, sizeof(src_large));
703
+ for (size_t i = 0; i < large_n; i++)
704
+ src_large[i] = 10.0 + 5.0 * std::sin(2.0 * ML_PI * (double)i / (double)n);
705
+ memcpy(dst_large, src_large, large_n * sizeof(calculated_number_t));
706
+
707
+ std::vector<DSample> pf_large;
708
+ ml_features_t features_large = {
709
+ diff_n, smooth_n, lag_n,
710
+ dst_large, large_n, src_large, large_n,
711
+ pf_large
712
+ };
713
+ ml_features_preprocess(&features_large, 1.0);
714
+
715
+ size_t expected_large = large_n - diff_n - smooth_n + 1 - lag_n;
716
+ snprintf(msg, sizeof(msg), "params(%zu,%zu,%zu) large window: expected %zu vectors",
717
+ diff_n, smooth_n, lag_n, expected_large);
718
+ ML_TEST_ASSERT(pf_large.size() == expected_large, msg);
719
+ }
720
+}
721
+
722
+extern "C" int ml_unittest()
723
+{
724
+ fprintf(stderr, "\nML unit tests:\n");
725
+
726
+ // Initialize minimal global state needed by ml_features_lag
727
+ Cfg.random_nums.clear();
728
+ Cfg.random_nums.reserve(2048);
729
+ for (size_t i = 0; i < 2048; i++)
730
+ Cfg.random_nums.push_back(0); // all zeros => all samples pass the cutoff check
731
+
732
+ tests_run = 0;
733
+ tests_failed = 0;
734
+
735
+ test_features_diff();
736
+ test_features_no_diff();
737
+ test_features_smooth();
738
+ test_kmeans_scoring();
739
+ test_full_pipeline();
740
+ test_circular_buffer_equivalence();
741
+ test_preprocess_predict_equivalence();
742
+ test_constant_input();
743
+ test_parameter_combinations();
744
+
745
+ fprintf(stderr, "\nML tests: %d run, %d failed\n", tests_run, tests_failed);
746
+
747
+ // Cleanup
748
+ Cfg.random_nums.clear();
749
+
750
+ return tests_failed == 0 ? 0 : 1;
751
+}
src/ml/ml_public.h
+2
@@ -60,6 +60,8 @@ bool ml_model_received_from_child(RRDHOST *host, const char *json);
60
61
void ml_host_disconnected(RRDHOST *host);
62
63
+int ml_unittest(void);
64
+
65
#ifdef __cplusplus
66
};
67
#endif