@cryptotaxi247 / netdata-1 / commits / 6c5f54409

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