-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathpredict-forecast.c
More file actions
3563 lines (3352 loc) · 116 KB
/
Copy pathpredict-forecast.c
File metadata and controls
3563 lines (3352 loc) · 116 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/* SPDX-License-Identifier: MIT OR Apache-2.0
* Copyright (c) 2026 Pure Storage, Inc.
*/
/* The series operations. In file order: the bundled statistical models
* (theta, seasonal-naive, tsb; sub-pca for anomalies), the rolling-origin
* backtest core, options parsing, series collection (backtest's front
* end), the shared serving core (model resolution, auto candidate
* discovery, per-series runs), the backtest() table-valued function
* the forecast()/detect_anomalies() aggregates, and the
* forecast_rows()/anomaly_rows() expansion functions. */
#include "predict-internal.h"
#include "predict-student.h"
#ifndef SQLITE_CORE
SQLITE_EXTENSION_INIT3
#endif
#define FORECAST_MAX_HORIZON 1000
#define PREDICT_STR_(x) #x
#define PREDICT_STR(x) PREDICT_STR_(x)
#define FORECAST_DEFAULT_CONTEXT_LIMIT 4096
#define FORECAST_MIN_HISTORY 8
#define FORECAST_MAX_TOTAL_ROWS (1 << 21)
#define FORECAST_MAX_GROUP_COLS 8
#pragma region models
/* Detect a seasonal period: autocorrelation of the OLS-detrended series
* (a trend inflates raw ACF at every lag and buries the seasonal peak),
* accepting only local ACF peaks above threshold. Returns 0 when nothing
* convincing is found. */
static int detect_period(const f64 *y, int n) {
if (n < 3 * 2)
return 0;
int max_lag = n / 2;
if (max_lag > 366)
max_lag = 366;
f64 *d = sqlite3_malloc(sizeof(f64) * n);
if (!d)
return 0;
f64 st = 0, sy = 0, stt = 0, sty = 0;
for (int i = 0; i < n; i++) {
st += i;
sy += y[i];
stt += (f64)i * i;
sty += (f64)i * y[i];
}
f64 denom = (f64)n * stt - st * st;
f64 b = denom != 0 ? ((f64)n * sty - st * sy) / denom : 0;
f64 a = (sy - b * st) / n;
for (int i = 0; i < n; i++)
d[i] = y[i] - (a + b * i);
f64 c0 = 0;
for (int i = 0; i < n; i++)
c0 += d[i] * d[i];
if (c0 <= 0) {
sqlite3_free(d);
return 0;
}
f64 *acf = sqlite3_malloc(sizeof(f64) * (max_lag + 2));
if (!acf) {
sqlite3_free(d);
return 0;
}
for (int lag = 1; lag <= max_lag + 1 && lag < n; lag++) {
f64 c = 0;
for (int i = lag; i < n; i++)
c += d[i] * d[i - lag];
acf[lag] = c / c0;
}
int best = 0;
f64 best_acf = 0.3; /* threshold: below this, treat as non-seasonal */
for (int lag = 2; lag <= max_lag && lag + 1 < n; lag++) {
int is_peak = acf[lag] > acf[lag - 1] && acf[lag] >= acf[lag + 1];
if (is_peak && acf[lag] > best_acf) {
best_acf = acf[lag];
best = lag;
}
}
sqlite3_free(d);
sqlite3_free(acf);
return best;
}
/* Seasonal naive with drift. Baseline floor: repeat last season (or last
* value) plus the global drift. */
static void model_seasonal_naive(const f64 *y, int n, int horizon, f64 *fc,
f64 *sigma) {
int p = detect_period(y, n);
f64 drift = n > 1 ? (y[n - 1] - y[0]) / (n - 1) : 0;
for (int h = 1; h <= horizon; h++) {
f64 base;
i64 gap; /* steps between the base observation and the target */
if (p > 0 && n >= p) {
int base_idx = n - p + ((h - 1) % p);
base = y[base_idx];
gap = (n - 1 + h) - base_idx;
} else {
base = y[n - 1];
gap = h;
}
fc[h - 1] = base + drift * (f64)gap;
}
if (!sigma)
return; /* point-only path (backtesting): skip the interval computation */
/* Per-horizon sigma from an in-sample backtest of this exact forecast:
* for each past origin, forecast h steps ahead and record the error, then
* take the RMS per h. This calibrates the interval to the model's real
* h-step error, with no lag-1-vs-seasonal scale confusion and no random-walk
* sqrt(h) growth assumption (both of which made the old intervals too wide
* on seasonal data). Falls back to lag-1 * sqrt(h) only on OOM. */
f64 *ss = sqlite3_malloc(sizeof(f64) * horizon);
int *cnt = sqlite3_malloc(sizeof(int) * horizon);
if (ss && cnt) {
for (int h = 0; h < horizon; h++) {
ss[h] = 0;
cnt[h] = 0;
}
int t0 = p > 0 ? p : 1;
for (int t = t0; t < n; t++) { /* history y[0..t-1], forecast y[t..] */
f64 dr = t > 1 ? (y[t - 1] - y[0]) / (t - 1) : 0;
for (int h = 1; h <= horizon && t - 1 + h < n; h++) {
f64 base;
i64 gap;
if (p > 0 && t >= p) {
int bi = t - p + ((h - 1) % p);
base = y[bi];
gap = (t - 1 + h) - bi;
} else {
base = y[t - 1];
gap = h;
}
f64 e = y[t - 1 + h] - (base + dr * (f64)gap);
ss[h - 1] += e * e;
cnt[h - 1]++;
}
}
f64 fb = 0; /* lag-1 fallback scale for thin horizons */
int fm = 0;
for (int i = 1; i < n; i++) {
f64 r = y[i] - y[i - 1];
fb += r * r;
fm++;
}
fb = fm > 0 ? sqrt(fb / fm) : 0;
for (int h = 1; h <= horizon; h++)
sigma[h - 1] =
cnt[h - 1] >= 2 ? sqrt(ss[h - 1] / cnt[h - 1]) : fb * sqrt((f64)h);
} else {
f64 s2 = 0;
int m = 0;
for (int i = 1; i < n; i++) {
f64 r = y[i] - y[i - 1];
s2 += r * r;
m++;
}
f64 sigma1 = m > 0 ? sqrt(s2 / m) : 0;
for (int h = 1; h <= horizon; h++)
sigma[h - 1] = sigma1 * sqrt((f64)h);
}
sqlite3_free(ss);
sqlite3_free(cnt);
}
/* Classic Theta(0,2) with additive seasonal adjustment: average of the
* linear-regression line extrapolation and SES (grid-searched alpha) on
* the theta=2 line. The strongest of the simple statistical methods. */
static void model_theta(const f64 *y, int n, int horizon, f64 *fc,
f64 *sigma) {
int p = detect_period(y, n);
f64 *yd = sqlite3_malloc(sizeof(f64) * n);
f64 *seas = NULL;
if (!yd) {
model_seasonal_naive(y, n, horizon, fc, sigma);
return;
}
memcpy(yd, y, sizeof(f64) * n);
/* additive seasonal indices from period-phase means of the DETRENDED
* series — computing them on raw values leaks the trend slope into
* the indices, one phase-width of drift per index */
if (p > 1 && n >= 2 * p) {
seas = sqlite3_malloc(sizeof(f64) * p);
if (seas) {
f64 st0 = 0, sy0 = 0, stt0 = 0, sty0 = 0;
for (int i = 0; i < n; i++) {
st0 += i;
sy0 += y[i];
stt0 += (f64)i * i;
sty0 += (f64)i * y[i];
}
f64 den0 = (f64)n * stt0 - st0 * st0;
f64 b0 = den0 != 0 ? ((f64)n * sty0 - st0 * sy0) / den0 : 0;
f64 a0 = (sy0 - b0 * st0) / n;
for (int k = 0; k < p; k++) {
f64 s = 0;
int c = 0;
for (int i = k; i < n; i += p) {
s += y[i] - (a0 + b0 * i);
c++;
}
seas[k] = c ? s / c : 0;
}
for (int i = 0; i < n; i++)
yd[i] -= seas[i % p];
}
}
/* OLS line a + b*t over the deseasonalized series */
f64 st = 0, sy = 0, stt = 0, sty = 0;
for (int i = 0; i < n; i++) {
st += i;
sy += yd[i];
stt += (f64)i * i;
sty += i * yd[i];
}
f64 denom = n * stt - st * st;
f64 b = denom != 0 ? (n * sty - st * sy) / denom : 0;
f64 a = (sy - b * st) / n;
/* SES over the theta=2 line, alpha by grid search on one-step SSE */
f64 best_alpha = 0.5, best_sse = -1, best_level = yd[n - 1];
for (f64 alpha = 0.05; alpha < 0.96; alpha += 0.05) {
f64 level = 2 * yd[0] - (a);
f64 sse = 0;
for (int i = 1; i < n; i++) {
f64 z = 2 * yd[i] - (a + b * i);
f64 e = z - level;
sse += e * e;
level += alpha * e;
}
if (best_sse < 0 || sse < best_sse) {
best_sse = sse;
best_alpha = alpha;
best_level = level;
}
}
f64 sigma1 = n > 1 ? sqrt(best_sse / (n - 1)) / 2 : 0;
for (int h = 1; h <= horizon; h++) {
f64 line = a + b * (n - 1 + h);
f64 theta_fc = 0.5 * line + 0.5 * best_level;
if (seas && p > 0)
theta_fc += seas[(n + h - 1) % p];
fc[h - 1] = theta_fc;
}
if (!sigma) { /* point-only path (backtesting): skip the interval computation */
sqlite3_free(yd);
sqlite3_free(seas);
return;
}
/* Per-horizon sigma from the fitted model's in-sample h-step errors: roll
* the theta forecast forward from every past origin using that origin's SES
* level, and take the RMS error per h. Empirically calibrated per horizon,
* replacing the old ad-hoc sigma1 * sqrt(h) / 2 growth. */
f64 *lev = sqlite3_malloc(sizeof(f64) * n);
f64 *ss = sqlite3_malloc(sizeof(f64) * horizon);
int *cnt = sqlite3_malloc(sizeof(int) * horizon);
if (lev && ss && cnt) {
f64 level = 2 * yd[0] - a;
lev[0] = level;
for (int i = 1; i < n; i++) {
f64 z = 2 * yd[i] - (a + b * i);
level += best_alpha * (z - level);
lev[i] = level;
}
for (int h = 0; h < horizon; h++) {
ss[h] = 0;
cnt[h] = 0;
}
for (int t = 0; t < n; t++)
for (int h = 1; h <= horizon && t + h < n; h++) {
f64 f = 0.5 * (a + b * (t + h)) + 0.5 * lev[t];
if (seas && p > 0)
f += seas[(t + h) % p];
f64 e = y[t + h] - f;
ss[h - 1] += e * e;
cnt[h - 1]++;
}
for (int h = 1; h <= horizon; h++)
sigma[h - 1] =
cnt[h - 1] >= 2 ? sqrt(ss[h - 1] / cnt[h - 1]) : sigma1 * sqrt((f64)h);
} else {
for (int h = 1; h <= horizon; h++)
sigma[h - 1] = sigma1 * sqrt((f64)h);
}
sqlite3_free(lev);
sqlite3_free(ss);
sqlite3_free(cnt);
sqlite3_free(yd);
sqlite3_free(seas);
}
/* Teunter-Syntetos-Babai (TSB) for intermittent demand: separate exponential
* smoothing of the demand probability and the demand size, forecast = p*z (a
* flat rate). Handles the sparse, mostly-zero series (rare events: errors,
* retries, uncommon tool calls) that theta and seasonal-naive model poorly.
* Non-negative demand is assumed; a value <= 0 counts as no demand. */
static void model_tsb(const f64 *y, int n, int horizon, f64 *fc, f64 *sigma) {
const f64 alpha = 0.1, beta = 0.1; /* size and probability smoothing */
f64 sz = 0, sq = 0;
int nnz = 0;
for (int i = 0; i < n; i++) {
sq += y[i] * y[i];
if (y[i] > 0) {
sz += y[i];
nnz++;
}
}
f64 z = nnz ? sz / nnz : 0; /* mean nonzero demand size */
f64 p = n ? (f64)nnz / n : 0; /* demand probability */
f64 *rate = sqlite3_malloc(sizeof(f64) * (n > 0 ? n : 1));
f64 cur = p * z;
for (int t = 0; t < n; t++) {
if (y[t] > 0) {
z += alpha * (y[t] - z);
p += beta * (1.0 - p);
} else {
p += beta * (0.0 - p);
}
cur = p * z;
if (rate)
rate[t] = cur; /* causal forecast made after seeing y[0..t] */
}
for (int h = 0; h < horizon; h++)
fc[h] = cur; /* intermittent forecast is a flat rate */
if (!sigma) { /* point-only path (backtesting): skip the interval computation */
sqlite3_free(rate);
return;
}
f64 *ss = sqlite3_malloc(sizeof(f64) * horizon);
int *cnt = sqlite3_malloc(sizeof(int) * horizon);
if (rate && ss && cnt) {
for (int h = 0; h < horizon; h++) {
ss[h] = 0;
cnt[h] = 0;
}
f64 fb2 = 0;
int fbm = 0;
for (int t = 1; t < n; t++) { /* h-step errors from each causal origin */
f64 r = rate[t - 1];
f64 e0 = y[t] - r;
fb2 += e0 * e0;
fbm++;
for (int h = 1; h <= horizon && t - 1 + h < n; h++) {
f64 e = y[t - 1 + h] - r;
ss[h - 1] += e * e;
cnt[h - 1]++;
}
}
f64 fb = fbm > 0 ? sqrt(fb2 / fbm) : 0;
for (int h = 1; h <= horizon; h++)
sigma[h - 1] = cnt[h - 1] >= 2 ? sqrt(ss[h - 1] / cnt[h - 1]) : fb;
} else { /* OOM fallback: flat sigma from the overall series std */
f64 smean = 0;
for (int i = 0; i < n; i++)
smean += y[i];
f64 mean = n ? smean / n : 0;
f64 var = n ? sq / n - mean * mean : 0;
for (int h = 0; h < horizon; h++)
sigma[h] = var > 0 ? sqrt(var) : 0;
}
sqlite3_free(rate);
sqlite3_free(ss);
sqlite3_free(cnt);
}
typedef void (*predict0_ts_model)(const f64 *, int, int, f64 *, f64 *);
typedef struct {
const char *id;
predict0_ts_model run;
} ts_model_entry;
static const ts_model_entry TS_MODELS[] = {
{"stub-seasonal-naive", model_seasonal_naive},
{"theta-classic", model_theta},
{"tsb", model_tsb},
};
static predict0_ts_model resolve_ts_model(const char *name) {
if (!name || strcmp(name, "default-ts") == 0)
return model_theta;
for (usize i = 0; i < countof(TS_MODELS); i++) {
if (strcmp(TS_MODELS[i].id, name) == 0)
return TS_MODELS[i].run;
}
return NULL;
}
static const char *resolve_ts_model_id(const char *name) {
if (!name || strcmp(name, "default-ts") == 0)
return "theta-classic";
for (usize i = 0; i < countof(TS_MODELS); i++) {
if (strcmp(TS_MODELS[i].id, name) == 0)
return TS_MODELS[i].id;
}
return NULL;
}
/* ---- shared rolling-origin backtest (auto-selection, conformal, backtest()) ---- */
#define FORECAST_DEFAULT_FOLDS 20
#define FORECAST_MAX_FOLDS 512
#define CONFORMAL_MIN_FOLDS 3
#define FORECAST_MAX_CANDIDATES 8
/* Mean absolute one-step difference of y[0..n): the in-sample naive-forecast
* MAE that scales MASE. 0 for a constant (or length < 2) series. */
static f64 naive_scale(const f64 *y, int n) {
f64 s = 0;
int m = 0;
for (int i = 1; i < n; i++) {
s += fabs(y[i] - y[i - 1]);
m++;
}
return m > 0 ? s / m : 0;
}
static f64 metric_mae(const f64 *a, const f64 *f, int m) {
f64 s = 0;
for (int i = 0; i < m; i++)
s += fabs(a[i] - f[i]);
return m > 0 ? s / m : 0;
}
static f64 metric_rmse(const f64 *a, const f64 *f, int m) {
f64 s = 0;
for (int i = 0; i < m; i++) {
f64 e = a[i] - f[i];
s += e * e;
}
return m > 0 ? sqrt(s / m) : 0;
}
/* Symmetric MAPE in [0,2]. A pair with |a|+|f| == 0 is a perfect 0-vs-0 hit:
* it contributes 0 and is still counted, so two identical zero series score 0. */
static f64 metric_smape(const f64 *a, const f64 *f, int m) {
f64 s = 0;
for (int i = 0; i < m; i++) {
f64 d = fabs(a[i]) + fabs(f[i]);
if (d > 0)
s += 2.0 * fabs(a[i] - f[i]) / d;
}
return m > 0 ? s / m : 0;
}
/* Rolling-origin backtest of a point model over series y[0..n) with timestamps
* ts. For each of up to `nfolds` contiguous origins ending at the latest usable
* cutoff, fit `model` on the prefix and forecast gap+horizon steps, keeping the
* last `horizon` (so `gap` points sit between train end and the first scored
* target: a leakage guard). Fills act/fcst[folds*horizon] row-major by fold;
* when sig != NULL, the model's per-horizon sigma too; when cutoff_ms != NULL,
* each origin's last-training timestamp. Returns the number of folds evaluated
* (0 if history is too short), or a negative SQLITE_ code on OOM. */
static int ts_backtest(predict0_ts_model model, const f64 *y, const i64 *ts,
int n, int horizon, int gap, int nfolds, f64 *act,
f64 *fcst, f64 *sig, i64 *cutoff_ms) {
int H2 = gap + horizon;
int c_max = n - H2; /* latest cutoff with H2 held-out actuals */
int c_min = FORECAST_MIN_HISTORY; /* minimum training length */
if (c_max < c_min)
return 0;
int avail = c_max - c_min + 1;
int folds = nfolds < avail ? nfolds : avail;
f64 *fb = sqlite3_malloc(sizeof(f64) * H2);
f64 *sb = sig ? sqlite3_malloc(sizeof(f64) * H2) : NULL;
if (!fb || (sig && !sb)) {
sqlite3_free(fb);
sqlite3_free(sb);
return -SQLITE_NOMEM;
}
for (int f = 0; f < folds; f++) {
int c = c_max - (folds - 1 - f); /* ascending cutoffs; last fold = c_max */
model(y, c, H2, fb, sb);
for (int h = 0; h < horizon; h++) {
int step = gap + h;
act[f * horizon + h] = y[c + step];
fcst[f * horizon + h] = fb[step];
if (sig)
sig[f * horizon + h] = sb[step];
}
if (cutoff_ms)
cutoff_ms[f] = ts[c - 1];
}
sqlite3_free(fb);
sqlite3_free(sb);
return folds;
}
/* Pick the TS_MODELS index that minimizes rolling-origin MASE on this series.
* scale is naive_scale(y,n); act/fcst are scratch [nfolds*horizon]. Returns the
* index, -1 if no fold was usable, or a negative SQLITE_ code on OOM. */
static int ts_auto_select(const f64 *y, const i64 *ts, int n, int horizon,
int gap, int nfolds, f64 scale, f64 *act, f64 *fcst) {
int best = -1;
f64 best_mase = 0;
for (usize mi = 0; mi < countof(TS_MODELS); mi++) {
int nf = ts_backtest(TS_MODELS[mi].run, y, ts, n, horizon, gap, nfolds, act,
fcst, NULL, NULL);
if (nf < 0)
return nf;
if (nf == 0)
continue;
f64 mae = metric_mae(act, fcst, nf * horizon);
f64 mase = scale > 0 ? mae / scale : mae;
if (best < 0 || mase < best_mase) {
best = (int)mi;
best_mase = mase;
}
}
return best;
}
/* The conformal quantile rule over m absolute residuals in col[0..m):
* sort ascending, take rank ceil((m+1)*conf), clamped to [1, m]. One
* implementation on purpose: served intervals (ts_conformal_q) and
* backtest-reported coverage (bt_lofo_q) must agree or coverage claims
* silently diverge from what forecast() serves. Mutates col. */
static f64 conformal_quantile(f64 *col, int m, f64 conf) {
for (int i = 1; i < m; i++) { /* insertion sort (m small) */
f64 v = col[i];
int j = i - 1;
while (j >= 0 && col[j] > v) {
col[j + 1] = col[j];
j--;
}
col[j + 1] = v;
}
int rank = (int)ceil((f64)(m + 1) * conf);
if (rank > m)
rank = m;
if (rank < 1)
rank = 1;
return col[rank - 1];
}
/* Per-step conformal absolute-residual quantiles at level conf, from an out-of-
* sample rolling-origin backtest of `model`. Fills q[horizon]; act/fcst are
* scratch [nfolds*horizon] and col is scratch [nfolds]. Returns the fold count
* used, 0 if too short, or a negative SQLITE_ code on OOM. */
static int ts_conformal_q(predict0_ts_model model, const f64 *y, const i64 *ts,
int n, int horizon, int gap, int nfolds, f64 conf,
f64 *act, f64 *fcst, f64 *col, f64 *q) {
int nf = ts_backtest(model, y, ts, n, horizon, gap, nfolds, act, fcst, NULL,
NULL);
if (nf <= 0)
return nf;
for (int h = 0; h < horizon; h++) {
for (int f = 0; f < nf; f++)
col[f] = fabs(act[f * horizon + h] - fcst[f * horizon + h]);
q[h] = conformal_quantile(col, nf, conf);
}
return nf;
}
/* ---- forecast student serving (PSFCST blobs) ---- */
/* Forecast H steps from the length-L window `src` (raw units): instance-
* normalize by the window's own (mean, sd), apply the net, de-normalize. */
static void fcst_infer(const MLP *m, const f64 *src, f32 *win, f32 *hid,
f32 *out, f64 *mu_out, f64 *sd_out) {
int L = m->nfeat;
f64 mu = 0;
for (int i = 0; i < L; i++)
mu += src[i];
mu /= L;
f64 v = 0;
for (int i = 0; i < L; i++) {
f64 d = src[i] - mu;
v += d * d;
}
f64 sd = sqrt(v / L);
if (sd < 1e-9)
sd = 1e-9;
for (int i = 0; i < L; i++)
win[i] = (f32)((src[i] - mu) / sd);
predict0_mlp_forward(m, win, hid, out);
*mu_out = mu;
*sd_out = sd;
}
/* Value of the quantile at level p by linear interpolation over the student's
* (ascending) levels, extrapolating the tails (e.g. deciles to a 95% band). */
f64 predict0_quantile_at(const f32 *lev, const f64 *val, int Q, f64 p) {
if (Q == 1)
return val[0];
if (p <= lev[0])
return val[0] + (val[1] - val[0]) / (lev[1] - lev[0]) * (p - lev[0]);
if (p >= lev[Q - 1])
return val[Q - 1] +
(val[Q - 1] - val[Q - 2]) / (lev[Q - 1] - lev[Q - 2]) * (p - lev[Q - 1]);
for (int i = 1; i < Q; i++)
if (p <= lev[i])
return val[i - 1] +
(p - lev[i - 1]) / (lev[i] - lev[i - 1]) * (val[i] - val[i - 1]);
return val[Q - 1];
}
/* Serve a forecast student over series y[0..n-1]. Fills fc/lo/hi[horizon] (the
* point and the interval at `conf`). For a quantile student (nquant>1) the
* point and bounds come straight off the emitted fan; for a point student the
* bound comes from a refit-free backtest of the student over its own history.
* horizon MUST be <= fs->horizon (checked by the caller). */
static int fcst_run(const ForecastStudent *fs, const f64 *y, int n, int horizon,
f64 conf, f64 *fc, f64 *lo, f64 *hi) {
const MLP *m = &fs->mlp;
int L = m->nfeat, H = fs->horizon, Q = fs->nquant, rc = SQLITE_OK;
f32 *win = sqlite3_malloc(sizeof(f32) * L);
f32 *hid = sqlite3_malloc(sizeof(f32) * (m->nhid > 0 ? m->nhid : 1));
f32 *out = sqlite3_malloc(sizeof(f32) * m->nout);
f64 *wbuf = sqlite3_malloc(sizeof(f64) * L);
f64 *val = sqlite3_malloc(sizeof(f64) * Q);
f64 *ss = sqlite3_malloc(sizeof(f64) * H);
int *cnt = sqlite3_malloc(sizeof(int) * H);
if (!win || !hid || !out || !wbuf || !val || !ss || !cnt) {
rc = SQLITE_NOMEM;
goto done;
}
/* most-recent window, front-padded with y[0] when the series is short */
for (int i = 0; i < L; i++) {
int idx = n - L + i;
wbuf[i] = idx >= 0 ? y[idx] : y[0];
}
f64 mu, sd;
fcst_infer(m, wbuf, win, hid, out, &mu, &sd);
f64 plo = (1 - conf) / 2, phi = (1 + conf) / 2;
for (int h = 0; h < horizon; h++) {
for (int qq = 0; qq < Q; qq++) /* de-normalize this step's fan */
val[qq] = (f64)out[h * Q + qq] * sd + mu;
for (int i = 1; i < Q; i++) /* sort for monotone quantiles */
for (int j = i; j > 0 && val[j] < val[j - 1]; j--) {
f64 t = val[j];
val[j] = val[j - 1];
val[j - 1] = t;
}
fc[h] = predict0_quantile_at(fs->levels, val, Q, 0.5);
if (Q > 1) {
lo[h] = predict0_quantile_at(fs->levels, val, Q, plo);
hi[h] = predict0_quantile_at(fs->levels, val, Q, phi);
}
}
if (Q == 1) { /* point student: interval from a refit-free backtest */
f64 z = predict0_norm_quantile(0.5 + conf / 2);
for (int k = 0; k < H; k++) {
ss[k] = 0;
cnt[k] = 0;
}
int stride = H > 1 ? H / 2 : 1;
for (int t = L; t + H <= n; t += stride) {
f64 bmu, bsd;
fcst_infer(m, y + t - L, win, hid, out, &bmu, &bsd);
for (int k = 0; k < H; k++) {
f64 e = y[t + k] - ((f64)out[k] * bsd + bmu);
ss[k] += e * e;
cnt[k]++;
}
}
for (int h = 0; h < horizon; h++) {
f64 sg = cnt[h] ? sqrt(ss[h] / cnt[h]) : sd; /* fallback: window scale */
lo[h] = fc[h] - z * sg;
hi[h] = fc[h] + z * sg;
}
}
done:
sqlite3_free(win);
sqlite3_free(hid);
sqlite3_free(out);
sqlite3_free(wbuf);
sqlite3_free(val);
sqlite3_free(ss);
sqlite3_free(cnt);
return rc;
}
/* Load a forecast student registered under model_id, if the id names one.
* Returns 1 and fills *fs on hit; 0 if the id is not a registered forecast
* student; a negative SQLITE_ code (and *errmsg) on a load error. */
static int load_fcst_student(sqlite3 *db, const char *model_id,
ForecastStudent *fs, char **errmsg) {
/* any PSFCST version (the deserializer validates the exact version) */
static const char FCST_PREFIX[6] = {'P', 'S', 'F', 'C', 'S', 'T'};
predict0_model_row row;
int lr = predict0_registry_lookup(db, model_id, &row);
if (lr == 2) { /* tampered/corrupted weights: loud, never a fallback */
*errmsg = sqlite3_mprintf("%s: weights do not match content_hash: %s",
PREDICT_ERR_MODEL_HASH, model_id);
return -SQLITE_ERROR;
}
if (lr != 0)
return 0; /* absent or lookup error: treat as not-a-forecast-student */
int is_fcst = row.weights && row.weights_len >= (int)sizeof(FCST_PREFIX) &&
memcmp(row.weights, FCST_PREFIX, sizeof(FCST_PREFIX)) == 0;
if (!is_fcst) {
predict0_model_row_free(&row);
return 0;
}
int rc = predict0_fcst_deserialize(row.weights, row.weights_len, fs, errmsg);
predict0_model_row_free(&row);
return rc == SQLITE_OK ? 1 : -rc;
}
/* Point forecast of a student over y[0..n): the median of its emitted fan per
* step (no interval). Lets a student be rolling-origin-evaluated as an auto
* candidate without the cost of its interval backtest. */
static int fcst_point(const ForecastStudent *fs, const f64 *y, int n,
int horizon, f64 *fc) {
const MLP *m = &fs->mlp;
int L = m->nfeat, Q = fs->nquant, rc = SQLITE_OK;
f32 *win = sqlite3_malloc(sizeof(f32) * L);
f32 *hid = sqlite3_malloc(sizeof(f32) * (m->nhid > 0 ? m->nhid : 1));
f32 *out = sqlite3_malloc(sizeof(f32) * m->nout);
f64 *wbuf = sqlite3_malloc(sizeof(f64) * L);
f64 *val = sqlite3_malloc(sizeof(f64) * Q);
if (!win || !hid || !out || !wbuf || !val) {
rc = SQLITE_NOMEM;
goto done;
}
for (int i = 0; i < L; i++) {
int idx = n - L + i;
wbuf[i] = idx >= 0 ? y[idx] : y[0];
}
f64 mu, sd;
fcst_infer(m, wbuf, win, hid, out, &mu, &sd);
for (int h = 0; h < horizon; h++) {
for (int qq = 0; qq < Q; qq++)
val[qq] = (f64)out[h * Q + qq] * sd + mu;
for (int i = 1; i < Q; i++)
for (int j = i; j > 0 && val[j] < val[j - 1]; j--) {
f64 t = val[j];
val[j] = val[j - 1];
val[j - 1] = t;
}
fc[h] = predict0_quantile_at(fs->levels, val, Q, 0.5);
}
done:
sqlite3_free(win);
sqlite3_free(hid);
sqlite3_free(out);
sqlite3_free(wbuf);
sqlite3_free(val);
return rc;
}
/* An auto candidate: a bundled statistical model or a loaded forecast student. */
typedef struct {
const char *id; /* borrowed: static id, or opts.candidates[i] */
predict0_ts_model stat; /* non-NULL for a statistical model */
ForecastStudent student; /* valid when is_student */
int is_student;
} Candidate;
/* Point forecast of a candidate on y[0..n) -> fc[horizon]. */
static int candidate_point(const Candidate *c, const f64 *y, int n, int horizon,
f64 *fc) {
if (c->is_student)
return fcst_point(&c->student, y, n, horizon, fc);
c->stat(y, n, horizon, fc, NULL);
return SQLITE_OK;
}
/* Rolling-origin aggregate MAE of a candidate (the metric auto ranks by; the
* MASE scale is constant per series, so MAE ordering == MASE ordering).
* fc_scratch is sized >= gap+horizon. Sets *nf to folds used (0 if too short);
* returns the MAE, or a negative SQLITE_ code on OOM. */
static f64 candidate_mae(const Candidate *c, const f64 *y, int n, int horizon,
int gap, int nfolds, f64 *fc_scratch, int *nf) {
int H2 = gap + horizon, c_max = n - H2, c_min = FORECAST_MIN_HISTORY;
*nf = 0;
if (c_max < c_min)
return 0;
int avail = c_max - c_min + 1;
int folds = nfolds < avail ? nfolds : avail;
f64 sum = 0;
int cnt = 0;
for (int f = 0; f < folds; f++) {
int cc = c_max - (folds - 1 - f);
int rc = candidate_point(c, y, cc, H2, fc_scratch);
if (rc != SQLITE_OK)
return -rc;
for (int h = 0; h < horizon; h++) {
sum += fabs(y[cc + gap + h] - fc_scratch[gap + h]);
cnt++;
}
}
*nf = folds;
return cnt ? sum / cnt : 0;
}
#pragma endregion
#pragma region options
/* Options for all three series operations (forecast, detect_anomalies,
* backtest). `confidence` doubles as anomaly_prob_threshold for
* detect_anomalies (same (0,1) validation, different default). */
typedef struct {
char *time_col;
char *value_col;
char *group_cols[FORECAST_MAX_GROUP_COLS];
int n_group_cols;
f64 confidence;
int context_limit;
char *model;
int interval_conformal; /* interval_method: 0 residual (default), 1 conformal */
int folds; /* rolling-origin folds; 0 = FORECAST_DEFAULT_FOLDS */
int gap; /* leakage gap between train end and first target */
char *candidates[FORECAST_MAX_CANDIDATES]; /* auto pool, when non-empty */
int n_candidates;
} ForecastOpts;
/* Parse a name-list option value: a JSON array of strings, or a bare
* string as a one-element list. Crude but sufficient tokenizer (strip
* [ ] " and split on ,): names with commas or quotes are not supported
* (and not sane). Frees any previously-parsed entries first (duplicate
* key). Returns 0 on success, 1 with *errmsg set. One implementation
* for group_cols and candidates. */
static int parse_name_list(sqlite3_value *value, int vtype, char **dst,
int max, int *n, const char *what,
char **errmsg) {
const char *t = (const char *)sqlite3_value_text(value);
if (!t)
return -1; /* caller reports wrong type */
for (int i = 0; i < *n; i++)
sqlite3_free(dst[i]);
*n = 0;
if (t[0] != '[') {
if (vtype != SQLITE_TEXT)
return -1;
dst[0] = sqlite3_mprintf("%s", t);
*n = 1;
return 0;
}
const char *pch = t + 1;
while (*pch && *pch != ']') {
while (*pch == ' ' || *pch == '"' || *pch == ',')
pch++;
if (*pch == ']' || !*pch)
break;
const char *end = pch;
while (*end && *end != '"' && *end != ',' && *end != ']')
end++;
if (*n >= max) {
*errmsg = sqlite3_mprintf("%s: too many %s (max %d)",
PREDICT_ERR_OPTIONS, what, max);
return 1;
}
dst[(*n)++] = sqlite3_mprintf("%.*s", (int)(end - pch), pch);
pch = end;
}
if (*n == 0) {
*errmsg = sqlite3_mprintf("%s: %s must not be empty", PREDICT_ERR_OPTIONS,
what);
return 1;
}
return 0;
}
static void forecast_opts_free(ForecastOpts *o) {
sqlite3_free(o->time_col);
sqlite3_free(o->value_col);
sqlite3_free(o->model);
for (int i = 0; i < o->n_group_cols; i++)
sqlite3_free(o->group_cols[i]);
for (int i = 0; i < o->n_candidates; i++)
sqlite3_free(o->candidates[i]);
}
static char *dup_text(sqlite3_value *v) {
const char *t = (const char *)sqlite3_value_text(v);
return t ? sqlite3_mprintf("%s", t) : NULL;
}
static int forecast_opt_cb(void *ctx, const char *key, sqlite3_value *value,
char **errmsg) {
ForecastOpts *o = ctx;
int vtype = sqlite3_value_type(value);
if (strcmp(key, "time_col") == 0 || strcmp(key, "value_col") == 0 ||
strcmp(key, "model") == 0) {
if (vtype != SQLITE_TEXT)
goto wrong_type;
char **dst = strcmp(key, "time_col") == 0 ? &o->time_col
: strcmp(key, "value_col") == 0 ? &o->value_col
: &o->model;
sqlite3_free(*dst); /* duplicate key: free before overwrite */
*dst = dup_text(value);
return 0;
}
if (strcmp(key, "confidence_level") == 0 ||
strcmp(key, "anomaly_prob_threshold") == 0) {
if (vtype != SQLITE_FLOAT && vtype != SQLITE_INTEGER)
goto wrong_type;
o->confidence = sqlite3_value_double(value);
if (o->confidence <= 0 || o->confidence >= 1) {
*errmsg = sqlite3_mprintf(
"%s: %s must be in (0,1)",
strcmp(key, "confidence_level") == 0 ? PREDICT_ERR_OPTIONS
: PREDICT_ERR_THRESHOLD,
key);
return 1;
}
return 0;
}
if (strcmp(key, "context_limit") == 0) {
if (vtype != SQLITE_INTEGER)
goto wrong_type;
o->context_limit = sqlite3_value_int(value);
if (o->context_limit < 1) {
*errmsg = sqlite3_mprintf("%s: context_limit must be >= 1",
PREDICT_ERR_OPTIONS);
return 1;
}
return 0;
}
if (strcmp(key, "group_cols") == 0) {
int prc = parse_name_list(value, vtype, o->group_cols,
FORECAST_MAX_GROUP_COLS, &o->n_group_cols,
"group_cols", errmsg);
if (prc < 0)
goto wrong_type;
return prc;
}
if (strcmp(key, "interval_method") == 0) {
if (vtype != SQLITE_TEXT)
goto wrong_type;
const char *m = (const char *)sqlite3_value_text(value);
if (m && strcmp(m, "residual") == 0)
o->interval_conformal = 0;
else if (m && strcmp(m, "conformal") == 0)
o->interval_conformal = 1;
else {
*errmsg = sqlite3_mprintf(
"%s: interval_method must be 'residual' or 'conformal'",
PREDICT_ERR_OPTIONS);
return 1;
}
return 0;
}
if (strcmp(key, "folds") == 0) {
if (vtype != SQLITE_INTEGER)
goto wrong_type;
o->folds = sqlite3_value_int(value);
if (o->folds < 1 || o->folds > FORECAST_MAX_FOLDS) {
*errmsg = sqlite3_mprintf("%s: folds must be in 1..%d",
PREDICT_ERR_OPTIONS, FORECAST_MAX_FOLDS);
return 1;
}
return 0;
}
if (strcmp(key, "gap") == 0) {
if (vtype != SQLITE_INTEGER)
goto wrong_type;
o->gap = sqlite3_value_int(value);
if (o->gap < 0) {
*errmsg = sqlite3_mprintf("%s: gap must be >= 0", PREDICT_ERR_OPTIONS);
return 1;
}
return 0;
}
if (strcmp(key, "candidates") == 0) {
/* the pool auto ranks over */
int prc = parse_name_list(value, vtype, o->candidates,
FORECAST_MAX_CANDIDATES, &o->n_candidates,
"candidates", errmsg);
if (prc < 0)
goto wrong_type;
return prc;
}
wrong_type:
*errmsg = sqlite3_mprintf("%s: wrong type for option '%s'",
PREDICT_ERR_OPTIONS, key);
return 1;
}
/* Resolve opts->candidates into a Candidate array (statistical models +
* distilled forecast students). Rejects onnx/unknown ids, a conformal request
* over a student candidate, and a student trained for a shorter horizon than
* gap+horizon. On SQLITE_OK the caller owns *out and MUST free each loaded
* student (predict0_fcst_student_free) then the array; on failure everything is freed
* here and *errmsg is set. */
static int resolve_candidates(sqlite3 *db, const ForecastOpts *opts, int horizon,
Candidate **out, int *out_n, char **errmsg) {
*out = NULL;
*out_n = 0;
Candidate *cands = sqlite3_malloc(sizeof(Candidate) * opts->n_candidates);
if (!cands)
return SQLITE_NOMEM;