master
cc 1,087 lines 42.6 KB
Raw
1 // SPDX-License-Identifier: GPL-3.0-or-later
2
3 #include "ml_config.h"
4 #include "ml_dimension.h"
5 #include "ml_features.h"
6 #include "ml_kmeans.h"
7 #include "ml_private.h"
8
9 #include <algorithm>
10 #include <cmath>
11 #include <cstdio>
12 #include <cstring>
13 #include <utility>
14 #include <vector>
15
16 static constexpr double ML_PI = 3.14159265358979323846;
17
18 static int tests_run = 0;
19 static int tests_failed = 0;
20
21 #define ML_TEST_ASSERT(cond, msg) do { \
22 tests_run++; \
23 if (!(cond)) { \
24 fprintf(stderr, " FAIL: %s (line %d)\n", msg, __LINE__); \
25 tests_failed++; \
26 } \
27 } while (0)
28
29 #define ML_TEST_ASSERT_DOUBLE_EQ(a, b, eps, msg) do { \
30 tests_run++; \
31 if (std::fabs((a) - (b)) > (eps)) { \
32 fprintf(stderr, " FAIL: %s (line %d): expected %.10f, got %.10f\n", msg, __LINE__, (double)(b), (double)(a)); \
33 tests_failed++; \
34 } \
35 } while (0)
36
37 // Test: diff transform with diff_n=1
38 // Input: [1, 3, 6, 10, 15, 9, 9, 9, 9]
39 // Expected diff (high - low with lag 1): [2, 3, 4, 5, -6, 0, 0, 0]
40 // (last element zeroed)
41 static void test_features_diff()
42 {
43 fprintf(stderr, " test_features_diff...\n");
44
45 const size_t n = 9;
46 calculated_number_t src[16] = {1, 3, 6, 10, 15, 9, 9, 9, 9};
47 calculated_number_t dst[16] = {0};
48
49 std::vector<DSample> pf;
50 ml_features_t features = {
51 1, 1, 1, // diff_n=1, smooth_n=1, lag_n=1
52 dst, n, src, n
53 };
54
55 // ml_features_preprocess calls diff, smooth, lag in sequence.
56 // To test diff alone, we use preprocess with smooth_n=1 (no-op smooth)
57 // and lag_n=1, sampling_ratio=1.0.
58 // After diff with diff_n=1:
59 // src[0..7] = [2, 3, 4, 5, -6, 0, 0, 0], src[8] = 0
60 // After smooth with smooth_n=1 (no-op):
61 // unchanged, but last smooth_n=1 elements zeroed: src[8]=0 (already 0)
62 // After lag with lag_n=1:
63 // feature vectors: [src[i], src[i+1]] for i in 0..6
64 // n_vectors = 9 - 1 - 1 + 1 - 1 = 7
65
66 ml_features_preprocess(&features, pf, 1.0);
67
68 ML_TEST_ASSERT(pf.size() == 7, "lag should produce 7 feature vectors");
69 if (pf.size() >= 1) {
70 // First feature vector should be [2, 3]
71 ML_TEST_ASSERT_DOUBLE_EQ(pf[0](0), 2.0, 1e-9, "pf[0](0) == 2.0");
72 ML_TEST_ASSERT_DOUBLE_EQ(pf[0](1), 3.0, 1e-9, "pf[0](1) == 3.0");
73 }
74 if (pf.size() >= 4) {
75 // Fourth feature vector should be [5, -6]
76 ML_TEST_ASSERT_DOUBLE_EQ(pf[3](0), 5.0, 1e-9, "pf[3](0) == 5.0");
77 ML_TEST_ASSERT_DOUBLE_EQ(pf[3](1), -6.0, 1e-9, "pf[3](1) == -6.0");
78 }
79 }
80
81 // Test: diff_n=0 means no differencing
82 static void test_features_no_diff()
83 {
84 fprintf(stderr, " test_features_no_diff...\n");
85
86 const size_t n = 6;
87 calculated_number_t src[16] = {10, 20, 30, 40, 50, 60};
88 calculated_number_t dst[16] = {0};
89
90 std::vector<DSample> pf;
91 ml_features_t features = {
92 0, 1, 1, // diff_n=0, smooth_n=1, lag_n=1
93 dst, n, src, n
94 };
95
96 ml_features_preprocess(&features, pf, 1.0);
97
98 // With diff_n=0, smooth_n=1 (no-op), lag_n=1:
99 // n_vectors = 6 - 0 - 1 + 1 - 1 = 5
100 // But smooth zeros last smooth_n=1 elements, so src[5]=0
101 // Feature vectors: [src[i], src[i+1]] for i in 0..3
102 // Wait: n = src_n - diff_n - smooth_n + 1 - lag_n = 6 - 0 - 1 + 1 - 1 = 5
103 ML_TEST_ASSERT(pf.size() == 5, "no-diff should produce 5 feature vectors");
104
105 if (pf.size() >= 1) {
106 ML_TEST_ASSERT_DOUBLE_EQ(pf[0](0), 10.0, 1e-9, "pf[0](0) == 10.0");
107 ML_TEST_ASSERT_DOUBLE_EQ(pf[0](1), 20.0, 1e-9, "pf[0](1) == 20.0");
108 }
109 }
110
111 // Test: smoothing with smooth_n=3
112 static void test_features_smooth()
113 {
114 fprintf(stderr, " test_features_smooth...\n");
115
116 const size_t n = 9;
117 // Use a simple sequence: 1,2,3,4,5,6,7,8,9
118 calculated_number_t src[16] = {1, 2, 3, 4, 5, 6, 7, 8, 9};
119 calculated_number_t dst[16] = {0};
120
121 std::vector<DSample> pf;
122 ml_features_t features = {
123 0, 3, 1, // diff_n=0, smooth_n=3, lag_n=1
124 dst, n, src, n
125 };
126
127 ml_features_preprocess(&features, pf, 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: smooth_n=0 is normalized to the same effective smoothing window as
154 // smooth_n=1, for both training and prediction.
155 static void test_features_zero_smooth_matches_one()
156 {
157 fprintf(stderr, " test_features_zero_smooth_matches_one...\n");
158
159 const size_t n = 6;
160 calculated_number_t input[16] = {10, 20, 30, 40, 50, 60};
161
162 calculated_number_t src0[16], dst0[16];
163 memcpy(src0, input, n * sizeof(calculated_number_t));
164 memcpy(dst0, input, n * sizeof(calculated_number_t));
165 std::vector<DSample> pf0;
166 ml_features_t features0 = {
167 0, 0, 1,
168 dst0, n, src0, n
169 };
170 ml_features_preprocess(&features0, pf0, 1.0);
171
172 calculated_number_t src1[16], dst1[16];
173 memcpy(src1, input, n * sizeof(calculated_number_t));
174 memcpy(dst1, input, n * sizeof(calculated_number_t));
175 std::vector<DSample> pf1;
176 ml_features_t features1 = {
177 0, 1, 1,
178 dst1, n, src1, n
179 };
180 ml_features_preprocess(&features1, pf1, 1.0);
181
182 ML_TEST_ASSERT(pf0.size() == pf1.size(), "smooth_n=0 and smooth_n=1 should produce the same number of vectors");
183 for (size_t i = 0; i < pf0.size() && i < pf1.size(); i++) {
184 for (size_t j = 0; j < features0.lag_n + 1; j++) {
185 char msg[128];
186 snprintf(msg, sizeof(msg), "smooth_n=0 should match smooth_n=1 at feature[%zu](%zu)", i, j);
187 ML_TEST_ASSERT_DOUBLE_EQ(pf0[i](j), pf1[i](j), 1e-12, msg);
188 }
189 }
190
191 DSample sample0, sample1;
192 memcpy(src0, input, n * sizeof(calculated_number_t));
193 memcpy(dst0, input, n * sizeof(calculated_number_t));
194 ml_features_preprocess_predict(&features0, sample0);
195
196 memcpy(src1, input, n * sizeof(calculated_number_t));
197 memcpy(dst1, input, n * sizeof(calculated_number_t));
198 ml_features_preprocess_predict(&features1, sample1);
199
200 ML_TEST_ASSERT(sample0.size() == sample1.size(), "prediction sample size should match for smooth_n=0 and smooth_n=1");
201 for (size_t i = 0; i < features0.lag_n + 1; i++) {
202 char msg[128];
203 snprintf(msg, sizeof(msg), "prediction smooth_n=0 should match smooth_n=1 at sample(%zu)", i);
204 ML_TEST_ASSERT_DOUBLE_EQ(sample0(i), sample1(i), 1e-12, msg);
205 }
206 }
207
208 // Test: full pipeline with default-like params (diff_n=1, smooth_n=3, lag_n=5)
209 // Validates the feature vector shape and that a round-trip through
210 // train + score produces sensible anomaly scores.
211 static void test_full_pipeline()
212 {
213 fprintf(stderr, " test_full_pipeline...\n");
214
215 // diff_n=1, smooth_n=3, lag_n=5 => n = 1 + 3 + 5 = 9
216 const size_t diff_n = 1;
217 const size_t smooth_n = 3;
218 const size_t lag_n = 5;
219 const size_t n = diff_n + smooth_n + lag_n;
220
221 // Generate a "normal" pattern: sine wave with some noise-like variation
222 const size_t num_samples = 100;
223 calculated_number_t normal_data[100];
224 for (size_t i = 0; i < num_samples; i++)
225 normal_data[i] = 50.0 + 20.0 * std::sin(2.0 * ML_PI * (double)i / 25.0);
226
227 // Simulate prediction: slide a window of size n over the data,
228 // preprocess each window, collect the first feature vector.
229 std::vector<DSample> all_features;
230
231 for (size_t start = 0; start + n <= num_samples; start++) {
232 calculated_number_t src[128];
233 calculated_number_t dst[128];
234
235 memset(src, 0, sizeof(src));
236 memcpy(src, &normal_data[start], n * sizeof(calculated_number_t));
237 memcpy(dst, src, n * sizeof(calculated_number_t));
238
239 std::vector<DSample> pf;
240 ml_features_t features = {
241 diff_n, smooth_n, lag_n,
242 dst, n, src, n
243 };
244 ml_features_preprocess(&features, pf, 1.0);
245
246 // With these params:
247 // n_vectors = n - diff_n - smooth_n + 1 - lag_n = 9 - 1 - 3 + 1 - 5 = 1
248 ML_TEST_ASSERT(pf.size() == 1, "prediction window should produce exactly 1 feature vector");
249
250 if (pf.size() >= 1)
251 all_features.push_back(pf[0]);
252 }
253
254 ML_TEST_ASSERT(all_features.size() > 2, "should have enough features for kmeans");
255
256 // Each feature vector should have lag_n + 1 = 6 elements
257 if (all_features.size() > 0) {
258 ML_TEST_ASSERT(all_features[0].size() == (long)(lag_n + 1),
259 "feature vector should have lag_n+1 elements");
260 }
261
262 // Train a kmeans model on the normal data
263 std::vector<DSample> training_features = std::move(all_features);
264
265 ml_kmeans_t kmeans;
266 ml_kmeans_init(&kmeans);
267 ml_kmeans_train(&kmeans, training_features, 1000, 0, 100);
268
269 ML_TEST_ASSERT(kmeans.cluster_centers.size() == 2, "kmeans should have 2 cluster centers");
270 ML_TEST_ASSERT(kmeans.min_dist < kmeans.max_dist, "min_dist < max_dist after training");
271
272 // Score all training samples — the best score must be 0 (the sample at min_dist)
273 // and all training scores must be in [0, 100].
274 ml_kmeans_inlined_t inlined_km(kmeans);
275 calculated_number_t best_normal_score = 100.0;
276 for (size_t i = 0; i < training_features.size(); i++) {
277 calculated_number_t s = ml_kmeans_anomaly_score(&inlined_km, training_features[i]);
278 ML_TEST_ASSERT(!std::isnan(s), "training sample score should not be NaN");
279 ML_TEST_ASSERT(s >= 0.0 && s <= 100.0, "training sample score should be in [0, 100]");
280 if (s < best_normal_score)
281 best_normal_score = s;
282 }
283 calculated_number_t normal_score = best_normal_score;
284 ML_TEST_ASSERT_DOUBLE_EQ(normal_score, 0.0, 1e-6, "best training sample should score ~0");
285
286 // Create an anomalous sample: extreme spike values unlike the sine wave
287 calculated_number_t anomalous_window[9] = {50, 50, 50, 500, 50, 50, 50, 500, 50};
288 {
289 calculated_number_t src[128], dst[128];
290 memset(src, 0, sizeof(src));
291 memcpy(src, anomalous_window, n * sizeof(calculated_number_t));
292 memcpy(dst, src, n * sizeof(calculated_number_t));
293
294 std::vector<DSample> pf;
295 ml_features_t features = {
296 diff_n, smooth_n, lag_n,
297 dst, n, src, n
298 };
299 ml_features_preprocess(&features, pf, 1.0);
300
301 if (pf.size() >= 1) {
302 calculated_number_t anomaly_score = ml_kmeans_anomaly_score(&inlined_km, pf[0]);
303 ML_TEST_ASSERT(!std::isnan(anomaly_score), "anomaly score should not be NaN");
304 ML_TEST_ASSERT(anomaly_score > normal_score, "anomalous data should score higher than normal");
305 }
306 }
307 }
308
309 // Test: kmeans anomaly_score edge cases
310 static void test_kmeans_scoring()
311 {
312 fprintf(stderr, " test_kmeans_scoring...\n");
313
314 // Build a simple model with known cluster centers
315 ml_kmeans_inlined_t km;
316 km.cluster_centers[0].set_size(6);
317 km.cluster_centers[1].set_size(6);
318 for (int i = 0; i < 6; i++) {
319 km.cluster_centers[0](i) = 0.0;
320 km.cluster_centers[1](i) = 10.0;
321 }
322 km.min_dist = 1.0;
323 km.max_dist = 10.0;
324 km.after = 0;
325 km.before = 100;
326
327 // Sample at cluster center 0 — should score low
328 DSample at_center0;
329 at_center0.set_size(6);
330 for (int i = 0; i < 6; i++)
331 at_center0(i) = 0.0;
332
333 calculated_number_t score0 = ml_kmeans_anomaly_score(&km, at_center0);
334 ML_TEST_ASSERT(!std::isnan(score0), "score at center should not be NaN");
335
336 // Sample at midpoint — should score moderate
337 DSample at_mid;
338 at_mid.set_size(6);
339 for (int i = 0; i < 6; i++)
340 at_mid(i) = 5.0;
341
342 calculated_number_t score_mid = ml_kmeans_anomaly_score(&km, at_mid);
343 ML_TEST_ASSERT(!std::isnan(score_mid), "score at midpoint should not be NaN");
344
345 // Sample far away — should score high (capped at 100)
346 DSample far_away;
347 far_away.set_size(6);
348 for (int i = 0; i < 6; i++)
349 far_away(i) = 100.0;
350
351 calculated_number_t score_far = ml_kmeans_anomaly_score(&km, far_away);
352 ML_TEST_ASSERT_DOUBLE_EQ(score_far, 100.0, 1e-9, "far away sample should be capped at 100");
353
354 // When min_dist == max_dist, score should be 0
355 ml_kmeans_inlined_t km_equal;
356 km_equal.cluster_centers[0].set_size(6);
357 km_equal.cluster_centers[1].set_size(6);
358 for (int i = 0; i < 6; i++) {
359 km_equal.cluster_centers[0](i) = 5.0;
360 km_equal.cluster_centers[1](i) = 5.0;
361 }
362 km_equal.min_dist = 5.0;
363 km_equal.max_dist = 5.0;
364
365 calculated_number_t score_eq = ml_kmeans_anomaly_score(&km_equal, at_mid);
366 ML_TEST_ASSERT_DOUBLE_EQ(score_eq, 0.0, 1e-9, "equal min/max should return 0");
367 }
368
369 // Test: converting an empty ml_kmeans_t must produce deterministic zeroed centers.
370 static void test_kmeans_inlined_empty_source_is_zero_initialized()
371 {
372 fprintf(stderr, " test_kmeans_inlined_empty_source_is_zero_initialized...\n");
373
374 ml_kmeans_t empty_km;
375 empty_km.cluster_centers.clear();
376 empty_km.min_dist = 1.5;
377 empty_km.max_dist = 9.5;
378 empty_km.after = 11;
379 empty_km.before = 22;
380
381 ml_kmeans_inlined_t constructed(empty_km);
382 ML_TEST_ASSERT(constructed.after == empty_km.after, "constructed empty model should preserve 'after'");
383 ML_TEST_ASSERT(constructed.before == empty_km.before, "constructed empty model should preserve 'before'");
384 ML_TEST_ASSERT_DOUBLE_EQ(constructed.min_dist, empty_km.min_dist, 0.0, "constructed empty model should preserve min_dist");
385 ML_TEST_ASSERT_DOUBLE_EQ(constructed.max_dist, empty_km.max_dist, 0.0, "constructed empty model should preserve max_dist");
386 for (size_t center = 0; center < constructed.cluster_centers.size(); center++) {
387 ML_TEST_ASSERT(constructed.cluster_centers[center].size() == 6, "constructed empty-source centers must keep fixed-size geometry");
388 for (long i = 0; i < constructed.cluster_centers[center].size(); i++) {
389 char msg[160];
390 snprintf(msg, sizeof(msg), "constructed empty-source center[%zu](%ld) should be zero", center, i);
391 ML_TEST_ASSERT_DOUBLE_EQ(constructed.cluster_centers[center](i), 0.0, 0.0, msg);
392 }
393 }
394
395 ml_kmeans_inlined_t assigned;
396 for (int i = 0; i < 6; i++) {
397 assigned.cluster_centers[0](i) = 10.0 + i;
398 assigned.cluster_centers[1](i) = 20.0 + i;
399 }
400 assigned = empty_km;
401
402 ML_TEST_ASSERT(assigned.after == empty_km.after, "assigned empty model should preserve 'after'");
403 ML_TEST_ASSERT(assigned.before == empty_km.before, "assigned empty model should preserve 'before'");
404 ML_TEST_ASSERT_DOUBLE_EQ(assigned.min_dist, empty_km.min_dist, 0.0, "assigned empty model should preserve min_dist");
405 ML_TEST_ASSERT_DOUBLE_EQ(assigned.max_dist, empty_km.max_dist, 0.0, "assigned empty model should preserve max_dist");
406 for (size_t center = 0; center < assigned.cluster_centers.size(); center++) {
407 ML_TEST_ASSERT(assigned.cluster_centers[center].size() == 6, "empty-source centers must keep fixed-size geometry");
408 for (long i = 0; i < assigned.cluster_centers[center].size(); i++) {
409 char msg[160];
410 snprintf(msg, sizeof(msg), "empty-source center[%zu](%ld) should be zero", center, i);
411 ML_TEST_ASSERT_DOUBLE_EQ(assigned.cluster_centers[center](i), 0.0, 0.0, msg);
412 }
413 }
414 }
415
416 // Test: at the smallest legal input (src_n == diff_n + smooth_n + lag_n),
417 // ml_features_preprocess yields exactly one feature vector. This guards the
418 // preprocess boundary that triggers the <2-vectors early-return in
419 // ml_dimension_train_model. Scope is intentionally limited to preprocess output:
420 // ml_dimension_train_model itself depends on a live ml_dimension_t (worker, rd,
421 // rrdset, host, sqlite) and is not unit-testable without significant plumbing.
422 static void test_features_preprocess_below_min_for_kmeans()
423 {
424 fprintf(stderr, " test_features_preprocess_below_min_for_kmeans...\n");
425
426 const size_t diff_n = 1;
427 const size_t smooth_n = 1;
428 const size_t lag_n = 1;
429 const size_t src_n = diff_n + smooth_n + lag_n; // 3, the minimum allowed by ml_validate_features_input
430
431 calculated_number_t src[16] = {1.0, 2.0, 3.0};
432 calculated_number_t dst[16] = {0};
433
434 std::vector<DSample> pf;
435 ml_features_t features = {
436 diff_n, smooth_n, lag_n,
437 dst, src_n, src, src_n
438 };
439
440 ml_features_preprocess(&features, pf, 1.0);
441
442 // n_vectors = src_n - diff_n - smooth_n + 1 - lag_n = 3 - 1 - 1 + 1 - 1 = 1
443 ML_TEST_ASSERT(pf.size() == 1, "boundary input should yield exactly 1 feature vector");
444 ML_TEST_ASSERT(pf.size() < 2, "<2 vectors must trigger the kmeans-skip early-return in ml_dimension_train_model");
445 }
446
447 // Test: ml_dimension_finalize_constant_state is the shared post-cycle state
448 // transition used by both the successful-training path and the undersampled
449 // early-return. It sets mt = CONSTANT, ts = TRAINED, and resets suppression
450 // counters. Matches the existing master behavior in ml_dimension_update_models.
451 static void test_dimension_finalize_constant_state()
452 {
453 fprintf(stderr, " test_dimension_finalize_constant_state...\n");
454
455 ml_dimension_t dim = {};
456 spinlock_init(&dim.slock);
457 dim.mt = METRIC_TYPE_VARIABLE;
458 dim.ts = TRAINING_STATUS_UNTRAINED;
459 dim.suppression_anomaly_counter = 7;
460 dim.suppression_window_counter = 13;
461
462 // Match the helper's documented contract (caller holds dim->slock).
463 spinlock_lock(&dim.slock);
464 ml_dimension_finalize_constant_state(&dim);
465 spinlock_unlock(&dim.slock);
466
467 ML_TEST_ASSERT(dim.mt == METRIC_TYPE_CONSTANT, "mt must become CONSTANT");
468 ML_TEST_ASSERT(dim.ts == TRAINING_STATUS_TRAINED, "ts must become TRAINED");
469 ML_TEST_ASSERT(dim.suppression_anomaly_counter == 0, "anomaly counter must reset");
470 ML_TEST_ASSERT(dim.suppression_window_counter == 0, "window counter must reset");
471 }
472
473 // Test: circular buffer linearization produces the same result as std::rotate
474 static void test_circular_buffer_equivalence()
475 {
476 fprintf(stderr, " test_circular_buffer_equivalence...\n");
477
478 const size_t diff_n = 1;
479 const size_t smooth_n = 3;
480 const size_t lag_n = 5;
481 const size_t n = diff_n + smooth_n + lag_n;
482
483 // Simulate feeding values into the dimension
484 calculated_number_t values[] = {
485 1.0, 2.5, 3.7, 4.1, 5.9, 6.3, 7.8, 8.2, 9.0, // fill buffer (n=9 values)
486 10.5, 11.3, 12.8, 13.1, 14.7, 15.2 // 6 more values to rotate
487 };
488 size_t num_values = sizeof(values) / sizeof(values[0]);
489
490 // Method 1: std::rotate (master approach)
491 std::vector<calculated_number_t> cns_rotate;
492 std::vector<DSample> rotate_results;
493
494 for (size_t i = 0; i < num_values; i++) {
495 if (cns_rotate.size() < n) {
496 cns_rotate.push_back(values[i]);
497 continue;
498 }
499
500 std::rotate(cns_rotate.begin(), cns_rotate.begin() + 1, cns_rotate.end());
501 cns_rotate[n - 1] = values[i];
502
503 calculated_number_t src[128], dst[128];
504 memset(src, 0, sizeof(src));
505 memcpy(src, cns_rotate.data(), n * sizeof(calculated_number_t));
506 memcpy(dst, cns_rotate.data(), n * sizeof(calculated_number_t));
507
508 std::vector<DSample> pf;
509 ml_features_t features = {
510 diff_n, smooth_n, lag_n,
511 dst, n, src, n
512 };
513 ml_features_preprocess(&features, pf, 1.0);
514
515 if (pf.size() >= 1)
516 rotate_results.push_back(pf[0]);
517 }
518
519 // Method 2: circular buffer (branch approach)
520 std::vector<calculated_number_t> cns_circ;
521 size_t cns_head = 0;
522 std::vector<DSample> circ_results;
523
524 for (size_t i = 0; i < num_values; i++) {
525 if (cns_circ.size() < n) {
526 cns_circ.push_back(values[i]);
527 continue;
528 }
529
530 cns_circ[cns_head] = values[i];
531 cns_head = (cns_head + 1) % n;
532
533 // Linearize circular buffer
534 calculated_number_t src[128], dst[128];
535 size_t first_chunk = n - cns_head;
536 memcpy(src, cns_circ.data() + cns_head, first_chunk * sizeof(calculated_number_t));
537 if (cns_head)
538 memcpy(src + first_chunk, cns_circ.data(), cns_head * sizeof(calculated_number_t));
539 memcpy(dst, src, n * sizeof(calculated_number_t));
540
541 std::vector<DSample> pf;
542 ml_features_t features = {
543 diff_n, smooth_n, lag_n,
544 dst, n, src, n
545 };
546 ml_features_preprocess(&features, pf, 1.0);
547
548 if (pf.size() >= 1)
549 circ_results.push_back(pf[0]);
550 }
551
552 ML_TEST_ASSERT(rotate_results.size() == circ_results.size(),
553 "both methods should produce same number of results");
554
555 for (size_t i = 0; i < rotate_results.size() && i < circ_results.size(); i++) {
556 for (long j = 0; j < rotate_results[i].size(); j++) {
557 char msg[128];
558 snprintf(msg, sizeof(msg), "result[%zu](%ld) should match between rotate and circular", i, j);
559 ML_TEST_ASSERT_DOUBLE_EQ(rotate_results[i](j), circ_results[i](j), 1e-12, msg);
560 }
561 }
562 }
563
564 // Test: same_value must compare against the previous newest sample, not the
565 // oldest slot being overwritten. This locks in the intentional semantic change
566 // in ml_dimension_predict().
567 static void test_same_value_uses_newest_sample()
568 {
569 fprintf(stderr, " test_same_value_uses_newest_sample...\n");
570
571 const size_t n = 5;
572 std::vector<calculated_number_t> cns = {7.0, 2.0, 3.0, 4.0, 5.0};
573 size_t cns_head = 0;
574 calculated_number_t incoming = 7.0;
575
576 // Circular buffer state:
577 // oldest slot being overwritten = cns[cns_head] = 7.0
578 // previous newest sample = cns[(cns_head + n - 1) % n] = 5.0
579 // If we compared against the oldest slot, same_value would be true and we'd
580 // miss the transition from 5.0 -> 7.0. Comparing against newest is correct.
581 bool old_rotate_equivalent = (cns[cns_head] == incoming);
582 size_t newest_idx = (cns_head + n - 1) % n;
583 bool new_ring_semantics = (cns[newest_idx] == incoming);
584
585 ML_TEST_ASSERT(old_rotate_equivalent,
586 "oldest-slot comparison should report same_value for this edge case");
587 ML_TEST_ASSERT(!new_ring_semantics,
588 "newest-sample comparison should detect the changed incoming value");
589 }
590
591 // Test: ml_features_preprocess with a prediction-sized window produces the same
592 // feature vector as a manual reimplementation of diff + smooth + extract.
593 // This validates the preprocessing math and serves as a baseline for verifying
594 // that any optimized prediction path (e.g. ml_features_preprocess_predict)
595 // produces identical results.
596 static void test_preprocess_predict_equivalence()
597 {
598 fprintf(stderr, " test_preprocess_predict_equivalence...\n");
599
600 struct {
601 size_t diff_n, smooth_n, lag_n;
602 } param_sets[] = {
603 {1, 3, 5}, // default params
604 {0, 3, 5}, // no diff
605 {1, 1, 5}, // minimal smooth
606 {1, 3, 1}, // minimal lag
607 {0, 1, 1}, // minimal everything
608 {1, 5, 3}, // larger smooth, smaller lag
609 };
610
611 // Various input patterns
612 auto fill_sine = [](calculated_number_t *buf, size_t n) {
613 for (size_t i = 0; i < n; i++)
614 buf[i] = 50.0 + 20.0 * std::sin(2.0 * ML_PI * (double)i / 7.0);
615 };
616 auto fill_ramp = [](calculated_number_t *buf, size_t n) {
617 for (size_t i = 0; i < n; i++)
618 buf[i] = 1.0 + 0.7 * (double)i;
619 };
620 auto fill_spike = [](calculated_number_t *buf, size_t n) {
621 for (size_t i = 0; i < n; i++)
622 buf[i] = (i == n / 2) ? 500.0 : 10.0;
623 };
624
625 void (*fillers[])(calculated_number_t *, size_t) = {fill_sine, fill_ramp, fill_spike};
626 const char *filler_names[] = {"sine", "ramp", "spike"};
627
628 for (size_t p = 0; p < sizeof(param_sets) / sizeof(param_sets[0]); p++) {
629 size_t diff_n = param_sets[p].diff_n;
630 size_t smooth_n = param_sets[p].smooth_n;
631 size_t lag_n = param_sets[p].lag_n;
632 size_t n = diff_n + smooth_n + lag_n;
633
634 for (size_t f = 0; f < 3; f++) {
635 calculated_number_t input[128];
636 fillers[f](input, n);
637
638 // Path 1: ml_features_preprocess (master prediction path)
639 calculated_number_t src1[128], dst1[128];
640 memset(src1, 0, sizeof(src1));
641 memcpy(src1, input, n * sizeof(calculated_number_t));
642 memcpy(dst1, src1, n * sizeof(calculated_number_t));
643
644 std::vector<DSample> pf;
645 ml_features_t features1 = {
646 diff_n, smooth_n, lag_n,
647 dst1, n, src1, n
648 };
649 ml_features_preprocess(&features1, pf, 1.0);
650
651 // With prediction-sized window: n_vectors = n - diff_n - smooth_n + 1 - lag_n = 1
652 char msg[256];
653 snprintf(msg, sizeof(msg), "params(%zu,%zu,%zu) %s: preprocess should produce 1 vector",
654 diff_n, smooth_n, lag_n, filler_names[f]);
655 ML_TEST_ASSERT(pf.size() == 1, msg);
656 if (pf.size() != 1) continue;
657
658 // Path 2: ml_features_preprocess_predict should produce the same
659 // prediction-sized feature vector as the training preprocess path.
660 calculated_number_t src2[128], dst2[128];
661 memset(src2, 0, sizeof(src2));
662 memcpy(src2, input, n * sizeof(calculated_number_t));
663 memcpy(dst2, src2, n * sizeof(calculated_number_t));
664
665 ml_features_t features2 = {
666 diff_n, smooth_n, lag_n,
667 dst2, n, src2, n
668 };
669 DSample predicted_feature;
670 ml_features_preprocess_predict(&features2, predicted_feature);
671
672 // Compare: pf[0] from full pipeline must match the direct prediction path.
673 for (size_t fi = 0; fi < lag_n + 1; fi++) {
674 snprintf(msg, sizeof(msg), "params(%zu,%zu,%zu) %s: feature[%zu] preprocess vs predict",
675 diff_n, smooth_n, lag_n, filler_names[f], fi);
676 ML_TEST_ASSERT_DOUBLE_EQ(pf[0](fi), predicted_feature(fi), 1e-12, msg);
677 }
678 }
679 }
680 }
681
682 // Test: constant input values produce zero-diff features and don't cause anomalies
683 static void test_constant_input()
684 {
685 fprintf(stderr, " test_constant_input...\n");
686
687 const size_t diff_n = 1;
688 const size_t smooth_n = 3;
689 const size_t lag_n = 5;
690 const size_t n = diff_n + smooth_n + lag_n;
691
692 // All constant values
693 calculated_number_t src[16], dst[16];
694 for (size_t i = 0; i < n; i++)
695 src[i] = 42.0;
696 memcpy(dst, src, n * sizeof(calculated_number_t));
697
698 std::vector<DSample> pf;
699 ml_features_t features = {
700 diff_n, smooth_n, lag_n,
701 dst, n, src, n
702 };
703 ml_features_preprocess(&features, pf, 1.0);
704
705 ML_TEST_ASSERT(pf.size() == 1, "constant input should produce 1 feature vector");
706
707 // With diff_n=1 on constant input, all diffs are 0.
708 // After smooth, still all 0. Feature vector should be all zeros.
709 if (pf.size() >= 1) {
710 for (size_t i = 0; i < lag_n + 1; i++) {
711 char msg[128];
712 snprintf(msg, sizeof(msg), "constant input: feature[%zu] should be 0", i);
713 ML_TEST_ASSERT_DOUBLE_EQ(pf[0](i), 0.0, 1e-12, msg);
714 }
715 }
716
717 // All-zero feature with diff_n=0 should preserve the constant value
718 calculated_number_t src2[16], dst2[16];
719 for (size_t i = 0; i < n; i++)
720 src2[i] = 42.0;
721 memcpy(dst2, src2, n * sizeof(calculated_number_t));
722
723 std::vector<DSample> pf2;
724 ml_features_t features2 = {
725 0, smooth_n, lag_n,
726 dst2, n, src2, n
727 };
728 ml_features_preprocess(&features2, pf2, 1.0);
729
730 // With diff_n=0, smooth on constant values gives the same constant.
731 // Feature vector should be all 42.0.
732 if (pf2.size() >= 1) {
733 for (size_t i = 0; i < lag_n + 1; i++) {
734 char msg[128];
735 snprintf(msg, sizeof(msg), "constant no-diff: feature[%zu] should be 42", i);
736 ML_TEST_ASSERT_DOUBLE_EQ(pf2[0](i), 42.0, 1e-9, msg);
737 }
738 }
739
740 // Simulate same_value detection with circular buffer
741 // Feed the same value repeatedly — same_value should be true every time
742 std::vector<calculated_number_t> cns;
743 size_t cns_head = 0;
744 bool all_same = true;
745
746 for (size_t i = 0; i < n + 10; i++) {
747 calculated_number_t value = 42.0;
748
749 if (cns.size() < n) {
750 cns.push_back(value);
751 continue;
752 }
753
754 size_t newest_idx = (cns_head + n - 1) % n;
755 bool same_value = (cns[newest_idx] == value);
756 cns[cns_head] = value;
757 cns_head = (cns_head + 1) % n;
758
759 if (!same_value)
760 all_same = false;
761 }
762 ML_TEST_ASSERT(all_same, "constant input should always detect same_value");
763
764 // Now feed a different value — same_value should be false
765 {
766 calculated_number_t value = 99.0;
767 size_t newest_idx = (cns_head + n - 1) % n;
768 bool same_value = (cns[newest_idx] == value);
769 ML_TEST_ASSERT(!same_value, "different value should not detect same_value");
770 }
771 }
772
773 // Test: various parameter combinations produce correctly sized outputs
774 static void test_parameter_combinations()
775 {
776 fprintf(stderr, " test_parameter_combinations...\n");
777
778 struct {
779 size_t diff_n, smooth_n, lag_n;
780 size_t expected_vectors; // from a window of size diff_n + smooth_n + lag_n
781 } cases[] = {
782 // n_vectors = n - diff_n - smooth_n + 1 - lag_n
783 // For prediction-sized window (n = diff_n + smooth_n + lag_n):
784 // n_vectors = (diff_n + smooth_n + lag_n) - diff_n - smooth_n + 1 - lag_n = 1
785 {0, 1, 1, 1},
786 {0, 1, 2, 1},
787 {0, 1, 3, 1},
788 {0, 1, 5, 1},
789 {0, 2, 5, 1},
790 {0, 3, 5, 1},
791 {0, 5, 5, 1},
792 {1, 1, 1, 1},
793 {1, 1, 5, 1},
794 {1, 2, 5, 1},
795 {1, 3, 5, 1},
796 {1, 5, 5, 1},
797 {1, 3, 1, 1},
798 {1, 3, 3, 1},
799 {1, 5, 3, 1},
800 };
801
802 for (size_t c = 0; c < sizeof(cases) / sizeof(cases[0]); c++) {
803 size_t diff_n = cases[c].diff_n;
804 size_t smooth_n = cases[c].smooth_n;
805 size_t lag_n = cases[c].lag_n;
806 size_t n = diff_n + smooth_n + lag_n;
807
808 // Fill with a sine wave to get non-trivial values
809 calculated_number_t src[128], dst[128];
810 memset(src, 0, sizeof(src));
811 for (size_t i = 0; i < n; i++)
812 src[i] = 10.0 + 5.0 * std::sin(2.0 * ML_PI * (double)i / (double)n);
813 memcpy(dst, src, n * sizeof(calculated_number_t));
814
815 std::vector<DSample> pf;
816 ml_features_t features = {
817 diff_n, smooth_n, lag_n,
818 dst, n, src, n
819 };
820 ml_features_preprocess(&features, pf, 1.0);
821
822 char msg[256];
823 snprintf(msg, sizeof(msg), "params(%zu,%zu,%zu): expected %zu vectors, got %zu",
824 diff_n, smooth_n, lag_n, cases[c].expected_vectors, pf.size());
825 ML_TEST_ASSERT(pf.size() == cases[c].expected_vectors, msg);
826
827 // Verify feature vector is a 6x1 matrix (DSample is fixed-size)
828 if (pf.size() >= 1) {
829 snprintf(msg, sizeof(msg), "params(%zu,%zu,%zu): DSample should be 6 elements",
830 diff_n, smooth_n, lag_n);
831 ML_TEST_ASSERT(pf[0].size() == 6, msg);
832
833 // Verify no NaN/Inf in the lag_n+1 active feature elements
834 bool has_nan_inf = false;
835 for (size_t fi = 0; fi < lag_n + 1; fi++) {
836 if (std::isnan(pf[0](fi)) || std::isinf(pf[0](fi)))
837 has_nan_inf = true;
838 }
839 snprintf(msg, sizeof(msg), "params(%zu,%zu,%zu): no NaN/Inf in features",
840 diff_n, smooth_n, lag_n);
841 ML_TEST_ASSERT(!has_nan_inf, msg);
842 }
843
844 // With a larger window, verify we get more vectors
845 size_t large_n = n + 10;
846 calculated_number_t src_large[128], dst_large[128];
847 memset(src_large, 0, sizeof(src_large));
848 for (size_t i = 0; i < large_n; i++)
849 src_large[i] = 10.0 + 5.0 * std::sin(2.0 * ML_PI * (double)i / (double)large_n);
850 memcpy(dst_large, src_large, large_n * sizeof(calculated_number_t));
851
852 std::vector<DSample> pf_large;
853 ml_features_t features_large = {
854 diff_n, smooth_n, lag_n,
855 dst_large, large_n, src_large, large_n
856 };
857 ml_features_preprocess(&features_large, pf_large, 1.0);
858
859 size_t expected_large = large_n - diff_n - smooth_n + 1 - lag_n;
860 snprintf(msg, sizeof(msg), "params(%zu,%zu,%zu) large window: expected %zu vectors",
861 diff_n, smooth_n, lag_n, expected_large);
862 ML_TEST_ASSERT(pf_large.size() == expected_large, msg);
863 }
864 }
865
866 // Test: timestamps > INT32_MAX must survive serialize -> deserialize unchanged.
867 // Before the bounds-check fix, the (time_t) cast of json_object_get_int64()
868 // would silently truncate on 32-bit time_t, breaking model ordering/pruning.
869 static void test_kmeans_timestamp_roundtrip()
870 {
871 fprintf(stderr, " test_kmeans_timestamp_roundtrip...\n");
872
873 // 3 000 000 000 > INT32_MAX (2 147 483 647). On 32-bit time_t the value
874 // doesn't fit, so skip — the guard would correctly reject it on the way in.
875 if (sizeof(time_t) < 8) {
876 fprintf(stderr, " skipped (time_t is 32-bit on this platform)\n");
877 return;
878 }
879
880 const time_t large_after = (time_t) 3000000000LL;
881 const time_t large_before = (time_t) 3000003600LL;
882
883 ml_kmeans_inlined_t original;
884 original.cluster_centers[0].set_size(6);
885 original.cluster_centers[1].set_size(6);
886 for (int i = 0; i < 6; i++) {
887 original.cluster_centers[0](i) = (double)(i + 1);
888 original.cluster_centers[1](i) = (double)(i + 7);
889 }
890 original.min_dist = 1.5;
891 original.max_dist = 9.5;
892 original.after = large_after;
893 original.before = large_before;
894
895 BUFFER *wb = buffer_create(0, NULL);
896 buffer_json_initialize(wb, "\"", "\"", 0, true, BUFFER_JSON_OPTIONS_MINIFY);
897 ml_kmeans_serialize(&original, wb);
898 buffer_json_finalize(wb);
899
900 struct json_object *root = json_tokener_parse(buffer_tostring(wb));
901 ML_TEST_ASSERT(root != NULL, "round-trip: serialized output must be valid JSON");
902
903 if (root) {
904 ml_kmeans_inlined_t result;
905 result.cluster_centers[0].set_size(6);
906 result.cluster_centers[1].set_size(6);
907
908 bool ok = ml_kmeans_deserialize(&result, root);
909 ML_TEST_ASSERT(ok, "round-trip: deserialize must succeed for large timestamp");
910
911 if (ok) {
912 ML_TEST_ASSERT(result.after == large_after,
913 "round-trip: 'after' must survive unchanged (> INT32_MAX)");
914 ML_TEST_ASSERT(result.before == large_before,
915 "round-trip: 'before' must survive unchanged (> INT32_MAX)");
916 }
917
918 json_object_put(root);
919 }
920
921 buffer_free(wb);
922 }
923
924 // Test: deserialize must reject models carrying negative timestamps.
925 // Negative Unix timestamps are never valid for ML model windows.
926 static void test_kmeans_timestamp_rejection()
927 {
928 fprintf(stderr, " test_kmeans_timestamp_rejection...\n");
929
930 // Build a fully-valid kmeans JSON object and then override one timestamp
931 // field to an invalid value, verifying that ml_kmeans_deserialize rejects it.
932 auto make_full_root = [](int64_t after_val, int64_t before_val) -> struct json_object * {
933 struct json_object *r = json_object_new_object();
934 json_object_object_add(r, "after", json_object_new_int64(after_val));
935 json_object_object_add(r, "before", json_object_new_int64(before_val));
936 json_object_object_add(r, "min_dist", json_object_new_double(1.0));
937 json_object_object_add(r, "max_dist", json_object_new_double(9.0));
938
939 struct json_object *cc = json_object_new_array();
940 for (int c = 0; c < 2; c++) {
941 struct json_object *cv = json_object_new_array();
942 for (int i = 0; i < 6; i++)
943 json_object_array_add(cv, json_object_new_double((double)(c * 6 + i + 1)));
944 json_object_array_add(cc, cv);
945 }
946 json_object_object_add(r, "cluster_centers", cc);
947 return r;
948 };
949
950 {
951 struct json_object *r = make_full_root(-1LL, 100LL);
952 ml_kmeans_inlined_t km;
953 km.cluster_centers[0].set_size(6);
954 km.cluster_centers[1].set_size(6);
955 bool ok = ml_kmeans_deserialize(&km, r);
956 ML_TEST_ASSERT(!ok, "negative 'after' must be rejected");
957 json_object_put(r);
958 }
959
960 {
961 struct json_object *r = make_full_root(100LL, -1LL);
962 ml_kmeans_inlined_t km;
963 km.cluster_centers[0].set_size(6);
964 km.cluster_centers[1].set_size(6);
965 bool ok = ml_kmeans_deserialize(&km, r);
966 ML_TEST_ASSERT(!ok, "negative 'before' must be rejected");
967 json_object_put(r);
968 }
969 }
970
971 static void test_downstream_model_short_circuit_and_requeue()
972 {
973 fprintf(stderr, " test_downstream_model_short_circuit_and_requeue...\n");
974
975 enum ml_worker_result worker_res = ML_WORKER_RESULT_OK;
976 bool should_short_circuit = ml_dimension_train_model_precheck(METRIC_TYPE_VARIABLE,
977 true,
978 false,
979 &worker_res);
980 ML_TEST_ASSERT(should_short_circuit,
981 "downstream-supplied dimensions should short-circuit local training");
982 ML_TEST_ASSERT(worker_res == ML_WORKER_RESULT_DOWNSTREAM_MODEL_SUPPLIED,
983 "downstream-supplied dimensions should short-circuit local training");
984 ML_TEST_ASSERT(!ml_should_requeue_create_new_model(worker_res),
985 "downstream-supplied result should stop CREATE_NEW_MODEL requeueing");
986
987 worker_res = ML_WORKER_RESULT_OK;
988 should_short_circuit = ml_dimension_train_model_precheck(METRIC_TYPE_CONSTANT,
989 true,
990 false,
991 &worker_res);
992 ML_TEST_ASSERT(should_short_circuit,
993 "constant downstream-supplied dimensions should short-circuit local training");
994 ML_TEST_ASSERT(worker_res == ML_WORKER_RESULT_DOWNSTREAM_MODEL_SUPPLIED,
995 "constant downstream-supplied dimensions should drain CREATE_NEW_MODEL items");
996 ML_TEST_ASSERT(!ml_should_requeue_create_new_model(worker_res),
997 "constant downstream-supplied result should stop CREATE_NEW_MODEL requeueing");
998
999 worker_res = ML_WORKER_RESULT_OK;
1000 should_short_circuit = ml_dimension_train_model_precheck(METRIC_TYPE_VARIABLE,
1001 false,
1002 false,
1003 &worker_res);
1004 ML_TEST_ASSERT(!should_short_circuit,
1005 "dimensions without downstream models should continue to training");
1006 ML_TEST_ASSERT(ml_should_requeue_create_new_model(ML_WORKER_RESULT_OK),
1007 "ordinary training results should keep CREATE_NEW_MODEL items requeueing");
1008
1009 worker_res = ML_WORKER_RESULT_OK;
1010 should_short_circuit = ml_dimension_train_model_precheck(METRIC_TYPE_VARIABLE,
1011 false,
1012 true,
1013 &worker_res);
1014 ML_TEST_ASSERT(should_short_circuit,
1015 "training-in-progress dimensions should short-circuit local training");
1016 ML_TEST_ASSERT(worker_res == ML_WORKER_RESULT_TRAINING_IN_PROGRESS,
1017 "training-in-progress dimensions should report the distinct result");
1018 ML_TEST_ASSERT(ml_should_requeue_create_new_model(worker_res),
1019 "training-in-progress result should keep CREATE_NEW_MODEL items requeueing "
1020 "so the dim stays in the periodic retrain cycle");
1021 }
1022
1023 static void test_reset_generation_cancels_model_publish()
1024 {
1025 fprintf(stderr, " test_reset_generation_cancels_model_publish...\n");
1026
1027 bool training_in_progress = true;
1028 bool should_publish = ml_should_publish_model_update(true, 8, 7, &training_in_progress);
1029 ML_TEST_ASSERT(!should_publish,
1030 "generation mismatch should cancel model publication");
1031 ML_TEST_ASSERT(!training_in_progress,
1032 "generation mismatch should clear training_in_progress");
1033
1034 training_in_progress = true;
1035 should_publish = ml_should_publish_model_update(false, 7, 7, &training_in_progress);
1036 ML_TEST_ASSERT(!should_publish,
1037 "stopped hosts should cancel model publication");
1038 ML_TEST_ASSERT(!training_in_progress,
1039 "stopped-host cancellation should clear training_in_progress");
1040
1041 training_in_progress = true;
1042 should_publish = ml_should_publish_model_update(true, 7, 7, &training_in_progress);
1043 ML_TEST_ASSERT(should_publish,
1044 "matching generation on a running host should allow model publication");
1045 ML_TEST_ASSERT(training_in_progress,
1046 "successful publication path should leave training_in_progress unchanged");
1047 }
1048
1049 extern "C" int ml_unittest()
1050 {
1051 fprintf(stderr, "\nML unit tests:\n");
1052
1053 // Initialize minimal global state needed by ml_features_lag
1054 Cfg.random_nums.clear();
1055 Cfg.random_nums.reserve(2048);
1056 for (size_t i = 0; i < 2048; i++)
1057 Cfg.random_nums.push_back(0); // all zeros => all samples pass the cutoff check
1058
1059 tests_run = 0;
1060 tests_failed = 0;
1061
1062 test_features_diff();
1063 test_features_no_diff();
1064 test_features_smooth();
1065 test_features_zero_smooth_matches_one();
1066 test_kmeans_scoring();
1067 test_full_pipeline();
1068 test_kmeans_inlined_empty_source_is_zero_initialized();
1069 test_features_preprocess_below_min_for_kmeans();
1070 test_dimension_finalize_constant_state();
1071 test_circular_buffer_equivalence();
1072 test_same_value_uses_newest_sample();
1073 test_preprocess_predict_equivalence();
1074 test_constant_input();
1075 test_parameter_combinations();
1076 test_kmeans_timestamp_roundtrip();
1077 test_kmeans_timestamp_rejection();
1078 test_downstream_model_short_circuit_and_requeue();
1079 test_reset_generation_cancels_model_publish();
1080
1081 fprintf(stderr, "\nML tests: %d run, %d failed\n", tests_run, tests_failed);
1082
1083 // Cleanup
1084 Cfg.random_nums.clear();
1085
1086 return tests_failed == 0 ? 0 : 1;
1087 }