47
std::vector<DSample> pf;
48
ml_features_t features = {
49
1, 1, 1, // diff_n=1, smooth_n=1, lag_n=1
50
- dst, n, src, n,
51
- pf
50
+ dst, n, src, n
51
};
52
53
// ml_features_preprocess calls diff, smooth, lag in sequence.
61
// feature vectors: [src[i], src[i+1]] for i in 0..6
62
// n_vectors = 9 - 1 - 1 + 1 - 1 = 7
63
65
- ml_features_preprocess(&features, 1.0);
64
+ ml_features_preprocess(&features, pf, 1.0);
65
66
ML_TEST_ASSERT(pf.size() == 7, "lag should produce 7 feature vectors");
67
if (pf.size() >= 1) {
88
std::vector<DSample> pf;
89
ml_features_t features = {
90
0, 1, 1, // diff_n=0, smooth_n=1, lag_n=1
92
- dst, n, src, n,
93
- pf
91
+ dst, n, src, n
92
};
93
96
- ml_features_preprocess(&features, 1.0);
94
+ ml_features_preprocess(&features, pf, 1.0);
95
96
// With diff_n=0, smooth_n=1 (no-op), lag_n=1:
97
// n_vectors = 6 - 0 - 1 + 1 - 1 = 5
119
std::vector<DSample> pf;
120
ml_features_t features = {
121
0, 3, 1, // diff_n=0, smooth_n=3, lag_n=1
124
- dst, n, src, n,
125
- pf
122
+ dst, n, src, n
123
};
124
128
- ml_features_preprocess(&features, 1.0);
125
+ ml_features_preprocess(&features, pf, 1.0);
126
127
// With diff_n=0: no diff
128
// Smooth with smooth_n=3, operating on src_n - diff_n = 9 elements:
148
}
149
}
150
151
+// Test: smooth_n=0 is normalized to the same effective smoothing window as
152
+// smooth_n=1, for both training and prediction.
153
+static void test_features_zero_smooth_matches_one()
154
+{
155
+ fprintf(stderr, " test_features_zero_smooth_matches_one...\n");
156
+
157
+ const size_t n = 6;
158
+ calculated_number_t input[16] = {10, 20, 30, 40, 50, 60};
159
+
160
+ calculated_number_t src0[16], dst0[16];
161
+ memcpy(src0, input, n * sizeof(calculated_number_t));
162
+ memcpy(dst0, input, n * sizeof(calculated_number_t));
163
+ std::vector<DSample> pf0;
164
+ ml_features_t features0 = {
165
+ 0, 0, 1,
166
+ dst0, n, src0, n
167
+ };
168
+ ml_features_preprocess(&features0, pf0, 1.0);
169
+
170
+ calculated_number_t src1[16], dst1[16];
171
+ memcpy(src1, input, n * sizeof(calculated_number_t));
172
+ memcpy(dst1, input, n * sizeof(calculated_number_t));
173
+ std::vector<DSample> pf1;
174
+ ml_features_t features1 = {
175
+ 0, 1, 1,
176
+ dst1, n, src1, n
177
+ };
178
+ ml_features_preprocess(&features1, pf1, 1.0);
179
+
180
+ ML_TEST_ASSERT(pf0.size() == pf1.size(), "smooth_n=0 and smooth_n=1 should produce the same number of vectors");
181
+ for (size_t i = 0; i < pf0.size() && i < pf1.size(); i++) {
182
+ for (long j = 0; j < pf0[i].size(); j++) {
183
+ char msg[128];
184
+ snprintf(msg, sizeof(msg), "smooth_n=0 should match smooth_n=1 at feature[%zu](%ld)", i, j);
185
+ ML_TEST_ASSERT_DOUBLE_EQ(pf0[i](j), pf1[i](j), 1e-12, msg);
186
+ }
187
+ }
188
+
189
+ DSample sample0, sample1;
190
+ memcpy(src0, input, n * sizeof(calculated_number_t));
191
+ memcpy(dst0, input, n * sizeof(calculated_number_t));
192
+ ml_features_preprocess_predict(&features0, sample0);
193
+
194
+ memcpy(src1, input, n * sizeof(calculated_number_t));
195
+ memcpy(dst1, input, n * sizeof(calculated_number_t));
196
+ ml_features_preprocess_predict(&features1, sample1);
197
+
198
+ ML_TEST_ASSERT(sample0.size() == sample1.size(), "prediction sample size should match for smooth_n=0 and smooth_n=1");
199
+ for (size_t i = 0; i < features0.lag_n + 1; i++) {
200
+ char msg[128];
201
+ snprintf(msg, sizeof(msg), "prediction smooth_n=0 should match smooth_n=1 at sample(%zu)", i);
202
+ ML_TEST_ASSERT_DOUBLE_EQ(sample0(i), sample1(i), 1e-12, msg);
203
+ }
204
+}
205
+
206
// Test: full pipeline with default-like params (diff_n=1, smooth_n=3, lag_n=5)
207
// Validates the feature vector shape and that a round-trip through
208
// train + score produces sensible anomaly scores.
237
std::vector<DSample> pf;
238
ml_features_t features = {
239
diff_n, smooth_n, lag_n,
188
- dst, n, src, n,
189
- pf
240
+ dst, n, src, n
241
};
191
- ml_features_preprocess(&features, 1.0);
242
+ ml_features_preprocess(&features, pf, 1.0);
243
244
// With these params:
245
// n_vectors = n - diff_n - smooth_n + 1 - lag_n = 9 - 1 - 3 + 1 - 5 = 1
259
260
// Train a kmeans model on the normal data
261
std::vector<DSample> training_features = std::move(all_features);
211
- ml_features_t train_ft = {
212
- diff_n, smooth_n, lag_n,
213
- nullptr, 0, nullptr, 0,
214
- training_features
215
- };
262
263
ml_kmeans_t kmeans;
264
ml_kmeans_init(&kmeans);
219
- ml_kmeans_train(&kmeans, &train_ft, 1000, 0, 100);
265
+ ml_kmeans_train(&kmeans, training_features, 1000, 0, 100);
266
267
ML_TEST_ASSERT(kmeans.cluster_centers.size() == 2, "kmeans should have 2 cluster centers");
268
ML_TEST_ASSERT(kmeans.min_dist < kmeans.max_dist, "min_dist < max_dist after training");
292
std::vector<DSample> pf;
293
ml_features_t features = {
294
diff_n, smooth_n, lag_n,
249
- dst, n, src, n,
250
- pf
295
+ dst, n, src, n
296
};
252
- ml_features_preprocess(&features, 1.0);
297
+ ml_features_preprocess(&features, pf, 1.0);
298
299
if (pf.size() >= 1) {
300
calculated_number_t anomaly_score = ml_kmeans_anomaly_score(&inlined_km, pf[0]);
402
std::vector<DSample> pf;
403
ml_features_t features = {
404
diff_n, smooth_n, lag_n,
360
- dst, n, src, n,
361
- pf
405
+ dst, n, src, n
406
};
363
- ml_features_preprocess(&features, 1.0);
407
+ ml_features_preprocess(&features, pf, 1.0);
408
409
if (pf.size() >= 1)
410
rotate_results.push_back(pf[0]);
435
std::vector<DSample> pf;
436
ml_features_t features = {
437
diff_n, smooth_n, lag_n,
394
- dst, n, src, n,
395
- pf
438
+ dst, n, src, n
439
};
397
- ml_features_preprocess(&features, 1.0);
440
+ ml_features_preprocess(&features, pf, 1.0);
441
442
if (pf.size() >= 1)
443
circ_results.push_back(pf[0]);
455
}
456
}
457
458
+// Test: same_value must compare against the previous newest sample, not the
459
+// oldest slot being overwritten. This locks in the intentional semantic change
460
+// in ml_dimension_predict().
461
+static void test_same_value_uses_newest_sample()
462
+{
463
+ fprintf(stderr, " test_same_value_uses_newest_sample...\n");
464
+
465
+ const size_t n = 5;
466
+ std::vector<calculated_number_t> cns = {7.0, 2.0, 3.0, 4.0, 5.0};
467
+ size_t cns_head = 0;
468
+ calculated_number_t incoming = 7.0;
469
+
470
+ // Circular buffer state:
471
+ // oldest slot being overwritten = cns[cns_head] = 7.0
472
+ // previous newest sample = cns[(cns_head + n - 1) % n] = 5.0
473
+ // If we compared against the oldest slot, same_value would be true and we'd
474
+ // miss the transition from 5.0 -> 7.0. Comparing against newest is correct.
475
+ bool old_rotate_equivalent = (cns[cns_head] == incoming);
476
+ size_t newest_idx = (cns_head + n - 1) % n;
477
+ bool new_ring_semantics = (cns[newest_idx] == incoming);
478
+
479
+ ML_TEST_ASSERT(old_rotate_equivalent,
480
+ "oldest-slot comparison should report same_value for this edge case");
481
+ ML_TEST_ASSERT(!new_ring_semantics,
482
+ "newest-sample comparison should detect the changed incoming value");
483
+}
484
+
485
// Test: ml_features_preprocess with a prediction-sized window produces the same
486
// feature vector as a manual reimplementation of diff + smooth + extract.
487
// This validates the preprocessing math and serves as a baseline for verifying
538
std::vector<DSample> pf;
539
ml_features_t features1 = {
540
diff_n, smooth_n, lag_n,
471
- dst1, n, src1, n,
472
- pf
541
+ dst1, n, src1, n
542
};
474
- ml_features_preprocess(&features1, 1.0);
543
+ ml_features_preprocess(&features1, pf, 1.0);
544
545
// With prediction-sized window: n_vectors = n - diff_n - smooth_n + 1 - lag_n = 1
546
char msg[256];
549
ML_TEST_ASSERT(pf.size() == 1, msg);
550
if (pf.size() != 1) continue;
551
483
- // Path 2: manual extraction matching what ml_features_preprocess_predict does:
484
- // diff + smooth, then read first lag_n+1 values from src.
485
- // This validates the logic without depending on the branch function existing.
552
+ // Path 2: ml_features_preprocess_predict should produce the same
553
+ // prediction-sized feature vector as the training preprocess path.
554
calculated_number_t src2[128], dst2[128];
555
memset(src2, 0, sizeof(src2));
556
memcpy(src2, input, n * sizeof(calculated_number_t));
557
memcpy(dst2, src2, n * sizeof(calculated_number_t));
558
491
- // Replicate diff
492
- if (diff_n > 0) {
493
- for (size_t idx = 0; idx != (n - diff_n); idx++) {
494
- size_t high = (n - 1) - idx;
495
- size_t low = high - diff_n;
496
- dst2[low] = src2[high] - src2[low];
497
- }
498
- memcpy(src2, dst2, (n - diff_n) * sizeof(calculated_number_t));
499
- for (size_t idx = n - diff_n; idx != n; idx++)
500
- src2[idx] = 0.0;
501
- }
502
-
503
- // Replicate smooth
504
- {
505
- calculated_number_t sum = 0.0;
506
- size_t idx = 0;
507
- for (; idx != smooth_n - 1; idx++)
508
- sum += src2[idx];
509
- for (; idx != (n - diff_n); idx++) {
510
- sum += src2[idx];
511
- calculated_number_t prev = src2[idx - (smooth_n - 1)];
512
- src2[idx - (smooth_n - 1)] = sum / smooth_n;
513
- sum -= prev;
514
- }
515
- for (idx = 0; idx != smooth_n; idx++)
516
- src2[(n - 1) - idx] = 0.0;
517
- }
518
-
519
- // Extract feature: first lag_n+1 values (what preprocess_predict does)
520
- DSample direct_feature;
521
- direct_feature.set_size(lag_n + 1);
522
- for (size_t fi = 0; fi != lag_n + 1; fi++)
523
- direct_feature(fi) = src2[fi];
559
+ ml_features_t features2 = {
560
+ diff_n, smooth_n, lag_n,
561
+ dst2, n, src2, n
562
+ };
563
+ DSample predicted_feature;
564
+ ml_features_preprocess_predict(&features2, predicted_feature);
565
525
- // Compare: pf[0] from full pipeline must match direct extraction
566
+ // Compare: pf[0] from full pipeline must match the direct prediction path.
567
for (size_t fi = 0; fi < lag_n + 1; fi++) {
527
- snprintf(msg, sizeof(msg), "params(%zu,%zu,%zu) %s: feature[%zu] preprocess vs direct",
568
+ snprintf(msg, sizeof(msg), "params(%zu,%zu,%zu) %s: feature[%zu] preprocess vs predict",
569
diff_n, smooth_n, lag_n, filler_names[f], fi);
529
- ML_TEST_ASSERT_DOUBLE_EQ(pf[0](fi), direct_feature(fi), 1e-12, msg);
570
+ ML_TEST_ASSERT_DOUBLE_EQ(pf[0](fi), predicted_feature(fi), 1e-12, msg);
571
}
572
}
573
}
592
std::vector<DSample> pf;
593
ml_features_t features = {
594
diff_n, smooth_n, lag_n,
554
- dst, n, src, n,
555
- pf
595
+ dst, n, src, n
596
};
557
- ml_features_preprocess(&features, 1.0);
597
+ ml_features_preprocess(&features, pf, 1.0);
598
599
ML_TEST_ASSERT(pf.size() == 1, "constant input should produce 1 feature vector");
600
617
std::vector<DSample> pf2;
618
ml_features_t features2 = {
619
0, smooth_n, lag_n,
580
- dst2, n, src2, n,
581
- pf2
620
+ dst2, n, src2, n
621
};
583
- ml_features_preprocess(&features2, 1.0);
622
+ ml_features_preprocess(&features2, pf2, 1.0);
623
624
// With diff_n=0, smooth on constant values gives the same constant.
625
// Feature vector should be all 42.0.
709
std::vector<DSample> pf;
710
ml_features_t features = {
711
diff_n, smooth_n, lag_n,
673
- dst, n, src, n,
674
- pf
712
+ dst, n, src, n
713
};
676
- ml_features_preprocess(&features, 1.0);
714
+ ml_features_preprocess(&features, pf, 1.0);
715
716
char msg[256];
717
snprintf(msg, sizeof(msg), "params(%zu,%zu,%zu): expected %zu vectors, got %zu",
746
std::vector<DSample> pf_large;
747
ml_features_t features_large = {
748
diff_n, smooth_n, lag_n,
711
- dst_large, large_n, src_large, large_n,
712
- pf_large
749
+ dst_large, large_n, src_large, large_n
750
};
714
- ml_features_preprocess(&features_large, 1.0);
751
+ ml_features_preprocess(&features_large, pf_large, 1.0);
752
753
size_t expected_large = large_n - diff_n - smooth_n + 1 - lag_n;
754
snprintf(msg, sizeof(msg), "params(%zu,%zu,%zu) large window: expected %zu vectors",
773
test_features_diff();
774
test_features_no_diff();
775
test_features_smooth();
776
+ test_features_zero_smooth_matches_one();
777
test_kmeans_scoring();
778
test_full_pipeline();
779
test_circular_buffer_equivalence();
780
+ test_same_value_uses_newest_sample();
781
test_preprocess_predict_equivalence();
782
test_constant_input();
783
test_parameter_combinations();