From 5db8f4da49b60b8e40fb5b800c3f12bedd343be7 Mon Sep 17 00:00:00 2001 From: mstrathman Date: Thu, 30 Jul 2026 17:48:26 -0700 Subject: [PATCH 1/2] feat: fit()/predict()/backtest() SQL surface + hardened training core Reshape the tabular and evaluation surface into the scikit-learn shape agents guess, and extract and harden the native-student training core that backs it. Surface: - fit(f1, ..., fN, label [, options]) is an aggregate over the caller's rows and returns a registered model id or a serialized blob; predict(model, f1, ..., fN [, options]) is a scalar serving one prediction per row (deserialized once, cached on the model argument). The label is the last positional argument; the optional trailing '{...}' is always the options object. - backtest(ts, value, horizon [, options]) is an aggregate (matching forecast / detect_anomalies) that runs rolling-origin evaluation and returns one JSON document; backtest_rows() expands it into typed rows. The old TVF is renamed predict_batch. - The tabular training core (tree / gradient-boosted / MLP fitters) moves to predict-train.{c,h}, shared by fit() and the distill recipes. Correctness and safety hardening (a thorough review pass, fixed at the root and swept across every sibling, not only where first flagged): - Memory safety: a compile-time assert that predict()'s feature buffer holds any student the loader admits; re-fetch after sqlite3_set_auxdata to avoid a use-after-free; propagate OOM through the row-cursor mprintf paths instead of silent NULL columns; 64-bit sizing for model-scaled allocations; bounded, overflow-safe onnx io_spec arrays (a 32-bit-wasm overflow); a fit() training-set cap. - Determinism: total-order split comparators so the tree, and its content_hash, are identical across platforms/libc (no reads of uninitialized task-union fields). - Fail loud, never silently: reject non-finite features/labels/teacher cells and NULL labels; surface malformed-JSON parse errors, prepare failures, and mid-read step errors; preserve distinct error codes; cap the classifier vocabulary at PREDICT0_MAX_CLASS during interning and parsing; require a positive json-array cap rather than a silent-unbounded footgun. - The receipt tool's operation taxonomy is restructured (aggregate vs row-shaped, with the *_rows TVFs first-class and wrapper-aware inference). Tests: adversarial coverage for the new surface and every hardening path (malformed/oversized/boundary/non-finite inputs asserting concrete PREDICT_ERR_* codes); the corrupt-blob tests now supply a matching content hash so they reach the bounds-checked deserializer; the C soak drivers assert row counts and the specific error code per branch, and propagate statement failures to the exit code. Co-Authored-By: Claude Opus 4.8 --- Makefile | 2 +- predict-distill.c | 885 ++--------- predict-forecast.c | 1310 ++++++++--------- predict-internal.h | 28 +- predict-onnx.c | 45 +- predict-student.c | 168 ++- predict-student.h | 1 + predict-tabular.c | 583 +++++++- predict-train.c | 938 ++++++++++++ predict-train.h | 63 + scripts/amalgamate.py | 6 +- skills/prediction-receipts/scripts/receipt.py | 23 +- sqlite-predict.c | 37 +- tests/soak.c | 108 +- tests/soak_onnx.c | 147 +- tests/test_backtest.py | 91 +- tests/test_backtest_errors.py | 87 -- tests/test_distill.py | 157 +- tests/test_errors.py | 52 - tests/test_fit.py | 255 ++++ tests/test_forecast_student.py | 7 +- tests/test_hardening.py | 16 +- tests/test_onnx.py | 54 +- tests/test_predict.py | 4 +- tests/test_skills.py | 10 +- 25 files changed, 3290 insertions(+), 1787 deletions(-) create mode 100644 predict-train.c create mode 100644 predict-train.h delete mode 100644 tests/test_backtest_errors.py create mode 100644 tests/test_fit.py diff --git a/Makefile b/Makefile index 069d31e..5aa6c9e 100644 --- a/Makefile +++ b/Makefile @@ -37,7 +37,7 @@ prefix?=dist TARGET_LOADABLE=$(prefix)/predict0.$(LOADABLE_EXTENSION) -OBJS=sqlite-predict.c predict-forecast.c predict-registry.c predict-tabular.c predict-student.c predict-distill.c vendor/sha256.c +OBJS=sqlite-predict.c predict-forecast.c predict-registry.c predict-tabular.c predict-student.c predict-train.c predict-distill.c vendor/sha256.c ONNX_OBJS=$(OBJS) predict-onnx.c # onnxruntime is a build+runtime dependency of the loadable-onnx variant diff --git a/predict-distill.c b/predict-distill.c index 7b9e4a3..9541dd5 100644 --- a/predict-distill.c +++ b/predict-distill.c @@ -1,10 +1,9 @@ /* SPDX-License-Identifier: MIT OR Apache-2.0 * Copyright (c) 2026 Pure Storage, Inc. */ -/* distill_predict() and the native-student TRAINERS. +/* distill_predict() / distill_forecast(): the distillation recipes. * - * The training half of distillation: the CART / gradient-boosted / MLP - * trainers and the distill_predict() table-valued function. distill_predict() fits a student + * Fits a native student via the trainers in predict-train.c. distill_predict() fits a student * on a training signal (the target column by default, or a named teacher * model's predictions), evaluates it on a held-out fraction, serializes it via * predict-student.c, and registers it in _predict_models. The student then @@ -12,631 +11,12 @@ * deserializers, and inference runtime all live in predict-student.c. */ #include "predict-internal.h" #include "predict-student.h" +#include "predict-train.h" #ifndef SQLITE_CORE SQLITE_EXTENSION_INIT3 #endif -#define TREE_MAX_DEPTH 8 -#define TREE_MIN_SPLIT 5 -#define DISTILL_MIN_ROWS 8 - -#define GBT_ROUNDS 200 -#define GBT_DEPTH 3 -#define GBT_MIN_SPLIT 5 -#define GBT_LR 0.1f /* shrinkage: many small steps generalize better than few big ones */ -#define GBT_LAMBDA 1.0f /* L2 leaf regularization (XGBoost reg_lambda default) */ - -/* ---- MLP student trainer (deterministic full-batch Adam) ---- - * A smooth learner for boundaries an axis-aligned tree ensemble cannot render. - * Seeded init and no shuffle, so the blob and its predictions replay exactly. - * The blob format and inference runtime live in predict-student.c. */ -#define MLP_HIDDEN 48 -#define MLP_EPOCHS 400 -#define MLP_LR 0.02 -#define MLP_L2 1e-4 -#define MLP_BETA1 0.9 -#define MLP_BETA2 0.999 -/* FCST_RES_SCALE (the linear-skip hidden-path scale) is in predict-student.h, - * shared with the serving forward. */ - -/* deterministic xorshift32 -> f32 in [-1, 1) */ -static f32 mlp_rng(u32 *s) { - u32 x = *s ? *s : 1; - x ^= x << 13; - x ^= x >> 17; - x ^= x << 5; - *s = x; - return (f32)((f64)x / 2147483648.0 - 1.0); -} - -/* One Adam step over a parameter array (with L2), then zero its gradient. */ -static void mlp_adam(f32 *p, f64 *g, f64 *mm, f64 *vv, int sz, f64 lr, - f64 scale, f64 bc1, f64 bc2) { - for (int i = 0; i < sz; i++) { - f64 gg = g[i] * scale + MLP_L2 * p[i]; - mm[i] = MLP_BETA1 * mm[i] + (1 - MLP_BETA1) * gg; - vv[i] = MLP_BETA2 * vv[i] + (1 - MLP_BETA2) * gg * gg; - p[i] -= (f32)(lr * (mm[i] / bc1) / (sqrt(vv[i] / bc2) + 1e-8)); - g[i] = 0; - } -} - -/* Train an MLP with a configurable width and task. - * task 0 (classify): softmax head over `nout` classes; the target is `soft` - * (a row-major [n, nout] teacher-probability matrix) when non-NULL, else the - * hard class yc. task 1 (regress): linear head, MSE against the [n, nout] - * target matrix `soft` (yc unused) -- this is how the multi-output forecast - * student is fit. `use_skip` adds a direct linear map Wskip*x to the output (a - * DLinear/TiDE skip: the linear part carries seasonal-naive + trend, the hidden - * path a scaled nonlinear correction); with nhid=0 the model is purely linear. - * Deterministic full-batch Adam; the caller sets any feat_names/labels. */ -static int train_mlp(const f32 *X, int n, int nfeat, int nout, const i32 *yc, - const f32 *soft, int task, int nhid, int epochs, f32 lr, - int use_skip, MLP *m, char **errmsg) { - memset(m, 0, sizeof(*m)); - int nW1 = nhid * nfeat, nW2 = nout * nhid, nWs = use_skip ? nout * nfeat : 0; - f64 rs = use_skip ? FCST_RES_SCALE : 1.0; /* hidden-path scale (see #define) */ - m->task = task; - m->nfeat = nfeat; - m->nhid = nhid; - m->nout = nout; - m->nclass = task == 0 ? nout : 0; - - int rc = SQLITE_OK; - m->mean = sqlite3_malloc(sizeof(f32) * nfeat); - m->sd = sqlite3_malloc(sizeof(f32) * nfeat); - m->b2 = sqlite3_malloc(sizeof(f32) * nout); - /* hidden-layer blocks exist only when nhid>0; skip block only when use_skip */ - if (nhid > 0) { - m->W1 = sqlite3_malloc(sizeof(f32) * nW1); - m->b1 = sqlite3_malloc(sizeof(f32) * nhid); - m->W2 = sqlite3_malloc(sizeof(f32) * nW2); - } - if (use_skip) - m->Wskip = sqlite3_malloc(sizeof(f32) * nWs); - f64 *mW1 = nhid ? sqlite3_malloc(sizeof(f64) * nW1) : NULL, - *vW1 = nhid ? sqlite3_malloc(sizeof(f64) * nW1) : NULL, - *gW1 = nhid ? sqlite3_malloc(sizeof(f64) * nW1) : NULL; - f64 *mW2 = nhid ? sqlite3_malloc(sizeof(f64) * nW2) : NULL, - *vW2 = nhid ? sqlite3_malloc(sizeof(f64) * nW2) : NULL, - *gW2 = nhid ? sqlite3_malloc(sizeof(f64) * nW2) : NULL; - f64 *mb1 = nhid ? sqlite3_malloc(sizeof(f64) * nhid) : NULL, - *vb1 = nhid ? sqlite3_malloc(sizeof(f64) * nhid) : NULL, - *gb1 = nhid ? sqlite3_malloc(sizeof(f64) * nhid) : NULL; - f64 *mWs = use_skip ? sqlite3_malloc(sizeof(f64) * nWs) : NULL, - *vWs = use_skip ? sqlite3_malloc(sizeof(f64) * nWs) : NULL, - *gWs = use_skip ? sqlite3_malloc(sizeof(f64) * nWs) : NULL; - f64 *mb2 = sqlite3_malloc(sizeof(f64) * nout), - *vb2 = sqlite3_malloc(sizeof(f64) * nout), - *gb2 = sqlite3_malloc(sizeof(f64) * nout); - f32 *hid = nhid ? sqlite3_malloc(sizeof(f32) * nhid) : NULL, - *out = sqlite3_malloc(sizeof(f32) * nout), - *xs = sqlite3_malloc(sizeof(f32) * nfeat); - f64 *dout = sqlite3_malloc(sizeof(f64) * nout); - int hid_ok = nhid == 0 || (m->W1 && m->b1 && m->W2 && mW1 && vW1 && gW1 && - mW2 && vW2 && gW2 && mb1 && vb1 && gb1 && hid); - int skip_ok = !use_skip || (m->Wskip && mWs && vWs && gWs); - if (!m->mean || !m->sd || !m->b2 || !mb2 || !vb2 || !gb2 || !out || !xs || - !dout || !hid_ok || !skip_ok) { - rc = SQLITE_NOMEM; - *errmsg = sqlite3_mprintf("%s: out of memory", PREDICT_ERR_RESOURCE); - goto done; - } - for (int i = 0; i < nW1; i++) - mW1[i] = vW1[i] = gW1[i] = 0; - for (int i = 0; i < nW2; i++) - mW2[i] = vW2[i] = gW2[i] = 0; - for (int i = 0; i < nhid; i++) - mb1[i] = vb1[i] = gb1[i] = 0; - for (int i = 0; i < nWs; i++) - mWs[i] = vWs[i] = gWs[i] = 0; - for (int i = 0; i < nout; i++) - mb2[i] = vb2[i] = gb2[i] = 0; - - for (int i = 0; i < nfeat; i++) { /* standardize features */ - f64 mu = 0; - for (int r = 0; r < n; r++) - mu += X[(size_t)r * nfeat + i]; - mu /= n; - f64 var = 0; - for (int r = 0; r < n; r++) { - f64 d = X[(size_t)r * nfeat + i] - mu; - var += d * d; - } - var /= n; - f64 s = sqrt(var); - m->mean[i] = (f32)mu; - m->sd[i] = (f32)(s > 1e-6 ? s : 1e-6); - } - - u32 seed = 0x243f6a88u; /* Xavier-uniform init, deterministic */ - f32 a1 = (f32)sqrt(6.0 / (nfeat + (nhid ? nhid : 1))); - for (int i = 0; i < nW1; i++) - m->W1[i] = a1 * mlp_rng(&seed); - for (int j = 0; j < nhid; j++) - m->b1[j] = 0; - f32 a2 = (f32)sqrt(6.0 / ((nhid ? nhid : 1) + nout)); - for (int i = 0; i < nW2; i++) - m->W2[i] = a2 * mlp_rng(&seed); - f32 as = (f32)sqrt(6.0 / (nfeat + nout)); - for (int i = 0; i < nWs; i++) - m->Wskip[i] = as * mlp_rng(&seed); - for (int k = 0; k < nout; k++) - m->b2[k] = 0; - - f64 b1p = 1, b2p = 1; - for (int ep = 0; ep < epochs; ep++) { - b1p *= MLP_BETA1; - b2p *= MLP_BETA2; - for (int row = 0; row < n; row++) { - const f32 *x = &X[(size_t)row * nfeat]; - for (int i = 0; i < nfeat; i++) - xs[i] = (x[i] - m->mean[i]) / m->sd[i]; - for (int j = 0; j < nhid; j++) { - f64 s = m->b1[j]; - for (int i = 0; i < nfeat; i++) - s += (f64)m->W1[j * nfeat + i] * xs[i]; - hid[j] = (f32)tanh(s); - } - for (int k = 0; k < nout; k++) { - f64 s = m->b2[k]; - for (int j = 0; j < nhid; j++) - s += rs * (f64)m->W2[k * nhid + j] * hid[j]; - if (use_skip) - for (int i = 0; i < nfeat; i++) - s += (f64)m->Wskip[k * nfeat + i] * xs[i]; - out[k] = (f32)s; - } - if (task == 0) { /* softmax cross-entropy: dL/dlogit = softmax - target */ - f64 mx = out[0]; - for (int k = 1; k < nout; k++) - if (out[k] > mx) - mx = out[k]; - f64 sm = 0; - for (int k = 0; k < nout; k++) { - dout[k] = exp((f64)out[k] - mx); - sm += dout[k]; - } - for (int k = 0; k < nout; k++) { - f64 tgt = soft ? soft[(size_t)row * nout + k] : (yc[row] == k ? 1.0 : 0.0); - dout[k] = dout[k] / sm - tgt; - } - } else { /* regression MSE on the linear head: dL/dout = out - target */ - for (int k = 0; k < nout; k++) - dout[k] = (f64)out[k] - soft[(size_t)row * nout + k]; - } - for (int k = 0; k < nout; k++) { - gb2[k] += dout[k]; - for (int j = 0; j < nhid; j++) - gW2[k * nhid + j] += dout[k] * rs * hid[j]; - if (use_skip) - for (int i = 0; i < nfeat; i++) - gWs[k * nfeat + i] += dout[k] * xs[i]; - } - for (int j = 0; j < nhid; j++) { - f64 dh = 0; - for (int k = 0; k < nout; k++) - dh += dout[k] * rs * m->W2[k * nhid + j]; - dh *= 1.0 - (f64)hid[j] * hid[j]; /* tanh' */ - gb1[j] += dh; - for (int i = 0; i < nfeat; i++) - gW1[j * nfeat + i] += dh * xs[i]; - } - } - f64 scale = 1.0 / n, bc1 = 1 - b1p, bc2 = 1 - b2p; - if (nhid > 0) { - mlp_adam(m->W1, gW1, mW1, vW1, nW1, lr, scale, bc1, bc2); - mlp_adam(m->b1, gb1, mb1, vb1, nhid, lr, scale, bc1, bc2); - mlp_adam(m->W2, gW2, mW2, vW2, nW2, lr, scale, bc1, bc2); - } - if (use_skip) - mlp_adam(m->Wskip, gWs, mWs, vWs, nWs, lr, scale, bc1, bc2); - mlp_adam(m->b2, gb2, mb2, vb2, nout, lr, scale, bc1, bc2); - } - -done: - sqlite3_free(mWs); - sqlite3_free(vWs); - sqlite3_free(gWs); - sqlite3_free(mW1); - sqlite3_free(vW1); - sqlite3_free(gW1); - sqlite3_free(mW2); - sqlite3_free(vW2); - sqlite3_free(gW2); - sqlite3_free(mb1); - sqlite3_free(vb1); - sqlite3_free(gb1); - sqlite3_free(mb2); - sqlite3_free(vb2); - sqlite3_free(gb2); - sqlite3_free(hid); - sqlite3_free(out); - sqlite3_free(xs); - sqlite3_free(dout); - if (rc != SQLITE_OK) - predict0_mlp_free(m); - return rc; -} - -/* ---- CART training ---- */ - -typedef struct { - TreeNode *nodes; - int n, cap; - const f32 *X; /* [nrow, nfeat] */ - int nfeat; - const i32 *yc; /* classify targets (teacher class index) */ - const f32 *yr; /* regress targets */ - int nclass; - int task; - int max_depth; /* 0 => TREE_MAX_DEPTH; shallow for GBT weak learners */ - int min_split; /* 0 => TREE_MIN_SPLIT */ - const f32 *hess; /* GBT only: per-row Hessian; NULL => mean-value leaves */ - f32 lambda; /* GBT only: L2 leaf regularization */ -} Builder; - -static int bld_new_node(Builder *b) { - if (b->n == b->cap) { - int nc = b->cap ? b->cap * 2 : 64; - TreeNode *g = sqlite3_realloc(b->nodes, sizeof(TreeNode) * nc); - if (!g) - return -1; - b->nodes = g; - b->cap = nc; - } - memset(&b->nodes[b->n], 0, sizeof(TreeNode)); - return b->n++; -} - -/* Fill node `ni` as a leaf over rows idx[0..n). */ -static void bld_leaf(Builder *b, int ni, const int *idx, int n) { - TreeNode *nd = &b->nodes[ni]; - nd->feature = -1; - nd->left = nd->right = -1; - if (b->task == 0) { - int *cnt = sqlite3_malloc(sizeof(int) * b->nclass); - int best = 0, bestc = -1; - if (cnt) { - memset(cnt, 0, sizeof(int) * b->nclass); - for (int i = 0; i < n; i++) - cnt[b->yc[idx[i]]]++; - for (int c = 0; c < b->nclass; c++) - if (cnt[c] > bestc) { - bestc = cnt[c]; - best = c; - } - sqlite3_free(cnt); - } - nd->klass = best; - nd->conf = n ? (f32)bestc / (f32)n : 0.f; - } else if (b->hess) { - /* Newton leaf: the tree fits the gradient (b->yr), but the leaf value is - * the second-order step sum(grad) / (sum(hess) + lambda) -- what lifts a - * gradient booster to XGBoost-quality on non-squared losses. */ - f64 g = 0, h = 0; - for (int i = 0; i < n; i++) { - g += b->yr[idx[i]]; - h += b->hess[idx[i]]; - } - nd->value = (f32)(g / (h + b->lambda)); - nd->klass = -1; - } else { - f64 s = 0; - for (int i = 0; i < n; i++) - s += b->yr[idx[i]]; - nd->value = n ? (f32)(s / n) : 0.f; - nd->klass = -1; - } -} - -/* Best (feature, threshold) split minimizing impurity, or feature<0 if none - * improves. cmp buffer of (value, target) sorted per feature. */ -typedef struct { - f32 v; - i32 yc; - f32 yr; -} VY; -static int vy_cmp(const void *a, const void *b) { - f32 x = ((const VY *)a)->v, y = ((const VY *)b)->v; - return x < y ? -1 : x > y ? 1 : 0; -} - -static int bld_best_split(Builder *b, const int *idx, int n, int *feat_out, - f32 *thr_out) { - *feat_out = -1; - VY *buf = sqlite3_malloc(sizeof(VY) * n); - if (!buf) - return SQLITE_NOMEM; - f64 best_score = 0; /* impurity decrease; want > 0 */ - - for (int f = 0; f < b->nfeat; f++) { - for (int i = 0; i < n; i++) { - buf[i].v = b->X[(size_t)idx[i] * b->nfeat + f]; - if (b->task == 0) - buf[i].yc = b->yc[idx[i]]; - else - buf[i].yr = b->yr[idx[i]]; - } - qsort(buf, n, sizeof(VY), vy_cmp); - if (buf[0].v == buf[n - 1].v) - continue; /* constant feature */ - - if (b->task == 0) { - int *ltot = sqlite3_malloc(sizeof(int) * b->nclass * 2); - if (!ltot) { - sqlite3_free(buf); - return SQLITE_NOMEM; - } - int *rtot = ltot + b->nclass; - memset(ltot, 0, sizeof(int) * b->nclass * 2); - for (int i = 0; i < n; i++) - rtot[buf[i].yc]++; - int nl = 0; - for (int i = 0; i < n - 1; i++) { - ltot[buf[i].yc]++; - rtot[buf[i].yc]--; - nl++; - if (buf[i].v == buf[i + 1].v) - continue; /* can't split between equal values */ - int nr = n - nl; - f64 gl = 1, gr = 1; - for (int c = 0; c < b->nclass; c++) { - f64 pl = (f64)ltot[c] / nl, pr = (f64)rtot[c] / nr; - gl -= pl * pl; - gr -= pr * pr; - } - f64 score = -((f64)nl * gl + (f64)nr * gr) / n; /* maximize */ - if (*feat_out < 0 || score > best_score) { - best_score = score; - *feat_out = f; - *thr_out = (buf[i].v + buf[i + 1].v) / 2.f; - } - } - sqlite3_free(ltot); - } else { - f64 tot = 0, totsq = 0; - for (int i = 0; i < n; i++) { - tot += buf[i].yr; - totsq += (f64)buf[i].yr * buf[i].yr; - } - f64 lsum = 0, lsq = 0; - int nl = 0; - for (int i = 0; i < n - 1; i++) { - lsum += buf[i].yr; - lsq += (f64)buf[i].yr * buf[i].yr; - nl++; - if (buf[i].v == buf[i + 1].v) - continue; - int nr = n - nl; - f64 rsum = tot - lsum, rsq = totsq - lsq; - f64 lvar = lsq - lsum * lsum / nl; /* SSE left */ - f64 rvar = rsq - rsum * rsum / nr; /* SSE right */ - f64 score = -(lvar + rvar); /* maximize (minimize SSE) */ - if (*feat_out < 0 || score > best_score) { - best_score = score; - *feat_out = f; - *thr_out = (buf[i].v + buf[i + 1].v) / 2.f; - } - } - } - } - sqlite3_free(buf); - return SQLITE_OK; -} - -/* Recursively build; returns the node index, or -1 on OOM. */ -static int bld_build(Builder *b, int *idx, int n, int depth) { - int ni = bld_new_node(b); - if (ni < 0) - return -1; - - int pure = 1; - if (b->task == 0) { - for (int i = 1; i < n; i++) - if (b->yc[idx[i]] != b->yc[idx[0]]) { - pure = 0; - break; - } - } else { - pure = 0; /* regression leaves split on impurity, not purity */ - } - int maxd = b->max_depth ? b->max_depth : TREE_MAX_DEPTH; - int mins = b->min_split ? b->min_split : TREE_MIN_SPLIT; - if (depth >= maxd || n < mins || pure) { - bld_leaf(b, ni, idx, n); - return ni; - } - int feat; - f32 thr; - if (bld_best_split(b, idx, n, &feat, &thr) != SQLITE_OK) - return -1; - if (feat < 0) { - bld_leaf(b, ni, idx, n); - return ni; - } - /* partition idx: rows with X[.,feat] < thr to the front */ - int lo = 0, hi = n - 1; - while (lo <= hi) { - if (b->X[(size_t)idx[lo] * b->nfeat + feat] < thr) { - lo++; - } else { - int tmp = idx[lo]; - idx[lo] = idx[hi]; - idx[hi] = tmp; - hi--; - } - } - int nl = lo; - if (nl == 0 || nl == n) { /* degenerate split; make a leaf */ - bld_leaf(b, ni, idx, n); - return ni; - } - int L = bld_build(b, idx, nl, depth + 1); - if (L < 0) - return -1; - int R = bld_build(b, idx + nl, n - nl, depth + 1); - if (R < 0) - return -1; - /* node index ni is stable across the reallocs above; set it now */ - b->nodes[ni].feature = feat; - b->nodes[ni].threshold = thr; - b->nodes[ni].left = L; - b->nodes[ni].right = R; - b->nodes[ni].klass = -1; - return ni; -} - - -/* Train a GBT on the teacher targets. Fills the numeric parts of *fo; the - * caller sets feat_names and labels (as for the single-tree path). For - * classification, `soft` (when - * non-NULL) is a row-major [n, nclass] matrix of teacher class probabilities - * that replaces the hard one-hot label: the student then matches the teacher's - * whole distribution (soft-label distillation), transferring the calibrated - * probabilities a hard argmax throws away. NULL `soft` keeps the hard-label - * path. Regression ignores `soft`. */ -static int train_gbt(const f32 *X, int n, int nfeat, int task, int nclass, - const i32 *yc, const f32 *yr, const f32 *soft, Forest *fo, - char **errmsg) { - memset(fo, 0, sizeof(*fo)); - int rounds = GBT_ROUNDS, nscore = task == 0 ? nclass : 1; - fo->task = task; - fo->nfeat = nfeat; - fo->nclass = nclass; - fo->n_score = nscore; - fo->n_rounds = rounds; - fo->lr = GBT_LR; - - int rc = SQLITE_OK; - fo->init = sqlite3_malloc(sizeof(f32) * nscore); - fo->tree_off = sqlite3_malloc(sizeof(int) * (rounds * nscore + 1)); - f64 *F = sqlite3_malloc(sizeof(f64) * (size_t)n * nscore); - f64 *p = task == 0 ? sqlite3_malloc(sizeof(f64) * (size_t)n * nscore) : NULL; - int *idx = sqlite3_malloc(sizeof(int) * n); /* scratch for bld_build */ - f32 *grad = sqlite3_malloc(sizeof(f32) * n); - f32 *hess = task == 0 ? sqlite3_malloc(sizeof(f32) * n) : NULL; - if (!fo->init || !fo->tree_off || !F || !idx || !grad || - (task == 0 && (!p || !hess))) { - rc = SQLITE_NOMEM; - *errmsg = sqlite3_mprintf("%s: out of memory", PREDICT_ERR_RESOURCE); - goto done; - } - - if (task == 0) { - for (int c = 0; c < nscore; c++) { - f64 pri; - if (soft) { /* mean teacher probability for the class */ - f64 sum = 0; - for (int i = 0; i < n; i++) - sum += soft[(size_t)i * nscore + c]; - pri = sum / n; - if (pri < 1e-6) - pri = 1e-6; - } else { - int cnt = 0; - for (int i = 0; i < n; i++) - cnt += yc[i] == c; - pri = (cnt + 1.0) / (n + nscore); /* smoothed */ - } - fo->init[c] = (f32)log(pri); - } - for (int i = 0; i < n; i++) - for (int c = 0; c < nscore; c++) - F[(size_t)i * nscore + c] = fo->init[c]; - } else { - f64 m = 0; - for (int i = 0; i < n; i++) - m += yr[i]; - m /= n; - fo->init[0] = (f32)m; - for (int i = 0; i < n; i++) - F[i] = m; - } - - fo->tree_off[0] = 0; - int nt = 0, pool_cap = 0; - for (int r = 0; r < rounds; r++) { - if (task == 0) { /* round-start softmax for every row */ - for (int i = 0; i < n; i++) { - f64 *Fi = &F[(size_t)i * nscore], mx = Fi[0]; - for (int c = 1; c < nscore; c++) - if (Fi[c] > mx) - mx = Fi[c]; - f64 sum = 0; - for (int c = 0; c < nscore; c++) - sum += (p[(size_t)i * nscore + c] = exp(Fi[c] - mx)); - for (int c = 0; c < nscore; c++) - p[(size_t)i * nscore + c] /= sum; - } - } - for (int s = 0; s < nscore; s++) { - if (task == 0) - for (int i = 0; i < n; i++) { - f64 pi = p[(size_t)i * nscore + s]; - f32 tgt = - soft ? soft[(size_t)i * nscore + s] : (yc[i] == s ? 1.f : 0.f); - grad[i] = tgt - (f32)pi; - hess[i] = (f32)(pi * (1.0 - pi)); /* softmax curvature */ - } - else - for (int i = 0; i < n; i++) - grad[i] = (f32)(yr[i] - F[i]); - - Builder b; - memset(&b, 0, sizeof(b)); - b.X = X; - b.nfeat = nfeat; - b.yr = grad; - b.task = 1; - b.max_depth = GBT_DEPTH; - b.min_split = GBT_MIN_SPLIT; - b.hess = hess; /* NULL for regression => mean leaves (Newton for MSE) */ - b.lambda = GBT_LAMBDA; - for (int i = 0; i < n; i++) - idx[i] = i; - int root = bld_build(&b, idx, n, 0); - if (root < 0) { - sqlite3_free(b.nodes); - rc = SQLITE_NOMEM; - *errmsg = sqlite3_mprintf("%s: out of memory", PREDICT_ERR_RESOURCE); - goto done; - } - for (int i = 0; i < n; i++) - F[(size_t)i * nscore + s] += - fo->lr * predict0_reg_tree_value(b.nodes, b.n, &X[(size_t)i * nfeat]); - - int need = fo->tree_off[nt] + b.n; - if (need > pool_cap) { - pool_cap = need > pool_cap * 2 ? need : pool_cap * 2; - TreeNode *g = sqlite3_realloc(fo->nodes, sizeof(TreeNode) * pool_cap); - if (!g) { - sqlite3_free(b.nodes); - rc = SQLITE_NOMEM; - *errmsg = sqlite3_mprintf("%s: out of memory", PREDICT_ERR_RESOURCE); - goto done; - } - fo->nodes = g; - } - memcpy(&fo->nodes[fo->tree_off[nt]], b.nodes, sizeof(TreeNode) * b.n); - fo->tree_off[nt + 1] = need; - nt++; - sqlite3_free(b.nodes); - } - } - fo->n_trees = nt; - -done: - sqlite3_free(F); - sqlite3_free(p); - sqlite3_free(idx); - sqlite3_free(grad); - sqlite3_free(hess); - if (rc != SQLITE_OK) - predict0_forest_free(fo); - return rc; -} /* ---- the distill_predict() operation ---- */ @@ -659,28 +39,6 @@ static void dist_opts_free(DistOpts *o) { /* Return the class index for `s`, interning it into *labels (growing *cap and * *nclass on first sight). Returns -1 and sets *rc = SQLITE_NOMEM on OOM; a * valid index is always >= 0. */ -static int intern_label(char ***labels, int *nclass, int *cap, const char *s, - int *rc) { - for (int k = 0; k < *nclass; k++) - if (strcmp((*labels)[k], s) == 0) - return k; - if (*nclass == *cap) { - int nc = *cap ? *cap * 2 : 8; - char **g = sqlite3_realloc(*labels, sizeof(char *) * nc); - if (!g) { - *rc = SQLITE_NOMEM; - return -1; - } - *labels = g; - *cap = nc; - } - (*labels)[*nclass] = sqlite3_mprintf("%s", s); - if (!(*labels)[*nclass]) { - *rc = SQLITE_NOMEM; - return -1; - } - return (*nclass)++; -} static int dist_opt_cb(void *ctx, const char *key, sqlite3_value *value, char **errmsg) { @@ -738,43 +96,6 @@ static void append_ident(sqlite3_str *s, const char *nm) { * in-core runtime" as opposed to onnx). hash_out receives the * content_hash hex. Returns SQLITE_OK, or SQLITE_ERROR with *errmsg set * (STUDENT_EXISTS on an id collision). */ -static int register_student(sqlite3 *db, const char *student_id, - const void *blob, int blob_len, - char hash_out[PREDICT_HEX_BUFSIZE], - char **errmsg) { - predict0_hasher h; - predict0_hash_init(&h); - sha256_update(&h.sha, (const u8 *)blob, (usize)blob_len); - predict0_hash_hex(&h, hash_out); - - sqlite3_stmt *ins = NULL; - if (sqlite3_prepare_v2( - db, - "INSERT INTO _predict_models (model_id, kind, runtime, weights," - " io_spec, content_hash, license) VALUES (?1,'student','tree',?2," - " NULL,?3,'unspecified')", - -1, &ins, NULL) != SQLITE_OK) { - *errmsg = sqlite3_mprintf("%s: cannot prepare student insert", - PREDICT_ERR_RESOURCE); - return SQLITE_ERROR; - } - sqlite3_bind_text(ins, 1, student_id, -1, SQLITE_STATIC); - sqlite3_bind_blob(ins, 2, blob, blob_len, SQLITE_STATIC); - sqlite3_bind_text(ins, 3, hash_out, -1, SQLITE_STATIC); - int irc = sqlite3_step(ins); - sqlite3_finalize(ins); - if (irc == SQLITE_CONSTRAINT) { - *errmsg = sqlite3_mprintf("%s: student '%s' already exists", - PREDICT_ERR_STUDENT_EXISTS, student_id); - return SQLITE_ERROR; - } - if (irc != SQLITE_DONE) { - *errmsg = sqlite3_mprintf("%s: student insert failed: %s", - PREDICT_ERR_RESOURCE, sqlite3_errmsg(db)); - return SQLITE_ERROR; - } - return SQLITE_OK; -} /* The training pipeline. Fills *res on success. */ static int distill_train(sqlite3 *db, const char *tq, DistOpts *o, @@ -885,15 +206,14 @@ static int distill_train(sqlite3 *db, const char *tq, DistOpts *o, PREDICT_ERR_OPTIONS); goto done; } - if (predict0_json_str_array(db, o->proba, NULL, &proba_names, - &nproba, errmsg) != - SQLITE_OK || - predict0_json_str_array(db, o->classes, NULL, &soft_labels, - &nsoft, errmsg) != - SQLITE_OK) { - rc = SQLITE_ERROR; + rc = predict0_json_str_array(db, o->proba, NULL, &proba_names, &nproba, + PREDICT0_MAX_CLASS, errmsg); + if (rc != SQLITE_OK) + goto done; + rc = predict0_json_str_array(db, o->classes, NULL, &soft_labels, &nsoft, + PREDICT0_MAX_CLASS, errmsg); + if (rc != SQLITE_OK) goto done; - } if (nproba < 2 || nproba != nsoft) { rc = SQLITE_ERROR; *errmsg = sqlite3_mprintf( @@ -901,6 +221,14 @@ static int distill_train(sqlite3 *db, const char *tq, DistOpts *o, PREDICT_ERR_OPTIONS); goto done; } + /* Defensive invariant: predict0_json_str_array already caps proba/classes + * at PREDICT0_MAX_CLASS during parsing; kept in case that changes. */ + if (nproba > PREDICT0_MAX_CLASS) { + rc = SQLITE_ERROR; + *errmsg = sqlite3_mprintf("%s: too many classes (%d); the maximum is %d", + PREDICT_ERR_SCHEMA, nproba, PREDICT0_MAX_CLASS); + goto done; + } proba_col = sqlite3_malloc(sizeof(int) * nproba); if (!proba_col) { rc = SQLITE_NOMEM; @@ -950,7 +278,8 @@ static int distill_train(sqlite3 *db, const char *tq, DistOpts *o, PREDICT_ERR_SCHEMA, sqlite3_errmsg(db)); goto done; } - while (sqlite3_step(rq) == SQLITE_ROW) { + int sr; + while ((sr = sqlite3_step(rq)) == SQLITE_ROW) { if (n == cap) { cap = cap ? cap * 2 : 256; f32 *gx = sqlite3_realloc(X, sizeof(f32) * (size_t)cap * nfeat); @@ -1011,8 +340,18 @@ static int distill_train(sqlite3 *db, const char *tq, DistOpts *o, } n++; } + /* A terminal step code other than DONE is a read failure, not end-of-data: + * surface it rather than train on the partial rows collected so far. Capture + * the message before finalize clears it. */ + if (rc == SQLITE_OK && sr != SQLITE_DONE) { + rc = SQLITE_ERROR; + *errmsg = sqlite3_mprintf("%s: could not read train_query: %s", + PREDICT_ERR_SCHEMA, sqlite3_errmsg(db)); + } sqlite3_finalize(rq); rq = NULL; + if (rc != SQLITE_OK) + goto done; if (n < DISTILL_MIN_ROWS) { rc = SQLITE_ERROR; *errmsg = sqlite3_mprintf("%s: need at least %d train rows, got %d", @@ -1079,7 +418,9 @@ static int distill_train(sqlite3 *db, const char *tq, DistOpts *o, } else if (!teacher) { for (int i = 0; i < n; i++) { if (classify) { - int cl = intern_label(&labels, &nclass, &nlab_cap, y_true_c[i], &rc); + int cl = predict0_intern_label(&labels, &nclass, &nlab_cap, + y_true_c[i], PREDICT0_MAX_CLASS, &rc, + errmsg); if (cl < 0) goto done; y_teach[i] = cl; @@ -1092,7 +433,7 @@ static int distill_train(sqlite3 *db, const char *tq, DistOpts *o, "SELECT (row_number() OVER ()) AS _rid, %s FROM (%s) ORDER BY _rid", feat_list, tq); teacher_sql = sqlite3_mprintf( - "SELECT prediction FROM predict(%Q, %Q, json_object('target',%Q,'task'," + "SELECT prediction FROM predict_batch(%Q, %Q, json_object('target',%Q,'task'," "%Q,'model',%Q))", tq, apply_sql, o->target, task, teacher); if (!apply_sql || !teacher_sql) { @@ -1112,8 +453,9 @@ static int distill_train(sqlite3 *db, const char *tq, DistOpts *o, while ((tstep = sqlite3_step(tqs)) == SQLITE_ROW && ti < n) { if (classify) { const char *pred = (const char *)sqlite3_column_text(tqs, 0); - int cl = - intern_label(&labels, &nclass, &nlab_cap, pred ? pred : "", &rc); + int cl = predict0_intern_label(&labels, &nclass, &nlab_cap, + pred ? pred : "", PREDICT0_MAX_CLASS, &rc, + errmsg); if (cl < 0) goto done; y_teach[ti] = cl; @@ -1122,15 +464,20 @@ static int distill_train(sqlite3 *db, const char *tq, DistOpts *o, } ti++; } - int tdone = tstep == SQLITE_DONE || ti == n; + /* Require exactly n teacher labels: DONE with ti == n. If the loop stopped + * because ti hit n while the query still had rows (tstep == SQLITE_ROW), the + * teacher over-produced; fail rather than silently drop the extra. */ + int tdone = tstep == SQLITE_DONE && ti == n; char *terr = tdone ? NULL : sqlite3_mprintf("%s", sqlite3_errmsg(db)); sqlite3_finalize(tqs); tqs = NULL; - if (!tdone || ti != n) { + if (!tdone) { + int too_many = tstep == SQLITE_ROW; rc = SQLITE_ERROR; *errmsg = sqlite3_mprintf( - "%s: teacher produced %d labels for %d rows (%s)", - PREDICT_ERR_RESOURCE, ti, n, terr ? terr : "short read"); + "%s: teacher produced %s labels for %d train rows (%s)", + PREDICT_ERR_SCHEMA, too_many ? "too many" : "too few", n, + terr ? terr : (too_many ? "extra rows" : "short read")); sqlite3_free(terr); goto done; } @@ -1143,6 +490,15 @@ static int distill_train(sqlite3 *db, const char *tq, DistOpts *o, teacher ? "teacher" : "target column"); goto done; } + /* Defensive invariant: predict0_intern_label and the soft-label nproba check + * already cap this before any allocation; kept so the bound holds even if + * those paths change. */ + if (classify && nclass > PREDICT0_MAX_CLASS) { + rc = SQLITE_ERROR; + *errmsg = sqlite3_mprintf("%s: too many classes (%d); the maximum is %d", + PREDICT_ERR_SCHEMA, nclass, PREDICT0_MAX_CLASS); + goto done; + } /* ---- 4. fit the student on the fit split (teacher targets) ---- */ int n_hold = n / 5; @@ -1162,7 +518,7 @@ static int distill_train(sqlite3 *db, const char *tq, DistOpts *o, PREDICT_ERR_OPTIONS); goto done; } - rc = train_mlp(X, n_fit, nfeat, nclass, y_teach, soft ? soft_P : NULL, + rc = predict0_train_mlp(X, n_fit, nfeat, nclass, y_teach, soft ? soft_P : NULL, 0 /* classify */, MLP_HIDDEN, MLP_EPOCHS, MLP_LR, 0 /* no skip */, &mlp, errmsg); if (rc != SQLITE_OK) @@ -1213,7 +569,7 @@ static int distill_train(sqlite3 *db, const char *tq, DistOpts *o, goto done; } } else if (is_gbt) { - rc = train_gbt(X, n_fit, nfeat, classify ? 0 : 1, nclass, y_teach, + rc = predict0_train_gbt(X, n_fit, nfeat, classify ? 0 : 1, nclass, y_teach, y_teach_r, soft ? soft_P : NULL, &forest, errmsg); if (rc != SQLITE_OK) goto done; @@ -1279,7 +635,7 @@ static int distill_train(sqlite3 *db, const char *tq, DistOpts *o, b.yr = y_teach_r; b.nclass = nclass; b.task = classify ? 0 : 1; - int root = bld_build(&b, idx, n_fit, 0); + int root = predict0_bld_build(&b, idx, n_fit, 0); if (root < 0) { sqlite3_free(b.nodes); rc = SQLITE_NOMEM; @@ -1326,7 +682,7 @@ static int distill_train(sqlite3 *db, const char *tq, DistOpts *o, goto done; } } - rc = register_student(db, o->student_id, blob, blob_len, res->content_hash, + rc = predict0_register_student(db, o->student_id, blob, blob_len, res->content_hash, errmsg); if (rc != SQLITE_OK) goto done; @@ -1657,7 +1013,7 @@ static int fdistill_fit(sqlite3 *db, const f32 *X, const f32 *Y, int n, int L, if (n_hold < 1) n_hold = 1; int n_fit = n - n_hold; - rc = train_mlp(X, n_fit, L, nout, NULL, Y, 1 /* regress */, nhid, epochs, lr, + rc = predict0_train_mlp(X, n_fit, L, nout, NULL, Y, 1 /* regress */, nhid, epochs, lr, 1 /* linear skip */, &mlp, errmsg); if (rc != SQLITE_OK) goto done; @@ -1692,7 +1048,7 @@ static int fdistill_fit(sqlite3 *db, const f32 *X, const f32 *Y, int n, int L, sqlite3_mprintf("%s: student serialize failed", PREDICT_ERR_RESOURCE); goto done; } - rc = register_student(db, student_id, blob, blob_len, res->content_hash, + rc = predict0_register_student(db, student_id, blob, blob_len, res->content_hash, errmsg); if (rc != SQLITE_OK) goto done; @@ -1730,9 +1086,28 @@ static int fdistill_train(sqlite3 *db, const char *tq, int L, int H, int Q, rc = SQLITE_NOMEM; goto done; } - while (sqlite3_step(q) == SQLITE_ROW) { - for (int c = 0; c < ncol; c++) + int sr; + while ((sr = sqlite3_step(q)) == SQLITE_ROW) { + for (int c = 0; c < ncol; c++) { + /* sqlite3_column_double coerces NULL/text to 0.0, which would silently + * skew the normalized windows; require a real, finite number instead. */ + int ct = sqlite3_column_type(q, c); + if (ct != SQLITE_INTEGER && ct != SQLITE_FLOAT) { + *errmsg = sqlite3_mprintf( + "%s: train_query cell at column %d is not numeric", + PREDICT_ERR_SCHEMA, c); + rc = SQLITE_ERROR; + goto done; + } wrow[c] = sqlite3_column_double(q, c); + if (!isfinite(wrow[c])) { + *errmsg = sqlite3_mprintf( + "%s: train_query cell at column %d is not finite", + PREDICT_ERR_SCHEMA, c); + rc = SQLITE_ERROR; + goto done; + } + } f64 mu = 0; for (int i = 0; i < L; i++) mu += wrow[i]; @@ -1747,8 +1122,8 @@ static int fdistill_train(sqlite3 *db, const char *tq, int L, int H, int Q, sd = 1e-9; if (n == cap) { int nc = cap ? cap * 2 : 256; - f32 *nX = sqlite3_realloc(X, (int)(sizeof(f32) * (size_t)nc * L)); - f32 *nY = sqlite3_realloc(Y, (int)(sizeof(f32) * (size_t)nc * nout)); + f32 *nX = sqlite3_realloc64(X, sizeof(f32) * (size_t)nc * L); + f32 *nY = sqlite3_realloc64(Y, sizeof(f32) * (size_t)nc * nout); if (nX) X = nX; if (nY) @@ -1765,6 +1140,12 @@ static int fdistill_train(sqlite3 *db, const char *tq, int L, int H, int Q, Y[(size_t)n * nout + k] = (f32)((wrow[L + k] - mu) / sd); n++; } + /* A terminal step code other than DONE is a read failure, not end-of-data. */ + if (rc == SQLITE_OK && sr != SQLITE_DONE) { + rc = SQLITE_ERROR; + *errmsg = sqlite3_mprintf("%s: could not read teacher rows: %s", + PREDICT_ERR_SCHEMA, sqlite3_errmsg(db)); + } if (rc == SQLITE_OK) rc = fdistill_fit(db, X, Y, n, L, H, Q, levels, student_id, nhid, epochs, lr, res, errmsg); @@ -1818,8 +1199,8 @@ static int fcst_teacher_flush(sqlite3 *db, const predict0_model_row *trow, int nout = H * *Q; if (*n == *cap) { int nc = *cap ? *cap * 2 : 256; - f32 *nX = sqlite3_realloc(*X, (int)(sizeof(f32) * (size_t)nc * L)); - f32 *nY = sqlite3_realloc(*Y, (int)(sizeof(f32) * (size_t)nc * nout)); + f32 *nX = sqlite3_realloc64(*X, sizeof(f32) * (size_t)nc * L); + f32 *nY = sqlite3_realloc64(*Y, sizeof(f32) * (size_t)nc * nout); if (nX) *X = nX; if (nY) @@ -1876,8 +1257,17 @@ static int fdistill_train_teacher(sqlite3 *db, const char *tq, } int qcol = sqlite3_column_count(q), has_key = qcol >= 2; - - while (sqlite3_step(q) == SQLITE_ROW && n < FCST_TEACHER_MAX_WIN) { + /* A single-key series never trips the n < FCST_TEACHER_MAX_WIN flush guard, so + * bound the raw rows kept per series. The window budget consumes at most this + * many rows; beyond it a series yields no further windows and would only grow + * memory. Computed in 64-bit and clamped so the bound itself cannot overflow. */ + int win_stride = H >= 8 ? H / 4 : 1; + sqlite3_int64 budget_rows = + (sqlite3_int64)L + (sqlite3_int64)FCST_TEACHER_MAX_WIN * win_stride + H; + int max_series = budget_rows > (1 << 23) ? (1 << 23) : (int)budget_rows; + + int sr; + while ((sr = sqlite3_step(q)) == SQLITE_ROW && n < FCST_TEACHER_MAX_WIN) { const char *key = has_key ? (const char *)sqlite3_column_text(q, 0) : ""; if (!key) key = ""; @@ -1894,17 +1284,45 @@ static int fdistill_train_teacher(sqlite3 *db, const char *tq, } else if (!curkey) { curkey = sqlite3_mprintf("%s", key); } - if (sn == scap) { - int nc = scap ? scap * 2 : 512; - f64 *ns = sqlite3_realloc(series, (int)(sizeof(f64) * nc)); - if (!ns) { - rc = SQLITE_NOMEM; + if (sn < max_series) { + if (sn == scap) { + int nc = scap ? scap * 2 : 512; + if (nc > max_series) + nc = max_series; + /* 64-bit sizing so the byte product can't narrow to int and overrun */ + f64 *ns = sqlite3_realloc64(series, sizeof(f64) * (size_t)nc); + if (!ns) { + rc = SQLITE_NOMEM; + goto done; + } + series = ns; + scap = nc; + } + /* sqlite3_column_double coerces NULL/text to 0.0 and lets NaN/Inf through, + * which would train on corrupted rows; require a real, finite number. */ + int ct = sqlite3_column_type(q, qcol - 1); + if (ct != SQLITE_INTEGER && ct != SQLITE_FLOAT) { + *errmsg = sqlite3_mprintf("%s: teacher series value is not numeric", + PREDICT_ERR_SCHEMA); + rc = SQLITE_ERROR; + goto done; + } + f64 v = sqlite3_column_double(q, qcol - 1); + if (!isfinite(v)) { + *errmsg = sqlite3_mprintf("%s: teacher series value is not finite", + PREDICT_ERR_SCHEMA); + rc = SQLITE_ERROR; goto done; } - series = ns; - scap = nc; + series[sn++] = v; } - series[sn++] = sqlite3_column_double(q, qcol - 1); + } + /* sr == ROW means the FCST_TEACHER_MAX_WIN cap stopped us (not an error); any + * terminal code other than ROW or DONE is a read failure, not end-of-data. */ + if (rc == SQLITE_OK && sr != SQLITE_ROW && sr != SQLITE_DONE) { + rc = SQLITE_ERROR; + *errmsg = sqlite3_mprintf("%s: could not read teacher rows: %s", + PREDICT_ERR_SCHEMA, sqlite3_errmsg(db)); } if (rc == SQLITE_OK && sn >= L) { rc = fcst_teacher_flush(db, &trow, &bopts, series, sn, L, H, &X, &Y, &n, @@ -2130,10 +1548,11 @@ static int fd_filter(sqlite3_vtab_cursor *pCur, int idxNum, const char *idxStr, if (sqlite3_prepare_v2(db, "SELECT value FROM json_each(?1,'$.quantiles')", -1, &qs, NULL) == SQLITE_OK) { sqlite3_bind_text(qs, 1, options, -1, SQLITE_STATIC); - while (sqlite3_step(qs) == SQLITE_ROW) { + int sr; + while ((sr = sqlite3_step(qs)) == SQLITE_ROW) { if (cnt == cap) { int nc = cap ? cap * 2 : 8; - f32 *nl = sqlite3_realloc(levels, (int)(sizeof(f32) * nc)); + f32 *nl = sqlite3_realloc64(levels, sizeof(f32) * (size_t)nc); if (!nl) { sqlite3_finalize(qs); FD_FAIL("%s: out of memory", PREDICT_ERR_RESOURCE); @@ -2143,7 +1562,27 @@ static int fd_filter(sqlite3_vtab_cursor *pCur, int idxNum, const char *idxStr, } levels[cnt++] = (f32)sqlite3_column_double(qs, 0); } + /* A terminal step code other than DONE is a read failure. Build the + * message from db before finalize clears it, then take FD_FAIL's cleanup + * path (free student_id/levels/teacher, return). */ + if (sr != SQLITE_DONE) { + sqlite3_free(vtab->base.zErrMsg); + vtab->base.zErrMsg = sqlite3_mprintf( + "%s: could not read quantile levels: %s", PREDICT_ERR_OPTIONS, + sqlite3_errmsg(db)); + sqlite3_finalize(qs); + sqlite3_free(student_id); + sqlite3_free(levels); + sqlite3_free(teacher); + return SQLITE_ERROR; + } sqlite3_finalize(qs); + } else { + /* Prepare failure is a real error, not "no quantiles": an absent + * $.quantiles key prepares fine and yields zero rows (the median-only + * path below). Fail loudly rather than silently train the wrong model. */ + FD_FAIL("%s: could not parse quantiles: %s", PREDICT_ERR_OPTIONS, + sqlite3_errmsg(db)); } if (cnt > 0) Q = cnt; diff --git a/predict-forecast.c b/predict-forecast.c index 96365d1..a8b286f 100644 --- a/predict-forecast.c +++ b/predict-forecast.c @@ -985,11 +985,6 @@ static int forecast_opt_cb(void *ctx, const char *key, sqlite3_value *value, return 1; } -static const char *const BACKTEST_OPTION_KEYS[] = { - "time_col", "value_col", "group_cols", "confidence_level", - "context_limit", "folds", "gap", "interval_method", - "model", NULL}; - /* 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 @@ -1092,25 +1087,6 @@ typedef struct { int non_numeric; } SeriesBuf; -static SeriesBuf *series_find(SeriesBuf **all, int *n_series, int *cap, - const char *key) { - for (int i = 0; i < *n_series; i++) { - if (strcmp((*all)[i].key, key) == 0) - return &(*all)[i]; - } - if (*n_series == *cap) { - int nc = *cap ? *cap * 2 : 8; - SeriesBuf *grown = sqlite3_realloc(*all, sizeof(SeriesBuf) * nc); - if (!grown) - return NULL; - *all = grown; - *cap = nc; - } - SeriesBuf *s = &(*all)[(*n_series)++]; - memset(s, 0, sizeof(*s)); - s->key = sqlite3_mprintf("%s", key); - return s; -} /* sort series rows chronologically (insertion sort: input is usually * already ordered, making this near-linear) */ @@ -1129,14 +1105,6 @@ static void series_sort(SeriesBuf *s) { } } -static void series_array_free(SeriesBuf *series, int n) { - for (int i = 0; i < n; i++) { - sqlite3_free(series[i].key); - sqlite3_free(series[i].ts); - sqlite3_free(series[i].val); - } - sqlite3_free(series); -} /* Coerce one timestamp cell to epoch milliseconds. INTEGER cells are * epoch seconds or milliseconds (PREDICT_EPOCH_MS_THRESHOLD decides); @@ -1158,234 +1126,6 @@ static int coerce_ts(int vtype, i64 raw, const char *txt, i64 *ms_out) { return predict0_parse_timestamp(txt, ms_out) ? 1 : 0; } -/* Prepare `query`, resolve or infer the time/value/group columns per - * `opts`, and collect its rows into a series array. backtest()'s front - * end (the aggregates receive their rows pushed instead). - * - * On SQLITE_OK: out_series/out_n hold the collected series (free with - * series_array_free), and out_time/out_value hold the resolved column - * names (sqlite3_malloc'd). On failure: returns an - * error code, frees everything internally, and sets errmsg to a - * "CODE: detail" string (sqlite3_malloc'd) for the caller's zErrMsg. */ -static int collect_series(sqlite3 *db, const char *query, - const ForecastOpts *opts, SeriesBuf **out_series, - int *out_n, char **out_time, char **out_value, - char **errmsg) { - *out_series = NULL; - *out_n = 0; - *out_time = NULL; - *out_value = NULL; - -#define COLLECT_FAIL(code, msg, detail) \ - do { \ - *errmsg = sqlite3_mprintf("%s: %s%s", (code), (msg), \ - (detail) ? (detail) : ""); \ - return SQLITE_ERROR; \ - } while (0) - - sqlite3_stmt *stmt = NULL; - const char *tail = NULL; - if (sqlite3_prepare_v2(db, query, -1, &stmt, &tail) != SQLITE_OK || !stmt) { - char *e = sqlite3_mprintf("%s: query does not parse: %s", - PREDICT_ERR_SCHEMA, sqlite3_errmsg(db)); - if (stmt) - sqlite3_finalize(stmt); - *errmsg = e; - return SQLITE_ERROR; - } - while (tail && (*tail == ' ' || *tail == '\n' || *tail == ';' || - *tail == '\t')) - tail++; - if (tail && *tail != '\0') { - sqlite3_finalize(stmt); - COLLECT_FAIL(PREDICT_ERR_SCHEMA, "query must be a single statement", NULL); - } - if (!sqlite3_stmt_readonly(stmt)) { - sqlite3_finalize(stmt); - COLLECT_FAIL(PREDICT_ERR_QUERY_NOT_READONLY, - "query must be a read-only SELECT", NULL); - } - int ncol = sqlite3_column_count(stmt); - if (ncol < 2) { - sqlite3_finalize(stmt); - COLLECT_FAIL(PREDICT_ERR_SCHEMA, - "query must yield at least a time and a value column", NULL); - } - - /* resolve named columns */ - int time_idx = -1, value_idx = -1; - int group_idx[FORECAST_MAX_GROUP_COLS]; - for (int g = 0; g < opts->n_group_cols; g++) - group_idx[g] = -1; - for (int i = 0; i < ncol; i++) { - const char *name = sqlite3_column_name(stmt, i); - if (opts->time_col && name && strcmp(name, opts->time_col) == 0) - time_idx = i; - if (opts->value_col && name && strcmp(name, opts->value_col) == 0) - value_idx = i; - for (int g = 0; g < opts->n_group_cols; g++) - if (name && strcmp(name, opts->group_cols[g]) == 0) - group_idx[g] = i; - } - if (opts->time_col && time_idx < 0) { - char *d = sqlite3_mprintf("%s", opts->time_col); - sqlite3_finalize(stmt); - *errmsg = sqlite3_mprintf("%s: no such time_col: %s", PREDICT_ERR_SCHEMA, - d ? d : ""); - sqlite3_free(d); - return SQLITE_ERROR; - } - if (opts->value_col && value_idx < 0) { - char *d = sqlite3_mprintf("%s", opts->value_col); - sqlite3_finalize(stmt); - *errmsg = sqlite3_mprintf("%s: no such value_col: %s", PREDICT_ERR_SCHEMA, - d ? d : ""); - sqlite3_free(d); - return SQLITE_ERROR; - } - for (int g = 0; g < opts->n_group_cols; g++) { - if (group_idx[g] < 0) { - char *d = sqlite3_mprintf("%s", opts->group_cols[g]); - sqlite3_finalize(stmt); - *errmsg = sqlite3_mprintf("%s: no such group col: %s", - PREDICT_ERR_SCHEMA, d ? d : ""); - sqlite3_free(d); - return SQLITE_ERROR; - } - } - - SeriesBuf *series = NULL; - int n_series = 0, series_cap = 0; - i64 total_rows = 0; - int inference_done = (time_idx >= 0 && value_idx >= 0); - int rc; - - while ((rc = sqlite3_step(stmt)) == SQLITE_ROW) { - if (!inference_done) { - for (int i = 0; time_idx < 0 && i < ncol; i++) { - int t = sqlite3_column_type(stmt, i); - if (t == SQLITE_INTEGER) { - time_idx = i; - } else if (t == SQLITE_TEXT) { - i64 ms; - if (predict0_parse_timestamp( - (const char *)sqlite3_column_text(stmt, i), &ms) == 0) - time_idx = i; - } - } - for (int i = 0; value_idx < 0 && i < ncol; i++) { - if (i == time_idx) - continue; - int in_groups = 0; - for (int g = 0; g < opts->n_group_cols; g++) - if (group_idx[g] == i) - in_groups = 1; - if (in_groups) - continue; - int t = sqlite3_column_type(stmt, i); - if (t == SQLITE_INTEGER || t == SQLITE_FLOAT) - value_idx = i; - } - if (time_idx < 0 || value_idx < 0) { - sqlite3_finalize(stmt); - series_array_free(series, n_series); - COLLECT_FAIL(PREDICT_ERR_SCHEMA, - "could not infer time/value columns", NULL); - } - inference_done = 1; - } - - if (++total_rows > FORECAST_MAX_TOTAL_ROWS) { - sqlite3_finalize(stmt); - series_array_free(series, n_series); - COLLECT_FAIL(PREDICT_ERR_RESOURCE, "too many input rows", NULL); - } - - /* dynamic key: fixed buffers would truncate long group values and - * silently merge distinct series */ - char *key = sqlite3_mprintf("%s", ""); - for (int g = 0; key && g < opts->n_group_cols; g++) { - const char *gv = (const char *)sqlite3_column_text(stmt, group_idx[g]); - char sep[2] = {PREDICT_FS, 0}; - key = sqlite3_mprintf("%z%s%s", key, g ? sep : "", gv ? gv : ""); - } - if (!key) { - rc = SQLITE_NOMEM; - break; - } - SeriesBuf *s = series_find(&series, &n_series, &series_cap, key); - sqlite3_free(key); - if (!s) { - rc = SQLITE_NOMEM; - break; - } - if (s->non_numeric) - continue; - - i64 ms = 0; - int tt = sqlite3_column_type(stmt, time_idx); - if (coerce_ts(tt, - tt == SQLITE_INTEGER ? sqlite3_column_int64(stmt, time_idx) - : 0, - tt == SQLITE_INTEGER - ? NULL - : (const char *)sqlite3_column_text(stmt, time_idx), - &ms)) { - s->non_numeric = 1; - continue; - } - int vt = sqlite3_column_type(stmt, value_idx); - if (vt != SQLITE_INTEGER && vt != SQLITE_FLOAT) { - s->non_numeric = 1; - continue; - } - - if (s->n == s->cap) { - int nc = s->cap ? s->cap * 2 : 64; - i64 *nts = sqlite3_realloc(s->ts, sizeof(i64) * nc); - f64 *nval = sqlite3_realloc(s->val, sizeof(f64) * nc); - if (!nts || !nval) { - sqlite3_free(nts); - sqlite3_free(nval); - rc = SQLITE_NOMEM; - break; - } - s->ts = nts; - s->val = nval; - s->cap = nc; - } - s->ts[s->n] = ms; - s->val[s->n] = sqlite3_column_double(stmt, value_idx); - s->n++; - } - - char *rtime = NULL, *rvalue = NULL; - if (time_idx >= 0) - rtime = sqlite3_mprintf("%s", sqlite3_column_name(stmt, time_idx)); - if (value_idx >= 0) - rvalue = sqlite3_mprintf("%s", sqlite3_column_name(stmt, value_idx)); - sqlite3_finalize(stmt); - - if (rc == SQLITE_NOMEM) { - sqlite3_free(rtime); - sqlite3_free(rvalue); - series_array_free(series, n_series); - return SQLITE_NOMEM; - } - if (rc != SQLITE_DONE) { - sqlite3_free(rtime); - sqlite3_free(rvalue); - series_array_free(series, n_series); - COLLECT_FAIL(PREDICT_ERR_RESOURCE, "query execution failed", NULL); - } - - *out_series = series; - *out_n = n_series; - *out_time = rtime; - *out_value = rvalue; - return SQLITE_OK; -#undef COLLECT_FAIL -} /* Resolved backing-model context for one forecast call: which model * (or auto candidate pool) will serve, with ownership of anything @@ -2347,27 +2087,10 @@ static int an_run_series(SeriesBuf *series, int n_series, #pragma region backtest -/* backtest(query, horizon, options) — eponymous table-valued function. Rolling- - * origin evaluation of a statistical model (or auto) over each series: for each - * fold it reports point-accuracy metrics and, for the chosen interval_method, - * interval coverage + width. This is the on-device audit primitive: an agent - * scores its own forecasts, and validates conformal coverage, locally. */ - -#define BT_COL_SERIES_KEY 0 -#define BT_COL_FOLD 1 -#define BT_COL_CUTOFF 2 -#define BT_COL_MODEL 3 -#define BT_COL_N 4 -#define BT_COL_MAE 5 -#define BT_COL_RMSE 6 -#define BT_COL_MASE 7 -#define BT_COL_SMAPE 8 -#define BT_COL_COVERAGE 9 -#define BT_COL_WIDTH 10 -#define BT_COL_STATUS 11 -#define BT_COL_QUERY 12 -#define BT_COL_HORIZON 13 -#define BT_COL_OPTIONS 14 +/* Rolling-origin backtest of a series: for each fold, point-accuracy metrics + * and (for the chosen interval_method) coverage + width. Exposed as the + * aggregate backtest(ts, value, horizon[, options]) in the aggregate region; + * bt_compute_series is the per-series compute that aggregate's final runs. */ typedef struct { char *series_key; @@ -2377,121 +2100,11 @@ typedef struct { int n; f64 mae, rmse, mase, smape, coverage, width; int has_metrics; /* 0 for status-only rows */ + int has_mase; /* 0 when the naive scale is 0 (constant series): MASE undefined */ int has_band; /* 0 when no interval was computed */ const char *status; } BtRow; - -typedef struct { - sqlite3_vtab base; - sqlite3 *db; -} bt_vtab; - -typedef struct { - sqlite3_vtab_cursor base; - BtRow *rows; - int n_rows; - int i; -} bt_cursor; - -static int bt_connect(sqlite3 *db, void *pAux, int argc, const char *const *argv, - sqlite3_vtab **ppVtab, char **pzErr) { - UNUSED_PARAMETER(pAux); - UNUSED_PARAMETER(argc); - UNUSED_PARAMETER(argv); - UNUSED_PARAMETER(pzErr); - bt_vtab *v = sqlite3_malloc(sizeof(*v)); - if (!v) - return SQLITE_NOMEM; - memset(v, 0, sizeof(*v)); - v->db = db; - int rc = sqlite3_declare_vtab( - db, "CREATE TABLE x(series_key TEXT, fold INTEGER, cutoff_timestamp TEXT," - " model TEXT, n INTEGER, mae REAL, rmse REAL, mase REAL, smape REAL," - " coverage REAL, mean_interval_width REAL, status TEXT," - " query HIDDEN, horizon HIDDEN, options HIDDEN)"); - if (rc != SQLITE_OK) { - sqlite3_free(v); - return rc; - } - *ppVtab = &v->base; - return SQLITE_OK; -} - -static int bt_disconnect(sqlite3_vtab *pVtab) { - sqlite3_free(pVtab); - return SQLITE_OK; -} - -static int bt_best_index(sqlite3_vtab *pVtab, sqlite3_index_info *pIdx) { - int seen_query = 0, seen_horizon = 0; - for (int i = 0; i < pIdx->nConstraint; i++) { - const struct sqlite3_index_constraint *c = &pIdx->aConstraint[i]; - if (c->op != SQLITE_INDEX_CONSTRAINT_EQ) - continue; - if (c->iColumn == BT_COL_QUERY) { - if (!c->usable) - return SQLITE_CONSTRAINT; - pIdx->aConstraintUsage[i].argvIndex = 1; - pIdx->aConstraintUsage[i].omit = 1; - seen_query = 1; - } else if (c->iColumn == BT_COL_HORIZON) { - if (!c->usable) - return SQLITE_CONSTRAINT; - pIdx->aConstraintUsage[i].argvIndex = 2; - pIdx->aConstraintUsage[i].omit = 1; - seen_horizon = 1; - } else if (c->iColumn == BT_COL_OPTIONS) { - if (!c->usable) - return SQLITE_CONSTRAINT; - pIdx->aConstraintUsage[i].argvIndex = 3; - pIdx->aConstraintUsage[i].omit = 1; - } - } - if (!seen_query || !seen_horizon) { - pVtab->zErrMsg = sqlite3_mprintf( - "%s: backtest(query, horizon) requires both arguments", - PREDICT_ERR_SCHEMA); - return SQLITE_ERROR; - } - pIdx->estimatedCost = 1000; - return SQLITE_OK; -} - -static int bt_open(sqlite3_vtab *pVtab, sqlite3_vtab_cursor **ppCur) { - UNUSED_PARAMETER(pVtab); - bt_cursor *c = sqlite3_malloc(sizeof(*c)); - if (!c) - return SQLITE_NOMEM; - memset(c, 0, sizeof(*c)); - *ppCur = &c->base; - return SQLITE_OK; -} - -static void bt_rows_free(bt_cursor *c) { - for (int i = 0; i < c->n_rows; i++) - sqlite3_free(c->rows[i].series_key); - sqlite3_free(c->rows); - c->rows = NULL; - c->n_rows = 0; - c->i = 0; -} - -static int bt_close(sqlite3_vtab_cursor *pCur) { - bt_cursor *c = (bt_cursor *)pCur; - bt_rows_free(c); - sqlite3_free(c); - return SQLITE_OK; -} - -static int bt_error(bt_cursor *cur, const char *code, const char *msg, - const char *detail) { - sqlite3_free(cur->base.pVtab->zErrMsg); - cur->base.pVtab->zErrMsg = - sqlite3_mprintf("%s: %s%s", code, msg, detail ? detail : ""); - return SQLITE_ERROR; -} - /* Conformal leave-fold-out absolute-residual quantile for step h, excluding * fold `skip`. col is scratch [nf]. Returns the quantile at level conf. */ static f64 bt_lofo_q(const f64 *act, const f64 *fcst, int nf, int horizon, int h, @@ -2503,312 +2116,108 @@ static f64 bt_lofo_q(const f64 *act, const f64 *fcst, int nf, int horizon, int h return conformal_quantile(col, m, conf); } -static int bt_filter(sqlite3_vtab_cursor *pCur, int idxNum, const char *idxStr, - int argc, sqlite3_value **argv) { - UNUSED_PARAMETER(idxNum); - UNUSED_PARAMETER(idxStr); - bt_cursor *cur = (bt_cursor *)pCur; - bt_vtab *vtab = (bt_vtab *)cur->base.pVtab; - sqlite3 *db = vtab->db; - bt_rows_free(cur); - - if (argc < 2) - return bt_error(cur, PREDICT_ERR_SCHEMA, - "backtest(query, horizon) requires both arguments", NULL); - const char *query = (const char *)sqlite3_value_text(argv[0]); - if (!query) - return bt_error(cur, PREDICT_ERR_SCHEMA, "query must be text", NULL); - if (sqlite3_value_type(argv[1]) != SQLITE_INTEGER) - return bt_error(cur, PREDICT_ERR_HORIZON, "horizon must be an integer", NULL); - int horizon = sqlite3_value_int(argv[1]); - if (horizon < 1 || horizon > FORECAST_MAX_HORIZON) - return bt_error(cur, PREDICT_ERR_HORIZON, - "horizon out of range 1.." PREDICT_STR(FORECAST_MAX_HORIZON), - NULL); - - ForecastOpts opts; - memset(&opts, 0, sizeof(opts)); - opts.confidence = 0.95; - opts.context_limit = FORECAST_DEFAULT_CONTEXT_LIMIT; - - const char *options_json = - argc >= 3 ? (const char *)sqlite3_value_text(argv[2]) : NULL; - char *errmsg = NULL; - if (predict0_options_parse(db, options_json, BACKTEST_OPTION_KEYS, - forecast_opt_cb, &opts, &errmsg)) { - sqlite3_free(vtab->base.zErrMsg); - vtab->base.zErrMsg = errmsg; - forecast_opts_free(&opts); - return SQLITE_ERROR; - } - - int is_auto = opts.model && strcmp(opts.model, "auto") == 0; - predict0_ts_model fixed_model = is_auto ? NULL : resolve_ts_model(opts.model); - const char *fixed_id = is_auto ? "auto" : resolve_ts_model_id(opts.model); - if (!is_auto && !fixed_model) { - int rc = bt_error(cur, PREDICT_ERR_MODEL_NOT_FOUND, - "backtest() supports the statistical models " - "(theta-classic, stub-seasonal-naive, tsb) or auto: ", - opts.model); - forecast_opts_free(&opts); - return rc; +/* Backtest one series, appending BtRows at rows[*n_io]. rows has room for + * folds_req entries (or 1 status row). Scratch act/fcst/sig are + * [folds_req*horizon]; col/cutoff_ms are [folds_req]; z is the residual-band + * z-value. Returns SQLITE_OK or SQLITE_NOMEM. */ +static int bt_compute_series(SeriesBuf *s, int horizon, const ForecastOpts *opts, + int is_auto, predict0_ts_model fixed_model, + const char *fixed_id, int folds_req, f64 z, + BtRow *rows, int *n_io, f64 *act, f64 *fcst, + f64 *sig, f64 *col, i64 *cutoff_ms) { + const char *status = NULL; + if (s->non_numeric) + status = "non_numeric"; + else if (s->n < FORECAST_MIN_HISTORY) + status = "insufficient_history"; + if (status) { + BtRow *r = &rows[(*n_io)++]; + memset(r, 0, sizeof(*r)); + r->series_key = sqlite3_mprintf("%s", s->key); + r->status = status; + return SQLITE_OK; } - - SeriesBuf *series = NULL; - int n_series = 0; - char *resolved_time = NULL, *resolved_value = NULL, *cerr = NULL; - int rc = collect_series(db, query, &opts, &series, &n_series, &resolved_time, - &resolved_value, &cerr); - -#define BT_FREE_SERIES() \ - do { \ - series_array_free(series, n_series); \ - sqlite3_free(resolved_time); \ - sqlite3_free(resolved_value); \ - forecast_opts_free(&opts); \ - } while (0) - - if (rc != SQLITE_OK) { - sqlite3_free(vtab->base.zErrMsg); - vtab->base.zErrMsg = cerr; - forecast_opts_free(&opts); - return rc; + series_sort(s); + int truncated = 0; + f64 *y = s->val; + i64 *ts = s->ts; + int n = s->n; + if (opts->context_limit > 0 && n > opts->context_limit) { + y += n - opts->context_limit; + ts += n - opts->context_limit; + n = opts->context_limit; + truncated = 1; + } + predict0_ts_model use_model = fixed_model; + const char *use_id = fixed_id; + f64 scale = naive_scale(y, n); + if (is_auto) { + int bi = ts_auto_select(y, ts, n, horizon, opts->gap, folds_req, scale, act, + fcst); + if (bi <= -2) + return SQLITE_NOMEM; + use_model = bi >= 0 ? TS_MODELS[bi].run : model_theta; + use_id = bi >= 0 ? TS_MODELS[bi].id : "theta-classic"; } - - int folds_req = opts.folds > 0 ? opts.folds : FORECAST_DEFAULT_FOLDS; - f64 z = predict0_norm_quantile(0.5 + opts.confidence / 2); - - int max_rows = 0; - for (int i = 0; i < n_series; i++) { - SeriesBuf *s = &series[i]; - int usable = !s->non_numeric && s->n >= FORECAST_MIN_HISTORY; - max_rows += usable ? folds_req : 1; + int nf = ts_backtest(use_model, y, ts, n, horizon, opts->gap, folds_req, act, + fcst, sig, cutoff_ms); + if (nf <= -2) + return SQLITE_NOMEM; + if (nf == 0) { + BtRow *r = &rows[(*n_io)++]; + memset(r, 0, sizeof(*r)); + r->series_key = sqlite3_mprintf("%s", s->key); + r->status = "insufficient_history"; + return SQLITE_OK; } - cur->rows = sqlite3_malloc(sizeof(BtRow) * (max_rows ? max_rows : 1)); - f64 *act = sqlite3_malloc(sizeof(f64) * (size_t)folds_req * horizon); - f64 *fcst = sqlite3_malloc(sizeof(f64) * (size_t)folds_req * horizon); - f64 *sig = sqlite3_malloc(sizeof(f64) * (size_t)folds_req * horizon); - f64 *col = sqlite3_malloc(sizeof(f64) * folds_req); - i64 *cutoff_ms = sqlite3_malloc(sizeof(i64) * folds_req); - if (!cur->rows || !act || !fcst || !sig || !col || !cutoff_ms) - rc = SQLITE_NOMEM; - cur->n_rows = 0; - - for (int i = 0; rc == SQLITE_OK && i < n_series; i++) { - SeriesBuf *s = &series[i]; - const char *status = NULL; - if (s->non_numeric) - status = "non_numeric"; - else if (s->n < FORECAST_MIN_HISTORY) - status = "insufficient_history"; - if (status) { - BtRow *r = &cur->rows[cur->n_rows++]; - memset(r, 0, sizeof(*r)); - r->series_key = sqlite3_mprintf("%s", s->key); - r->status = status; - continue; - } - series_sort(s); - int truncated = 0; - f64 *y = s->val; - i64 *ts = s->ts; - int n = s->n; - if (opts.context_limit > 0 && n > opts.context_limit) { - y += n - opts.context_limit; - ts += n - opts.context_limit; - n = opts.context_limit; - truncated = 1; - } - - predict0_ts_model use_model = fixed_model; - const char *use_id = fixed_id; - f64 scale = naive_scale(y, n); - if (is_auto) { - int bi = ts_auto_select(y, ts, n, horizon, opts.gap, folds_req, scale, act, - fcst); - if (bi <= -2) { - rc = SQLITE_NOMEM; - break; + for (int f = 0; f < nf; f++) { + const f64 *fa = act + (size_t)f * horizon; + const f64 *ff = fcst + (size_t)f * horizon; + const f64 *fs = sig + (size_t)f * horizon; + BtRow *r = &rows[(*n_io)++]; + memset(r, 0, sizeof(*r)); + r->series_key = sqlite3_mprintf("%s", s->key); + r->fold = f; + predict0_format_timestamp(cutoff_ms[f], r->cutoff, sizeof(r->cutoff)); + r->model = use_id; + r->n = horizon; + r->mae = metric_mae(fa, ff, horizon); + r->rmse = metric_rmse(fa, ff, horizon); + r->has_mase = scale > 0; /* MASE is undefined when the naive scale is 0 */ + r->mase = scale > 0 ? r->mae / scale : 0; + r->smape = metric_smape(fa, ff, horizon); + r->has_metrics = 1; + r->status = truncated ? "truncated" : "ok"; + f64 cov = 0, wid = 0; + if (!opts->interval_conformal) { + for (int h = 0; h < horizon; h++) { + f64 lo = ff[h] - z * fs[h], hi = ff[h] + z * fs[h]; + wid += hi - lo; + if (fa[h] >= lo && fa[h] <= hi) + cov += 1; } - use_model = bi >= 0 ? TS_MODELS[bi].run : model_theta; - use_id = bi >= 0 ? TS_MODELS[bi].id : "theta-classic"; - } - int nf = ts_backtest(use_model, y, ts, n, horizon, opts.gap, folds_req, act, - fcst, sig, cutoff_ms); - if (nf <= -2) { - rc = SQLITE_NOMEM; - break; - } - if (nf == 0) { /* too short to form a single fold */ - BtRow *r = &cur->rows[cur->n_rows++]; - memset(r, 0, sizeof(*r)); - r->series_key = sqlite3_mprintf("%s", s->key); - r->status = "insufficient_history"; - continue; - } - for (int f = 0; f < nf; f++) { - const f64 *fa = act + (size_t)f * horizon; - const f64 *ff = fcst + (size_t)f * horizon; - const f64 *fs = sig + (size_t)f * horizon; - BtRow *r = &cur->rows[cur->n_rows++]; - memset(r, 0, sizeof(*r)); - r->series_key = sqlite3_mprintf("%s", s->key); - r->fold = f; - predict0_format_timestamp(cutoff_ms[f], r->cutoff, sizeof(r->cutoff)); - r->model = use_id; - r->n = horizon; - r->mae = metric_mae(fa, ff, horizon); - r->rmse = metric_rmse(fa, ff, horizon); - r->mase = scale > 0 ? r->mae / scale : 0; - r->smape = metric_smape(fa, ff, horizon); - r->has_metrics = 1; - r->status = truncated ? "truncated" : "ok"; - f64 cov = 0, wid = 0; - if (!opts.interval_conformal) { - for (int h = 0; h < horizon; h++) { - f64 lo = ff[h] - z * fs[h], hi = ff[h] + z * fs[h]; - wid += hi - lo; - if (fa[h] >= lo && fa[h] <= hi) - cov += 1; - } - r->coverage = cov / horizon; - r->width = wid / horizon; - r->has_band = 1; - } else if (nf >= CONFORMAL_MIN_FOLDS) { /* leave-fold-out conformal band */ - for (int h = 0; h < horizon; h++) { - f64 q = bt_lofo_q(act, fcst, nf, horizon, h, f, opts.confidence, col); - f64 lo = ff[h] - q, hi = ff[h] + q; - wid += hi - lo; - if (fa[h] >= lo && fa[h] <= hi) - cov += 1; - } - r->coverage = cov / horizon; - r->width = wid / horizon; - r->has_band = 1; + r->coverage = cov / horizon; + r->width = wid / horizon; + r->has_band = 1; + } else if (nf >= CONFORMAL_MIN_FOLDS) { + for (int h = 0; h < horizon; h++) { + f64 q = bt_lofo_q(act, fcst, nf, horizon, h, f, opts->confidence, col); + f64 lo = ff[h] - q, hi = ff[h] + q; + wid += hi - lo; + if (fa[h] >= lo && fa[h] <= hi) + cov += 1; } + r->coverage = cov / horizon; + r->width = wid / horizon; + r->has_band = 1; } } - - sqlite3_free(act); - sqlite3_free(fcst); - sqlite3_free(sig); - sqlite3_free(col); - sqlite3_free(cutoff_ms); - - if (rc != SQLITE_OK) { - BT_FREE_SERIES(); - bt_rows_free(cur); - return rc; - } - - BT_FREE_SERIES(); -#undef BT_FREE_SERIES - cur->i = 0; return SQLITE_OK; } -static int bt_next(sqlite3_vtab_cursor *pCur) { - ((bt_cursor *)pCur)->i++; - return SQLITE_OK; -} +#pragma endregion -static int bt_eof(sqlite3_vtab_cursor *pCur) { - bt_cursor *c = (bt_cursor *)pCur; - return c->i >= c->n_rows; -} - -static int bt_column(sqlite3_vtab_cursor *pCur, sqlite3_context *ctx, int col) { - bt_cursor *c = (bt_cursor *)pCur; - BtRow *r = &c->rows[c->i]; - switch (col) { - case BT_COL_SERIES_KEY: - sqlite3_result_text(ctx, r->series_key ? r->series_key : "", -1, - SQLITE_TRANSIENT); - break; - case BT_COL_FOLD: - if (r->has_metrics) - sqlite3_result_int(ctx, r->fold); - break; - case BT_COL_CUTOFF: - if (r->has_metrics) - sqlite3_result_text(ctx, r->cutoff, -1, SQLITE_TRANSIENT); - break; - case BT_COL_MODEL: - if (r->has_metrics) - sqlite3_result_text(ctx, r->model, -1, SQLITE_STATIC); - break; - case BT_COL_N: - if (r->has_metrics) - sqlite3_result_int(ctx, r->n); - break; - case BT_COL_MAE: - if (r->has_metrics) - sqlite3_result_double(ctx, r->mae); - break; - case BT_COL_RMSE: - if (r->has_metrics) - sqlite3_result_double(ctx, r->rmse); - break; - case BT_COL_MASE: - if (r->has_metrics) - sqlite3_result_double(ctx, r->mase); - break; - case BT_COL_SMAPE: - if (r->has_metrics) - sqlite3_result_double(ctx, r->smape); - break; - case BT_COL_COVERAGE: - if (r->has_band) - sqlite3_result_double(ctx, r->coverage); - break; - case BT_COL_WIDTH: - if (r->has_band) - sqlite3_result_double(ctx, r->width); - break; - case BT_COL_STATUS: - sqlite3_result_text(ctx, r->status, -1, SQLITE_STATIC); - break; - default: - break; - } - return SQLITE_OK; -} - -static int bt_rowid(sqlite3_vtab_cursor *pCur, sqlite3_int64 *pRowid) { - *pRowid = ((bt_cursor *)pCur)->i; - return SQLITE_OK; -} - -static sqlite3_module backtestModule = { - /* iVersion */ 0, - /* xCreate */ NULL, - /* xConnect */ bt_connect, - /* xBestIndex */ bt_best_index, - /* xDisconnect */ bt_disconnect, - /* xDestroy */ NULL, - /* xOpen */ bt_open, - /* xClose */ bt_close, - /* xFilter */ bt_filter, - /* xNext */ bt_next, - /* xEof */ bt_eof, - /* xColumn */ bt_column, - /* xRowid */ bt_rowid, - /* xUpdate */ NULL, - /* xBegin */ NULL, - /* xSync */ NULL, - /* xCommit */ NULL, - /* xRollback */ NULL, - /* xFindMethod */ NULL, - /* xRename */ NULL, - /* xSavepoint */ NULL, - /* xRelease */ NULL, - /* xRollbackTo */ NULL, - /* xShadowName */ NULL, - /* xIntegrity */ NULL}; - -#pragma endregion - -#pragma region aggregate +#pragma region aggregate /* forecast(ts, value, horizon[, options]) and detect_anomalies(ts, * value[, options]) — the aggregates. The @@ -3030,7 +2439,7 @@ static void agg_json_num(sqlite3_str *s, f64 v) { sqlite3_str_appendall(s, buf); } -static void agg_json_str(sqlite3_str *s, const char *t) { +void predict0_json_str(sqlite3_str *s, const char *t) { sqlite3_str_appendchar(s, 1, '"'); for (; *t; t++) { unsigned char c = (unsigned char)*t; @@ -3126,7 +2535,7 @@ static void agg_fc_final(sqlite3_context *ctx) { * which may be an mc-owned string, so mc is freed after the build. */ sqlite3_str *doc = sqlite3_str_new(db); sqlite3_str_appendall(doc, "{\"model\":"); - agg_json_str(doc, used_id ? used_id : model_id); + predict0_json_str(doc, used_id ? used_id : model_id); sqlite3_str_appendf(doc, ",\"status\":\"%s\",\"rows\":[", status); int first = 1; for (int i = 0; i < n_rows; i++) { @@ -3223,7 +2632,7 @@ static void agg_an_final(sqlite3_context *ctx) { sqlite3_str *doc = sqlite3_str_new(db); sqlite3_str_appendall(doc, "{\"model\":"); - agg_json_str(doc, model_id); + predict0_json_str(doc, model_id); sqlite3_str_appendf(doc, ",\"status\":\"%s\",\"rows\":[", status); int first = 1; for (int i = 0; i < n_rows; i++) { @@ -3270,6 +2679,172 @@ static void agg_an_final(sqlite3_context *ctx) { sqlite3_result_text(ctx, out, -1, sqlite3_free); } +/* backtest(ts, value, horizon[, options]) is an aggregate, matching + * forecast/detect_anomalies. It collects the series via agg_step, runs + * the rolling-origin evaluation per group, and returns one JSON document: + * {"model","status","rows":[{fold,cutoff_timestamp,n,mae,rmse,mase,smape, + * coverage,mean_interval_width}]}; expand it with backtest_rows(). */ + +/* No "candidates" key: backtest()'s auto path searches only the bundled + * TS_MODELS via ts_auto_select, so accepting candidates would silently ignore + * them. Rejecting the key keeps the contract honest; wiring resolve_candidates + * into the CV auto path is a tracked enhancement. */ +static const char *const AGG_BACKTEST_OPTION_KEYS[] = { + "confidence_level", "context_limit", "model", "interval_method", + "folds", "gap", NULL}; + +static void agg_bt_step(sqlite3_context *ctx, int argc, sqlite3_value **argv) { + agg_step(ctx, argc, argv, "backtest", 2); +} + +static void agg_bt_misuse(sqlite3_context *ctx, int argc, sqlite3_value **argv) { + UNUSED_PARAMETER(argc); + UNUSED_PARAMETER(argv); + sqlite3_result_error( + ctx, + PREDICT_ERR_SCHEMA + ": backtest does not take a query string; it is an aggregate over your " + "rows: SELECT backtest(ts, value, horizon[, options]) FROM ...", + -1); +} + +static void agg_bt_final(sqlite3_context *ctx) { + AggSeries *a = sqlite3_aggregate_context(ctx, 0); + if (!a || !a->started) { + agg_series_clear(a); + sqlite3_result_null(ctx); + return; + } + if (a->errored) { + agg_series_clear(a); + sqlite3_result_error_code(ctx, SQLITE_ERROR); + return; + } + sqlite3 *db = sqlite3_context_db_handle(ctx); + int horizon = (int)a->horizon; + + ForecastOpts opts; + memset(&opts, 0, sizeof(opts)); + opts.confidence = 0.95; + opts.context_limit = FORECAST_DEFAULT_CONTEXT_LIMIT; + char *perr = NULL; + if (predict0_options_parse(db, a->options_json, AGG_BACKTEST_OPTION_KEYS, + forecast_opt_cb, &opts, &perr)) { + agg_error(ctx, perr); + forecast_opts_free(&opts); + agg_series_clear(a); + return; + } + int is_auto = opts.model && strcmp(opts.model, "auto") == 0; + predict0_ts_model fixed_model = is_auto ? NULL : resolve_ts_model(opts.model); + const char *fixed_id = is_auto ? "auto" : resolve_ts_model_id(opts.model); + if (!is_auto && !fixed_model) { + char *m = sqlite3_mprintf( + "%s: backtest() supports theta-classic, stub-seasonal-naive, tsb, or " + "auto: %s", + PREDICT_ERR_MODEL_NOT_FOUND, opts.model ? opts.model : "(null)"); + agg_error(ctx, m); + forecast_opts_free(&opts); + agg_series_clear(a); + return; + } + int folds_req = opts.folds > 0 ? opts.folds : FORECAST_DEFAULT_FOLDS; + f64 z = predict0_norm_quantile(0.5 + opts.confidence / 2); + + SeriesBuf s; + memset(&s, 0, sizeof(s)); + s.key = ""; + s.ts = a->ts; + s.val = a->val; + s.n = a->n; + s.cap = a->cap; + s.non_numeric = a->non_numeric; + + BtRow *rows = sqlite3_malloc(sizeof(BtRow) * (folds_req > 0 ? folds_req : 1)); + f64 *act = sqlite3_malloc(sizeof(f64) * (size_t)folds_req * horizon); + f64 *fcst = sqlite3_malloc(sizeof(f64) * (size_t)folds_req * horizon); + f64 *sig = sqlite3_malloc(sizeof(f64) * (size_t)folds_req * horizon); + f64 *col = sqlite3_malloc(sizeof(f64) * folds_req); + i64 *cutoff_ms = sqlite3_malloc(sizeof(i64) * folds_req); + int n_rows = 0, rc = SQLITE_OK; + if (!rows || !act || !fcst || !sig || !col || !cutoff_ms) + rc = SQLITE_NOMEM; + else + rc = bt_compute_series(&s, horizon, &opts, is_auto, fixed_model, fixed_id, + folds_req, z, rows, &n_rows, act, fcst, sig, col, + cutoff_ms); + sqlite3_free(act); + sqlite3_free(fcst); + sqlite3_free(sig); + sqlite3_free(col); + sqlite3_free(cutoff_ms); + if (rc != SQLITE_OK) { + for (int i = 0; i < n_rows; i++) + sqlite3_free(rows[i].series_key); + sqlite3_free(rows); + sqlite3_result_error_nomem(ctx); + forecast_opts_free(&opts); + agg_series_clear(a); + return; + } + + const char *status = n_rows ? rows[0].status : "ok"; + const char *model_id = + (n_rows && rows[0].has_metrics && rows[0].model) ? rows[0].model + : fixed_id; + sqlite3_str *doc = sqlite3_str_new(db); + sqlite3_str_appendall(doc, "{\"model\":"); + predict0_json_str(doc, model_id ? model_id : ""); + sqlite3_str_appendf(doc, ",\"status\":\"%s\",\"rows\":[", status); + int first = 1; + for (int i = 0; i < n_rows; i++) { + if (!rows[i].has_metrics) + continue; + if (!first) + sqlite3_str_appendchar(doc, 1, ','); + first = 0; + sqlite3_str_appendf(doc, + "{\"fold\":%d,\"cutoff_timestamp\":\"%s\",\"n\":%d," + "\"mae\":", + rows[i].fold, rows[i].cutoff, rows[i].n); + agg_json_num(doc, rows[i].mae); + sqlite3_str_appendall(doc, ",\"rmse\":"); + agg_json_num(doc, rows[i].rmse); + sqlite3_str_appendall(doc, ",\"mase\":"); + if (rows[i].has_mase) + agg_json_num(doc, rows[i].mase); + else + sqlite3_str_appendall(doc, "null"); /* undefined for a constant series */ + sqlite3_str_appendall(doc, ",\"smape\":"); + agg_json_num(doc, rows[i].smape); + sqlite3_str_appendall(doc, ",\"coverage\":"); + if (rows[i].has_band) + agg_json_num(doc, rows[i].coverage); + else + sqlite3_str_appendall(doc, "null"); + sqlite3_str_appendall(doc, ",\"mean_interval_width\":"); + if (rows[i].has_band) + agg_json_num(doc, rows[i].width); + else + sqlite3_str_appendall(doc, "null"); + sqlite3_str_appendchar(doc, 1, '}'); + } + sqlite3_str_appendall(doc, "]}"); + + for (int i = 0; i < n_rows; i++) + sqlite3_free(rows[i].series_key); + sqlite3_free(rows); + forecast_opts_free(&opts); + agg_series_clear(a); + + char *out = sqlite3_str_finish(doc); + if (!out) { + sqlite3_result_error_nomem(ctx); + return; + } + sqlite3_result_text(ctx, out, -1, sqlite3_free); +} + #pragma endregion #pragma region expansion @@ -3429,6 +3004,10 @@ static int xr_filter(sqlite3_vtab_cursor *pCur, int idxNum, const char *idxStr, } cur->status = sqlite3_mprintf("%s", (const char *)sqlite3_column_text(top, 2)); + if (!cur->status) { + sqlite3_finalize(top); + return SQLITE_NOMEM; + } sqlite3_finalize(top); const char *rows_sql = @@ -3470,6 +3049,10 @@ static int xr_filter(sqlite3_vtab_cursor *pCur, int idxNum, const char *idxStr, /* ts, value, forecast, lower, upper, is_anomaly, probability */ if (sqlite3_column_type(stmt, 0) == SQLITE_TEXT) { r->ts = sqlite3_mprintf("%s", sqlite3_column_text(stmt, 0)); + if (!r->ts) { + sqlite3_finalize(stmt); + return SQLITE_NOMEM; + } r->has_ts = 1; } static const int real_col[5] = {1, 2, 3, 4, 6}; @@ -3490,6 +3073,10 @@ static int xr_filter(sqlite3_vtab_cursor *pCur, int idxNum, const char *idxStr, } if (sqlite3_column_type(stmt, 1) == SQLITE_TEXT) { r->ts = sqlite3_mprintf("%s", sqlite3_column_text(stmt, 1)); + if (!r->ts) { + sqlite3_finalize(stmt); + return SQLITE_NOMEM; + } r->has_ts = 1; } for (int k = 0; k < 3; k++) @@ -3613,22 +3200,334 @@ static sqlite3_module xrowsModule = { #pragma endregion -int predict0_forecast_init(sqlite3 *db) { - int rc = sqlite3_create_module(db, "backtest", &backtestModule, NULL); - if (rc != SQLITE_OK) +/* backtest_rows(doc) — expand a backtest aggregate document into typed rows. + * Columns match the former backtest() TVF: series_key ('' in the aggregate + * form; the caller's GROUP BY key names the series), fold, cutoff_timestamp, + * model, n, the metric columns, and status. */ + +typedef struct { + int fold, n; + u8 has_fold, has_n; + f64 reals[6]; /* mae, rmse, mase, smape, coverage, width */ + u8 has_real[6]; + char *cutoff; +} BtxRow; + +typedef struct { + sqlite3_vtab base; + sqlite3 *db; +} btrows_vtab; + +typedef struct { + sqlite3_vtab_cursor base; + BtxRow *rows; + int n_rows; + int i; + char *model; + char *status; +} btrows_cursor; + +static int btr_connect(sqlite3 *db, void *pAux, int argc, + const char *const *argv, sqlite3_vtab **ppVtab, + char **pzErr) { + UNUSED_PARAMETER(pAux); + UNUSED_PARAMETER(argc); + UNUSED_PARAMETER(argv); + UNUSED_PARAMETER(pzErr); + btrows_vtab *v = sqlite3_malloc(sizeof(*v)); + if (!v) + return SQLITE_NOMEM; + memset(v, 0, sizeof(*v)); + v->db = db; + int rc = sqlite3_declare_vtab( + db, "CREATE TABLE x(series_key TEXT, fold INTEGER, cutoff_timestamp TEXT," + " model TEXT, n INTEGER, mae REAL, rmse REAL, mase REAL, smape REAL," + " coverage REAL, mean_interval_width REAL, status TEXT, doc HIDDEN)"); + if (rc != SQLITE_OK) { + sqlite3_free(v); return rc; + } + *ppVtab = &v->base; + return SQLITE_OK; +} + +static int btr_disconnect(sqlite3_vtab *pVtab) { + sqlite3_free(pVtab); + return SQLITE_OK; +} + +static int btr_best_index(sqlite3_vtab *pVtab, sqlite3_index_info *pIdx) { + int seen = 0; + for (int i = 0; i < pIdx->nConstraint; i++) { + const struct sqlite3_index_constraint *c = &pIdx->aConstraint[i]; + if (c->iColumn == 12 && c->op == SQLITE_INDEX_CONSTRAINT_EQ) { + if (!c->usable) + return SQLITE_CONSTRAINT; + pIdx->aConstraintUsage[i].argvIndex = 1; + pIdx->aConstraintUsage[i].omit = 1; + seen = 1; + } + } + if (!seen) { + pVtab->zErrMsg = sqlite3_mprintf( + "%s: backtest_rows(doc) requires a document argument", + PREDICT_ERR_SCHEMA); + return SQLITE_ERROR; + } + pIdx->estimatedCost = 1000; + return SQLITE_OK; +} + +static int btr_open(sqlite3_vtab *pVtab, sqlite3_vtab_cursor **ppCur) { + UNUSED_PARAMETER(pVtab); + btrows_cursor *c = sqlite3_malloc(sizeof(*c)); + if (!c) + return SQLITE_NOMEM; + memset(c, 0, sizeof(*c)); + *ppCur = &c->base; + return SQLITE_OK; +} + +static void btr_rows_free(btrows_cursor *c) { + for (int i = 0; i < c->n_rows; i++) + sqlite3_free(c->rows[i].cutoff); + sqlite3_free(c->rows); + sqlite3_free(c->model); + sqlite3_free(c->status); + c->rows = NULL; + c->model = NULL; + c->status = NULL; + c->n_rows = 0; + c->i = 0; +} + +static int btr_close(sqlite3_vtab_cursor *pCur) { + btrows_cursor *c = (btrows_cursor *)pCur; + btr_rows_free(c); + sqlite3_free(c); + return SQLITE_OK; +} + +static int btr_err(btrows_cursor *cur, const char *detail) { + sqlite3_free(cur->base.pVtab->zErrMsg); + cur->base.pVtab->zErrMsg = + sqlite3_mprintf("%s: %s", PREDICT_ERR_SCHEMA, detail); + return SQLITE_ERROR; +} + +static int btr_filter(sqlite3_vtab_cursor *pCur, int idxNum, const char *idxStr, + int argc, sqlite3_value **argv) { + UNUSED_PARAMETER(idxNum); + UNUSED_PARAMETER(idxStr); + btrows_cursor *cur = (btrows_cursor *)pCur; + btrows_vtab *vtab = (btrows_vtab *)cur->base.pVtab; + sqlite3 *db = vtab->db; + btr_rows_free(cur); + + if (argc < 1 || sqlite3_value_type(argv[0]) == SQLITE_NULL) + return SQLITE_OK; + const char *doc = (const char *)sqlite3_value_text(argv[0]); + if (!doc) + return btr_err(cur, "document must be text"); + + sqlite3_stmt *top = NULL; + if (sqlite3_prepare_v2(db, + "SELECT json_type(?1), json_type(?1,'$.rows')," + " json_extract(?1,'$.status')," + " json_extract(?1,'$.model')", + -1, &top, NULL) != SQLITE_OK) + return btr_err(cur, "could not parse document"); + sqlite3_bind_text(top, 1, doc, -1, SQLITE_STATIC); + int rc = sqlite3_step(top); + const char *jt = + rc == SQLITE_ROW ? (const char *)sqlite3_column_text(top, 0) : NULL; + const char *rt = + rc == SQLITE_ROW ? (const char *)sqlite3_column_text(top, 1) : NULL; + if (!jt || strcmp(jt, "object") != 0 || !rt || strcmp(rt, "array") != 0 || + sqlite3_column_type(top, 2) != SQLITE_TEXT) { + sqlite3_finalize(top); + return btr_err(cur, "not a backtest result document"); + } + cur->status = sqlite3_mprintf("%s", (const char *)sqlite3_column_text(top, 2)); + if (!cur->status) { + sqlite3_finalize(top); + return SQLITE_NOMEM; + } + if (sqlite3_column_type(top, 3) == SQLITE_TEXT) { + cur->model = + sqlite3_mprintf("%s", (const char *)sqlite3_column_text(top, 3)); + if (!cur->model) { + sqlite3_finalize(top); + return SQLITE_NOMEM; + } + } + sqlite3_finalize(top); - /* expansion functions; pAux discriminates the two shapes */ - rc = sqlite3_create_module(db, "forecast_rows", &xrowsModule, NULL); + sqlite3_stmt *stmt = NULL; + if (sqlite3_prepare_v2( + db, + "SELECT json_extract(j.value,'$.fold')," + " json_extract(j.value,'$.cutoff_timestamp')," + " json_extract(j.value,'$.n'), json_extract(j.value,'$.mae')," + " json_extract(j.value,'$.rmse'), json_extract(j.value,'$.mase')," + " json_extract(j.value,'$.smape')," + " json_extract(j.value,'$.coverage')," + " json_extract(j.value,'$.mean_interval_width')" + " FROM json_each(?1,'$.rows') j ORDER BY j.key", + -1, &stmt, NULL) != SQLITE_OK) + return btr_err(cur, "could not parse document rows"); + sqlite3_bind_text(stmt, 1, doc, -1, SQLITE_STATIC); + + int cap = 0; + while ((rc = sqlite3_step(stmt)) == SQLITE_ROW) { + if (cur->n_rows == cap) { + int nc = cap ? cap * 2 : 32; + BtxRow *g = sqlite3_realloc(cur->rows, sizeof(BtxRow) * nc); + if (!g) { + sqlite3_finalize(stmt); + return SQLITE_NOMEM; + } + cur->rows = g; + cap = nc; + } + BtxRow *r = &cur->rows[cur->n_rows++]; + memset(r, 0, sizeof(*r)); + if (sqlite3_column_type(stmt, 0) != SQLITE_NULL) { + r->fold = sqlite3_column_int(stmt, 0); + r->has_fold = 1; + } + if (sqlite3_column_type(stmt, 1) == SQLITE_TEXT) { + r->cutoff = sqlite3_mprintf("%s", sqlite3_column_text(stmt, 1)); + if (!r->cutoff) { + sqlite3_finalize(stmt); + return SQLITE_NOMEM; + } + } + if (sqlite3_column_type(stmt, 2) != SQLITE_NULL) { + r->n = sqlite3_column_int(stmt, 2); + r->has_n = 1; + } + for (int k = 0; k < 6; k++) + if (sqlite3_column_type(stmt, 3 + k) != SQLITE_NULL) { + r->reals[k] = sqlite3_column_double(stmt, 3 + k); + r->has_real[k] = 1; + } + } + int done = rc == SQLITE_DONE; + sqlite3_finalize(stmt); + if (!done) + return btr_err(cur, "document rows are not valid JSON"); + + if (cur->n_rows == 0) { + cur->rows = sqlite3_malloc(sizeof(BtxRow)); + if (!cur->rows) + return SQLITE_NOMEM; + memset(cur->rows, 0, sizeof(BtxRow)); + cur->n_rows = 1; + } + cur->i = 0; + return SQLITE_OK; +} + +static int btr_next(sqlite3_vtab_cursor *pCur) { + ((btrows_cursor *)pCur)->i++; + return SQLITE_OK; +} + +static int btr_eof(sqlite3_vtab_cursor *pCur) { + btrows_cursor *c = (btrows_cursor *)pCur; + return c->i >= c->n_rows; +} + +static int btr_column(sqlite3_vtab_cursor *pCur, sqlite3_context *ctx, int col) { + btrows_cursor *c = (btrows_cursor *)pCur; + BtxRow *r = &c->rows[c->i]; + switch (col) { + case 0: + sqlite3_result_text(ctx, "", -1, SQLITE_STATIC); + break; + case 1: + if (r->has_fold) + sqlite3_result_int(ctx, r->fold); + break; + case 2: + if (r->cutoff) + sqlite3_result_text(ctx, r->cutoff, -1, SQLITE_TRANSIENT); + break; + case 3: + if (c->model && r->has_fold) + sqlite3_result_text(ctx, c->model, -1, SQLITE_TRANSIENT); + break; + case 4: + if (r->has_n) + sqlite3_result_int(ctx, r->n); + break; + case 5: + case 6: + case 7: + case 8: + case 9: + case 10: + if (r->has_real[col - 5]) + sqlite3_result_double(ctx, r->reals[col - 5]); + break; + case 11: + if (c->status) + sqlite3_result_text(ctx, c->status, -1, SQLITE_TRANSIENT); + break; + default: + break; + } + return SQLITE_OK; +} + +static int btr_rowid(sqlite3_vtab_cursor *pCur, sqlite3_int64 *pRowid) { + *pRowid = ((btrows_cursor *)pCur)->i; + return SQLITE_OK; +} + +static sqlite3_module btrowsModule = { + /* iVersion */ 0, + /* xCreate */ NULL, + /* xConnect */ btr_connect, + /* xBestIndex */ btr_best_index, + /* xDisconnect */ btr_disconnect, + /* xDestroy */ NULL, + /* xOpen */ btr_open, + /* xClose */ btr_close, + /* xFilter */ btr_filter, + /* xNext */ btr_next, + /* xEof */ btr_eof, + /* xColumn */ btr_column, + /* xRowid */ btr_rowid, + /* xUpdate */ NULL, + /* xBegin */ NULL, + /* xSync */ NULL, + /* xCommit */ NULL, + /* xRollback */ NULL, + /* xFindMethod */ NULL, + /* xRename */ NULL, + /* xSavepoint */ NULL, + /* xRelease */ NULL, + /* xRollbackTo */ NULL, + /* xShadowName */ NULL, + /* xIntegrity */ NULL}; + +int predict0_forecast_init(sqlite3 *db) { + /* expansion functions; pAux discriminates forecast_rows vs anomaly_rows */ + int rc = sqlite3_create_module(db, "forecast_rows", &xrowsModule, NULL); if (rc != SQLITE_OK) return rc; rc = sqlite3_create_module(db, "anomaly_rows", &xrowsModule, (void *)1); + if (rc != SQLITE_OK) + return rc; + rc = sqlite3_create_module(db, "backtest_rows", &btrowsModule, NULL); if (rc != SQLITE_OK) return rc; - /* forecast/detect_anomalies are aggregate functions — pure, one - * calling convention: the statement supplies the rows, GROUP BY - * splits series, and each group returns a JSON document */ + /* forecast/detect_anomalies/backtest are aggregates over the caller's rows: + * one calling convention, GROUP BY splits series, each group returns a JSON + * document. backtest replaces the former backtest(query, ...) TVF. */ static const int AGG_FLAGS = SQLITE_UTF8; rc = sqlite3_create_function_v2(db, "forecast", 3, AGG_FLAGS, NULL, NULL, agg_fc_step, agg_fc_final, NULL); @@ -3646,8 +3545,19 @@ int predict0_forecast_init(sqlite3 *db) { NULL, agg_an_step, agg_an_final, NULL); if (rc != SQLITE_OK) return rc; - /* guidance stub: a BigQuery-style forecast(query, horizon) attempt - * gets a self-correcting error instead of "wrong number of arguments" */ + rc = sqlite3_create_function_v2(db, "backtest", 3, AGG_FLAGS, NULL, NULL, + agg_bt_step, agg_bt_final, NULL); + if (rc != SQLITE_OK) + return rc; + rc = sqlite3_create_function_v2(db, "backtest", 4, AGG_FLAGS, NULL, NULL, + agg_bt_step, agg_bt_final, NULL); + if (rc != SQLITE_OK) + return rc; + /* self-correcting errors for the old query-string forms */ + rc = sqlite3_create_function_v2(db, "backtest", 2, AGG_FLAGS, NULL, + agg_bt_misuse, NULL, NULL, NULL); + if (rc != SQLITE_OK) + return rc; return sqlite3_create_function_v2(db, "forecast", 2, AGG_FLAGS, NULL, agg_fc_misuse, NULL, NULL, NULL); } diff --git a/predict-internal.h b/predict-internal.h index a81bc13..8580c14 100644 --- a/predict-internal.h +++ b/predict-internal.h @@ -93,9 +93,18 @@ int predict0_prepare_ro(sqlite3 *db, const char *sql, const char *what, /* Parse a JSON array of strings; path selects an array inside a larger * document, NULL means json itself is the array. out/n: sqlite3_malloc'd - * (caller frees elements + array). SQLITE_OK, or SQLITE_ code + *errmsg. */ + * (caller frees elements + array). max is the element-count cap and MUST be > 0 + * (a nonpositive cap is rejected, never treated as unbounded — pass an explicit + * large cap if a large array is intended); the parse rejects before allocating + * past max. Returns SQLITE_OK, or an SQLITE_ code with *errmsg set to a closed + * "PREDICT_ERR_*: detail" string the caller owns. */ int predict0_json_str_array(sqlite3 *db, const char *json, const char *path, - char ***out, int *n, char **errmsg); + char ***out, int *n, int max, char **errmsg); + +/* Append `t` to `s` as a JSON string literal (quoted, with " \ and control + * characters below 0x20 escaped). Shared by the aggregate result docs and the + * scalar predict() proba output. */ +void predict0_json_str(sqlite3_str *s, const char *t); /* Inverse standard-normal CDF (Acklam's rational approximation). * p in (0,1). Used for prediction-interval z values. */ @@ -196,6 +205,21 @@ int predict0_tree_run(sqlite3 *db, const char *model_id, const char *apply_sql, const predict0_backend_opts *opts, predict0_result **rows, int *n, char **errmsg); +/* The tabular training core (predict0_train_student and the tree/gbt/mlp + * trainers) lives in predict-train.{c,h}. */ + +/* ---- native student load/serve (predict-student.c) ---- + * A deserialized student (tree/gbt/mlp), loaded once and served per row. The + * scalar predict() caches this on its model argument via sqlite3_set_auxdata. */ +typedef struct predict0_loaded_student predict0_loaded_student; +int predict0_student_load(const void *blob, int len, + predict0_loaded_student **out, char **errmsg); +int predict0_student_nfeat(const predict0_loaded_student *s); +int predict0_student_predict(const predict0_loaded_student *s, const f32 *x, + char **pred, f64 *conf, int *has_conf, + char **errmsg); +void predict0_student_free(predict0_loaded_student *s); + /* Interpolate the quantile at level p over ascending levels lev[Q] with the * (ascending) values val[Q], extrapolating the tails (e.g. deciles to a 95% * band). Shared by the native forecast student and the onnx forecast backend. */ diff --git a/predict-onnx.c b/predict-onnx.c index 2e1b44e..79c7168 100644 --- a/predict-onnx.c +++ b/predict-onnx.c @@ -19,6 +19,7 @@ * and fails loud — a request for a provider this build lacks errors rather * than silently dropping to CPU. */ #include "predict-internal.h" +#include "predict-student.h" /* PREDICT0_MAX_CLASS: caps io_spec output labels */ #ifndef SQLITE_CORE SQLITE_EXTENSION_INIT3 @@ -328,15 +329,14 @@ static int json_flag(sqlite3 *db, const char *json, const char *path) { return on; } -/* json array at path -> sqlite3_malloc'd char*[]; *n set. 0 on success. - * Absent/invalid arrays yield an empty result: these fields are optional - * in an io_spec, so the callers treat empty as "not declared". */ +/* json array at path -> sqlite3_malloc'd char*[]; *n set. SQLITE_OK on success. + * An absent array is not an error: it yields *n == 0, so optional fields read + * as "not declared". A present-but-malformed array is an error and is never + * swallowed: the return code and *errmsg (ownership transfers to the caller) + * always propagate. max caps the element count (0 = unbounded). */ static int json_arr(sqlite3 *db, const char *json, const char *path, - char ***out, int *n) { - char *e = NULL; - int rc = predict0_json_str_array(db, json, path, out, n, &e); - sqlite3_free(e); - return rc; + char ***out, int *n, int max, char **errmsg) { + return predict0_json_str_array(db, json, path, out, n, max, errmsg); } static int onnx_io_parse(sqlite3 *db, const char *io_spec, onnx_io *io, @@ -364,8 +364,23 @@ static int onnx_io_parse(sqlite3 *db, const char *io_spec, onnx_io *io, * nfeatures (if given) is the count to validate against. */ io->output_name = json_str(db, io_spec, "$.output.name"); io->output_kind = json_str(db, io_spec, "$.output.kind"); - json_arr(db, io_spec, "$.features", &io->features, &io->nfeat); - json_arr(db, io_spec, "$.output.labels", &io->labels, &io->nlabels); + /* features[] is optional (absent -> nfeat 0, positional mapping), but a + * present-but-malformed array must fail loudly rather than read as absent. */ + int rc_features = json_arr(db, io_spec, "$.features", &io->features, + &io->nfeat, PREDICT_MAX_FEAT, errmsg); + if (rc_features != SQLITE_OK) { + onnx_io_free(io); + return rc_features; + } + /* Labels bound the class space; cap them and surface an oversized set instead + * of silently truncating. Features use PREDICT_MAX_FEAT and positional + * mapping. */ + int rc_labels = json_arr(db, io_spec, "$.output.labels", &io->labels, + &io->nlabels, PREDICT0_MAX_CLASS, errmsg); + if (rc_labels != SQLITE_OK) { + onnx_io_free(io); + return rc_labels; + } char *nf = json_str(db, io_spec, "$.nfeatures"); io->nfeatures = nf ? atoi(nf) : io->nfeat; /* names imply their own count */ sqlite3_free(nf); @@ -987,7 +1002,6 @@ int predict0_onnx_forecast_fan(sqlite3 *db, const predict0_model_row *model, int fixed_context = json_flag(db, model->io_spec, "$.fixed_context"); int two_head = point_name && quant_name; char **lvl_s = NULL; - json_arr(db, model->io_spec, "$.quantiles", &lvl_s, &nq); f32 *levels = NULL; f64 *fan = NULL; f32 *inbuf = NULL; @@ -996,6 +1010,15 @@ int predict0_onnx_forecast_fan(sqlite3 *db, const predict0_model_row *model, OrtTensorTypeAndShapeInfo *ti = NULL; OrtSession *session = NULL; + /* Parse quantiles only after every resource above is NULL-initialized, so the + * error path can goto done safely. Absent quantiles -> nq 0, caught by the + * nq <= 0 io_spec check below; a present-but-malformed array must fail loudly + * here, not read as absent. */ + rc = json_arr(db, model->io_spec, "$.quantiles", &lvl_s, &nq, FCST_MAX_QUANT, + errmsg); + if (rc != SQLITE_OK) + goto done; + int outputs_ok = two_head ? (point_name && quant_name) : (output_name != NULL); if (!layout || strcmp(layout, "sequence") != 0 || !input_name || !outputs_ok || nq <= 0 || patch < 1) { diff --git a/predict-student.c b/predict-student.c index 5181591..ff342fe 100644 --- a/predict-student.c +++ b/predict-student.c @@ -19,11 +19,16 @@ SQLITE_EXTENSION_INIT3 void predict0_tree_free(Tree *t) { if (!t) return; - for (int i = 0; i < t->nfeat; i++) - sqlite3_free(t->feat_names[i]); + /* A deserializer can fail after setting nfeat/nclass but before allocating + * these arrays, so guard the loops: a malformed blob must free cleanly, not + * walk a NULL array by a header-supplied count. */ + if (t->feat_names) + for (int i = 0; i < t->nfeat; i++) + sqlite3_free(t->feat_names[i]); sqlite3_free(t->feat_names); - for (int i = 0; i < t->nclass; i++) - sqlite3_free(t->labels[i]); + if (t->labels) + for (int i = 0; i < t->nclass; i++) + sqlite3_free(t->labels[i]); sqlite3_free(t->labels); sqlite3_free(t->nodes); memset(t, 0, sizeof(*t)); @@ -72,6 +77,12 @@ static f32 rd_f32(Reader *r) { u32 u = rd_u32(r); f32 v; memcpy(&v, &u, 4); + /* A hand-crafted blob can encode NaN/Inf, which would flow into predictions + * and proba output. Reject via the same err path as an overrun. */ + if (!isfinite(v)) { + r->err = 1; + return 0; + } return v; } static char *rd_str(Reader *r) { @@ -144,8 +155,8 @@ static int tree_deserialize(const void *blob, int len, Tree *t, char **errmsg) { t->nclass = (int)rd_u32(&r); t->n_nodes = (int)rd_u32(&r); if (r.err || t->task < 0 || t->task > 1 || t->nfeat <= 0 || - t->nfeat > TREE_MAX_FEAT || t->nclass < 0 || t->n_nodes <= 0 || - t->n_nodes > (1 << 24)) + t->nfeat > TREE_MAX_FEAT || t->nclass < 0 || t->nclass > PREDICT0_MAX_CLASS || + t->n_nodes <= 0 || t->n_nodes > (1 << 24)) goto bad; t->feat_names = sqlite3_malloc(sizeof(char *) * t->nfeat); @@ -224,11 +235,16 @@ static const char GBT_MAGIC[8] = {'P', 'S', 'G', 'B', 'T', '0', '1', '\0'}; void predict0_forest_free(Forest *f) { if (!f) return; - for (int i = 0; i < f->nfeat; i++) - sqlite3_free(f->feat_names[i]); + /* Guard the loops: a deserializer can fail after reading nfeat/nclass but + * before allocating these arrays, and a malformed blob must free cleanly + * rather than walk a NULL array by a header-supplied count. */ + if (f->feat_names) + for (int i = 0; i < f->nfeat; i++) + sqlite3_free(f->feat_names[i]); sqlite3_free(f->feat_names); - for (int i = 0; i < f->nclass; i++) - sqlite3_free(f->labels[i]); + if (f->labels) + for (int i = 0; i < f->nclass; i++) + sqlite3_free(f->labels[i]); sqlite3_free(f->labels); sqlite3_free(f->init); sqlite3_free(f->tree_off); @@ -350,8 +366,9 @@ static int forest_deserialize(const void *blob, int len, Forest *f, f->lr = rd_f32(&r); int want_score = f->task == 0 ? f->nclass : 1; if (r.err || f->task < 0 || f->task > 1 || f->nfeat <= 0 || - f->nfeat > TREE_MAX_FEAT || f->nclass < 0 || f->n_rounds <= 0 || - f->n_rounds > (1 << 20) || f->n_score != want_score || f->n_score <= 0) + f->nfeat > TREE_MAX_FEAT || f->nclass < 0 || f->nclass > PREDICT0_MAX_CLASS || + f->n_rounds <= 0 || f->n_rounds > (1 << 20) || f->n_score != want_score || + f->n_score <= 0) goto bad; f->init = sqlite3_malloc(sizeof(f32) * f->n_score); @@ -376,7 +393,8 @@ static int forest_deserialize(const void *blob, int len, Forest *f, goto bad; } f->n_trees = (int)rd_u32(&r); - if (r.err || f->n_trees != f->n_rounds * f->n_score) + if (r.err || f->n_trees <= 0 || f->n_trees > (1 << 24) || + (i64)f->n_trees != (i64)f->n_rounds * f->n_score) goto bad; f->tree_off = sqlite3_malloc(sizeof(int) * (f->n_trees + 1)); if (!f->tree_off) @@ -450,7 +468,8 @@ void predict0_mlp_free(MLP *m) { /* forward on RAW features x; writes hidden[nhid] and out[nout] logits. With a * linear skip (Wskip, forecast student) the hidden path is scaled down and the - * skip term added; nhid=0 makes it a pure linear map. Must match train_mlp. */ + * skip term added; nhid=0 makes it a pure linear map. Must match + * predict0_train_mlp. */ void predict0_mlp_forward(const MLP *m, const f32 *x, f32 *hid, f32 *out) { f64 rs = m->Wskip ? FCST_RES_SCALE : 1.0; for (int j = 0; j < m->nhid; j++) { @@ -551,7 +570,8 @@ static int mlp_deserialize(const void *blob, int len, MLP *m, char **errmsg) { m->nclass = (int)rd_u32(&r); if (r.err || m->task < 0 || m->task > 1 || m->nfeat <= 0 || m->nfeat > TREE_MAX_FEAT || m->nhid <= 0 || m->nhid > 2048 || - m->nout <= 0 || m->nout > 2048 || m->nclass < 0 || m->nclass > 2048 || + m->nout <= 0 || m->nout > 2048 || m->nclass < 0 || + m->nclass > PREDICT0_MAX_CLASS || (m->task == 0 && m->nout != m->nclass) || (m->task == 1 && m->nout != 1)) goto bad; m->mean = sqlite3_malloc(sizeof(f32) * m->nfeat); @@ -967,3 +987,121 @@ int predict0_tree_run(sqlite3 *db, const char *model_id, const char *apply_sql, } return rc; } + +/* ---- load once, serve per row (the scalar predict() path) ---- + * predict0_tree_run above deserializes per apply-query; the scalar predict() + * instead loads a student once, caches it on its model argument + * (sqlite3_set_auxdata), and serves one feature vector per call. */ + +struct predict0_loaded_student { + int kind; /* 0 tree, 1 forest, 2 mlp */ + Tree tree; + Forest forest; + MLP mlp; + f64 *scbuf; /* forest classify scratch [n_score] */ + f32 *mhid, *mout; /* mlp scratch [nhid], [nout] */ +}; + +int predict0_student_load(const void *blob, int len, + predict0_loaded_student **out, char **errmsg) { + *out = NULL; + if (!blob || len <= 0) { + *errmsg = sqlite3_mprintf("%s: empty student blob", PREDICT_ERR_SCHEMA); + return SQLITE_ERROR; + } + predict0_loaded_student *s = sqlite3_malloc(sizeof(*s)); + if (!s) { + *errmsg = sqlite3_mprintf("%s: out of memory", PREDICT_ERR_RESOURCE); + return SQLITE_NOMEM; + } + memset(s, 0, sizeof(*s)); + int is_forest = len >= (int)sizeof(GBT_MAGIC) && + memcmp(blob, GBT_MAGIC, sizeof(GBT_MAGIC)) == 0; + int is_mlp = len >= (int)sizeof(MLP_MAGIC) && + memcmp(blob, MLP_MAGIC, sizeof(MLP_MAGIC)) == 0; + int rc; + if (is_mlp) { + s->kind = 2; + rc = mlp_deserialize(blob, len, &s->mlp, errmsg); + } else if (is_forest) { + s->kind = 1; + rc = forest_deserialize(blob, len, &s->forest, errmsg); + } else { + s->kind = 0; + rc = tree_deserialize(blob, len, &s->tree, errmsg); + } + if (rc != SQLITE_OK) { + sqlite3_free(s); + return rc; + } + if (s->kind == 1 && s->forest.task == 0) { + s->scbuf = sqlite3_malloc(sizeof(f64) * s->forest.n_score); + if (!s->scbuf) + goto oom; + } else if (s->kind == 2) { + s->mhid = sqlite3_malloc(sizeof(f32) * s->mlp.nhid); + s->mout = sqlite3_malloc(sizeof(f32) * s->mlp.nout); + if (!s->mhid || !s->mout) + goto oom; + } + *out = s; + return SQLITE_OK; +oom: + predict0_student_free(s); + *errmsg = sqlite3_mprintf("%s: out of memory", PREDICT_ERR_RESOURCE); + return SQLITE_NOMEM; +} + +int predict0_student_nfeat(const predict0_loaded_student *s) { + return s->kind == 2 ? s->mlp.nfeat + : s->kind == 1 ? s->forest.nfeat + : s->tree.nfeat; +} + +int predict0_student_predict(const predict0_loaded_student *s, const f32 *x, + char **pred, f64 *conf, int *has_conf, + char **errmsg) { + int rc; + if (s->kind == 2) + rc = predict0_mlp_predict_row(&s->mlp, x, s->mhid, s->mout, pred, conf, + has_conf); + else if (s->kind == 1) + rc = predict0_forest_predict_row(&s->forest, x, s->scbuf, pred, conf, + has_conf); + else { + int leaf = predict0_tree_walk(&s->tree, x); + if (leaf < 0) { + *errmsg = + sqlite3_mprintf("%s: malformed tree traversal", PREDICT_ERR_SCHEMA); + return SQLITE_ERROR; + } + const TreeNode *ln = &s->tree.nodes[leaf]; + if (s->tree.task == 0) { + *pred = sqlite3_mprintf("%s", s->tree.labels[ln->klass]); + *conf = ln->conf; + *has_conf = 1; + } else { + *pred = sqlite3_mprintf("%.17g", (f64)ln->value); + *has_conf = 0; + } + rc = *pred ? SQLITE_OK : SQLITE_NOMEM; + } + if (rc == SQLITE_NOMEM) + *errmsg = sqlite3_mprintf("%s: out of memory", PREDICT_ERR_RESOURCE); + return rc; +} + +void predict0_student_free(predict0_loaded_student *s) { + if (!s) + return; + if (s->kind == 2) + predict0_mlp_free(&s->mlp); + else if (s->kind == 1) + predict0_forest_free(&s->forest); + else + predict0_tree_free(&s->tree); + sqlite3_free(s->scbuf); + sqlite3_free(s->mhid); + sqlite3_free(s->mout); + sqlite3_free(s); +} diff --git a/predict-student.h b/predict-student.h index de3ca4c..c638017 100644 --- a/predict-student.h +++ b/predict-student.h @@ -18,6 +18,7 @@ #define TREE_MAX_FEAT PREDICT_MAX_FEAT /* alias; see predict-internal.h */ #define FCST_MAX_CONTEXT 4096 /* forecast student: max context length (nfeat) */ #define FCST_MAX_QUANT 64 /* forecast student: max quantile levels */ +#define PREDICT0_MAX_CLASS 2048 /* tree/gbt/mlp classifier: max distinct classes */ /* Forecast student with a linear skip: the hidden path is a small correction on * the dominant linear map, so its output is scaled by this. Shared by the * trainer (train_mlp) and the serving forward (predict0_mlp_forward). */ diff --git a/predict-tabular.c b/predict-tabular.c index 839f739..1d035ec 100644 --- a/predict-tabular.c +++ b/predict-tabular.c @@ -8,6 +8,7 @@ * and the opt-in ONNX build; the benchmarks showed raw in-context FMs * are teachers, not serving paths (30s+/call on CPU). */ #include "predict-internal.h" +#include "predict-train.h" #ifndef SQLITE_CORE SQLITE_EXTENSION_INIT3 @@ -17,6 +18,16 @@ SQLITE_EXTENSION_INIT3 #define TAB_MAX_FEAT PREDICT_MAX_FEAT #define TAB_K 5 +/* Compile-time guarantee (the codebase builds -std=c99, so no _Static_assert): + * every fixed feature buffer here is sized to TAB_MAX_FEAT, and predict() fills + * one with a loaded student's features. The student loader caps a student's + * nfeat at TREE_MAX_FEAT (predict-student.h), so TAB_MAX_FEAT MUST be at least + * that or a loaded student would overflow these buffers. Both currently alias + * PREDICT_MAX_FEAT; if they ever diverge this array size goes negative and the + * build fails here, instead of overflowing at runtime. */ +typedef char predict0_assert_tab_buf_holds_max_student + [(TREE_MAX_FEAT <= TAB_MAX_FEAT) ? 1 : -1]; + #define PR_COL_REF 0 #define PR_COL_PRED 1 #define PR_COL_CONF 2 @@ -185,7 +196,7 @@ static int pr_best_index(sqlite3_vtab *pVtab, sqlite3_index_info *pIdx) { } if (!seen_train || !seen_apply) { pVtab->zErrMsg = sqlite3_mprintf( - "%s: predict(train_query, apply_query) requires both arguments", + "%s: predict_batch(train_query, apply_query) requires both arguments", PREDICT_ERR_SCHEMA); return SQLITE_ERROR; } @@ -642,9 +653,15 @@ static int pr_filter(sqlite3_vtab_cursor *pCur, int idxNum, out->ref.i = sqlite3_column_int64(as, 0); else if (out->ref.type == SQLITE_FLOAT) out->ref.f = sqlite3_column_double(as, 0); - else if (out->ref.type != SQLITE_NULL) + else if (out->ref.type != SQLITE_NULL) { out->ref.t = sqlite3_mprintf("%s", (const char *)sqlite3_column_text(as, 0)); + if (!out->ref.t) { + sqlite3_finalize(as); + rc = SQLITE_NOMEM; + goto fail_train; + } + } f64 q[TAB_MAX_FEAT]; int row_bad = 0; @@ -836,6 +853,566 @@ static sqlite3_module predictModule = { /* xShadowName */ NULL, /* xIntegrity */ NULL}; +/* ====================================================================== + * fit() aggregate + predict() scalar: the guessable tabular pair. + * fit(f1, ..., fN, label [, options]) trains a native student over the rows of + * the statement (label is the last positional argument, no target option) and + * returns the plain registered id text when options request registration + * ({"register":"my-id"}), otherwise its serialized model blob. The optional + * trailing options is a TEXT JSON object; a trailing '{...}' is therefore always + * read as options, so a class label must not itself be a JSON-object string. + * predict(model, f1, ..., fN [, options]) serves that student per row, features + * positional, deserialized once and cached on the model argument. + * ====================================================================== */ + +/* ---- fit() aggregate ---- */ + +typedef struct { + char *kind; /* 'gbt' (default) | 'tree' */ + char *task; /* 'classify' (default) | 'regress' */ + char *reg; /* register name, or NULL to return the blob */ +} FitOpts; + +static void fit_opts_free(FitOpts *o) { + sqlite3_free(o->kind); + sqlite3_free(o->task); + sqlite3_free(o->reg); +} + +static int fit_opt_cb(void *ctx, const char *key, sqlite3_value *value, + char **errmsg) { + FitOpts *o = ctx; + if (sqlite3_value_type(value) != SQLITE_TEXT) { + *errmsg = sqlite3_mprintf("%s: wrong type for option '%s'", + PREDICT_ERR_OPTIONS, key); + return 1; + } + char **slot = (strcmp(key, "kind") == 0 || strcmp(key, "student_kind") == 0) + ? &o->kind + : strcmp(key, "task") == 0 ? &o->task + : strcmp(key, "register") == 0 ? &o->reg + : NULL; + if (slot) { + char *copy = sqlite3_mprintf("%s", (const char *)sqlite3_value_text(value)); + if (!copy) { + *errmsg = sqlite3_mprintf("%s: out of memory", PREDICT_ERR_RESOURCE); + return 1; + } + sqlite3_free(*slot); /* free-before-assign: duplicate JSON key */ + *slot = copy; + } + return 0; +} + +static const char *const FIT_OPTION_KEYS[] = {"kind", "student_kind", "task", + "register", NULL}; + +/* Per-aggregate accumulator. Lives in sqlite3_aggregate_context (SQLite owns + * the block); fit_ctx_free releases the pointers it holds. */ +typedef struct { + int configured; + int has_opts; + char *opts_raw; /* trailing options text of the first row (owned), or NULL */ + int nfeat; + int classify; + char *kind; /* borrowed by predict0_train_student; owned here */ + char *reg_id; /* register name, or NULL */ + f32 *X; /* row-major [cap, nfeat] */ + char **ylab; /* [cap] classify labels */ + f64 *yval; /* [cap] regress values */ + int n, cap; + int err; /* SQLITE_ code, reported in fit_final */ + char *errmsg; +} FitCtx; + +static void fit_ctx_free(FitCtx *c) { + if (!c) + return; + sqlite3_free(c->X); + if (c->ylab) { + for (int i = 0; i < c->n; i++) + sqlite3_free(c->ylab[i]); + sqlite3_free(c->ylab); + } + sqlite3_free(c->yval); + sqlite3_free(c->kind); + sqlite3_free(c->reg_id); + sqlite3_free(c->opts_raw); + sqlite3_free(c->errmsg); + memset(c, 0, sizeof(*c)); +} + +static void fit_step(sqlite3_context *ctx, int argc, sqlite3_value **argv) { + FitCtx *c = sqlite3_aggregate_context(ctx, sizeof(FitCtx)); + if (!c) { + /* NULL means the context allocation failed; SQLite prescribes signalling + * NOMEM here rather than deferring to a misleading no-rows message. */ + sqlite3_result_error_nomem(ctx); + return; + } + if (c->err) + return; + /* Determine this row's trailing options: the last argument when it is TEXT + * beginning with '{'. Presence and content must be constant across the group, + * or the feature/label split and the model config would depend on the order + * SQLite happens to visit rows in. Consequence of this convention: the trailing + * '{...}' is ALWAYS the options object, so a class label must not be a JSON + * object string (it would be consumed as options). A malformed options object + * fails loudly at predict0_options_parse; the only quiet case is a label that + * is itself a well-formed, valid-key options object, which is why the label + * contract is documented rather than guessed. */ + int has_opts = 0; + const char *opts_txt = NULL; + if (argc >= 3 && sqlite3_value_type(argv[argc - 1]) == SQLITE_TEXT) { + const char *t = (const char *)sqlite3_value_text(argv[argc - 1]); + if (t && t[0] == '{') { + has_opts = 1; + opts_txt = t; + } + } + + if (!c->configured) { + int nfeat = argc - 1 - has_opts; + if (nfeat < 1) { + c->err = SQLITE_ERROR; + c->errmsg = sqlite3_mprintf("%s: fit(feature, ..., label [, options])" + " needs at least one feature and a label", + PREDICT_ERR_SCHEMA); + return; + } + if (nfeat > TAB_MAX_FEAT) { + c->err = SQLITE_ERROR; + c->errmsg = sqlite3_mprintf("%s: too many feature columns (max %d)", + PREDICT_ERR_SCHEMA, TAB_MAX_FEAT); + return; + } + FitOpts o; + memset(&o, 0, sizeof(o)); + if (has_opts) { + char *emsg = NULL; + if (predict0_options_parse(sqlite3_context_db_handle(ctx), opts_txt, + FIT_OPTION_KEYS, fit_opt_cb, &o, &emsg)) { + c->err = SQLITE_ERROR; + c->errmsg = emsg; + fit_opts_free(&o); + return; + } + } + int classify = 1; + if (o.task) { + if (strcmp(o.task, "regress") == 0) + classify = 0; + else if (strcmp(o.task, "classify") != 0) { + c->err = SQLITE_ERROR; + c->errmsg = sqlite3_mprintf("%s: task must be classify|regress: %s", + PREDICT_ERR_TASK, o.task); + fit_opts_free(&o); + return; + } + } + c->nfeat = nfeat; + c->classify = classify; + c->kind = o.kind; /* transfer ownership */ + o.kind = NULL; + c->reg_id = o.reg; + o.reg = NULL; + fit_opts_free(&o); + c->has_opts = has_opts; + if (has_opts) { + c->opts_raw = sqlite3_mprintf("%s", opts_txt); + if (!c->opts_raw) { + c->err = SQLITE_NOMEM; + return; + } + } + c->configured = 1; + } else if (has_opts != c->has_opts || + (has_opts && (!c->opts_raw || strcmp(opts_txt, c->opts_raw) != 0))) { + c->err = SQLITE_ERROR; + c->errmsg = sqlite3_mprintf("%s: options must be constant within a group", + PREDICT_ERR_OPTIONS); + return; + } + + if (c->n == c->cap) { + /* Bound the training set: fit() buffers the whole matrix in memory, and an + * uncapped c->cap*2 (int) goes negative past INT_MAX/2, making the byte size + * a huge size_t (UB). Cap at TAB_MAX_TRAIN and fail loudly instead. */ + if (c->n >= TAB_MAX_TRAIN) { + c->err = SQLITE_ERROR; + c->errmsg = sqlite3_mprintf("%s: fit training set exceeds %d rows", + PREDICT_ERR_CONTEXT_TOO_LARGE, TAB_MAX_TRAIN); + return; + } + int nc = c->cap ? c->cap * 2 : 256; + if (nc > TAB_MAX_TRAIN) + nc = TAB_MAX_TRAIN; + f32 *gx = sqlite3_realloc(c->X, sizeof(f32) * (size_t)nc * c->nfeat); + if (!gx) { + c->err = SQLITE_NOMEM; + return; + } + c->X = gx; + if (c->classify) { + char **g = sqlite3_realloc(c->ylab, sizeof(char *) * nc); + if (!g) { + c->err = SQLITE_NOMEM; + return; + } + c->ylab = g; + } else { + f64 *g = sqlite3_realloc(c->yval, sizeof(f64) * nc); + if (!g) { + c->err = SQLITE_NOMEM; + return; + } + c->yval = g; + } + c->cap = nc; + } + f32 *row = &c->X[(size_t)c->n * c->nfeat]; + for (int i = 0; i < c->nfeat; i++) { + int t = sqlite3_value_type(argv[i]); + if (t != SQLITE_INTEGER && t != SQLITE_FLOAT) { + c->err = SQLITE_ERROR; + c->errmsg = sqlite3_mprintf("%s: fit feature %d must be numeric", + PREDICT_ERR_SCHEMA, i + 1); + return; + } + f64 fv = sqlite3_value_double(argv[i]); + if (!isfinite(fv)) { /* 1e999 -> +Inf as SQLITE_FLOAT; NaN poisons splits */ + c->err = SQLITE_ERROR; + c->errmsg = sqlite3_mprintf("%s: fit feature %d must be finite", + PREDICT_ERR_SCHEMA, i + 1); + return; + } + row[i] = (f32)fv; + } + sqlite3_value *lv = argv[c->nfeat]; + if (c->classify) { + /* A NULL class label is not a real class; fail loudly rather than train a + * silent empty-string class. A NULL text under memory pressure is NOMEM. */ + if (sqlite3_value_type(lv) == SQLITE_NULL) { + c->err = SQLITE_ERROR; + c->errmsg = sqlite3_mprintf("%s: classify label must not be NULL", + PREDICT_ERR_TARGET); + return; + } + const char *s = (const char *)sqlite3_value_text(lv); + if (!s) { + c->err = SQLITE_NOMEM; + return; + } + c->ylab[c->n] = sqlite3_mprintf("%s", s); + if (!c->ylab[c->n]) { + c->err = SQLITE_NOMEM; + return; + } + } else { + int t = sqlite3_value_type(lv); + if (t != SQLITE_INTEGER && t != SQLITE_FLOAT) { + c->err = SQLITE_ERROR; + c->errmsg = sqlite3_mprintf("%s: regress label must be numeric", + PREDICT_ERR_TARGET); + return; + } + f64 lval = sqlite3_value_double(lv); + if (!isfinite(lval)) { + c->err = SQLITE_ERROR; + c->errmsg = sqlite3_mprintf("%s: regress label must be finite", + PREDICT_ERR_TARGET); + return; + } + c->yval[c->n] = lval; + } + c->n++; +} + +static void fit_final(sqlite3_context *ctx) { + FitCtx *c = sqlite3_aggregate_context(ctx, 0); + if (!c) { + sqlite3_result_error( + ctx, PREDICT_ERR_SCHEMA ": fit received no training rows", -1); + return; + } + /* A first-row config error (bad task, bad arity, options parse failure) sets + * c->err but returns before c->configured, so surface the real diagnostic + * before the generic no-rows message, or it would mask every such failure. */ + if (c->err) { + if (c->err == SQLITE_NOMEM && !c->errmsg) + sqlite3_result_error_nomem(ctx); + else + sqlite3_result_error(ctx, c->errmsg ? c->errmsg : "fit failed", -1); + fit_ctx_free(c); + return; + } + if (!c->configured) { + sqlite3_result_error( + ctx, PREDICT_ERR_SCHEMA ": fit received no training rows", -1); + fit_ctx_free(c); + return; + } + char **fn = sqlite3_malloc(sizeof(char *) * c->nfeat); + if (!fn) { + sqlite3_result_error_nomem(ctx); + fit_ctx_free(c); + return; + } + memset(fn, 0, sizeof(char *) * c->nfeat); + int ok = 1; + for (int i = 0; i < c->nfeat && ok; i++) + if (!(fn[i] = sqlite3_mprintf("f%d", i))) + ok = 0; + if (!ok) { + for (int i = 0; i < c->nfeat; i++) + sqlite3_free(fn[i]); + sqlite3_free(fn); + sqlite3_result_error_nomem(ctx); + fit_ctx_free(c); + return; + } + void *blob = NULL; + int blob_len = 0; + char *emsg = NULL; + int rc = predict0_train_student( + sqlite3_context_db_handle(ctx), c->X, c->n, c->nfeat, fn, c->classify, + c->classify ? c->ylab : NULL, c->classify ? NULL : c->yval, c->kind, + c->reg_id, &blob, &blob_len, &emsg); + for (int i = 0; i < c->nfeat; i++) + sqlite3_free(fn[i]); + sqlite3_free(fn); + if (rc != SQLITE_OK) { + sqlite3_result_error(ctx, emsg ? emsg : "fit training failed", -1); + sqlite3_free(emsg); + fit_ctx_free(c); + return; + } + if (c->reg_id) + sqlite3_result_text(ctx, c->reg_id, -1, SQLITE_TRANSIENT); + else + sqlite3_result_blob(ctx, blob, blob_len, SQLITE_TRANSIENT); + sqlite3_free(blob); + fit_ctx_free(c); +} + +/* ---- predict() scalar ---- */ + +typedef struct { + int proba; +} PredScalarOpts; + +static int predict_scalar_opt_cb(void *ctx, const char *key, + sqlite3_value *value, char **errmsg) { + PredScalarOpts *o = ctx; + if (strcmp(key, "proba") == 0) { + int t = sqlite3_value_type(value); + if (t != SQLITE_INTEGER && t != SQLITE_FLOAT) { + *errmsg = sqlite3_mprintf("%s: wrong type for option '%s'", + PREDICT_ERR_OPTIONS, key); + return 1; + } + o->proba = sqlite3_value_int(value) != 0; + } + return 0; +} + +static const char *const PRED_SCALAR_OPTION_KEYS[] = {"proba", NULL}; + +/* auxdata destructor with the exact void(*)(void*) type SQLite invokes it + * through. Casting predict0_student_free (which takes predict0_loaded_student*) + * to that pointer type and calling through it is undefined behavior that + * -fsanitize=function flags on Linux. */ +static void predict_scalar_free_aux(void *p) { + predict0_student_free((predict0_loaded_student *)p); +} + +static void predict_scalar(sqlite3_context *ctx, int argc, + sqlite3_value **argv) { + if (argc < 2) { + sqlite3_result_error(ctx, + PREDICT_ERR_SCHEMA ": predict(model, feature, ...)" + " needs a model and >= 1 feature", + -1); + return; + } + int has_opts = 0; + if (sqlite3_value_type(argv[argc - 1]) == SQLITE_TEXT) { + const char *t = (const char *)sqlite3_value_text(argv[argc - 1]); + if (t && t[0] == '{') + has_opts = 1; + } + int nfeat_in = argc - 1 - has_opts; + if (nfeat_in < 1) { + sqlite3_result_error( + ctx, PREDICT_ERR_SCHEMA ": predict needs >= 1 feature after the model", + -1); + return; + } + PredScalarOpts po; + memset(&po, 0, sizeof(po)); + if (has_opts) { + char *emsg = NULL; + if (predict0_options_parse(sqlite3_context_db_handle(ctx), + (const char *)sqlite3_value_text(argv[argc - 1]), + PRED_SCALAR_OPTION_KEYS, predict_scalar_opt_cb, + &po, &emsg)) { + sqlite3_result_error( + ctx, emsg ? emsg : PREDICT_ERR_OPTIONS ": cannot parse options", -1); + sqlite3_free(emsg); + return; + } + } + + predict0_loaded_student *st = sqlite3_get_auxdata(ctx, 0); + if (!st) { + int mt = sqlite3_value_type(argv[0]); + const void *blob = NULL; + int blen = 0; + predict0_model_row mrow; + int owned = 0; + char *emsg = NULL; + if (mt == SQLITE_BLOB) { + blob = sqlite3_value_blob(argv[0]); + blen = sqlite3_value_bytes(argv[0]); + } else if (mt == SQLITE_TEXT) { + const char *id = (const char *)sqlite3_value_text(argv[0]); + int look = + predict0_registry_lookup(sqlite3_context_db_handle(ctx), id, &mrow); + if (look == 1) { + char *m = sqlite3_mprintf("%s: no such model: %s", + PREDICT_ERR_MODEL_NOT_FOUND, id); + sqlite3_result_error(ctx, m, -1); + sqlite3_free(m); + return; + } + if (look == 2) { + char *m = sqlite3_mprintf("%s: weights do not match content_hash: %s", + PREDICT_ERR_MODEL_HASH, id); + sqlite3_result_error(ctx, m, -1); + sqlite3_free(m); + return; + } + if (look != 0) { + sqlite3_result_error( + ctx, PREDICT_ERR_RESOURCE ": model registry unavailable", -1); + return; + } + owned = 1; + if (!mrow.runtime || strcmp(mrow.runtime, "tree") != 0) { + char *m = sqlite3_mprintf( + "%s: predict() serves native students; use predict_batch() for" + " runtime '%s'", + PREDICT_ERR_RUNTIME_UNAVAILABLE, mrow.runtime ? mrow.runtime : "?"); + predict0_model_row_free(&mrow); + sqlite3_result_error(ctx, m, -1); + sqlite3_free(m); + return; + } + if (!mrow.weights) { + predict0_model_row_free(&mrow); + sqlite3_result_error( + ctx, PREDICT_ERR_SCHEMA ": model has no inline weights", -1); + return; + } + blob = mrow.weights; + blen = mrow.weights_len; + } else { + sqlite3_result_error(ctx, + PREDICT_ERR_SCHEMA ": predict model must be a" + " registered id (text) or a" + " fit() blob", + -1); + return; + } + int rc = predict0_student_load(blob, blen, &st, &emsg); + if (owned) + predict0_model_row_free(&mrow); + if (rc != SQLITE_OK) { + sqlite3_result_error(ctx, emsg ? emsg : "cannot load model", -1); + sqlite3_free(emsg); + return; + } + sqlite3_set_auxdata(ctx, 0, st, predict_scalar_free_aux); + /* SQLite may invoke the destructor immediately (before set_auxdata even + * returns) if it cannot store the value under memory pressure, so st must + * not be used afterward. Re-fetch it and bail if it was discarded. */ + st = sqlite3_get_auxdata(ctx, 0); + if (!st) { + sqlite3_result_error_nomem(ctx); + return; + } + } + + /* need <= TAB_MAX_FEAT holds by construction, so filling x[TAB_MAX_FEAT] below + * cannot overflow: the loader caps a student's nfeat at TREE_MAX_FEAT and the + * compile-time assert (predict0_assert_tab_buf_holds_max_student) guarantees + * TREE_MAX_FEAT <= TAB_MAX_FEAT. A feature-count mismatch is still reported. */ + int need = predict0_student_nfeat(st); + if (nfeat_in != need) { + char *m = sqlite3_mprintf( + "%s: predict got %d features but the model expects %d", + PREDICT_ERR_SCHEMA, nfeat_in, need); + sqlite3_result_error(ctx, m, -1); + sqlite3_free(m); + return; + } + f32 x[TAB_MAX_FEAT]; + for (int i = 0; i < nfeat_in; i++) { + int t = sqlite3_value_type(argv[1 + i]); + if (t != SQLITE_INTEGER && t != SQLITE_FLOAT) { + sqlite3_result_error( + ctx, PREDICT_ERR_SCHEMA ": predict features must be numeric", -1); + return; + } + f64 fv = sqlite3_value_double(argv[1 + i]); + if (!isfinite(fv)) { /* symmetry with fit(): a non-finite feature can't be + trained on, so it must not be scored on either */ + sqlite3_result_error( + ctx, PREDICT_ERR_SCHEMA ": predict features must be finite", -1); + return; + } + x[i] = (f32)fv; + } + char *pred = NULL; + f64 conf = 0; + int hc = 0; + char *emsg = NULL; + if (predict0_student_predict(st, x, &pred, &conf, &hc, &emsg) != SQLITE_OK) { + sqlite3_result_error(ctx, emsg ? emsg : "predict failed", -1); + sqlite3_free(emsg); + return; + } + if (po.proba) { + sqlite3_str *j = sqlite3_str_new(NULL); + sqlite3_str_appendall(j, "{\"prediction\":"); + predict0_json_str(j, pred); + if (hc) + sqlite3_str_appendf(j, ",\"confidence\":%.17g", conf); + sqlite3_str_appendchar(j, 1, '}'); + char *js = sqlite3_str_finish(j); + if (js) + sqlite3_result_text(ctx, js, -1, sqlite3_free); + else + sqlite3_result_error_nomem(ctx); + } else { + sqlite3_result_text(ctx, pred, -1, SQLITE_TRANSIENT); + } + sqlite3_free(pred); +} + int predict0_tabular_init(sqlite3 *db) { - return sqlite3_create_module(db, "predict", &predictModule, NULL); + /* predict_batch: the former predict() TVF, now the batched escape hatch + * (knn5-incontext, onnx, and registered-student batch serving over an apply + * query). The guessable per-row scalar predict() is registered below. It is + * NOT marked deterministic: a registered id resolves against _predict_models, + * so the result depends on external state, not on the SQL arguments alone. */ + int rc = sqlite3_create_module(db, "predict_batch", &predictModule, NULL); + if (rc != SQLITE_OK) + return rc; + rc = sqlite3_create_function(db, "predict", -1, SQLITE_UTF8, NULL, + predict_scalar, NULL, NULL); + if (rc != SQLITE_OK) + return rc; + return sqlite3_create_function(db, "fit", -1, SQLITE_UTF8, NULL, NULL, + fit_step, fit_final); } diff --git a/predict-train.c b/predict-train.c new file mode 100644 index 0000000..9acd84b --- /dev/null +++ b/predict-train.c @@ -0,0 +1,938 @@ +/* SPDX-License-Identifier: MIT OR Apache-2.0 + * Copyright (c) 2026 Pure Storage, Inc. + */ +/* Native-student TRAINERS: the CART / gradient-boosted / MLP fitters, shared + * by fit() (predict-tabular.c) and the distill recipes (predict-distill.c). + * Blob format, deserializers, and inference live in predict-student.c. */ +#include "predict-internal.h" +#include "predict-student.h" +#include "predict-train.h" + +#ifndef SQLITE_CORE +SQLITE_EXTENSION_INIT3 +#endif + +#define TREE_MAX_DEPTH 8 +#define TREE_MIN_SPLIT 5 +#define GBT_ROUNDS 200 +#define GBT_DEPTH 3 +#define GBT_MIN_SPLIT 5 +#define GBT_LR 0.1f /* shrinkage: many small steps generalize better */ +#define GBT_LAMBDA 1.0f /* L2 leaf regularization (XGBoost reg_lambda default) */ +#define MLP_L2 1e-4 +#define MLP_BETA1 0.9 +#define MLP_BETA2 0.999 + +static f32 mlp_rng(u32 *s) { + u32 x = *s ? *s : 1; + x ^= x << 13; + x ^= x >> 17; + x ^= x << 5; + *s = x; + return (f32)((f64)x / 2147483648.0 - 1.0); +} + +/* One Adam step over a parameter array (with L2), then zero its gradient. */ +static void mlp_adam(f32 *p, f64 *g, f64 *mm, f64 *vv, int sz, f64 lr, + f64 scale, f64 bc1, f64 bc2) { + for (int i = 0; i < sz; i++) { + f64 gg = g[i] * scale + MLP_L2 * p[i]; + mm[i] = MLP_BETA1 * mm[i] + (1 - MLP_BETA1) * gg; + vv[i] = MLP_BETA2 * vv[i] + (1 - MLP_BETA2) * gg * gg; + p[i] -= (f32)(lr * (mm[i] / bc1) / (sqrt(vv[i] / bc2) + 1e-8)); + g[i] = 0; + } +} + +/* All n elements finite? A NULL array with n == 0 is vacuously finite (the + * hidden/skip blocks are absent when nhid == 0 / !use_skip). */ +static int arr_finite(const f32 *a, int n) { + for (int i = 0; i < n; i++) + if (!isfinite(a[i])) + return 0; + return 1; +} + +/* Train an MLP with a configurable width and task. + * task 0 (classify): softmax head over `nout` classes; the target is `soft` + * (a row-major [n, nout] teacher-probability matrix) when non-NULL, else the + * hard class yc. task 1 (regress): linear head, MSE against the [n, nout] + * target matrix `soft` (yc unused) -- this is how the multi-output forecast + * student is fit. `use_skip` adds a direct linear map Wskip*x to the output (a + * DLinear/TiDE skip: the linear part carries seasonal-naive + trend, the hidden + * path a scaled nonlinear correction); with nhid=0 the model is purely linear. + * Deterministic full-batch Adam; the caller sets any feat_names/labels. */ +int predict0_train_mlp(const f32 *X, int n, int nfeat, int nout, const i32 *yc, + const f32 *soft, int task, int nhid, int epochs, f32 lr, + int use_skip, MLP *m, char **errmsg) { + memset(m, 0, sizeof(*m)); + /* Target contract: task 1 (regress) needs the soft matrix; task 0 (classify) + * needs soft or the hard labels yc. Fail loudly at this cross-TU entry rather + * than dereference a NULL target in the training loop. */ + if ((task == 1 && !soft) || (task == 0 && !soft && !yc)) { + *errmsg = sqlite3_mprintf("%s: mlp training target missing", + PREDICT_ERR_TARGET); + return SQLITE_ERROR; + } + /* Shape contract: non-positive dimensions would make the weight sizes below + * (nhid*nfeat, nout*nhid, ...) zero or negative and the training loop read out + * of bounds; reject them loudly rather than train on a degenerate shape. */ + if (n <= 0 || nfeat <= 0 || nout <= 0 || nhid < 0 || epochs <= 0) { + *errmsg = sqlite3_mprintf( + "%s: mlp shape invalid (n=%d nfeat=%d nout=%d nhid=%d epochs=%d)", + PREDICT_ERR_SCHEMA, n, nfeat, nout, nhid, epochs); + return SQLITE_ERROR; + } + int nW1 = nhid * nfeat, nW2 = nout * nhid, nWs = use_skip ? nout * nfeat : 0; + f64 rs = use_skip ? FCST_RES_SCALE : 1.0; /* hidden-path scale (see #define) */ + m->task = task; + m->nfeat = nfeat; + m->nhid = nhid; + m->nout = nout; + m->nclass = task == 0 ? nout : 0; + + int rc = SQLITE_OK; + m->mean = sqlite3_malloc(sizeof(f32) * nfeat); + m->sd = sqlite3_malloc(sizeof(f32) * nfeat); + m->b2 = sqlite3_malloc(sizeof(f32) * nout); + /* hidden-layer blocks exist only when nhid>0; skip block only when use_skip */ + if (nhid > 0) { + m->W1 = sqlite3_malloc(sizeof(f32) * nW1); + m->b1 = sqlite3_malloc(sizeof(f32) * nhid); + m->W2 = sqlite3_malloc(sizeof(f32) * nW2); + } + if (use_skip) + m->Wskip = sqlite3_malloc(sizeof(f32) * nWs); + f64 *mW1 = nhid ? sqlite3_malloc(sizeof(f64) * nW1) : NULL, + *vW1 = nhid ? sqlite3_malloc(sizeof(f64) * nW1) : NULL, + *gW1 = nhid ? sqlite3_malloc(sizeof(f64) * nW1) : NULL; + f64 *mW2 = nhid ? sqlite3_malloc(sizeof(f64) * nW2) : NULL, + *vW2 = nhid ? sqlite3_malloc(sizeof(f64) * nW2) : NULL, + *gW2 = nhid ? sqlite3_malloc(sizeof(f64) * nW2) : NULL; + f64 *mb1 = nhid ? sqlite3_malloc(sizeof(f64) * nhid) : NULL, + *vb1 = nhid ? sqlite3_malloc(sizeof(f64) * nhid) : NULL, + *gb1 = nhid ? sqlite3_malloc(sizeof(f64) * nhid) : NULL; + f64 *mWs = use_skip ? sqlite3_malloc(sizeof(f64) * nWs) : NULL, + *vWs = use_skip ? sqlite3_malloc(sizeof(f64) * nWs) : NULL, + *gWs = use_skip ? sqlite3_malloc(sizeof(f64) * nWs) : NULL; + f64 *mb2 = sqlite3_malloc(sizeof(f64) * nout), + *vb2 = sqlite3_malloc(sizeof(f64) * nout), + *gb2 = sqlite3_malloc(sizeof(f64) * nout); + f32 *hid = nhid ? sqlite3_malloc(sizeof(f32) * nhid) : NULL, + *out = sqlite3_malloc(sizeof(f32) * nout), + *xs = sqlite3_malloc(sizeof(f32) * nfeat); + f64 *dout = sqlite3_malloc(sizeof(f64) * nout); + int hid_ok = nhid == 0 || (m->W1 && m->b1 && m->W2 && mW1 && vW1 && gW1 && + mW2 && vW2 && gW2 && mb1 && vb1 && gb1 && hid); + int skip_ok = !use_skip || (m->Wskip && mWs && vWs && gWs); + if (!m->mean || !m->sd || !m->b2 || !mb2 || !vb2 || !gb2 || !out || !xs || + !dout || !hid_ok || !skip_ok) { + rc = SQLITE_NOMEM; + *errmsg = sqlite3_mprintf("%s: out of memory", PREDICT_ERR_RESOURCE); + goto done; + } + for (int i = 0; i < nW1; i++) + mW1[i] = vW1[i] = gW1[i] = 0; + for (int i = 0; i < nW2; i++) + mW2[i] = vW2[i] = gW2[i] = 0; + for (int i = 0; i < nhid; i++) + mb1[i] = vb1[i] = gb1[i] = 0; + for (int i = 0; i < nWs; i++) + mWs[i] = vWs[i] = gWs[i] = 0; + for (int i = 0; i < nout; i++) + mb2[i] = vb2[i] = gb2[i] = 0; + + for (int i = 0; i < nfeat; i++) { /* standardize features */ + f64 mu = 0; + for (int r = 0; r < n; r++) + mu += X[(size_t)r * nfeat + i]; + mu /= n; + f64 var = 0; + for (int r = 0; r < n; r++) { + f64 d = X[(size_t)r * nfeat + i] - mu; + var += d * d; + } + var /= n; + f64 s = sqrt(var); + m->mean[i] = (f32)mu; + m->sd[i] = (f32)(s > 1e-6 ? s : 1e-6); + } + + u32 seed = 0x243f6a88u; /* Xavier-uniform init, deterministic */ + f32 a1 = (f32)sqrt(6.0 / (nfeat + (nhid ? nhid : 1))); + for (int i = 0; i < nW1; i++) + m->W1[i] = a1 * mlp_rng(&seed); + for (int j = 0; j < nhid; j++) + m->b1[j] = 0; + f32 a2 = (f32)sqrt(6.0 / ((nhid ? nhid : 1) + nout)); + for (int i = 0; i < nW2; i++) + m->W2[i] = a2 * mlp_rng(&seed); + f32 as = (f32)sqrt(6.0 / (nfeat + nout)); + for (int i = 0; i < nWs; i++) + m->Wskip[i] = as * mlp_rng(&seed); + for (int k = 0; k < nout; k++) + m->b2[k] = 0; + + f64 b1p = 1, b2p = 1; + for (int ep = 0; ep < epochs; ep++) { + b1p *= MLP_BETA1; + b2p *= MLP_BETA2; + for (int row = 0; row < n; row++) { + const f32 *x = &X[(size_t)row * nfeat]; + for (int i = 0; i < nfeat; i++) + xs[i] = (x[i] - m->mean[i]) / m->sd[i]; + for (int j = 0; j < nhid; j++) { + f64 s = m->b1[j]; + for (int i = 0; i < nfeat; i++) + s += (f64)m->W1[j * nfeat + i] * xs[i]; + hid[j] = (f32)tanh(s); + } + for (int k = 0; k < nout; k++) { + f64 s = m->b2[k]; + for (int j = 0; j < nhid; j++) + s += rs * (f64)m->W2[k * nhid + j] * hid[j]; + if (use_skip) + for (int i = 0; i < nfeat; i++) + s += (f64)m->Wskip[k * nfeat + i] * xs[i]; + out[k] = (f32)s; + } + if (task == 0) { /* softmax cross-entropy: dL/dlogit = softmax - target */ + f64 mx = out[0]; + for (int k = 1; k < nout; k++) + if (out[k] > mx) + mx = out[k]; + f64 sm = 0; + for (int k = 0; k < nout; k++) { + dout[k] = exp((f64)out[k] - mx); + sm += dout[k]; + } + for (int k = 0; k < nout; k++) { + f64 tgt = soft ? soft[(size_t)row * nout + k] : (yc[row] == k ? 1.0 : 0.0); + dout[k] = dout[k] / sm - tgt; + } + } else { /* regression MSE on the linear head: dL/dout = out - target */ + for (int k = 0; k < nout; k++) + dout[k] = (f64)out[k] - soft[(size_t)row * nout + k]; + } + for (int k = 0; k < nout; k++) { + gb2[k] += dout[k]; + for (int j = 0; j < nhid; j++) + gW2[k * nhid + j] += dout[k] * rs * hid[j]; + if (use_skip) + for (int i = 0; i < nfeat; i++) + gWs[k * nfeat + i] += dout[k] * xs[i]; + } + for (int j = 0; j < nhid; j++) { + f64 dh = 0; + for (int k = 0; k < nout; k++) + dh += dout[k] * rs * m->W2[k * nhid + j]; + dh *= 1.0 - (f64)hid[j] * hid[j]; /* tanh' */ + gb1[j] += dh; + for (int i = 0; i < nfeat; i++) + gW1[j * nfeat + i] += dh * xs[i]; + } + } + f64 scale = 1.0 / n, bc1 = 1 - b1p, bc2 = 1 - b2p; + if (nhid > 0) { + mlp_adam(m->W1, gW1, mW1, vW1, nW1, lr, scale, bc1, bc2); + mlp_adam(m->b1, gb1, mb1, vb1, nhid, lr, scale, bc1, bc2); + mlp_adam(m->W2, gW2, mW2, vW2, nW2, lr, scale, bc1, bc2); + } + if (use_skip) + mlp_adam(m->Wskip, gWs, mWs, vWs, nWs, lr, scale, bc1, bc2); + mlp_adam(m->b2, gb2, mb2, vb2, nout, lr, scale, bc1, bc2); + } + + /* Divergence guard on the WRITE path: Adam (tanh head, no leaf regularization) + * can push weights to NaN/Inf on a bad lr or degenerate data. Reject at fit + * time rather than serialize a blob the finite-checked loader (rd_f32) would + * later refuse. mean/sd are input-derived, finite when the inputs are; gbt and + * tree students are finite by construction (bounded, lambda-regularized leaves + * / means of finite data), so only the mlp needs this. */ + if (rc == SQLITE_OK && + (!arr_finite(m->W1, nW1) || !arr_finite(m->b1, nhid) || + !arr_finite(m->W2, nW2) || !arr_finite(m->b2, nout) || + !arr_finite(m->Wskip, nWs))) { + rc = SQLITE_ERROR; + *errmsg = sqlite3_mprintf( + "%s: training diverged to non-finite weights; reduce epochs or lr", + PREDICT_ERR_SCHEMA); + } + +done: + sqlite3_free(mWs); + sqlite3_free(vWs); + sqlite3_free(gWs); + sqlite3_free(mW1); + sqlite3_free(vW1); + sqlite3_free(gW1); + sqlite3_free(mW2); + sqlite3_free(vW2); + sqlite3_free(gW2); + sqlite3_free(mb1); + sqlite3_free(vb1); + sqlite3_free(gb1); + sqlite3_free(mb2); + sqlite3_free(vb2); + sqlite3_free(gb2); + sqlite3_free(hid); + sqlite3_free(out); + sqlite3_free(xs); + sqlite3_free(dout); + if (rc != SQLITE_OK) + predict0_mlp_free(m); + return rc; +} + +/* ---- CART training ---- */ + + +static int bld_new_node(Builder *b) { + if (b->n == b->cap) { + int nc = b->cap ? b->cap * 2 : 64; + TreeNode *g = sqlite3_realloc(b->nodes, sizeof(TreeNode) * nc); + if (!g) + return -1; + b->nodes = g; + b->cap = nc; + } + memset(&b->nodes[b->n], 0, sizeof(TreeNode)); + return b->n++; +} + +/* Fill node `ni` as a leaf over rows idx[0..n). Returns SQLITE_NOMEM (never a + * bogus leaf) if the class-count buffer cannot be allocated. */ +static int bld_leaf(Builder *b, int ni, const int *idx, int n) { + TreeNode *nd = &b->nodes[ni]; + nd->feature = -1; + nd->left = nd->right = -1; + if (b->task == 0) { + int *cnt = sqlite3_malloc(sizeof(int) * b->nclass); + if (!cnt) + return SQLITE_NOMEM; + memset(cnt, 0, sizeof(int) * b->nclass); + int best = 0, bestc = -1; + for (int i = 0; i < n; i++) + cnt[b->yc[idx[i]]]++; + for (int c = 0; c < b->nclass; c++) + if (cnt[c] > bestc) { + bestc = cnt[c]; + best = c; + } + sqlite3_free(cnt); + nd->klass = best; + nd->conf = n ? (f32)bestc / (f32)n : 0.f; + } else if (b->hess) { + /* Newton leaf: the tree fits the gradient (b->yr), but the leaf value is + * the second-order step sum(grad) / (sum(hess) + lambda) -- what lifts a + * gradient booster to XGBoost-quality on non-squared losses. */ + f64 g = 0, h = 0; + for (int i = 0; i < n; i++) { + g += b->yr[idx[i]]; + h += b->hess[idx[i]]; + } + nd->value = (f32)(g / (h + b->lambda)); + nd->klass = -1; + } else { + f64 s = 0; + for (int i = 0; i < n; i++) + s += b->yr[idx[i]]; + nd->value = n ? (f32)(s / n) : 0.f; + nd->klass = -1; + } + return SQLITE_OK; +} + +/* Best (feature, threshold) split minimizing impurity, or feature<0 if none + * improves. cmp buffer of (value, target) sorted per feature. */ +typedef struct { + f32 v; + i32 row; /* original row index: a stable tiebreak for a total order */ + i32 yc; + f32 yr; +} VY; +/* Total order on (v, row). Sorting only by v leaves equal-value rows in a + * libc-defined order, and the regression branch then sums f64-of-f32 in that + * order, so rounding — and near-tied split selection — would differ across + * platforms, breaking content_hash reproducibility. The row tiebreak fixes the + * order; the target union fields are never read here (yc/yr are only valid for + * one task, so comparing them would be undefined). */ +static int vy_cmp(const void *a, const void *b) { + const VY *x = (const VY *)a, *y = (const VY *)b; + if (x->v != y->v) + return x->v < y->v ? -1 : 1; + return x->row < y->row ? -1 : x->row > y->row ? 1 : 0; +} + +static int bld_best_split(Builder *b, const int *idx, int n, int *feat_out, + f32 *thr_out) { + *feat_out = -1; + VY *buf = sqlite3_malloc(sizeof(VY) * n); + if (!buf) + return SQLITE_NOMEM; + f64 best_score = 0; /* impurity decrease; want > 0 */ + + for (int f = 0; f < b->nfeat; f++) { + for (int i = 0; i < n; i++) { + buf[i].v = b->X[(size_t)idx[i] * b->nfeat + f]; + buf[i].row = idx[i]; + if (b->task == 0) + buf[i].yc = b->yc[idx[i]]; + else + buf[i].yr = b->yr[idx[i]]; + } + qsort(buf, n, sizeof(VY), vy_cmp); + if (buf[0].v == buf[n - 1].v) + continue; /* constant feature */ + + if (b->task == 0) { + int *ltot = sqlite3_malloc(sizeof(int) * b->nclass * 2); + if (!ltot) { + sqlite3_free(buf); + return SQLITE_NOMEM; + } + int *rtot = ltot + b->nclass; + memset(ltot, 0, sizeof(int) * b->nclass * 2); + for (int i = 0; i < n; i++) + rtot[buf[i].yc]++; + int nl = 0; + for (int i = 0; i < n - 1; i++) { + ltot[buf[i].yc]++; + rtot[buf[i].yc]--; + nl++; + if (buf[i].v == buf[i + 1].v) + continue; /* can't split between equal values */ + int nr = n - nl; + f64 gl = 1, gr = 1; + for (int c = 0; c < b->nclass; c++) { + f64 pl = (f64)ltot[c] / nl, pr = (f64)rtot[c] / nr; + gl -= pl * pl; + gr -= pr * pr; + } + f64 score = -((f64)nl * gl + (f64)nr * gr) / n; /* maximize */ + if (*feat_out < 0 || score > best_score) { + best_score = score; + *feat_out = f; + *thr_out = (buf[i].v + buf[i + 1].v) / 2.f; + } + } + sqlite3_free(ltot); + } else { + f64 tot = 0, totsq = 0; + for (int i = 0; i < n; i++) { + tot += buf[i].yr; + totsq += (f64)buf[i].yr * buf[i].yr; + } + f64 lsum = 0, lsq = 0; + int nl = 0; + for (int i = 0; i < n - 1; i++) { + lsum += buf[i].yr; + lsq += (f64)buf[i].yr * buf[i].yr; + nl++; + if (buf[i].v == buf[i + 1].v) + continue; + int nr = n - nl; + f64 rsum = tot - lsum, rsq = totsq - lsq; + f64 lvar = lsq - lsum * lsum / nl; /* SSE left */ + f64 rvar = rsq - rsum * rsum / nr; /* SSE right */ + f64 score = -(lvar + rvar); /* maximize (minimize SSE) */ + if (*feat_out < 0 || score > best_score) { + best_score = score; + *feat_out = f; + *thr_out = (buf[i].v + buf[i + 1].v) / 2.f; + } + } + } + } + sqlite3_free(buf); + return SQLITE_OK; +} + +/* Recursively build; returns the node index, or -1 on OOM. */ +int predict0_bld_build(Builder *b, int *idx, int n, int depth) { + int ni = bld_new_node(b); + if (ni < 0) + return -1; + + int pure = 1; + if (b->task == 0) { + for (int i = 1; i < n; i++) + if (b->yc[idx[i]] != b->yc[idx[0]]) { + pure = 0; + break; + } + } else { + pure = 0; /* regression leaves split on impurity, not purity */ + } + int maxd = b->max_depth ? b->max_depth : TREE_MAX_DEPTH; + int mins = b->min_split ? b->min_split : TREE_MIN_SPLIT; + if (depth >= maxd || n < mins || pure) { + if (bld_leaf(b, ni, idx, n) != SQLITE_OK) + return -1; + return ni; + } + int feat; + f32 thr; + if (bld_best_split(b, idx, n, &feat, &thr) != SQLITE_OK) + return -1; + if (feat < 0) { + if (bld_leaf(b, ni, idx, n) != SQLITE_OK) + return -1; + return ni; + } + /* partition idx: rows with X[.,feat] < thr to the front */ + int lo = 0, hi = n - 1; + while (lo <= hi) { + if (b->X[(size_t)idx[lo] * b->nfeat + feat] < thr) { + lo++; + } else { + int tmp = idx[lo]; + idx[lo] = idx[hi]; + idx[hi] = tmp; + hi--; + } + } + int nl = lo; + if (nl == 0 || nl == n) { /* degenerate split; make a leaf */ + if (bld_leaf(b, ni, idx, n) != SQLITE_OK) + return -1; + return ni; + } + int L = predict0_bld_build(b, idx, nl, depth + 1); + if (L < 0) + return -1; + int R = predict0_bld_build(b, idx + nl, n - nl, depth + 1); + if (R < 0) + return -1; + /* node index ni is stable across the reallocs above; set it now */ + b->nodes[ni].feature = feat; + b->nodes[ni].threshold = thr; + b->nodes[ni].left = L; + b->nodes[ni].right = R; + b->nodes[ni].klass = -1; + return ni; +} + + +/* Train a GBT on the teacher targets. Fills the numeric parts of *fo; the + * caller sets feat_names and labels (as for the single-tree path). For + * classification, `soft` (when + * non-NULL) is a row-major [n, nclass] matrix of teacher class probabilities + * that replaces the hard one-hot label: the student then matches the teacher's + * whole distribution (soft-label distillation), transferring the calibrated + * probabilities a hard argmax throws away. NULL `soft` keeps the hard-label + * path. Regression ignores `soft`. */ +int predict0_train_gbt(const f32 *X, int n, int nfeat, int task, int nclass, + const i32 *yc, const f32 *yr, const f32 *soft, Forest *fo, + char **errmsg) { + memset(fo, 0, sizeof(*fo)); + /* Shape/target contract at this cross-TU entry (also called from the distill + * recipes): non-positive dims make sum/n and m/n below NaN and size arrays to + * zero/negative, and a missing target is dereferenced (yc/yr/soft). The fit + * path validates these upstream in predict0_train_student; guard here too so + * the boundary is safe on its own, mirroring predict0_train_mlp. */ + if (n <= 0 || nfeat <= 0 || task < 0 || task > 1 || + (task == 0 && (nclass < 2 || nclass > PREDICT0_MAX_CLASS))) { + *errmsg = sqlite3_mprintf( + "%s: gbt shape invalid (n=%d nfeat=%d task=%d nclass=%d)", + PREDICT_ERR_SCHEMA, n, nfeat, task, nclass); + return SQLITE_ERROR; + } + if ((task == 0 && !soft && !yc) || (task == 1 && !yr)) { + *errmsg = sqlite3_mprintf("%s: gbt training target missing", + PREDICT_ERR_TARGET); + return SQLITE_ERROR; + } + int rounds = GBT_ROUNDS, nscore = task == 0 ? nclass : 1; + fo->task = task; + fo->nfeat = nfeat; + fo->nclass = nclass; + fo->n_score = nscore; + fo->n_rounds = rounds; + fo->lr = GBT_LR; + + int rc = SQLITE_OK; + fo->init = sqlite3_malloc(sizeof(f32) * nscore); + fo->tree_off = sqlite3_malloc(sizeof(int) * (rounds * nscore + 1)); + /* These scale with the row count, so their size_t byte product can exceed + * INT_MAX; use sqlite3_malloc64 to avoid narrowing the request to int and + * later overrunning F/p. */ + f64 *F = sqlite3_malloc64(sizeof(f64) * (size_t)n * nscore); + f64 *p = + task == 0 ? sqlite3_malloc64(sizeof(f64) * (size_t)n * nscore) : NULL; + int *idx = + sqlite3_malloc64(sizeof(int) * (size_t)n); /* scratch for bld_build */ + f32 *grad = sqlite3_malloc64(sizeof(f32) * (size_t)n); + f32 *hess = task == 0 ? sqlite3_malloc64(sizeof(f32) * (size_t)n) : NULL; + if (!fo->init || !fo->tree_off || !F || !idx || !grad || + (task == 0 && (!p || !hess))) { + rc = SQLITE_NOMEM; + *errmsg = sqlite3_mprintf("%s: out of memory", PREDICT_ERR_RESOURCE); + goto done; + } + + if (task == 0) { + for (int c = 0; c < nscore; c++) { + f64 pri; + if (soft) { /* mean teacher probability for the class */ + f64 sum = 0; + for (int i = 0; i < n; i++) + sum += soft[(size_t)i * nscore + c]; + pri = sum / n; + if (pri < 1e-6) + pri = 1e-6; + } else { + int cnt = 0; + for (int i = 0; i < n; i++) + cnt += yc[i] == c; + pri = (cnt + 1.0) / (n + nscore); /* smoothed */ + } + fo->init[c] = (f32)log(pri); + } + for (int i = 0; i < n; i++) + for (int c = 0; c < nscore; c++) + F[(size_t)i * nscore + c] = fo->init[c]; + } else { + f64 m = 0; + for (int i = 0; i < n; i++) + m += yr[i]; + m /= n; + fo->init[0] = (f32)m; + for (int i = 0; i < n; i++) + F[i] = m; + } + + fo->tree_off[0] = 0; + int nt = 0, pool_cap = 0; + for (int r = 0; r < rounds; r++) { + if (task == 0) { /* round-start softmax for every row */ + for (int i = 0; i < n; i++) { + f64 *Fi = &F[(size_t)i * nscore], mx = Fi[0]; + for (int c = 1; c < nscore; c++) + if (Fi[c] > mx) + mx = Fi[c]; + f64 sum = 0; + for (int c = 0; c < nscore; c++) + sum += (p[(size_t)i * nscore + c] = exp(Fi[c] - mx)); + for (int c = 0; c < nscore; c++) + p[(size_t)i * nscore + c] /= sum; + } + } + for (int s = 0; s < nscore; s++) { + if (task == 0) + for (int i = 0; i < n; i++) { + f64 pi = p[(size_t)i * nscore + s]; + f32 tgt = + soft ? soft[(size_t)i * nscore + s] : (yc[i] == s ? 1.f : 0.f); + grad[i] = tgt - (f32)pi; + hess[i] = (f32)(pi * (1.0 - pi)); /* softmax curvature */ + } + else + for (int i = 0; i < n; i++) + grad[i] = (f32)(yr[i] - F[i]); + + Builder b; + memset(&b, 0, sizeof(b)); + b.X = X; + b.nfeat = nfeat; + b.yr = grad; + b.task = 1; + b.max_depth = GBT_DEPTH; + b.min_split = GBT_MIN_SPLIT; + b.hess = hess; /* NULL for regression => mean leaves (Newton for MSE) */ + b.lambda = GBT_LAMBDA; + for (int i = 0; i < n; i++) + idx[i] = i; + int root = predict0_bld_build(&b, idx, n, 0); + if (root < 0) { + sqlite3_free(b.nodes); + rc = SQLITE_NOMEM; + *errmsg = sqlite3_mprintf("%s: out of memory", PREDICT_ERR_RESOURCE); + goto done; + } + for (int i = 0; i < n; i++) + F[(size_t)i * nscore + s] += + (f64)fo->lr * + predict0_reg_tree_value(b.nodes, b.n, &X[(size_t)i * nfeat]); + + int need = fo->tree_off[nt] + b.n; + if (need > pool_cap) { + pool_cap = need > pool_cap * 2 ? need : pool_cap * 2; + TreeNode *g = sqlite3_realloc(fo->nodes, sizeof(TreeNode) * pool_cap); + if (!g) { + sqlite3_free(b.nodes); + rc = SQLITE_NOMEM; + *errmsg = sqlite3_mprintf("%s: out of memory", PREDICT_ERR_RESOURCE); + goto done; + } + fo->nodes = g; + } + memcpy(&fo->nodes[fo->tree_off[nt]], b.nodes, sizeof(TreeNode) * b.n); + fo->tree_off[nt + 1] = need; + nt++; + sqlite3_free(b.nodes); + } + } + fo->n_trees = nt; + +done: + sqlite3_free(F); + sqlite3_free(p); + sqlite3_free(idx); + sqlite3_free(grad); + sqlite3_free(hess); + if (rc != SQLITE_OK) + predict0_forest_free(fo); + return rc; +} + +int predict0_intern_label(char ***labels, int *nclass, int *cap, const char *s, + int max, int *rc, char **errmsg) { + for (int k = 0; k < *nclass; k++) + if (strcmp((*labels)[k], s) == 0) + return k; + /* New distinct label. Enforce the cap here, before growing the array or + * allocating the string, so an oversized vocabulary fails loud instead of + * hitting SQLITE_NOMEM. max <= 0 means unbounded. */ + if (max > 0 && *nclass >= max) { + *rc = SQLITE_ERROR; + if (errmsg) + *errmsg = sqlite3_mprintf("%s: too many classes (%d); the maximum is %d", + PREDICT_ERR_SCHEMA, *nclass + 1, max); + return -1; + } + if (*nclass == *cap) { + int nc = *cap ? *cap * 2 : 8; + char **g = sqlite3_realloc(*labels, sizeof(char *) * nc); + if (!g) { + *rc = SQLITE_NOMEM; + return -1; + } + *labels = g; + *cap = nc; + } + (*labels)[*nclass] = sqlite3_mprintf("%s", s); + if (!(*labels)[*nclass]) { + *rc = SQLITE_NOMEM; + return -1; + } + return (*nclass)++; +} + +int predict0_register_student(sqlite3 *db, const char *student_id, + const void *blob, int blob_len, + char hash_out[PREDICT_HEX_BUFSIZE], + char **errmsg) { + predict0_hasher h; + predict0_hash_init(&h); + sha256_update(&h.sha, (const u8 *)blob, (usize)blob_len); + predict0_hash_hex(&h, hash_out); + + sqlite3_stmt *ins = NULL; + if (sqlite3_prepare_v2( + db, + "INSERT INTO _predict_models (model_id, kind, runtime, weights," + " io_spec, content_hash, license) VALUES (?1,'student','tree',?2," + " NULL,?3,'unspecified')", + -1, &ins, NULL) != SQLITE_OK) { + *errmsg = sqlite3_mprintf("%s: cannot prepare student insert: %s", + PREDICT_ERR_RESOURCE, sqlite3_errmsg(db)); + return SQLITE_ERROR; + } + sqlite3_bind_text(ins, 1, student_id, -1, SQLITE_STATIC); + sqlite3_bind_blob(ins, 2, blob, blob_len, SQLITE_STATIC); + sqlite3_bind_text(ins, 3, hash_out, -1, SQLITE_STATIC); + int irc = sqlite3_step(ins); + sqlite3_finalize(ins); + if (irc == SQLITE_CONSTRAINT) { + *errmsg = sqlite3_mprintf("%s: student '%s' already exists", + PREDICT_ERR_STUDENT_EXISTS, student_id); + return SQLITE_ERROR; + } + if (irc != SQLITE_DONE) { + *errmsg = sqlite3_mprintf("%s: student insert failed: %s", + PREDICT_ERR_RESOURCE, sqlite3_errmsg(db)); + return SQLITE_ERROR; + } + return SQLITE_OK; +} + +/* Shared tabular training core (declared in predict-train.h). The fit() + * aggregate calls this with an in-memory matrix; distill_predict() keeps its own + * pipeline (teacher relabeling, soft labels, holdout metric) for now. */ +int predict0_train_student(sqlite3 *db, const f32 *X, int n, int nfeat, + char *const *feat_names, int classify, + char *const *ylab, const f64 *yval, const char *kind, + const char *register_id, void **blob_out, + int *blob_len_out, char **errmsg) { + *blob_out = NULL; + *blob_len_out = 0; + *errmsg = NULL; /* required out-param; own its initial state, don't rely on the + caller pre-zeroing it for the done: NOMEM check */ + if (n < DISTILL_MIN_ROWS) { + *errmsg = sqlite3_mprintf("%s: need at least %d train rows, got %d", + PREDICT_ERR_SCHEMA, DISTILL_MIN_ROWS, n); + return SQLITE_ERROR; + } + int is_gbt = !kind || strcmp(kind, "gbt") == 0; + int is_tree = kind && strcmp(kind, "tree") == 0; + if (!is_gbt && !is_tree) { + *errmsg = sqlite3_mprintf("%s: fit kind must be 'gbt' or 'tree' (mlp," + " soft-label, and teacher paths via" + " distill_predict): %s", + PREDICT_ERR_OPTIONS, kind); + return SQLITE_ERROR; + } + + int rc = SQLITE_OK; + char **labels = NULL; /* class vocabulary (classify) */ + int nclass = 0, lcap = 0; + i32 *y_teach = NULL; /* classify: class indices */ + f32 *y_teach_r = NULL; /* regress: values */ + int *idx = NULL; + Tree tree; + memset(&tree, 0, sizeof(tree)); + Forest forest; + memset(&forest, 0, sizeof(forest)); + void *blob = NULL; + int blob_len = 0; + + if (classify) { + y_teach = sqlite3_malloc(sizeof(i32) * n); + if (!y_teach) { + rc = SQLITE_NOMEM; + goto done; + } + for (int i = 0; i < n; i++) { + int k = predict0_intern_label(&labels, &nclass, &lcap, + ylab[i] ? ylab[i] : "", PREDICT0_MAX_CLASS, + &rc, errmsg); + if (k < 0) + goto done; + y_teach[i] = k; + } + if (nclass < 2) { + rc = SQLITE_ERROR; + *errmsg = sqlite3_mprintf( + "%s: classify needs >= 2 distinct labels (got %d); pass" + " '{\"task\":\"regress\"}' for a numeric target", + PREDICT_ERR_TARGET, nclass); + goto done; + } + /* Defensive invariant: predict0_intern_label already caps this during + * interning; kept so the bound holds even if that path changes. */ + if (nclass > PREDICT0_MAX_CLASS) { + rc = SQLITE_ERROR; + *errmsg = sqlite3_mprintf("%s: too many classes (%d); the maximum is %d", + PREDICT_ERR_SCHEMA, nclass, PREDICT0_MAX_CLASS); + goto done; + } + } else { + y_teach_r = sqlite3_malloc(sizeof(f32) * n); + if (!y_teach_r) { + rc = SQLITE_NOMEM; + goto done; + } + for (int i = 0; i < n; i++) + y_teach_r[i] = (f32)yval[i]; + } + + if (is_gbt) { + rc = predict0_train_gbt(X, n, nfeat, classify ? 0 : 1, nclass, y_teach, y_teach_r, + NULL, &forest, errmsg); + if (rc != SQLITE_OK) + goto done; + forest.feat_names = sqlite3_malloc(sizeof(char *) * nfeat); + if (!forest.feat_names) { + rc = SQLITE_NOMEM; + goto done; + } + memset(forest.feat_names, 0, sizeof(char *) * nfeat); + for (int f = 0; f < nfeat; f++) + if (!(forest.feat_names[f] = sqlite3_mprintf("%s", feat_names[f]))) { + rc = SQLITE_NOMEM; + goto done; + } + forest.labels = labels; /* transfer ownership */ + labels = NULL; + rc = predict0_forest_serialize(&forest, &blob, &blob_len); + if (rc != SQLITE_OK) + goto done; + } else { /* single tree */ + idx = sqlite3_malloc(sizeof(int) * n); + if (!idx) { + rc = SQLITE_NOMEM; + goto done; + } + for (int i = 0; i < n; i++) + idx[i] = i; + Builder b; + memset(&b, 0, sizeof(b)); + b.X = X; + b.nfeat = nfeat; + b.yc = y_teach; + b.yr = y_teach_r; + b.nclass = nclass; + b.task = classify ? 0 : 1; + int root = predict0_bld_build(&b, idx, n, 0); + if (root < 0) { + sqlite3_free(b.nodes); + rc = SQLITE_NOMEM; + goto done; + } + tree.feat_names = sqlite3_malloc(sizeof(char *) * nfeat); + if (!tree.feat_names) { + sqlite3_free(b.nodes); + rc = SQLITE_NOMEM; + goto done; + } + memset(tree.feat_names, 0, sizeof(char *) * nfeat); + tree.nfeat = nfeat; /* set first so predict0_tree_free() releases partial + feat_names copies if an allocation below fails */ + for (int f = 0; f < nfeat; f++) + if (!(tree.feat_names[f] = sqlite3_mprintf("%s", feat_names[f]))) { + sqlite3_free(b.nodes); + rc = SQLITE_NOMEM; + goto done; + } + tree.task = b.task; + tree.nclass = nclass; + tree.labels = labels; /* transfer ownership */ + labels = NULL; + tree.n_nodes = b.n; + tree.nodes = b.nodes; + rc = predict0_tree_serialize(&tree, &blob, &blob_len); + if (rc != SQLITE_OK) + goto done; + } + + if (register_id) { + char hash[PREDICT_HEX_BUFSIZE]; + rc = predict0_registry_ensure(db, errmsg); /* create _predict_models if new */ + if (rc != SQLITE_OK) + goto done; + rc = predict0_register_student(db, register_id, blob, blob_len, hash, errmsg); + if (rc != SQLITE_OK) + goto done; + } + *blob_out = blob; + blob = NULL; + *blob_len_out = blob_len; + rc = SQLITE_OK; + +done: + if (rc == SQLITE_NOMEM && !*errmsg) + *errmsg = sqlite3_mprintf("%s: out of memory", PREDICT_ERR_RESOURCE); + sqlite3_free(blob); + sqlite3_free(idx); + sqlite3_free(y_teach); + sqlite3_free(y_teach_r); + if (labels) { + for (int k = 0; k < nclass; k++) + sqlite3_free(labels[k]); + sqlite3_free(labels); + } + predict0_forest_free(&forest); /* frees transferred feat_names/labels */ + predict0_tree_free(&tree); + return rc; +} diff --git a/predict-train.h b/predict-train.h new file mode 100644 index 0000000..dae9087 --- /dev/null +++ b/predict-train.h @@ -0,0 +1,63 @@ +/* SPDX-License-Identifier: MIT OR Apache-2.0 + * Copyright (c) 2026 Pure Storage, Inc. + */ +#ifndef PREDICT_TRAIN_H +#define PREDICT_TRAIN_H + +#include "predict-internal.h" +#include "predict-student.h" + +/* Shared training defaults (the core here + the distill recipes). */ +#define DISTILL_MIN_ROWS 8 +#define MLP_HIDDEN 48 +#define MLP_EPOCHS 400 +#define MLP_LR 0.02 + +/* Tree builder. The distill recipe constructs one in place and calls predict0_bld_build. */ +typedef struct { + TreeNode *nodes; + int n, cap; + const f32 *X; /* [nrow, nfeat] */ + int nfeat; + const i32 *yc; /* classify targets (teacher class index) */ + const f32 *yr; /* regress targets */ + int nclass; + int task; + int max_depth; /* 0 => TREE_MAX_DEPTH; shallow for GBT weak learners */ + int min_split; /* 0 => TREE_MIN_SPLIT */ + const f32 *hess; /* GBT only: per-row Hessian; NULL => mean-value leaves */ + f32 lambda; /* GBT only: L2 leaf regularization */ +} Builder; + +int predict0_bld_build(Builder *b, int *idx, int n, int depth); +int predict0_train_gbt(const f32 *X, int n, int nfeat, int task, int nclass, + const i32 *yc, const f32 *yr, const f32 *soft, Forest *fo, + char **errmsg); +int predict0_train_mlp(const f32 *X, int n, int nfeat, int nout, const i32 *yc, + const f32 *soft, int task, int nhid, int epochs, f32 lr, + int use_skip, MLP *m, char **errmsg); +/* Intern a label into the vocabulary, returning its class index (>=0), or -1 + * with *rc set on failure. max caps the distinct-class count: a new label is + * rejected (SQLITE_ERROR + *errmsg) before the array grows or the string is + * allocated once *nclass would exceed max; max <= 0 means unbounded. */ +int predict0_intern_label(char ***labels, int *nclass, int *cap, const char *s, + int max, int *rc, char **errmsg); +int predict0_register_student(sqlite3 *db, const char *student_id, const void *blob, + int blob_len, char hash_out[PREDICT_HEX_BUFSIZE], + char **errmsg); + +/* Train a native tree/gbt student from an in-memory feature matrix. X is + * row-major [n, nfeat]. For classify, ylab holds n labels (yval NULL); for + * regress, yval holds n values (ylab NULL). feat_names[nfeat] name the columns + * (placeholders are fine; the scalar predict() maps features by position). + * kind is "gbt" (default) or "tree". On SQLITE_OK, blob_out and blob_len_out + * receive the serialized student (caller frees the blob with sqlite3_free); + * if register_id is non-NULL the student is also inserted into _predict_models. + * Returns an SQLITE_ code with *errmsg set (PREDICT_ERR_* lead) on failure. */ +int predict0_train_student(sqlite3 *db, const f32 *X, int n, int nfeat, + char *const *feat_names, int classify, + char *const *ylab, const f64 *yval, const char *kind, + const char *register_id, void **blob_out, + int *blob_len_out, char **errmsg); + +#endif /* PREDICT_TRAIN_H */ diff --git a/scripts/amalgamate.py b/scripts/amalgamate.py index b3420cb..22c49df 100644 --- a/scripts/amalgamate.py +++ b/scripts/amalgamate.py @@ -23,15 +23,15 @@ # fixed order: public header, sha256 header, internal headers, then sources. # vendor/sha256 first among sources so its definitions precede any use. HEADERS = ["sqlite-predict.h", "vendor/sha256.h", "predict-internal.h", - "predict-student.h"] + "predict-student.h", "predict-train.h"] SOURCES = ["vendor/sha256.c", "sqlite-predict.c", "predict-forecast.c", "predict-registry.c", "predict-tabular.c", "predict-student.c", - "predict-distill.c"] + "predict-train.c", "predict-distill.c"] # internal includes to drop (their contents are inlined above) INTERNAL = re.compile( r'^\s*#\s*include\s*"(sqlite-predict\.h|sha256\.h|predict-internal\.h|' - r'predict-student\.h)"\s*$') + r'predict-student\.h|predict-train\.h)"\s*$') def strip_internal_includes(text): diff --git a/skills/prediction-receipts/scripts/receipt.py b/skills/prediction-receipts/scripts/receipt.py index eb2c784..5ac9ffd 100644 --- a/skills/prediction-receipts/scripts/receipt.py +++ b/skills/prediction-receipts/scripts/receipt.py @@ -41,7 +41,17 @@ result_sha256 TEXT NOT NULL )""" -OPERATIONS = ("forecast", "detect_anomalies", "predict", "backtest") +# Aggregate operations return one JSON document, hashed verbatim. Row-shaped +# operations (per-row predict, and the *_rows TVFs that expand an aggregate +# document into typed rows) hash the {"columns","rows"} form. A *_rows TVF nests +# its base aggregate in its argument, so it — not the nested aggregate — is the +# operation of record. +AGGREGATE_OPS = ("forecast", "detect_anomalies", "backtest") +ROW_OPS = ("predict", "predict_batch", "backtest_rows", "forecast_rows", + "anomaly_rows") +OPERATIONS = AGGREGATE_OPS + ROW_OPS +_ROWS_WRAPS = {"backtest_rows": "backtest", "forecast_rows": "forecast", + "anomaly_rows": "detect_anomalies"} def fail(code, msg): @@ -93,9 +103,6 @@ def connect(db_path, extension, untrusted=False): return db -AGGREGATE_OPS = ("forecast", "detect_anomalies") - - def canonical_document(cur, operation): """Aggregate operations hash their single JSON document verbatim; row-shaped operations always hash the {"columns","rows"} form, even @@ -125,7 +132,13 @@ def sha256_text(text): def infer_operation(sql): found = {op for op in OPERATIONS - if re.search(r"\b" + op + r"\s*\(", sql)} + if re.search(r"\b" + re.escape(op) + r"\s*\(", sql)} + # A *_rows TVF nests its base aggregate in its argument (e.g. + # backtest_rows((SELECT backtest(...)))). The outer TVF is the operation, so + # drop the base aggregate when its _rows wrapper is present. + for wrapper, base in _ROWS_WRAPS.items(): + if wrapper in found: + found.discard(base) if len(found) == 1: return found.pop() return None diff --git a/sqlite-predict.c b/sqlite-predict.c index c19217f..9b34f7b 100644 --- a/sqlite-predict.c +++ b/sqlite-predict.c @@ -171,9 +171,16 @@ void predict0_format_timestamp(i64 ms, char *buf, usize bufsize) { * is the array (validated as one). Returns SQLITE_OK and sets out/n; on * failure returns an SQLITE_ code with *errmsg set. */ int predict0_json_str_array(sqlite3 *db, const char *json, const char *path, - char ***out, int *n, char **errmsg) { + char ***out, int *n, int max, char **errmsg) { *out = NULL; *n = 0; + /* max is a hard cap; a nonpositive value is a caller bug, not "unbounded" — + * reject it rather than silently drop the allocation guard. */ + if (max <= 0) { + *errmsg = sqlite3_mprintf("%s: json array cap must be positive (got %d)", + PREDICT_ERR_SCHEMA, max); + return SQLITE_ERROR; + } int cap = 0; sqlite3_stmt *st = NULL; const char *sql = path ? "SELECT value FROM json_each(?1, ?2)" @@ -187,8 +194,15 @@ int predict0_json_str_array(sqlite3 *db, const char *json, const char *path, sqlite3_bind_text(st, 1, json, -1, SQLITE_STATIC); if (path) sqlite3_bind_text(st, 2, path, -1, SQLITE_STATIC); - int rc = SQLITE_OK; - while (sqlite3_step(st) == SQLITE_ROW) { + int rc = SQLITE_OK, sr; + while ((sr = sqlite3_step(st)) == SQLITE_ROW) { + /* reject during the parse, before allocating the (max+1)th element */ + if (*n >= max) { + rc = SQLITE_ERROR; + *errmsg = sqlite3_mprintf("%s: too many elements; the maximum is %d", + PREDICT_ERR_SCHEMA, max); + break; + } if (*n == cap) { cap = cap ? cap * 2 : 8; char **g = sqlite3_realloc(*out, sizeof(char *) * cap); @@ -206,9 +220,26 @@ int predict0_json_str_array(sqlite3 *db, const char *json, const char *path, } (*n)++; } + /* A step result other than DONE (e.g. malformed JSON in json_each) is a parse + * failure, not a successful empty read: surface it rather than return OK with + * partial output. Capture the message before finalize clears it. */ + if (rc == SQLITE_OK && sr != SQLITE_DONE) { + rc = SQLITE_ERROR; + *errmsg = sqlite3_mprintf("%s: could not parse option array: %s", + PREDICT_ERR_OPTIONS, sqlite3_errmsg(db)); + } sqlite3_finalize(st); if (rc == SQLITE_NOMEM) *errmsg = sqlite3_mprintf("%s: out of memory", PREDICT_ERR_RESOURCE); + /* On any failure free the partial array and reset the out-params, so a caller + * never sees (or has to free) a partial result. */ + if (rc != SQLITE_OK) { + for (int i = 0; i < *n; i++) + sqlite3_free((*out)[i]); + sqlite3_free(*out); + *out = NULL; + *n = 0; + } return rc; } diff --git a/tests/soak.c b/tests/soak.c index c031ad8..5ece371 100644 --- a/tests/soak.c +++ b/tests/soak.c @@ -129,10 +129,25 @@ int main(void) { "\"epochs\":50}')", 1); + /* fit() aggregate: train + register native students (gbt and tree) once + * here (a second register would hit MODEL_EXISTS); the scalar predict() + * inside the loop serves them per row. */ + if (run_discard(db, + "SELECT fit(f1, f2, label," + " '{\"kind\":\"gbt\",\"register\":\"soak_fit\"}') FROM tab", + 1) || + run_discard(db, + "SELECT fit(f1, f2, label," + " '{\"kind\":\"tree\",\"register\":\"soak_fit_tree\"}')" + " FROM tab", + 1)) + goto done_fail; + int fails = 0; for (int i = 0; i < 50; i++) { /* serving loop: the aggregate forms (the one calling convention for - * forecast/detect_anomalies) + predict, across models and options */ + * forecast/detect_anomalies) + batched predict_batch, across models + * and options */ if (run_discard(db, "SELECT forecast(ts, value, 6) FROM series", 1) || run_discard(db, "SELECT grp, forecast(ts, value, 4) FROM series" @@ -145,36 +160,49 @@ int main(void) { " '{\"model\":\"sub-pca\"}') FROM series", 1) || run_discard(db, - "SELECT * FROM predict(" + "SELECT * FROM predict_batch(" "'SELECT f1, f2, label FROM tab WHERE id < 100'," "'SELECT id, f1, f2 FROM tab WHERE id >= 100'," " '{\"target\":\"label\"}')", 1) || - run_discard(db, "SELECT * FROM predict(NULL,'SELECT id, f1, f2 FROM tab'," + run_discard(db, "SELECT * FROM predict_batch(NULL,'SELECT id, f1, f2 FROM tab'," " '{\"model\":\"soak_student\"}')", 1)) goto done_fail; - /* gbt-student (forest runtime) + tree/forest error paths */ - fails += run_discard(db, "SELECT * FROM predict(NULL,'SELECT id, f1, f2 FROM tab'," + /* scalar predict(): serve the fit()-registered students per row, plus + * proba, a fit() blob via subquery, and the arity/unknown-model errors */ + fails += run_discard(db, "SELECT id, predict('soak_fit', f1, f2) FROM tab", 1); + fails += run_discard(db, "SELECT id, predict('soak_fit_tree', f1, f2) FROM tab", 1); + fails += run_discard(db, "SELECT id, predict('soak_fit', f1, f2," + " '{\"proba\":true}') FROM tab", + 1); + fails += run_discard(db, "SELECT id, predict((SELECT fit(f1, f2, label)" + " FROM tab), f1, f2) FROM tab", + 1); + fails += run_discard(db, "SELECT predict('soak_fit', f1) FROM tab", 0); + fails += run_discard(db, "SELECT predict('nope', f1, f2) FROM tab", 0); + + /* gbt-student (forest runtime) + tree/forest error paths, served batched */ + fails += run_discard(db, "SELECT * FROM predict_batch(NULL,'SELECT id, f1, f2 FROM tab'," " '{\"model\":\"soak_gbt\"}')", 1); - fails += run_discard(db, "SELECT * FROM predict(NULL,'SELECT id, f1, f2 FROM tab'," + fails += run_discard(db, "SELECT * FROM predict_batch(NULL,'SELECT id, f1, f2 FROM tab'," " '{\"model\":\"soak_knn\"}')", 1); - fails += run_discard(db, "SELECT * FROM predict(NULL,'SELECT id, f1, f2 FROM tab'," + fails += run_discard(db, "SELECT * FROM predict_batch(NULL,'SELECT id, f1, f2 FROM tab'," " '{\"model\":\"soak_soft\"}')", 1); - fails += run_discard(db, "SELECT * FROM predict(NULL,'SELECT id, f1, f2 FROM tab'," + fails += run_discard(db, "SELECT * FROM predict_batch(NULL,'SELECT id, f1, f2 FROM tab'," " '{\"model\":\"soak_mlp\"}')", 1); - fails += run_discard(db, "SELECT * FROM predict(NULL,'SELECT id, f1, f2 FROM tab'," + fails += run_discard(db, "SELECT * FROM predict_batch(NULL,'SELECT id, f1, f2 FROM tab'," " '{\"model\":\"soak_bad\"}')", 0); - fails += run_discard(db, "SELECT * FROM predict(NULL,'SELECT id, f1, f2 FROM tab'," + fails += run_discard(db, "SELECT * FROM predict_batch(NULL,'SELECT id, f1, f2 FROM tab'," " '{\"model\":\"soak_gbt_bad\"}')", 0); - fails += run_discard(db, "SELECT * FROM predict(NULL,'SELECT id, f1, f2 FROM tab'," + fails += run_discard(db, "SELECT * FROM predict_batch(NULL,'SELECT id, f1, f2 FROM tab'," " '{\"model\":\"soak_mlp_bad\"}')", 0); fails += run_discard(db, @@ -185,8 +213,8 @@ int main(void) { " '{\"student_id\":\"nope\"}')", 0); - /* auto selection, conformal intervals, backtest() (grouped, gapped) -- - * exercises the rolling-origin backtest scratch allocations */ + /* auto selection, conformal intervals, backtest() aggregate (grouped, + * gapped) -- exercises the rolling-origin backtest scratch allocations */ fails += run_discard(db, "SELECT forecast(ts, value, 6," " '{\"model\":\"auto\"}') FROM series", 1); @@ -198,15 +226,19 @@ int main(void) { "\"conformal\",\"folds\":8,\"gap\":2}')" " FROM series GROUP BY grp", 1); - fails += run_discard(db, "SELECT * FROM backtest('SELECT ts, value FROM series', 6," - " '{\"folds\":10}')", + fails += run_discard(db, "SELECT backtest(ts, value, 6, '{\"folds\":10}')" + " FROM series", 1); - fails += run_discard(db, "SELECT * FROM backtest('SELECT ts, value FROM series', 6," + fails += run_discard(db, "SELECT backtest(ts, value, 6," " '{\"model\":\"auto\",\"interval_method\":\"conformal\"," - "\"folds\":12}')", + "\"folds\":12}') FROM series", + 1); + fails += run_discard(db, "SELECT grp, backtest(ts, value, 5, '{\"gap\":3}')" + " FROM series GROUP BY grp", 1); - fails += run_discard(db, "SELECT * FROM backtest('SELECT ts, value, grp FROM series'," - " 5, '{\"group_cols\":[\"grp\"],\"gap\":3}')", + /* backtest_rows expansion round-trip */ + fails += run_discard(db, "SELECT * FROM backtest_rows((SELECT backtest(ts," + " value, 6, '{\"folds\":8}') FROM series))", 1); /* option error + edge paths */ fails += run_discard(db, "SELECT forecast(ts, value, 6," @@ -215,11 +247,11 @@ int main(void) { fails += run_discard(db, "SELECT forecast(ts, value, 6, '{\"folds\":0}')" " FROM series", 0); - fails += run_discard(db, "SELECT * FROM backtest('SELECT ts, value FROM series', 6," - " '{\"model\":\"nope\"}')", + fails += run_discard(db, "SELECT backtest(ts, value, 6, '{\"model\":\"nope\"}')" + " FROM series", 0); - fails += run_discard(db, "SELECT * FROM backtest('SELECT ts, value FROM series', 6," - " '{\"gap\":100000}')", + fails += run_discard(db, "SELECT backtest(ts, value, 6, '{\"gap\":100000}')" + " FROM series", 1); /* tsb + auto candidate sets (statistical models; the student-candidate @@ -262,8 +294,8 @@ int main(void) { 0); /* error paths every iteration too: aggregate misuse and option - * rejection, plus backtest's collect_series failure branches, so - * valgrind sees the partial-series cleanup under load */ + * rejection, plus the backtest aggregate's partial-group cleanup under + * load, so valgrind sees the failure branches free everything */ fails += run_discard(db, "SELECT forecast('SELECT ts FROM series', value, 3)" " FROM series", 0); @@ -287,35 +319,34 @@ int main(void) { 0); fails += run_discard(db, "SELECT forecast(ts, value, 0) FROM series", 0); fails += run_discard(db, "SELECT forecast(ts, value, 4) FROM series WHERE 0", 1); - fails += run_discard(db, "SELECT * FROM backtest('DELETE FROM series', 3)", 0); - fails += run_discard(db, "SELECT * FROM backtest('NOT SQL', 3)", 0); - fails += run_discard(db, "SELECT * FROM backtest('SELECT ts FROM series', 3)", 0); - fails += run_discard(db, "SELECT * FROM backtest('SELECT ts, value FROM series', 3," - " '{\"time_col\":\"nope\"}')", + /* backtest aggregate error + edge paths (no inner query to validate now) */ + fails += run_discard(db, "SELECT backtest(ts, value, 0) FROM series", 0); + fails += run_discard(db, "SELECT backtest(ts, value, 3, '{\"bogus\":1}')" + " FROM series", 0); - fails += run_discard(db, "SELECT * FROM backtest('SELECT ts, value, grp FROM series'," - " 3, '{\"group_cols\":[\"nope\"]}')", + fails += run_discard(db, "SELECT backtest(ts, value, 3, '{\"time_col\":" + "\"nope\"}') FROM series", 0); + fails += run_discard(db, "SELECT backtest(ts, value, 3) FROM series WHERE 0", 1); /* duplicate option keys (the CI fuzzer's leak): last-wins, no leak */ fails += run_discard(db, "SELECT forecast(ts, value, 3," " '{\"model\":\"theta-classic\",\"model\":" "\"stub-seasonal-naive\"}') FROM series", 1); fails += run_discard(db, - "SELECT * FROM backtest('SELECT ts, value, grp FROM series'," - " 3, '{\"model\":\"theta-classic\",\"model\":" - "\"stub-seasonal-naive\",\"time_col\":\"ts\",\"time_col\":" - "\"ts\",\"group_cols\":[\"grp\"],\"group_cols\":[\"grp\"]}')", + "SELECT grp, backtest(ts, value, 3," + " '{\"model\":\"theta-classic\",\"model\":" + "\"stub-seasonal-naive\"}') FROM series GROUP BY grp", 1); fails += run_discard(db, - "SELECT * FROM predict('SELECT f1, f2, label FROM tab'," + "SELECT * FROM predict_batch('SELECT f1, f2, label FROM tab'," " 'SELECT id, f1, f2 FROM tab'," " '{\"target\":\"label\",\"task\":\"classify\",\"task\":" "\"classify\",\"model\":\"knn5-incontext\",\"model\":" "\"knn5-incontext\"}')", 1); fails += run_discard(db, - "SELECT * FROM predict('SELECT f1, f2, label FROM tab'," + "SELECT * FROM predict_batch('SELECT f1, f2, label FROM tab'," " 'SELECT id, f1 FROM tab', '{\"target\":\"label\"}')", 0); fails += run_discard(db, "SELECT predict_ulid('not a time')", 0); @@ -329,6 +360,7 @@ int main(void) { 1); fails += run_discard(db, "SELECT * FROM forecast_rows('not json')", 0); fails += run_discard(db, "SELECT * FROM anomaly_rows('[1,2]')", 0); + fails += run_discard(db, "SELECT * FROM backtest_rows('not json')", 0); fails += run_discard(db, "SELECT * FROM forecast_rows(NULL)", 1); } diff --git a/tests/soak_onnx.c b/tests/soak_onnx.c index d2f8f8c..57142ac 100644 --- a/tests/soak_onnx.c +++ b/tests/soak_onnx.c @@ -4,25 +4,57 @@ * logreg.onnx). Built by `make test-asan-onnx`; onnxruntime's own * still-reachable allocations are filtered by tests/onnx.supp. */ #include "predict-internal.h" +#include -static int run(sqlite3 *db, const char *sql, int expect_ok) { +#define APPLY_ROWS 1501 /* the apply table below holds 1501 rows (i = 0..1500) */ + +/* Run one statement and check its outcome. expect_err == NULL means it must + * succeed; when expect_rows >= 0 the success must return exactly that many rows + * (so a zero or truncated result is caught, not silently accepted). A non-NULL + * expect_err means it must fail at step with an error message containing that + * PREDICT_ERR_* code, proving the intended validation fired. A prepare failure + * is always a driver bug (the SQL here is fixed), never an expected error, so it + * fails the driver instead of masquerading as a passed error case. Returns 1 on + * any mismatch so main() can propagate it to the exit code. */ +static int run(sqlite3 *db, const char *sql, int expect_rows, + const char *expect_err) { sqlite3_stmt *st = NULL; - int rc = sqlite3_prepare_v2(db, sql, -1, &st, NULL); - if (rc != SQLITE_OK) - return expect_ok ? 1 : 0; - while ((rc = sqlite3_step(st)) == SQLITE_ROW) + if (sqlite3_prepare_v2(db, sql, -1, &st, NULL) != SQLITE_OK) { + fprintf(stderr, "FAIL (prepare): %s: %s\n", sql, sqlite3_errmsg(db)); + return 1; + } + int nrows = 0, rc; + while ((rc = sqlite3_step(st)) == SQLITE_ROW) { + nrows++; for (int i = 0; i < sqlite3_column_count(st); i++) (void)sqlite3_column_text(st, i); - sqlite3_finalize(st); - if (expect_ok && rc != SQLITE_DONE) { - fprintf(stderr, "FAIL (expected ok): %s: %s\n", sql, sqlite3_errmsg(db)); - return 1; } - if (!expect_ok && rc == SQLITE_DONE) { - fprintf(stderr, "FAIL (expected error): %s\n", sql); - return 1; + /* capture the message before finalize, which can clear it */ + char *msg = + rc != SQLITE_DONE ? sqlite3_mprintf("%s", sqlite3_errmsg(db)) : NULL; + sqlite3_finalize(st); + + int bad = 0; + if (expect_err) { + if (rc == SQLITE_DONE) { + fprintf(stderr, "FAIL (expected %s, got success): %s\n", expect_err, sql); + bad = 1; + } else if (!msg || !strstr(msg, expect_err)) { + fprintf(stderr, "FAIL (expected %s): %s: %s\n", expect_err, sql, + msg ? msg : "(no message)"); + bad = 1; + } + } else if (rc != SQLITE_DONE) { + fprintf(stderr, "FAIL (expected ok): %s: %s\n", sql, + msg ? msg : "(no message)"); + bad = 1; + } else if (expect_rows >= 0 && nrows != expect_rows) { + fprintf(stderr, "FAIL (expected %d rows, got %d): %s\n", expect_rows, nrows, + sql); + bad = 1; } - return 0; + sqlite3_free(msg); + return bad; } int main(int argc, char **argv) { @@ -57,7 +89,8 @@ int main(int argc, char **argv) { /* bare-path registration exercises the introspection + positional path */ char *regbare = sqlite3_mprintf( "SELECT predict_register('bare', %Q)", argv[1]); - if (run(db, reg, 1) || run(db, regic, 1) || run(db, regbare, 1)) { + if (run(db, reg, -1, NULL) || run(db, regic, -1, NULL) || + run(db, regbare, -1, NULL)) { fprintf(stderr, "register failed\n"); sqlite3_free(reg); sqlite3_free(regic); @@ -90,7 +123,7 @@ int main(int argc, char **argv) { "json_array(0.1,0.3,0.5,0.7,0.9),'patch',8,'flip_invariance'," "json('true'))))", two_head); - int bad = run(db, regth, 1) || run(db, regbad, 1); + int bad = run(db, regth, -1, NULL) || run(db, regbad, -1, NULL); sqlite3_free(regth); sqlite3_free(regbad); if (bad) { @@ -102,18 +135,18 @@ int main(int argc, char **argv) { int fails = 0; if (two_head) { - fails += run(db, "CREATE TABLE ser(ts TEXT, value REAL)", 1); + fails += run(db, "CREATE TABLE ser(ts TEXT, value REAL)", -1, NULL); fails += run(db, "WITH RECURSIVE n(i) AS (SELECT 0 UNION ALL SELECT i+1 FROM n WHERE" " i < 40) INSERT INTO ser SELECT datetime('2020-01-01', '+' || i ||" " ' hours'), 10.0 + (i%9) FROM n", - 1); + -1, NULL); fails += run(db, "CREATE TABLE sk(series_key INTEGER, t INTEGER," - " value REAL)", 1); + " value REAL)", -1, NULL); fails += run(db, "WITH RECURSIVE n(i) AS (SELECT 0 UNION ALL SELECT i+1 FROM n WHERE" " i < 60) INSERT INTO sk SELECT 0, i, 10.0 + (i%9) FROM n", - 1); + -1, NULL); /* distill the two-head teacher into forecast students -- exercises the * skip/nhid=0 training allocations: a TiDE student (hidden default) and a * pure-linear student (hidden=0), point and quantile. */ @@ -121,68 +154,76 @@ int main(int argc, char **argv) { "SELECT * FROM distill_forecast('SELECT series_key, value FROM sk" " ORDER BY series_key, t', json_object('teacher','th','context',8," "'horizon',3,'student_id','fs_tide','epochs',60))", - 1); + -1, NULL); fails += run(db, "SELECT * FROM distill_forecast('SELECT series_key, value FROM sk" " ORDER BY series_key, t', json_object('teacher','th','context',8," "'horizon',3,'hidden',0,'student_id','fs_lin','epochs',60))", - 1); + -1, NULL); } - fails += run(db, "CREATE TABLE apply(id INTEGER, f1 REAL, f2 REAL)", 1); + fails += run(db, "CREATE TABLE apply(id INTEGER, f1 REAL, f2 REAL)", -1, NULL); fails += run(db, "WITH RECURSIVE n(i) AS (SELECT 0 UNION ALL SELECT i+1 FROM n WHERE" " i < 1500) INSERT INTO apply SELECT i, (i%7)-3.0, (i%5)-2.0 FROM n", - 1); + -1, NULL); fails += run(db, "CREATE TABLE tr(id INTEGER, f1 REAL, f2 REAL," - " label TEXT)", 1); + " label TEXT)", -1, NULL); fails += run(db, "WITH RECURSIVE n(i) AS (SELECT 0 UNION ALL SELECT i+1 FROM n WHERE" " i < 40) INSERT INTO tr SELECT i, (i%5)-2.0, (i%3)-1.0," " CAST(i%2 AS TEXT) FROM n", - 1); + -1, NULL); for (int i = 0; i < 20; i++) { - /* vector: success (multi-batch: 1501 rows) */ - fails += run(db, "SELECT * FROM predict(NULL,'SELECT id, f1, f2 FROM" - " apply',json_object('model','clf'))", 1); + /* vector: success (multi-batch: APPLY_ROWS rows) */ + fails += run(db, "SELECT * FROM predict_batch(NULL,'SELECT id, f1, f2 FROM" + " apply',json_object('model','clf'))", APPLY_ROWS, NULL); /* introspected model + positional features */ - fails += run(db, "SELECT * FROM predict(NULL,'SELECT id, f1, f2 FROM" - " apply',json_object('model','bare'))", 1); - /* in_context: success (train context + 1501-row query, multi-batch) */ - fails += run(db, "SELECT * FROM predict('SELECT f1, f2, label FROM tr'," - "'SELECT id, f1, f2 FROM apply',json_object('model','knn1'))", 1); - /* every error branch, both layouts */ - fails += run(db, "SELECT * FROM predict(NULL,'SELECT id, f1, f2 FROM" - " apply',json_object('model','clf','device','banana'))", 0); - fails += run(db, "SELECT * FROM predict(NULL,'SELECT id, f1, f2 FROM" - " apply',json_object('model','clf','device','cuda'))", 0); - fails += run(db, "SELECT * FROM predict(NULL,'SELECT id, f1, f2 FROM" - " apply',json_object('model','clf','precision','fp16'))", 0); - fails += run(db, "SELECT * FROM predict(NULL,'SELECT id, f1 FROM apply'," - "json_object('model','clf'))", 0); - fails += run(db, "SELECT * FROM predict(NULL,'SELECT id, f1, f2 FROM" - " apply',json_object('model','ghost'))", 0); + fails += run(db, "SELECT * FROM predict_batch(NULL,'SELECT id, f1, f2 FROM" + " apply',json_object('model','bare'))", APPLY_ROWS, NULL); + /* in_context: success (train context + APPLY_ROWS-row query, multi-batch) */ + fails += run(db, "SELECT * FROM predict_batch('SELECT f1, f2, label FROM tr'," + "'SELECT id, f1, f2 FROM apply',json_object('model','knn1'))", + APPLY_ROWS, NULL); + /* every error branch, both layouts: assert the specific closed-set code */ + fails += run(db, "SELECT * FROM predict_batch(NULL,'SELECT id, f1, f2 FROM" + " apply',json_object('model','clf','device','banana'))", + -1, "PREDICT_ERR_OPTIONS"); + fails += run(db, "SELECT * FROM predict_batch(NULL,'SELECT id, f1, f2 FROM" + " apply',json_object('model','clf','device','cuda'))", + -1, "PREDICT_ERR_RUNTIME_UNAVAILABLE"); + fails += run(db, "SELECT * FROM predict_batch(NULL,'SELECT id, f1, f2 FROM" + " apply',json_object('model','clf','precision','fp16'))", + -1, "PREDICT_ERR_RUNTIME_UNAVAILABLE"); + fails += run(db, "SELECT * FROM predict_batch(NULL,'SELECT id, f1 FROM apply'," + "json_object('model','clf'))", -1, "PREDICT_ERR_SCHEMA"); + fails += run(db, "SELECT * FROM predict_batch(NULL,'SELECT id, f1, f2 FROM" + " apply',json_object('model','ghost'))", + -1, "PREDICT_ERR_MODEL_NOT_FOUND"); /* in_context error branches: no train, missing target, bad label */ - fails += run(db, "SELECT * FROM predict(NULL,'SELECT id, f1, f2 FROM" - " apply',json_object('model','knn1'))", 0); - fails += run(db, "SELECT * FROM predict('SELECT f1, f2 FROM tr'," - "'SELECT id, f1, f2 FROM apply',json_object('model','knn1'))", 0); + fails += run(db, "SELECT * FROM predict_batch(NULL,'SELECT id, f1, f2 FROM" + " apply',json_object('model','knn1'))", -1, "PREDICT_ERR_SCHEMA"); + fails += run(db, "SELECT * FROM predict_batch('SELECT f1, f2 FROM tr'," + "'SELECT id, f1, f2 FROM apply',json_object('model','knn1'))", + -1, "PREDICT_ERR_SCHEMA"); /* two-head forecast (aggregate form): reconstruction success + * point/interval, then the fail-loud single-output flip declaration */ if (two_head) { fails += run(db, "SELECT forecast(ts, value, 3," - "json_object('model','th','confidence_level',0.8)) FROM ser", 1); + "json_object('model','th','confidence_level',0.8)) FROM ser", + -1, NULL); fails += run(db, "SELECT forecast(ts, value, 3," - "json_object('model','th')) FROM ser", 1); + "json_object('model','th')) FROM ser", -1, NULL); fails += run(db, "SELECT forecast(ts, value, 3," - "json_object('model','badf')) FROM ser", 0); + "json_object('model','badf')) FROM ser", + -1, "PREDICT_ERR_IO_SPEC"); /* serve the distilled skip students (TiDE + pure linear) */ fails += run(db, "SELECT forecast(ts, value, 3," - "json_object('model','fs_tide')) FROM ser", 1); + "json_object('model','fs_tide')) FROM ser", -1, NULL); fails += run(db, "SELECT forecast(ts, value, 3," "json_object('model','fs_lin','confidence_level',0.8))" - " FROM ser", 1); + " FROM ser", -1, NULL); } } diff --git a/tests/test_backtest.py b/tests/test_backtest.py index ea5a320..71772f3 100644 --- a/tests/test_backtest.py +++ b/tests/test_backtest.py @@ -20,12 +20,14 @@ def rows(db, sql, *params): def bt(db, horizon, **options): return rows(db, "SELECT fold, model, n, mae, rmse, mase, smape, coverage," - " mean_interval_width, status FROM backtest(?, ?, ?)", - FC, horizon, json.dumps(options)) + " mean_interval_width, status FROM backtest_rows(" + "(SELECT backtest(ts, value, ?, ?) FROM series))", + horizon, json.dumps(options)) def avg_mase(db, model, horizon=12, folds=10): - return rows(db, "SELECT avg(mase) FROM backtest(?, ?, ?)", FC, horizon, + return rows(db, "SELECT avg(mase) FROM backtest_rows(" + "(SELECT backtest(ts, value, ?, ?) FROM series))", horizon, json.dumps({"model": model, "folds": folds}))[0][0] @@ -63,8 +65,9 @@ def coverage(conformal): opts = {"folds": 25, "confidence_level": 0.9} if conformal: opts["interval_method"] = "conformal" - return rows(db, "SELECT avg(coverage) FROM backtest(?, 6, ?)", - FC, json.dumps(opts))[0][0] + return rows(db, "SELECT avg(coverage) FROM backtest_rows(" + "(SELECT backtest(ts, value, 6, ?) FROM series))", + json.dumps(opts))[0][0] conf = coverage(True) resid = coverage(False) @@ -98,7 +101,10 @@ def test_backtest_constant_series_is_exact(db): assert len(out) == 5 for _fold, _m, _n, mae, rmse, mase, _sm, cov, width, _st in out: assert mae == 0.0 and rmse == 0.0 - assert mase == 0.0 # naive scale is 0 on a flat series + # MASE = mae / naive_scale is 0/0 on a flat series: the naive scale is 0, + # so MASE is undefined and reported as null (not 0, which would read as a + # perfect model). mae == 0 already carries the "exact forecast" signal. + assert mase is None assert cov == 1.0 # zero-width band on an exact forecast assert width == 0.0 @@ -129,18 +135,81 @@ def test_backtest_grouped_series(db): b, _ = syn.trend_season(n=120, noise=1.0, seed=2) syn.load_into(db, a, group="a") syn.load_into(db, b, group="b") - out = rows(db, "SELECT DISTINCT series_key FROM backtest(" - "'SELECT ts, value, grp FROM series', 6, ?)", - json.dumps({"group_cols": ["grp"], "folds": 5})) + out = rows(db, "SELECT g.grp, r.fold, r.mae FROM" + " (SELECT grp, backtest(ts, value, 6, ?) AS d FROM series" + " GROUP BY grp) g, backtest_rows(g.d) r", + json.dumps({"folds": 5})) assert {r[0] for r in out} == {"a", "b"} + assert len(out) == 10 # 2 groups x 5 folds; catches truncated grouped output + # each group produced real folds with populated metrics, not a synthesized + # blank row from an empty $.rows + for grp in ("a", "b"): + maes = [r[2] for r in out if r[0] == grp] + assert len(maes) == 5 and all(m is not None for m in maes) def test_backtest_rejects_unknown_model(db): series, _ = syn.trend_season(n=100, noise=1.0, seed=1) syn.load_into(db, series) - with pytest.raises(sqlite3.OperationalError): - rows(db, "SELECT * FROM backtest(?, 6, ?)", FC, + with pytest.raises(sqlite3.OperationalError) as e: + rows(db, "SELECT * FROM backtest_rows(" + "(SELECT backtest(ts, value, 6, ?) FROM series))", json.dumps({"model": "no-such-model"})) + assert "PREDICT_ERR_MODEL_NOT_FOUND" in str(e.value) + + +def test_backtest_rejects_unknown_option(db): + series, _ = syn.trend_season(n=80, noise=1.0, seed=3) + syn.load_into(db, series) + with pytest.raises(sqlite3.OperationalError) as e: + rows(db, "SELECT * FROM backtest_rows(" + "(SELECT backtest(ts, value, 6, ?) FROM series))", + json.dumps({"no_such_option": 1})) + assert "PREDICT_ERR_OPTIONS" in str(e.value) + + +def test_backtest_query_string_misuse_fails_loud(db): + """backtest is an aggregate over rows, not a TVF over a query string; the + old-TVF-syntax misuse must self-correct rather than silently do nothing.""" + series, _ = syn.trend_season(n=60, noise=1.0, seed=4) + syn.load_into(db, series) + with pytest.raises(sqlite3.OperationalError) as e: + db.execute( + "SELECT backtest('SELECT ts, value FROM series', 6)").fetchall() + assert "PREDICT_ERR_SCHEMA" in str(e.value) + + +@pytest.mark.parametrize( + "doc", ["not a document", "42", '{"rows": 5}', '["a", "b"]']) +def test_backtest_rows_rejects_hostile_document(db, doc): + """backtest_rows() takes a backtest() result document; a non-conforming + document is rejected cleanly (PREDICT_ERR_SCHEMA), never a crash.""" + with pytest.raises(sqlite3.OperationalError) as e: + db.execute("SELECT * FROM backtest_rows(?)", (doc,)).fetchall() + assert "PREDICT_ERR_SCHEMA" in str(e.value) + + +def test_backtest_rows_null_document_is_empty(db): + """NULL in, empty out: intentional and consistent with forecast_rows and + SQLite's own json_each(NULL), which also yield zero rows. This is not a + swallowed failure. A failed backtest() raises (the error propagates through + the subquery); NULL only arises from an empty series, i.e. genuinely nothing + to expand. A hostile *non-NULL* document still fails loud (see + test_backtest_rows_rejects_hostile_document).""" + assert db.execute("SELECT * FROM backtest_rows(NULL)").fetchall() == [] + + +def test_backtest_rejects_candidates_option(db): + """backtest()'s auto path searches only the bundled TS models, so it must not + silently accept a candidates list it would ignore. The key is rejected as + unknown (PREDICT_ERR_OPTIONS) rather than parsed and dropped.""" + series, _ = syn.trend_season(n=80, noise=1.0, seed=5) + syn.load_into(db, series) + with pytest.raises(sqlite3.OperationalError) as e: + rows(db, "SELECT * FROM backtest_rows(" + "(SELECT backtest(ts, value, 6, ?) FROM series))", + json.dumps({"candidates": ["theta-classic", "tsb"]})) + assert "PREDICT_ERR_OPTIONS" in str(e.value) # ------------------------------------------- tsb (intermittent) via backtest diff --git a/tests/test_backtest_errors.py b/tests/test_backtest_errors.py deleted file mode 100644 index d4697b3..0000000 --- a/tests/test_backtest_errors.py +++ /dev/null @@ -1,87 +0,0 @@ -"""Error paths of the backtest() TVF's series collection. - -With forecast() and detect_anomalies() now aggregates over the caller's -own rows, the shared collect_series() helper has one time-series -consumer left: the backtest() TVF. Every failure branch is exercised -through it. The point -is not just that the right error code comes back, but that the error -paths (which free partially-built series arrays and resolved-name -strings) are clean under ASan/valgrind — run via `make test-asan` / -`make test-valgrind`, whose C soak driver hits these same branches in a -loop. -""" - -import sqlite3 -import pytest -import synthetic as syn - -# (label, query, options) -> expected error code. -CASES = [ - ("unparseable_query", "NOT SQL AT ALL", None, "PREDICT_ERR_SCHEMA"), - ("multi_statement", "SELECT ts, value FROM series; SELECT 1", None, - "PREDICT_ERR_SCHEMA"), - ("write_query", "DELETE FROM series", None, - "PREDICT_ERR_QUERY_NOT_READONLY"), - ("single_column", "SELECT ts FROM series", None, "PREDICT_ERR_SCHEMA"), - ("bad_time_col", "SELECT ts, value FROM series", '{"time_col":"nope"}', - "PREDICT_ERR_SCHEMA"), - ("bad_value_col", "SELECT ts, value FROM series", '{"value_col":"nope"}', - "PREDICT_ERR_SCHEMA"), - ("bad_group_col", "SELECT ts, value FROM series", - '{"group_cols":["nope"]}', "PREDICT_ERR_SCHEMA"), - ("uninferable", "SELECT 'x' AS a, 'y' AS b FROM series", None, - "PREDICT_ERR_SCHEMA"), -] - - -def _call(db, query, opts): - sql = ("SELECT * FROM backtest(?, 3, ?)" if opts - else "SELECT * FROM backtest(?, 3)") - args = (query, opts) if opts else (query,) - return db.execute(sql, args).fetchall() - - -@pytest.fixture -def seeded(db): - rows, _ = syn.trend_season(n=60, seed=71) - syn.load_into(db, rows) - return db - - -@pytest.mark.parametrize("label,query,opts,code", - CASES, ids=[c[0] for c in CASES]) -def test_collect_error_paths(seeded, label, query, opts, code): - with pytest.raises(sqlite3.OperationalError) as e: - _call(seeded, query, opts) - assert code in str(e.value), f"{label}: {e.value}" - - -def test_multi_group_collection(db): - """collect_series splits groups via group_cols. (The partial-series - free path — n_series > 0 at error time — is only reachable via the - 2M-row cap and NOMEM, so it is covered by the valgrind soak, not - here.)""" - a, _ = syn.trend_season(n=60, level=10.0, seed=72) - b, _ = syn.trend_season(n=60, level=90.0, seed=73) - syn.load_into(db, a, group="a") - syn.load_into(db, b, group="b") - out = _call(db, "SELECT ts, value, grp FROM series", - '{"group_cols":["grp"]}') - assert {r[0] for r in out} == {"a", "b"} - - -def test_column_inference_with_overrides(seeded): - """collect_series must resolve the named columns out of a messy - query (a text time col, an int distractor, a float value).""" - seeded.execute("CREATE TABLE m(label TEXT, n INTEGER, ts TEXT, v REAL)") - seeded.executemany( - "INSERT INTO m VALUES ('x', ?, ?, ?)", - [(i, f"2026-01-{1 + i // 24:02d}T{i % 24:02d}:00:00Z", float(i)) - for i in range(60)], - ) - out = _call(seeded, "SELECT label, n, ts, v FROM m", - '{"time_col":"ts","value_col":"v"}') - assert len(out) >= 1 - statuses = {r[-1] for r in out} - assert statuses <= {"ok", "truncated", "insufficient_history", - "non_numeric"} diff --git a/tests/test_distill.py b/tests/test_distill.py index 808d355..fb0ad6e 100644 --- a/tests/test_distill.py +++ b/tests/test_distill.py @@ -5,6 +5,8 @@ inline-BLOB student that runs in the zero-dependency core. """ +import hashlib +import json import sqlite3 import pytest @@ -16,6 +18,21 @@ def _train_table(db, n=240, seed=3): syt.load_tabular(db, X, y) # table tab(id, f1, f2, label) +def _write_bad_row(db, blob, content_hash=None): + """Write a raw student row into _predict_models as model 'bad'. The registry + content-addresses inline weights: it verifies content_hash BEFORE the + bounds-checked deserializer runs. content_hash defaults to the real sha256 of + the blob so that gate passes and the deserializer is what must reject the + malformed blob; pass a wrong hash to test the content-address check itself.""" + if content_hash is None: + content_hash = hashlib.sha256(blob).hexdigest() + db.execute("DELETE FROM _predict_models WHERE model_id='bad'") + db.execute( + "INSERT INTO _predict_models (model_id, kind, runtime, weights," + " content_hash, license) VALUES ('bad','student','tree',?,?," + "'unspecified')", (blob, content_hash)) + + def test_distill_produces_a_student(db): _train_table(db) r = db.execute( @@ -40,7 +57,7 @@ def test_student_predicts(db): " json_object('target','label','student_id','s1'))").fetchone() # the student runs with no train_query rows = db.execute( - "SELECT row_ref, prediction, confidence, status FROM predict(NULL," + "SELECT row_ref, prediction, confidence, status FROM predict_batch(NULL," " 'SELECT id, f1, f2 FROM tab', json_object('model','s1'))").fetchall() assert len(rows) == 240 labels = {str(r[0]) for r in db.execute("SELECT DISTINCT label FROM tab")} @@ -54,8 +71,9 @@ def test_student_is_deterministic(db): _train_table(db) db.execute("SELECT * FROM distill_predict('SELECT f1, f2, label FROM tab'," " json_object('target','label','student_id','s1'))").fetchone() - q = ("SELECT row_ref, prediction FROM predict(NULL," - " 'SELECT id, f1, f2 FROM tab', json_object('model','s1'))") + q = ("SELECT row_ref, prediction FROM predict_batch(NULL," + " 'SELECT id, f1, f2 FROM tab', json_object('model','s1'))" + " ORDER BY row_ref") # compare a defined order, not the planner's assert db.execute(q).fetchall() == db.execute(q).fetchall() @@ -98,7 +116,7 @@ def test_regress_distill(db): ).fetchone()[0] assert kind == "tree" out = db.execute( - "SELECT prediction, status FROM predict(NULL,'SELECT id, f1, f2 FROM r'," + "SELECT prediction, status FROM predict_batch(NULL,'SELECT id, f1, f2 FROM r'," " json_object('model','rs'))").fetchall() assert all(s == "ok" and float(p) == float(p) for p, s in out) @@ -119,6 +137,62 @@ def test_missing_student_id_errors(db): assert "PREDICT_ERR_OPTIONS" in str(e.value) +def test_distill_rejects_too_many_classes(db): + """distill_predict must cap the class vocabulary at PREDICT0_MAX_CLASS (2048) + before fitting, the same limit the deserializer enforces on load, so it + never registers a student that cannot be reloaded.""" + db.execute("CREATE TABLE many(f1 real, f2 real, c integer)") + db.execute( + "INSERT INTO many(f1, f2, c)" + " WITH RECURSIVE s(i) AS (SELECT 0 UNION ALL SELECT i+1 FROM s WHERE i < 2048)" + " SELECT i * 1.0, i * 2.0, i FROM s") # 2049 distinct classes + with pytest.raises(sqlite3.OperationalError) as e: + db.execute( + "SELECT model_id FROM distill_predict('SELECT f1, f2, c FROM many'," + " json_object('target','c','task','classify','student_id','big'))" + ).fetchall() + assert "PREDICT_ERR_SCHEMA" in str(e.value) + # a rejected fit must leave no partial registration behind + assert db.execute("SELECT COUNT(*) FROM _predict_models" + " WHERE model_id='big'").fetchone()[0] == 0 + + +def test_distill_accepts_exactly_max_classes(db): + """Exactly PREDICT0_MAX_CLASS (2048) distinct classes must distill and + register, so an off-by-one in the cap (>= vs >) that rejected 2048 is caught. + A tree student keeps a 2048-way classifier cheap.""" + db.execute("CREATE TABLE max2048(f1 real, f2 real, c integer)") + db.execute( + "INSERT INTO max2048(f1, f2, c)" + " WITH RECURSIVE s(i) AS (SELECT 0 UNION ALL SELECT i+1 FROM s WHERE i < 2047)" + " SELECT i * 1.0, i * 0.5, i FROM s") # exactly 2048 distinct classes + mid = db.execute( + "SELECT model_id FROM distill_predict('SELECT f1, f2, c FROM max2048'," + " json_object('target','c','task','classify','student_kind','tree'," + "'student_id','ok2048'))").fetchone()[0] + assert mid == "ok2048" + + +def test_distill_rejects_too_many_soft_classes(db): + """The soft-label path caps the class vocabulary at PREDICT0_MAX_CLASS too. + proba and classes are the SAME oversized length (2049), so the only reason to + fail is the vocabulary cap, not a proba/classes length mismatch: the array is + rejected while parsing the options (PREDICT_ERR_SCHEMA), before any column + validation, and registers no partial student.""" + _soft_table(db) + over = 2049 + opts = json.dumps({"target": "label", + "proba": [f"p{i}" for i in range(over)], + "classes": [str(i) for i in range(over)], + "student_kind": "mlp", "student_id": "bigsoft"}) + with pytest.raises(sqlite3.OperationalError) as e: + db.execute("SELECT model_id FROM distill_predict(" + "'SELECT f1,f2,pA,pB,pC,label FROM st', ?)", (opts,)).fetchall() + assert "PREDICT_ERR_SCHEMA" in str(e.value) + assert db.execute("SELECT COUNT(*) FROM _predict_models" + " WHERE model_id='bigsoft'").fetchone()[0] == 0 + + def test_bad_task_errors(db): _train_table(db) with pytest.raises(sqlite3.OperationalError) as e: @@ -141,7 +215,7 @@ def test_mlp_student(db): " model_id='m'").fetchone() assert row[0] == "tree" and row[1] > 0 # inline student runtime labels = {str(x[0]) for x in db.execute("SELECT DISTINCT label FROM tab")} - q = ("SELECT row_ref, prediction FROM predict(NULL,'SELECT id,f1,f2 FROM" + q = ("SELECT row_ref, prediction FROM predict_batch(NULL,'SELECT id,f1,f2 FROM" " tab', json_object('model','m'))") rows = db.execute(q).fetchall() assert len(rows) == 240 and {p for _, p in rows} <= labels @@ -176,15 +250,11 @@ def test_corrupt_mlp_blob_rejected(db): db.execute("CREATE TABLE a(id INTEGER, f1 REAL, f2 REAL)") db.execute("INSERT INTO a VALUES (0, 0.1, 0.2)") for blob in (b"PSMLP01\x00" + b"\xff" * 40, b"PSMLP01\x00" + b"\x00" * 20): - db.execute("DELETE FROM _predict_models WHERE model_id='bad'") - db.execute( - "INSERT INTO _predict_models (model_id, kind, runtime, weights," - " content_hash, license) VALUES ('bad','student','tree',?,'x'," - "'unspecified')", (blob,)) + _write_bad_row(db, blob) # correct hash: the deserializer must reject it with pytest.raises(sqlite3.OperationalError) as e: - db.execute("SELECT * FROM predict(NULL,'SELECT id, f1, f2 FROM a'," + db.execute("SELECT * FROM predict_batch(NULL,'SELECT id, f1, f2 FROM a'," " json_object('model','bad'))").fetchall() - assert "PREDICT_ERR" in str(e.value) + assert "PREDICT_ERR_SCHEMA" in str(e.value) def test_too_few_rows_errors(db): @@ -237,7 +307,7 @@ def test_gbt_student_predicts(db): assert runtime == "tree" and blob > 0 db.execute("CREATE TABLE ap(id INTEGER, f1 REAL, f2 REAL)") db.execute("INSERT INTO ap VALUES (0,0.5,0.5),(1,-1.0,-1.0),(2,-1.0,1.0)") - q = ("SELECT row_ref, prediction, confidence, status FROM predict(NULL," + q = ("SELECT row_ref, prediction, confidence, status FROM predict_batch(NULL," " 'SELECT id,f1,f2 FROM ap', json_object('model','g'))") r1 = db.execute(q).fetchall() assert all(s == "ok" and p in ("0", "1", "2") and 0 <= c <= 1 @@ -257,7 +327,7 @@ def test_gbt_regression(db): "'student_kind','gbt'))").fetchone()[0] assert m >= 0 out = db.execute( - "SELECT prediction, status FROM predict(NULL,'SELECT id,f1,f2 FROM r'," + "SELECT prediction, status FROM predict_batch(NULL,'SELECT id,f1,f2 FROM r'," " json_object('model','rg'))").fetchall() assert all(s == "ok" and float(p) == float(p) for p, s in out) @@ -270,15 +340,11 @@ def test_corrupt_gbt_blob_rejected(db): db.execute("CREATE TABLE a(id INTEGER, f1 REAL, f2 REAL)") db.execute("INSERT INTO a VALUES (0, 0.1, 0.2)") for blob in (b"PSGBT01\x00" + b"\xff" * 40, b"PSGBT01\x00" + b"\x00" * 20): - db.execute("DELETE FROM _predict_models WHERE model_id='bad'") - db.execute( - "INSERT INTO _predict_models (model_id, kind, runtime, weights," - " content_hash, license) VALUES ('bad','student','tree',?,'x'," - "'unspecified')", (blob,)) + _write_bad_row(db, blob) # correct hash: the deserializer must reject it with pytest.raises(sqlite3.OperationalError) as e: - db.execute("SELECT * FROM predict(NULL,'SELECT id, f1, f2 FROM a'," + db.execute("SELECT * FROM predict_batch(NULL,'SELECT id, f1, f2 FROM a'," " json_object('model','bad'))").fetchall() - assert "PREDICT_ERR" in str(e.value) + assert "PREDICT_ERR_SCHEMA" in str(e.value) def test_default_trains_on_target_column(db): @@ -293,7 +359,7 @@ def test_default_trains_on_target_column(db): "'student_kind','gbt'))").fetchone()[0] assert 0.7 <= metric <= 1.0 # learnable boundary, trained on the labels rows = db.execute( - "SELECT row_ref, prediction FROM predict(NULL,'SELECT id,f1,f2 FROM" + "SELECT row_ref, prediction FROM predict_batch(NULL,'SELECT id,f1,f2 FROM" " tab', json_object('model','s'))").fetchall() assert len(rows) == 240 @@ -312,13 +378,13 @@ def test_default_learns_target_not_a_live_teacher(db): " json_object('target','teach','student_id','s'," "'student_kind','gbt'))").fetchone() preds = {r[0] for r in db.execute( - "SELECT DISTINCT prediction FROM predict(NULL,'SELECT id,f1,f2 FROM p'," + "SELECT DISTINCT prediction FROM predict_batch(NULL,'SELECT id,f1,f2 FROM p'," " json_object('model','s'))")} assert preds and preds <= {"ALPHA", "OMEGA"} def test_named_teacher_relabels_via_model(db): - """An explicit teacher names a registered predict() model, which distill + """An explicit teacher names a registered predict_batch() model, which distill re-runs over the rows to relabel them before fitting the student -- e.g. compressing the in-context knn5 into a standalone tree.""" _train_table(db) @@ -329,8 +395,9 @@ def test_named_teacher_relabels_via_model(db): assert 0.5 <= metric <= 1.0 labels = {str(x[0]) for x in db.execute("SELECT DISTINCT label FROM tab")} preds = {x[0] for x in db.execute( - "SELECT DISTINCT prediction FROM predict(NULL,'SELECT id,f1,f2 FROM tab'," + "SELECT DISTINCT prediction FROM predict_batch(NULL,'SELECT id,f1,f2 FROM tab'," " json_object('model','s'))")} + assert preds # non-empty: an empty set trivially satisfies the subset check assert preds <= labels @@ -371,7 +438,7 @@ def test_soft_distillation_produces(db): " WHERE model_id='s'").fetchone() assert row[0] == "tree" and row[1] > 0 out = db.execute( - "SELECT row_ref, prediction FROM predict(NULL,'SELECT id,f1,f2 FROM st'," + "SELECT row_ref, prediction FROM predict_batch(NULL,'SELECT id,f1,f2 FROM st'," " json_object('model','s'))").fetchall() assert len(out) == 360 and {p for _, p in out} <= {"A", "B", "C"} @@ -427,15 +494,34 @@ def test_corrupt_student_blob_errors_not_crashes(db): " json_object('target','label','student_id','good'))").fetchone() for blob in (b"", b"garbage", b"PSTREE01" + b"\xff" * 40, b"PSTREE01" + b"\x00" * 4): - db.execute("DELETE FROM _predict_models WHERE model_id='bad'") - db.execute( - "INSERT INTO _predict_models (model_id, kind, runtime, weights," - " content_hash, license) VALUES ('bad','student','tree',?," - " 'x','unspecified')", (blob,)) + _write_bad_row(db, blob) # correct hash: the deserializer must reject it with pytest.raises(sqlite3.OperationalError) as e: - db.execute("SELECT * FROM predict(NULL,'SELECT id, f1, f2 FROM a'," + db.execute("SELECT * FROM predict_batch(NULL,'SELECT id, f1, f2 FROM a'," " json_object('model','bad'))").fetchall() - assert "PREDICT_ERR" in str(e.value) + assert "PREDICT_ERR_SCHEMA" in str(e.value) + + +def test_corrupt_student_blob_hash_mismatch_rejected(db): + """The other gate: a row whose inline weights do NOT match its content_hash + is rejected at the content-address check (PREDICT_ERR_MODEL_HASH), before the + deserializer runs. A well-formed student blob under a wrong hash proves the + check fires on content integrity, not on malformed structure.""" + db.execute("CREATE TABLE a(id INTEGER, f1 REAL, f2 REAL)") + db.execute("INSERT INTO a VALUES (0, 0.1, 0.2)") + _train_table(db) + db.execute("SELECT * FROM distill_predict('SELECT f1,f2,label FROM tab'," + " json_object('target','label','student_id','good'))").fetchone() + good_blob = db.execute("SELECT weights FROM _predict_models WHERE" + " model_id='good'").fetchone()[0] + # a valid blob under a well-formed but wrong content_hash (flip one hex nibble + # of the real digest), so the check fails on content, not on hash format/length + actual = hashlib.sha256(good_blob).hexdigest() + wrong_hash = ("0" if actual[0] != "0" else "1") + actual[1:] + _write_bad_row(db, good_blob, content_hash=wrong_hash) + with pytest.raises(sqlite3.OperationalError) as e: + db.execute("SELECT * FROM predict_batch(NULL,'SELECT id, f1, f2 FROM a'," + " json_object('model','bad'))").fetchall() + assert "PREDICT_ERR_MODEL_HASH" in str(e.value) def test_student_rejects_train_query(db): @@ -446,7 +532,8 @@ def test_student_rejects_train_query(db): [(i * 0.3, (i * 7) % 5, "c%d" % (i % 2)) for i in range(60)]) db.execute("SELECT * FROM distill_predict('SELECT f1, f2, label FROM tt'," " '{\"target\":\"label\",\"student_id\":\"guard-s\"}')").fetchone() - with pytest.raises(sqlite3.OperationalError, match="no train_query"): - db.execute("SELECT * FROM predict('SELECT f1, f2, label FROM tt'," + with pytest.raises(sqlite3.OperationalError) as e: + db.execute("SELECT * FROM predict_batch('SELECT f1, f2, label FROM tt'," " 'SELECT rowid, f1, f2 FROM tt'," " '{\"model\":\"guard-s\"}')").fetchall() + assert "PREDICT_ERR_OPTIONS" in str(e.value) diff --git a/tests/test_errors.py b/tests/test_errors.py index 3ce32d1..77e9baf 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -123,55 +123,3 @@ def test_two_arg_query_call_redirects(db): "SELECT forecast('SELECT ts, value FROM series', 6)").fetchall() msg = str(e.value) assert "PREDICT_ERR_SCHEMA" in msg and "aggregate over" in msg - - -# ---- query-string validation, now on the backtest() TVF ---- - -BT = "SELECT * FROM backtest(?, 3, ?)" - - -def test_write_query_rejected(seeded): - expect_error(seeded, "PREDICT_ERR_QUERY_NOT_READONLY", BT, - ("DELETE FROM series", None)) - - -def test_multi_statement_rejected(seeded): - expect_error(seeded, "PREDICT_ERR_SCHEMA", BT, - ("SELECT ts, value FROM series; SELECT 1", None)) - - -def test_unparseable_query(seeded): - expect_error(seeded, "PREDICT_ERR_SCHEMA", BT, ("NOT EVEN SQL", None)) - - -def test_single_column_query(seeded): - expect_error(seeded, "PREDICT_ERR_SCHEMA", BT, - ("SELECT ts FROM series", None)) - - -def test_missing_named_columns(seeded): - expect_error(seeded, "PREDICT_ERR_SCHEMA", BT, - ("SELECT ts, value FROM series", '{"time_col": "nope"}')) - expect_error(seeded, "PREDICT_ERR_SCHEMA", BT, - ("SELECT ts, value FROM series", '{"value_col": "nope"}')) - expect_error(seeded, "PREDICT_ERR_SCHEMA", BT, - ("SELECT ts, value FROM series", '{"group_cols": ["nope"]}')) - - -# ---- data-shaped problems degrade, they don't raise ---- - -def test_non_numeric_value_is_status_not_error(db): - db.execute("CREATE TABLE t(ts TEXT, v TEXT)") - db.executemany( - "INSERT INTO t VALUES (?, ?)", - [(f"2026-01-01T{i:02d}:00:00Z", f"word{i}") for i in range(20)], - ) - (raw,) = db.execute("SELECT forecast(ts, v, 3) FROM t").fetchone() - doc = json.loads(raw) - assert doc["status"] == "non_numeric" - assert doc["rows"] == [] - - -def test_forecast_requires_arguments(db): - with pytest.raises(sqlite3.OperationalError): - db.execute("SELECT forecast()").fetchall() diff --git a/tests/test_fit.py b/tests/test_fit.py new file mode 100644 index 0000000..029c4f9 --- /dev/null +++ b/tests/test_fit.py @@ -0,0 +1,255 @@ +"""fit() aggregate + predict() scalar: the scikit-learn shape. + +The batched / in-context TVF path is predict_batch() and is covered in +test_predict.py. Here we cover the NEW formats: training with the fit() +aggregate (register or blob) and serving with the predict() scalar. +""" +import json +import sqlite3 + +import pytest + + +def _seed(db, n=120): + db.execute("CREATE TABLE h(region TEXT, tenure REAL, spend REAL," + " churned INTEGER)") + db.executemany( + "INSERT INTO h VALUES (?, ?, ?, ?)", + [("us" if k % 2 else "eu", (k % 20) + 1, ((k % 20) + 1) * 10 + (k % 3), + 1 if (k % 20) + 1 < 8 else 0) for k in range(n)]) + db.execute("CREATE TABLE a(id INTEGER, tenure REAL, spend REAL)") + db.executemany("INSERT INTO a VALUES (?, ?, ?)", + [(1, 2, 20), (2, 5, 55), (3, 15, 150), (4, 25, 250)]) + + +def test_fit_register_then_scalar_predict(db): + """fit() over the rows registers a model; predict() serves it per row, + composing like any scalar. Low tenure churns, high does not.""" + _seed(db) + mid = db.execute( + "SELECT fit(tenure, spend, churned," + " '{\"kind\":\"gbt\",\"register\":\"churn-v1\"}') FROM h").fetchone()[0] + assert mid == "churn-v1" + preds = [r[1] for r in db.execute( + "SELECT id, predict('churn-v1', tenure, spend) FROM a ORDER BY id")] + assert preds == ["1", "1", "0", "0"] + + +def test_fit_blob_served_via_cte(db): + """No registration: fit() returns a model blob, served in one statement.""" + _seed(db) + preds = [r[1] for r in db.execute( + "WITH m(model) AS (SELECT fit(tenure, spend, churned) FROM h)" + " SELECT a.id, predict((SELECT model FROM m), a.tenure, a.spend)" + " FROM a ORDER BY a.id")] + assert preds == ["1", "1", "0", "0"] + + +def test_fit_per_segment_via_group_by(db): + """A model per segment falls out of GROUP BY; each is a blob.""" + _seed(db) + out = db.execute("SELECT region, typeof(fit(tenure, spend, churned))" + " FROM h GROUP BY region").fetchall() + assert {r[0] for r in out} == {"us", "eu"} + assert all(r[1] == "blob" for r in out) + + +def test_fit_tree_kind(db): + _seed(db) + mid = db.execute( + "SELECT fit(tenure, spend, churned," + " '{\"kind\":\"tree\",\"register\":\"t\"}') FROM h").fetchone()[0] + assert mid == "t" + assert db.execute("SELECT predict('t', 2, 20)").fetchone()[0] == "1" + + +def test_predict_proba_returns_confidence(db): + _seed(db) + db.execute("SELECT fit(tenure, spend, churned, '{\"register\":\"p\"}')" + " FROM h").fetchone() + doc = json.loads(db.execute( + "SELECT predict('p', tenure, spend, '{\"proba\":true}')" + " FROM a LIMIT 1").fetchone()[0]) + assert set(doc) >= {"prediction", "confidence"} + assert 0.0 <= doc["confidence"] <= 1.0 + + +def test_predict_proba_rejects_wrong_type(db): + """A non-numeric proba is a typo, not false: it must fail loudly + (PREDICT_ERR_OPTIONS), not be silently coerced to 0 and dropped.""" + _seed(db) + db.execute("SELECT fit(tenure, spend, churned, '{\"register\":\"pt\"}')" + " FROM h").fetchone() + with pytest.raises(sqlite3.OperationalError) as e: + db.execute("SELECT predict('pt', tenure, spend, '{\"proba\":\"yes\"}')" + " FROM a").fetchall() + assert "PREDICT_ERR_OPTIONS" in str(e.value) + + +def test_predict_wrong_arity_fails_loud(db): + """Positional features must match the model's feature count.""" + _seed(db) + db.execute("SELECT fit(tenure, spend, churned, '{\"register\":\"q\"}')" + " FROM h").fetchone() + with pytest.raises(sqlite3.OperationalError) as e: + db.execute("SELECT predict('q', tenure) FROM a").fetchall() + assert "PREDICT_ERR_SCHEMA" in str(e.value) + + +def test_predict_unknown_model_fails_loud(db): + _seed(db) + with pytest.raises(sqlite3.OperationalError) as e: + db.execute("SELECT predict('nope', tenure, spend) FROM a").fetchall() + assert "PREDICT_ERR_MODEL_NOT_FOUND" in str(e.value) + + +def test_fit_needs_features_and_a_label(db): + """fit() with a single argument has no features to learn from.""" + _seed(db) + with pytest.raises(sqlite3.OperationalError) as e: + db.execute("SELECT fit(tenure) FROM h").fetchall() + assert "PREDICT_ERR_SCHEMA" in str(e.value) + + +def test_fit_rejects_non_finite_feature(db): + """A feature value of +/-Inf (e.g. 1e999) or NaN arrives as SQLITE_FLOAT and + passes the numeric-type check; it must still fail loudly (PREDICT_ERR_SCHEMA) + rather than poison the model with a non-finite value.""" + db.execute("CREATE TABLE nf(f1 REAL, f2 REAL, c INTEGER)") + db.executemany( + "INSERT INTO nf VALUES (?, ?, ?)", + [((float("inf") if i == 5 else i * 1.0), (i % 3) * 1.0, i % 2) + for i in range(20)]) + with pytest.raises(sqlite3.OperationalError) as e: + db.execute("SELECT fit(f1, f2, c) FROM nf").fetchall() + assert "PREDICT_ERR_SCHEMA" in str(e.value) + + +def test_predict_rejects_non_finite_feature(db): + """Symmetry with fit(): a non-finite feature at serve time fails loudly + (PREDICT_ERR_SCHEMA), not scored on a value that could never be trained on.""" + _seed(db) + db.execute("SELECT fit(tenure, spend, churned, '{\"register\":\"pf\"}')" + " FROM h").fetchone() + with pytest.raises(sqlite3.OperationalError) as e: + db.execute("SELECT predict('pf', ?, ?)", (float("inf"), 20.0)).fetchall() + assert "PREDICT_ERR_SCHEMA" in str(e.value) + + +def test_fit_json_label_with_unknown_keys_fails_loud(db): + """The trailing '{...}' argument is always the options object (a class label + must not be a JSON-object string). A JSON label with unknown keys therefore + fails loudly as a bad option (PREDICT_ERR_OPTIONS), not a silent mis-split.""" + db.execute("CREATE TABLE jl(f1 REAL, f2 REAL, lbl TEXT)") + db.executemany("INSERT INTO jl VALUES (?, ?, ?)", + [(i * 1.0, (i % 3) * 1.0, '{"category":"A"}') for i in range(20)]) + with pytest.raises(sqlite3.OperationalError) as e: + db.execute("SELECT fit(f1, f2, lbl) FROM jl").fetchall() + assert "PREDICT_ERR_OPTIONS" in str(e.value) + + +def test_fit_rejects_null_classify_label(db): + """A NULL class label is not a real class: fit must fail loudly + (PREDICT_ERR_TARGET) rather than silently train an empty-string class.""" + db.execute("CREATE TABLE n(f1 REAL, f2 REAL, label TEXT)") + db.executemany("INSERT INTO n VALUES (?, ?, ?)", + [(1.0, 2.0, "a"), (3.0, 4.0, None), (5.0, 6.0, "b")]) + with pytest.raises(sqlite3.OperationalError) as e: + db.execute("SELECT fit(f1, f2, label, '{\"task\":\"classify\"}')" + " FROM n").fetchall() + assert "PREDICT_ERR_TARGET" in str(e.value) + + +def test_fit_first_row_config_error_surfaces_its_real_code(db): + """A configuration error is detected on the first row, before the context is + marked configured. fit_final must surface that real diagnostic, not mask it + as the generic 'no training rows' (PREDICT_ERR_SCHEMA). A bad task yields + PREDICT_ERR_TASK; an unknown option yields PREDICT_ERR_OPTIONS.""" + _seed(db) + with pytest.raises(sqlite3.OperationalError) as e: + db.execute("SELECT fit(tenure, spend, churned, '{\"task\":\"bogus\"}')" + " FROM h").fetchall() + assert "PREDICT_ERR_TASK" in str(e.value) + with pytest.raises(sqlite3.OperationalError) as e: + db.execute("SELECT fit(tenure, spend, churned, '{\"nope\":1}')" + " FROM h").fetchall() + assert "PREDICT_ERR_OPTIONS" in str(e.value) + + +def test_predict_rejects_corrupt_blob(db): + """A fit() blob is untrusted input to predict(). Corrupting its declared + dimensions must fail cleanly (PREDICT_ERR_SCHEMA), never crash or read out + of bounds. The gbt header is magic[8] then task, nfeat, nclass, n_score, + n_rounds (u32 each); we stomp each dimension word with a huge value.""" + _seed(db) + blob = db.execute("SELECT fit(tenure, spend, churned) FROM h").fetchone()[0] + assert isinstance(blob, (bytes, bytearray)) and len(blob) > 28 + for off in (12, 16, 20, 24): # nfeat, nclass, n_score, n_rounds + bad = bytearray(blob) + bad[off:off + 4] = (0x7FFFFFFF).to_bytes(4, "little") + with pytest.raises(sqlite3.OperationalError) as e: + db.execute("SELECT predict(?, 2, 20)", (bytes(bad),)).fetchall() + assert "PREDICT_ERR_SCHEMA" in str(e.value) + + +def test_predict_rejects_nonfinite_float_in_blob(db): + """A crafted blob can encode NaN/Inf floats. Corrupt exactly one documented + float field (the first tree node's threshold), leaving every framing and + integrity field intact, so the only reason predict() can fail is rd_f32() + rejecting the non-finite value. Tree blob layout (predict-student.c): + magic 'PSTREE01' (8), task/nfeat/nclass/n_nodes (u32 x4), then length-prefixed + feat_names and labels, then nodes: feature (u32), threshold (f32), ...""" + import math + import struct + + _seed(db) + blob = db.execute( + "SELECT fit(tenure, spend, churned, '{\"kind\":\"tree\"}') FROM h" + ).fetchone()[0] + assert blob[:8] == b"PSTREE01" + p = 8 + _task, nfeat, nclass, n_nodes = struct.unpack_from("= 1 + p += 4 # skip node[0].feature (u32); p now points at node[0].threshold (f32) + assert math.isfinite(struct.unpack_from("= vs >) would be caught. tree kind keeps a 2048-way + classifier cheap to fit.""" + db.execute("CREATE TABLE max2048(f1 real, f2 real, c integer)") + db.execute( + "INSERT INTO max2048(f1, f2, c)" + " WITH RECURSIVE s(i) AS (SELECT 0 UNION ALL SELECT i+1 FROM s WHERE i < 2047)" + " SELECT i * 1.0, i * 0.5, i FROM s") # exactly 2048 distinct classes + model = db.execute( + "SELECT fit(f1, f2, c," + " '{\"task\":\"classify\",\"kind\":\"tree\",\"register\":\"cap2048\"}')" + " FROM max2048").fetchone()[0] + assert model == "cap2048" # trained + registered, cap not tripped at exactly 2048 diff --git a/tests/test_forecast_student.py b/tests/test_forecast_student.py index da6a10b..e1423c4 100644 --- a/tests/test_forecast_student.py +++ b/tests/test_forecast_student.py @@ -130,9 +130,12 @@ def test_predict_rejects_a_forecast_student(db): _train(db) db.execute("CREATE TABLE q(id INTEGER, f0 REAL)") db.execute("INSERT INTO q VALUES (1, 0.5)") - with pytest.raises(sqlite3.OperationalError): - db.execute("SELECT * FROM predict(NULL, 'SELECT id, f0 FROM q'," + with pytest.raises(sqlite3.OperationalError) as e: + db.execute("SELECT * FROM predict_batch(NULL, 'SELECT id, f0 FROM q'," " json_object('model','f'))").fetchall() + # predict_batch serves tabular students; a forecast student's weights are not + # a tree-student blob, so the deserializer rejects it loudly. + assert "PREDICT_ERR_SCHEMA" in str(e.value) def test_distill_forecast_requires_student_id(db): diff --git a/tests/test_hardening.py b/tests/test_hardening.py index 43625cf..5f83574 100644 --- a/tests/test_hardening.py +++ b/tests/test_hardening.py @@ -18,7 +18,7 @@ def test_too_many_features_errors_loudly(db): db.execute("INSERT INTO t SELECT * FROM t LIMIT 1") with pytest.raises(sqlite3.OperationalError) as e: db.execute( - "SELECT * FROM predict(?, ?, '{\"target\":\"label\"}')", + "SELECT * FROM predict_batch(?, ?, '{\"target\":\"label\"}')", ("SELECT * FROM t", "SELECT 1, * FROM t"), ).fetchall() assert "PREDICT_ERR_SCHEMA" in str(e.value) @@ -26,21 +26,19 @@ def test_too_many_features_errors_loudly(db): def test_long_group_keys_stay_distinct(db): - """Audit bug 2: >512-byte keys truncated and silently merged. The - key builder lives in collect_series, now exercised via backtest.""" + """>512-byte group keys must not be merged. Grouping is plain SQL + GROUP BY over the backtest aggregate now, so long keys stay distinct.""" prefix = "x" * 600 a, _ = syn.trend_season(n=60, level=10.0, seed=61) b, _ = syn.trend_season(n=60, level=90.0, seed=62) syn.load_into(db, a, group=prefix + "A") syn.load_into(db, b, group=prefix + "B") out = db.execute( - "SELECT * FROM backtest(?, 3, ?)", - ("SELECT ts, value, grp FROM series", '{"group_cols":["grp"]}'), + "SELECT g.grp FROM (SELECT grp, backtest(ts, value, 3) AS d" + " FROM series GROUP BY grp) g, backtest_rows(g.d) r GROUP BY g.grp" ).fetchall() keys = {r[0] for r in out} assert len(keys) == 2, "long keys were merged" - for k in keys: - assert sum(1 for r in out if r[0] == k) >= 1 def test_epoch_milliseconds_not_mangled(db): @@ -139,7 +137,7 @@ def batch(n): db.execute( "SELECT detect_anomalies(ts, value) FROM series").fetchall() db.execute( - "SELECT * FROM predict(?, ?, ?)", + "SELECT * FROM predict_batch(?, ?, ?)", ("SELECT f1, f2, label FROM tab WHERE id < 100", "SELECT id, f1, f2 FROM tab WHERE id >= 100", '{"target":"label"}')).fetchall() @@ -175,6 +173,6 @@ def test_duplicate_option_keys_no_leak_predict(db): syt.load_tabular(db, X, y) dup = '{"target":"label","task":"classify","task":"classify"}' out = db.execute( - "SELECT * FROM predict('SELECT f1, f2, label FROM tab WHERE id<100'," + "SELECT * FROM predict_batch('SELECT f1, f2, label FROM tab WHERE id<100'," " 'SELECT id, f1, f2 FROM tab WHERE id>=100', ?)", (dup,)).fetchall() assert len(out) >= 1 diff --git a/tests/test_onnx.py b/tests/test_onnx.py index 23106d7..d482421 100644 --- a/tests/test_onnx.py +++ b/tests/test_onnx.py @@ -96,7 +96,7 @@ def test_bare_path_registration_derives_io_spec(db): assert spec["output"]["kind"] == "probs" assert spec["output"]["labels"] == ["0", "1"] rows = db.execute( - "SELECT * FROM predict(NULL,'SELECT id, f1, f2 FROM apply'," + "SELECT * FROM predict_batch(NULL,'SELECT id, f1, f2 FROM apply'," " '{\"model\":\"clf\"}')").fetchall() exp = {c["id"]: c for c in cases} for rid, pred, *_ in rows: @@ -113,7 +113,7 @@ def test_bare_path_regressor_derives_value_kind(db): db.executemany("INSERT INTO ra VALUES (?,?,?)", [(c["id"], c["f1"], c["f2"]) for c in cases]) rows = db.execute( - "SELECT row_ref, prediction FROM predict(NULL,'SELECT id, f1, f2 FROM" + "SELECT row_ref, prediction FROM predict_batch(NULL,'SELECT id, f1, f2 FROM" " ra', '{\"model\":\"reg\"}')").fetchall() exp = {c["id"]: c["value"] for c in cases} for rid, pred in rows: @@ -139,7 +139,7 @@ def test_incontext_weights_plus_target(db): ).fetchone()[0]) assert spec["layout"] == "in_context" and spec["target"] == "label" rows = db.execute( - "SELECT row_ref, prediction FROM predict('SELECT f1,f2,label FROM tr'," + "SELECT row_ref, prediction FROM predict_batch('SELECT f1,f2,label FROM tr'," " 'SELECT id,f1,f2 FROM ap', '{\"model\":\"knn1\"}')").fetchall() exp = {c["id"]: c["label"] for c in data["apply"]} for rid, pred in rows: @@ -155,7 +155,7 @@ def test_default_license_is_unspecified_and_runs(db): assert lic == "unspecified" db.execute("CREATE TABLE a(id INTEGER, f1 REAL, f2 REAL)") db.execute("INSERT INTO a VALUES (0, 0.5, 0.5)") - rows = db.execute("SELECT * FROM predict(NULL,'SELECT id, f1, f2 FROM a'," + rows = db.execute("SELECT * FROM predict_batch(NULL,'SELECT id, f1, f2 FROM a'," " '{\"model\":\"clf\"}')").fetchall() assert len(rows) == 1 @@ -167,7 +167,7 @@ def test_positional_feature_count_mismatch_errors(db): db.execute("CREATE TABLE a(id INTEGER, f1 REAL, f2 REAL, f3 REAL)") db.execute("INSERT INTO a VALUES (0, 0.1, 0.2, 0.3)") with pytest.raises(sqlite3.OperationalError) as e: - db.execute("SELECT * FROM predict(NULL,'SELECT id, f1, f2, f3 FROM a'," + db.execute("SELECT * FROM predict_batch(NULL,'SELECT id, f1, f2, f3 FROM a'," " '{\"model\":\"clf\"}')").fetchall() assert "PREDICT_ERR_SCHEMA" in str(e.value) @@ -177,7 +177,7 @@ def test_classifier_matches_reference(db): cases = json.load(open(_abs("logreg_cases.json"))) _load_apply(db, cases) rows = db.execute( - "SELECT * FROM predict(NULL, 'SELECT id, f1, f2 FROM apply'," + "SELECT * FROM predict_batch(NULL, 'SELECT id, f1, f2 FROM apply'," " json_object('model','clf'))").fetchall() assert len(rows) == len(cases) exp = {c["id"]: c for c in cases} @@ -194,7 +194,7 @@ def test_regressor_matches_reference(db): cases = json.load(open(_abs("linreg_cases.json"))) _load_apply(db, cases) rows = db.execute( - "SELECT row_ref, prediction, confidence, status FROM predict(" + "SELECT row_ref, prediction, confidence, status FROM predict_batch(" "NULL, 'SELECT id, f1, f2 FROM apply', json_object('model','reg'))" ).fetchall() exp = {c["id"]: c for c in cases} @@ -209,7 +209,7 @@ def test_incontext_matches_reference(db): must match the pure-Python 1-NN reference the fixture was built from.""" data = _load_incontext(db) rows = db.execute( - "SELECT * FROM predict('SELECT f1, f2, label FROM tr'," + "SELECT * FROM predict_batch('SELECT f1, f2, label FROM tr'," " 'SELECT id, f1, f2 FROM ap', json_object('model','knn1'))" ).fetchall() assert len(rows) == len(data["apply"]) @@ -224,7 +224,7 @@ def test_incontext_requires_train_query(db): _load_incontext(db) with pytest.raises(sqlite3.OperationalError) as e: db.execute( - "SELECT * FROM predict(NULL, 'SELECT id, f1, f2 FROM ap'," + "SELECT * FROM predict_batch(NULL, 'SELECT id, f1, f2 FROM ap'," " json_object('model','knn1'))").fetchall() assert "PREDICT_ERR_SCHEMA" in str(e.value) @@ -233,7 +233,7 @@ def test_incontext_train_missing_target_errors(db): _load_incontext(db) with pytest.raises(sqlite3.OperationalError) as e: db.execute( - "SELECT * FROM predict('SELECT f1, f2 FROM tr'," + "SELECT * FROM predict_batch('SELECT f1, f2 FROM tr'," " 'SELECT id, f1, f2 FROM ap', json_object('model','knn1'))" ).fetchall() assert "PREDICT_ERR_SCHEMA" in str(e.value) @@ -244,7 +244,7 @@ def test_incontext_unknown_train_label_errors(db): db.execute("UPDATE tr SET label = 'purple' WHERE id = 0") with pytest.raises(sqlite3.OperationalError) as e: db.execute( - "SELECT * FROM predict('SELECT f1, f2, label FROM tr'," + "SELECT * FROM predict_batch('SELECT f1, f2, label FROM tr'," " 'SELECT id, f1, f2 FROM ap', json_object('model','knn1'))" ).fetchall() assert "PREDICT_ERR_IO_SPEC" in str(e.value) @@ -258,7 +258,7 @@ def test_incontext_nonnumeric_train_feature_errors(db): db.execute("INSERT INTO ap VALUES (0, 0.5, 0.5)") with pytest.raises(sqlite3.OperationalError) as e: db.execute( - "SELECT * FROM predict('SELECT f1, f2, label FROM tr'," + "SELECT * FROM predict_batch('SELECT f1, f2, label FROM tr'," " 'SELECT id, f1, f2 FROM ap', json_object('model','knn1'))" ).fetchall() assert "PREDICT_ERR_SCHEMA" in str(e.value) @@ -271,7 +271,7 @@ def test_incontext_nonnumeric_query_row_flagged(db): db.execute("CREATE TABLE ap(id INTEGER, f1 REAL, f2 ANY)") db.execute("INSERT INTO ap VALUES (0, 1.0, 1.0), (1, 0.0, 'oops')") rows = {r[0]: r for r in db.execute( - "SELECT row_ref, prediction, status FROM predict(" + "SELECT row_ref, prediction, status FROM predict_batch(" " 'SELECT f1, f2, label FROM tr', 'SELECT id, f1, f2 FROM ap'," " json_object('model','knn1'))").fetchall()} assert rows[0][2] == "ok" @@ -288,7 +288,7 @@ def test_incontext_batch_boundary(db): pts = [(i, (i % 9) - 4.0, ((i * 3) % 7) - 3.0) for i in range(n)] db.executemany("INSERT INTO bigq VALUES (?,?,?)", pts) rows = db.execute( - "SELECT row_ref, prediction FROM predict(" + "SELECT row_ref, prediction FROM predict_batch(" " 'SELECT f1, f2, label FROM tr', 'SELECT id, f1, f2 FROM bigq" " ORDER BY id', json_object('model','knn1'))").fetchall() assert len(rows) == n @@ -315,7 +315,7 @@ def test_batch_boundary_many_rows(db): "INSERT INTO big VALUES (?,?,?)", [(i, (i % 7) - 3.0, (i % 5) - 2.0) for i in range(n)]) rows = db.execute( - "SELECT row_ref, prediction FROM predict(NULL," + "SELECT row_ref, prediction FROM predict_batch(NULL," " 'SELECT id, f1, f2 FROM big ORDER BY id'," " json_object('model','clf'))").fetchall() assert len(rows) == n @@ -331,7 +331,7 @@ def test_non_numeric_feature_is_flagged_not_fed(db): db.execute("CREATE TABLE mixed(id INTEGER, f1 REAL, f2 ANY)") db.execute("INSERT INTO mixed VALUES (1, 0.5, 0.5), (2, 0.5, 'oops')") rows = {r[0]: r for r in db.execute( - "SELECT row_ref, prediction, status FROM predict(NULL," + "SELECT row_ref, prediction, status FROM predict_batch(NULL," " 'SELECT id, f1, f2 FROM mixed'," " json_object('model','clf'))").fetchall()} assert rows[1][2] == "ok" @@ -344,12 +344,12 @@ def test_license_gate_blocks_then_accepts(db): _load_apply(db, json.load(open(_abs("logreg_cases.json")))) with pytest.raises(sqlite3.OperationalError) as e: db.execute( - "SELECT * FROM predict(NULL,'SELECT id, f1, f2 FROM apply'," + "SELECT * FROM predict_batch(NULL,'SELECT id, f1, f2 FROM apply'," " json_object('model','nc'))").fetchall() assert "PREDICT_ERR_LICENSE" in str(e.value) # naming the license unlocks it rows = db.execute( - "SELECT * FROM predict(NULL,'SELECT id, f1, f2 FROM apply'," + "SELECT * FROM predict_batch(NULL,'SELECT id, f1, f2 FROM apply'," " json_object('model','nc','accept_license','CC-BY-NC-4.0'))" ).fetchall() assert len(rows) == 24 @@ -360,12 +360,12 @@ def test_unknown_device_and_gpu_fail_loud(db): _load_apply(db, json.load(open(_abs("logreg_cases.json")))) # a bogus device name with pytest.raises(sqlite3.OperationalError) as e: - db.execute("SELECT * FROM predict(NULL,'SELECT id, f1, f2 FROM apply'," + db.execute("SELECT * FROM predict_batch(NULL,'SELECT id, f1, f2 FROM apply'," " json_object('model','clf','device','banana'))").fetchall() assert "PREDICT_ERR_OPTIONS" in str(e.value) # cuda is real but not in this CPU build: no silent fallback with pytest.raises(sqlite3.OperationalError) as e: - db.execute("SELECT * FROM predict(NULL,'SELECT id, f1, f2 FROM apply'," + db.execute("SELECT * FROM predict_batch(NULL,'SELECT id, f1, f2 FROM apply'," " json_object('model','clf','device','cuda'))").fetchall() assert "PREDICT_ERR_RUNTIME_UNAVAILABLE" in str(e.value) @@ -374,7 +374,7 @@ def test_unsupported_precision_fails_loud(db): _register(db, "clf", "logreg.onnx", CLF_IO) _load_apply(db, json.load(open(_abs("logreg_cases.json")))) with pytest.raises(sqlite3.OperationalError) as e: - db.execute("SELECT * FROM predict(NULL,'SELECT id, f1, f2 FROM apply'," + db.execute("SELECT * FROM predict_batch(NULL,'SELECT id, f1, f2 FROM apply'," " json_object('model','clf','precision','fp16'))").fetchall() assert "PREDICT_ERR_RUNTIME_UNAVAILABLE" in str(e.value) @@ -384,7 +384,7 @@ def test_feature_mismatch_errors(db): db.execute("CREATE TABLE w(id INTEGER, f1 REAL, f9 REAL)") db.execute("INSERT INTO w VALUES (1, 0.1, 0.2)") with pytest.raises(sqlite3.OperationalError) as e: - db.execute("SELECT * FROM predict(NULL,'SELECT id, f1, f9 FROM w'," + db.execute("SELECT * FROM predict_batch(NULL,'SELECT id, f1, f9 FROM w'," " json_object('model','clf'))").fetchall() assert "PREDICT_ERR_SCHEMA" in str(e.value) @@ -394,7 +394,7 @@ def test_wrong_feature_count_errors(db): db.execute("CREATE TABLE w(id INTEGER, f1 REAL)") db.execute("INSERT INTO w VALUES (1, 0.1)") with pytest.raises(sqlite3.OperationalError) as e: - db.execute("SELECT * FROM predict(NULL,'SELECT id, f1 FROM w'," + db.execute("SELECT * FROM predict_batch(NULL,'SELECT id, f1 FROM w'," " json_object('model','clf'))").fetchall() assert "PREDICT_ERR_SCHEMA" in str(e.value) @@ -402,7 +402,7 @@ def test_wrong_feature_count_errors(db): def test_unknown_model_errors(db): db.execute("CREATE TABLE a(id INTEGER, f1 REAL, f2 REAL)") with pytest.raises(sqlite3.OperationalError) as e: - db.execute("SELECT * FROM predict(NULL,'SELECT id, f1, f2 FROM a'," + db.execute("SELECT * FROM predict_batch(NULL,'SELECT id, f1, f2 FROM a'," " json_object('model','ghost'))").fetchall() assert "PREDICT_ERR_MODEL_NOT_FOUND" in str(e.value) @@ -415,7 +415,7 @@ def test_onnx_options_rejected_on_stat_model(db): syt.load_tabular(db, X, y) with pytest.raises(sqlite3.OperationalError) as e: db.execute( - "SELECT * FROM predict('SELECT f1, f2, label FROM tab WHERE id<100'," + "SELECT * FROM predict_batch('SELECT f1, f2, label FROM tab WHERE id<100'," " 'SELECT id, f1, f2 FROM tab WHERE id>=100'," " json_object('target','label','device','cpu'))").fetchall() assert "PREDICT_ERR_OPTIONS" in str(e.value) @@ -428,7 +428,7 @@ def test_no_memory_growth_over_many_calls(db): import resource _register(db, "clf", "logreg.onnx", CLF_IO) _load_apply(db, json.load(open(_abs("logreg_cases.json")))) - q = ("SELECT * FROM predict(NULL, 'SELECT id, f1, f2 FROM apply'," + q = ("SELECT * FROM predict_batch(NULL, 'SELECT id, f1, f2 FROM apply'," " json_object('model','clf'))") def batch(n): @@ -448,7 +448,7 @@ def test_session_cache_reuse_is_fast(db): predictions (a coarse check that caching doesn't corrupt state).""" _register(db, "clf", "logreg.onnx", CLF_IO) _load_apply(db, json.load(open(_abs("logreg_cases.json")))) - q = ("SELECT row_ref, prediction FROM predict(NULL," + q = ("SELECT row_ref, prediction FROM predict_batch(NULL," " 'SELECT id, f1, f2 FROM apply', json_object('model','clf'))") first = db.execute(q).fetchall() second = db.execute(q).fetchall() diff --git a/tests/test_predict.py b/tests/test_predict.py index dc3f1b0..4e6e3b1 100644 --- a/tests/test_predict.py +++ b/tests/test_predict.py @@ -6,7 +6,7 @@ def run_predict(db, train_q, apply_q, opts='{"target":"label"}'): return db.execute( - "SELECT * FROM predict(?, ?, ?)", (train_q, apply_q, opts) + "SELECT * FROM predict_batch(?, ?, ?)", (train_q, apply_q, opts) ).fetchall() @@ -81,7 +81,7 @@ def test_errors(db): def expect(code, t, a, opts): with pytest.raises(sqlite3.OperationalError) as e: - db.execute("SELECT * FROM predict(?, ?, ?)", + db.execute("SELECT * FROM predict_batch(?, ?, ?)", (t, a, opts)).fetchall() assert code in str(e.value), str(e.value) diff --git a/tests/test_skills.py b/tests/test_skills.py index 28b615d..f85c62f 100644 --- a/tests/test_skills.py +++ b/tests/test_skills.py @@ -165,10 +165,10 @@ def test_receipt_pins_a_registered_student(receipt_db): assert out["model_id"] == "theta-classic" assert out["content_hash"], "registered models must record their pin" - # rows-shaped results canonicalize too: predict() through a student + # rows-shaped results canonicalize too: predict_batch() through a student rec2 = run_receipt( "record", receipt_db, - "SELECT rowid, prediction FROM predict(NULL," + "SELECT rowid, prediction FROM predict_batch(NULL," " 'SELECT rowid, f1, f2 FROM tab', '{\"model\":\"s1\"}')", "--model-id", "s1") assert rec2.returncode == 0, rec2.stderr @@ -190,9 +190,9 @@ def test_receipt_adversarial_paths(receipt_db): # a backtest projected to one text column: operation-based # canonicalization must hash the {"columns","rows"} form, so the # receipt hash must NOT equal the raw-cell hash - one_cell_sql = ("SELECT model FROM backtest(" - "'SELECT ts, value FROM readings', 4," - " '{\"model\":\"theta-classic\"}') LIMIT 1") + one_cell_sql = ("SELECT model FROM backtest_rows((SELECT backtest(" + "ts, value, 4, '{\"model\":\"theta-classic\"}')" + " FROM readings)) LIMIT 1") rec = run_receipt("record", receipt_db, one_cell_sql, "--model-id", "theta-classic") assert rec.returncode == 0, rec.stderr From 0786b26967483bbe572c7f766f7945fd3bc24ab8 Mon Sep 17 00:00:00 2001 From: mstrathman Date: Thu, 30 Jul 2026 17:48:26 -0700 Subject: [PATCH 2/2] docs: fit/predict/backtest reference, skills, and receipts Document the new surface for a first-time reader: the functions reference (fit/predict/backtest, predict_batch's optional train_query and required apply_query, the JSON-object-label convention), the models and options pages, the sqlite-predict / distill-lifecycle / interpret-backtest skills, README, and the CHANGELOG. Scope the read-only guarantee to the serving functions (registration writes to _predict_models). Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 18 +++ README.md | 62 +++++----- skills/distill-lifecycle/SKILL.md | 14 ++- skills/interpret-backtest/SKILL.md | 14 ++- skills/sqlite-predict/SKILL.md | 38 ++++-- .../src/content/docs/guides/backtesting.md | 25 ++-- .../src/content/docs/guides/distillation.md | 21 ++-- website/src/content/docs/guides/operations.md | 74 ++++++------ website/src/content/docs/index.mdx | 10 +- .../src/content/docs/reference/functions.md | 113 +++++++++++------- website/src/content/docs/reference/models.md | 6 +- website/src/content/docs/reference/options.md | 38 ++++-- 12 files changed, 273 insertions(+), 160 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f9b7a37..3f3c3d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,24 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Changed + +- **The tabular and evaluation surface is now the scikit-learn `fit`/`predict` + shape.** `fit(f1, ..., fN, label [, options])` is a new aggregate that trains a + native student over your rows (the label is the last positional argument). + With the `'{"register":"name"}'` option it registers the trained model and + returns its id; without it, `fit()` returns the student as a serialized blob. + `predict(model, f1, ..., fN [, options])` is now a per-row **scalar** that + serves a student (a registered id or a `fit()` blob) and composes with + `WHERE`, joins, and ORMs. `backtest(ts, value, horizon [, options])` is now an + **aggregate** over your rows like `forecast`/`detect_anomalies`, one JSON + document per group, expanded with the new `backtest_rows()`. The former + `predict(train_query, apply_query)` table-valued function is renamed + **`predict_batch`** (the batched, in-context `knn5-incontext`, and ONNX serving + path); the old query-string `backtest(query, horizon)` TVF is removed. This is + a pre-1.0 break; `distill_predict`/`distill_forecast`, `forecast`, and + `detect_anomalies` are unchanged. + ## [0.1.0] - 2026-07-30 ### Changed diff --git a/README.md b/README.md index 03e957c..6ee19af 100644 --- a/README.md +++ b/README.md @@ -109,8 +109,7 @@ SELECT model_id, holdout_metric FROM distill_predict( '{"target":"churned","student_id":"churn-v1","student_kind":"gbt"}'); -- forever: serve the student per row, in microseconds, no runtime attached -SELECT * FROM predict(NULL, 'SELECT id, tenure, spend FROM customers', - '{"model":"churn-v1"}'); +SELECT id, predict('churn-v1', tenure, spend) FROM customers; -- the same move for time series: distill a Chronos-class teacher's forecasts -- into a DLinear/TiDE student, then serve it through forecast() @@ -159,25 +158,32 @@ skills-capable agent, or read them as the condensed operator's manual. | --- | --- | --- | | `forecast(ts, value, horizon [, options])` | Where is this metric going? | an aggregate over your rows: forecast steps with prediction intervals, one JSON document per group | | `detect_anomalies(ts, value [, options])` | Which points are abnormal? | an aggregate over your rows: per-point anomaly probability and interval, one JSON document per group | -| `predict(train_query, apply_query [, options])` | Classify/regress unseen rows | a prediction and confidence per row, zero-shot from in-context examples | -| `distill_predict(train_query [, options])` | Compress a slow teacher into a fast student | a tiny native model (decision tree or gradient-boosted forest), registered and ready for `predict()` | -| `distill_forecast(train_query [, options])` | Compress a forecast foundation model into a fast student | a native DLinear/TiDE forecast net (a linear skip plus a small residual), registered and ready for `forecast()` | -| `backtest(query, horizon [, options])` | How accurate is the model here? | per-fold accuracy (MAE, RMSE, MASE, sMAPE) and interval coverage from a rolling-origin evaluation | - -One calling convention per operation. `forecast` and `detect_anomalies` are -**aggregates**: your statement supplies the rows, so `WHERE`, joins, bound -parameters, and `GROUP BY` all compose naturally, from the CLI or from an ORM -(Drizzle, SQLAlchemy, Diesel). They are **pure functions**: nothing is ever -written, so they run on read-only databases and inside views. Each group -returns one JSON document; expand it with `forecast_rows()` / -`anomaly_rows()`, or `JSON.parse` it in your app. Each language's +| `backtest(ts, value, horizon [, options])` | How accurate is the model here? | an aggregate over your rows: per-fold MAE, RMSE, MASE, sMAPE and coverage from rolling-origin evaluation | +| `fit(f1, ..., fN, label [, options])` | Train a model on labeled rows | an aggregate over your rows: a native tabular student, registered by id or returned as a blob | +| `predict(model, f1, ..., fN [, options])` | Classify or regress a row | a scalar: one prediction per row from a fitted student | +| `distill_predict(train_query [, options])` | Compress a slow teacher into a fast student | a tiny native model (decision tree, gradient-boosted forest, or MLP), registered and served by `predict` | +| `distill_forecast(train_query [, options])` | Compress a forecast foundation model into a fast student | a native DLinear/TiDE forecast net (a linear skip plus a small residual), registered and served by `forecast` | + +One rule for the whole surface. `forecast`, `detect_anomalies`, `backtest`, and +`fit` are **aggregates**: your statement supplies the rows, so `WHERE`, joins, +bound parameters, and `GROUP BY` all compose naturally, from the CLI or from an +ORM (Drizzle, SQLAlchemy, Diesel). The first three are **pure functions**: +nothing is written, so they run on read-only databases and inside views. Each +returns one JSON document; expand it with `forecast_rows()` / `anomaly_rows()` / +`backtest_rows()`, or `JSON.parse` it in your app. `predict` is a **scalar**, +one prediction per row, so it drops into any `SELECT` beside your other columns. +Each language's [getting-started guide](https://code.purestorage.com/sqlite-predict/getting-started/python/) shows the pattern through its native ORM (SQLAlchemy, Drizzle, Diesel). -`backtest`, `predict`, and the `distill_*` operations take read-only SELECT -queries (training needs labeled or windowed row sets); their results are -ordinary rows you can join, filter, and materialize. Options everywhere are a -trailing JSON object (`'{"confidence_level":0.9}'`). +`distill_predict`, `distill_forecast`, and `predict_batch` take read-only SELECT +queries (training needs labeled or windowed row sets, batched serving re-runs a +query); their results are ordinary rows you can join, filter, and materialize. +`predict_batch(train_query, apply_query, ...)` scores the rows of `apply_query`, +which is required; `train_query` is optional, used only by in-context models, and +is `NULL` when serving a registered model: `predict_batch(NULL, apply_query, +'{"model":"..."}')`. +Options everywhere are a trailing JSON object (`'{"confidence_level":0.9}'`). ### Calibrated intervals, auto-selection, and backtesting @@ -200,8 +206,8 @@ trailing JSON object (`'{"confidence_level":0.9}'`). ```sql -- confirm the conformal band actually covers at the nominal 90% -SELECT avg(coverage) FROM backtest('SELECT ts, value FROM readings', 6, - '{"interval_method":"conformal","confidence_level":0.9,"folds":25}'); +SELECT avg(coverage) FROM backtest_rows((SELECT backtest(ts, value, 6, + '{"interval_method":"conformal","confidence_level":0.9,"folds":25}') FROM readings)); ``` ## Models @@ -219,7 +225,7 @@ well-studied statistical models: subsequence-reconstruction detector (windowed PCA reconstruction error), the method family that leads the TSB-AD-U benchmark; ~2x the residual detector on the typical series there -- `knn5-incontext` (z-scored 5-NN) for `predict()` +- `knn5-incontext` (z-scored 5-NN) for `predict_batch()` Foundation models are treated as *teachers*, not serving paths: in benchmarking they were far too slow to call per query on CPU. The path to @@ -298,8 +304,8 @@ count), so the common case is one line: ```sql SELECT predict_register('churn', '/models/churn.onnx'); -SELECT * FROM predict(NULL, 'SELECT id, tenure, spend FROM customers', - '{"model":"churn"}'); +SELECT * FROM predict_batch(NULL, 'SELECT id, tenure, spend FROM customers', + '{"model":"churn"}'); ``` Feature columns map by position (apply-query order); pass an explicit @@ -384,10 +390,12 @@ build `make loadable-onnx-gpu`. ## How it works -`forecast` and `detect_anomalies` are SQL aggregate functions; `backtest`, -`predict`, and the `distill_*` operations are [eponymous table-valued -functions][tvf] (virtual table modules) over the rows of an inner query. -Statistical models run in-process on CPU and are deterministic. Registered +`forecast`, `detect_anomalies`, `backtest`, and `fit` are SQL aggregate +functions; `predict` is a scalar; `distill_predict`, `distill_forecast`, and +`predict_batch` are [eponymous table-valued functions][tvf] (virtual table +modules) over the rows of an inner query (`predict_batch` takes two: an optional +`train_query` and the required `apply_query`). Statistical models run in-process on +CPU and are deterministic. Registered models and distilled students live in one `_predict_models` table the extension manages, content-addressed so the exact bytes are pinned and verified before deserialization. diff --git a/skills/distill-lifecycle/SKILL.md b/skills/distill-lifecycle/SKILL.md index 69df48e..7d5428f 100644 --- a/skills/distill-lifecycle/SKILL.md +++ b/skills/distill-lifecycle/SKILL.md @@ -34,10 +34,18 @@ SELECT model_id FROM distill_forecast('SELECT series_key, value FROM obs', '{"context":96,"horizon":24,"student_id":"traffic-v1"}'); ``` +For your own hard labels, `fit(f1, f2, label, '{"register":"churn-v1"}')` is +the one-call training path. `distill_predict` trains on hard labels too; on top +of that it supports two optional inputs, either or both: teacher relabeling (a +registered model relabels the rows before fitting) and soft-label distillation +(pass `proba`/`classes` to fit probability targets). Omit both to train on the +labels exactly as given, which is what the examples above do. + Students register in `_predict_models` as content-hashed rows; they -snapshot, fork, and sync with the database. Serve by name: -`predict(NULL, apply_sql, '{"model":"churn-v1"}')` or -`forecast(ts, value, 24, '{"model":"traffic-v1"}')`. +snapshot, fork, and sync with the database. Serve a tabular student per +row with the `predict` scalar, `SELECT id, predict('churn-v1', f1, f2) +FROM rows`, and a forecast student through `forecast(ts, value, 24, +'{"model":"traffic-v1"}')`. ## Verify before serving diff --git a/skills/interpret-backtest/SKILL.md b/skills/interpret-backtest/SKILL.md index 798c24e..db35924 100644 --- a/skills/interpret-backtest/SKILL.md +++ b/skills/interpret-backtest/SKILL.md @@ -14,11 +14,13 @@ metadata: `backtest` answers "how would this model have done on my series" with rolling-origin evaluation: it repeatedly hides the tail of the series, -forecasts it, and scores against what actually happened. +forecasts it, and scores against what actually happened. It is an +aggregate over your rows, like `forecast`, and returns one JSON document; +expand it with `backtest_rows(...)`. ```sql -SELECT * FROM backtest('SELECT ts, value FROM readings', 24, - '{"model":"theta-classic","folds":5}'); +SELECT * FROM backtest_rows((SELECT backtest(ts, value, 24, + '{"model":"theta-classic","folds":5}') FROM readings)); ``` ## The metrics that matter @@ -43,9 +45,9 @@ the repo). When interval truth matters, request conformal intervals in the same call: ```sql -SELECT * FROM backtest('SELECT ts, value FROM readings', 24, - '{"model":"theta-classic", - "interval_method":"conformal"}'); +SELECT * FROM backtest_rows((SELECT backtest(ts, value, 24, + '{"model":"theta-classic","interval_method":"conformal"}') + FROM readings)); ``` Conformal intervals calibrate to measured out-of-sample residuals and diff --git a/skills/sqlite-predict/SKILL.md b/skills/sqlite-predict/SKILL.md index be48081..92a8f9e 100644 --- a/skills/sqlite-predict/SKILL.md +++ b/skills/sqlite-predict/SKILL.md @@ -13,8 +13,11 @@ metadata: # Using sqlite-predict The extension turns prediction into SQL. Load `predict0` (pip/npm/cargo -package `sqlite-predict`), then call functions over your own rows. Serving -is pure: no writes, works on read-only databases and inside views. +package `sqlite-predict`), then call functions over your own rows. Serving is +pure: `forecast`, `detect_anomalies`, `backtest`, `predict`, and `fit` without +`register` do not write, so they run on read-only databases and inside views. +Registering a model (`fit` with `'{"register":"m"}'`, and the distillers) +persists to `_predict_models` and needs a writable database. ## Which operation for which question @@ -22,14 +25,17 @@ is pure: no writes, works on read-only databases and inside views. | --- | --- | | "What will this metric do next?" | `SELECT forecast(ts, value, horizon) FROM t` | | "Which points are anomalous?" | `SELECT detect_anomalies(ts, value) FROM t` | -| "Predict a label/value for these rows" | `SELECT * FROM predict(train_sql, apply_sql, options)` | -| "How accurate would this be on my data?" | `SELECT * FROM backtest(series_sql, horizon)` | +| "Train a model on labeled rows" | `SELECT fit(f1, ..., fN, label, '{"register":"m"}') FROM t` | +| "Predict a label/value per row" | `SELECT predict('m', f1, ..., fN) FROM t` | +| "How accurate would this be on my data?" | `SELECT backtest(ts, value, horizon) FROM t` | | "Make serving instant and self-contained" | `distill_predict` / `distill_forecast` (see the distill-lifecycle skill) | -`forecast` and `detect_anomalies` are aggregates: plain SQL supplies the -rows, `GROUP BY` splits series, `WHERE` and joins compose, ORMs work -unchanged. `predict`, `backtest`, and the distillers are table-valued -functions that take a query string. +`forecast`, `detect_anomalies`, `backtest`, and `fit` are aggregates: +plain SQL supplies the rows, `GROUP BY` splits series or segments, `WHERE` +and joins compose, ORMs work unchanged. `predict` is a scalar, one +prediction per row, so it drops into any `SELECT` beside your other +columns. The distillers and `predict_batch` are table-valued functions +that take a query string. ## Reading results @@ -40,6 +46,10 @@ Each aggregate group returns one JSON document: `{"model", "status", SELECT r.* FROM forecast_rows((SELECT forecast(ts, value, 24) FROM t)) AS r; ``` +`anomaly_rows(...)` and `backtest_rows(...)` expand anomaly and backtest +documents the same way. `predict` returns its value directly, no +expansion. + - `model` reports the model that actually served. With no model named, `auto` selects the best available per series and reports the winner's id, never the string "auto". @@ -74,8 +84,16 @@ problem and often the fix. on smooth data; pass `'{"interval_method":"conformal"}'` for calibrated coverage (statistical models only) and verify with `backtest` (see the interpret-backtest skill). -- `predict` with `NULL` train_query serves a distilled student; with a - train query it runs the in-context `knn5-incontext` model zero-shot. +- `fit(f1, ..., fN, label)` trains over your rows; the label is the last + argument, there is no `target` option. `'{"register":"churn-v1"}'` + registers the model and returns its id; otherwise it returns a model + blob you can pass to `predict`. Default kind is `gbt`; `'{"kind":"tree"}'` + for a single tree. +- `predict(model, f1, ..., fN)` serves a native student per row; the + model is a registered id or a `fit()` blob, and features are positional, + so a count mismatch fails loud. Pass `'{"proba":true}'` for a + `{"prediction": "1", "confidence": 0.98}` document. For in-context `knn5-incontext` + (a train query, no fit step) or ONNX serving, use `predict_batch`. ## Checking the environment diff --git a/website/src/content/docs/guides/backtesting.md b/website/src/content/docs/guides/backtesting.md index 5a7e55d..5dc024c 100644 --- a/website/src/content/docs/guides/backtesting.md +++ b/website/src/content/docs/guides/backtesting.md @@ -3,21 +3,22 @@ title: Backtesting description: Score a model's accuracy on your own data with rolling-origin evaluation. --- -`backtest(query, horizon [, options])` runs a rolling-origin evaluation: for each -of several past cutoffs it fits the model, forecasts, and compares to the held-out -actuals. It returns per-fold accuracy and interval quality, so a caller (say, an -agent auditing its own forecasts) can measure quality locally. +`backtest(ts, value, horizon [, options])` runs a rolling-origin evaluation: for +each of several past cutoffs it fits the model, forecasts, and compares to the +held-out actuals. It is an aggregate over your rows, like `forecast`, so `WHERE`, +joins, and `GROUP BY` compose. Each group returns one JSON document of per-fold +results; expand it to typed rows with `backtest_rows()`: ```sql -SELECT fold, model, mae, rmse, mase, smape, coverage, mean_interval_width -FROM backtest('SELECT ts, value FROM readings', 6, '{"folds":20}'); +SELECT r.fold, r.model, r.mae, r.rmse, r.mase, r.smape, r.coverage, r.mean_interval_width +FROM backtest_rows((SELECT backtest(ts, value, 6, '{"folds":20}') FROM readings)) AS r; ``` -Aggregate in plain SQL: +Aggregate over the folds in plain SQL: ```sql -SELECT avg(mase) FROM backtest('SELECT ts, value FROM readings', 6, - '{"model":"auto","folds":20}'); +SELECT avg(mase) FROM backtest_rows((SELECT backtest(ts, value, 6, + '{"model":"auto","folds":20}') FROM readings)); ``` ## Options @@ -31,10 +32,10 @@ SELECT avg(mase) FROM backtest('SELECT ts, value FROM readings', 6, ## Validate conformal coverage -Because `backtest()` reports coverage for the chosen interval method, you can +Because `backtest` reports coverage for the chosen interval method, you can confirm the conformal band actually covers at the nominal level on your data: ```sql -SELECT avg(coverage) FROM backtest('SELECT ts, value FROM readings', 6, - '{"interval_method":"conformal","confidence_level":0.9,"folds":25}'); +SELECT avg(coverage) FROM backtest_rows((SELECT backtest(ts, value, 6, + '{"interval_method":"conformal","confidence_level":0.9,"folds":25}') FROM readings)); ``` diff --git a/website/src/content/docs/guides/distillation.md b/website/src/content/docs/guides/distillation.md index 725f041..5bf2847 100644 --- a/website/src/content/docs/guides/distillation.md +++ b/website/src/content/docs/guides/distillation.md @@ -22,17 +22,20 @@ SELECT model_id, holdout_metric FROM distill_predict( 'SELECT f1, f2, label FROM training', '{"target":"label","student_id":"churn-v1","student_kind":"gbt"}'); --- forever: serve it. train_query is NULL because the student already --- learned; only the rows to predict are needed. -SELECT row_ref, prediction, confidence FROM predict(NULL, - 'SELECT id, f1, f2 FROM customers', - '{"model":"churn-v1"}'); +-- forever: serve it per row with the predict scalar +SELECT id, predict('churn-v1', f1, f2) AS prediction FROM customers; ``` -The `NULL` first argument is the signature of serving a student: `predict` -normally takes a training query for in-context models, but a student carries -its training inside its blob, so passing a query alongside a student is -rejected (`PREDICT_ERR_OPTIONS`) rather than silently ignored. +`predict('churn-v1', f1, f2)` serves the registered student one row at a +time, so it drops into any `SELECT` beside your other columns and composes +with `WHERE` and joins. Features are positional and must match what the +student was trained on: a count mismatch fails loudly rather than guessing. +Pass `'{"proba":true}'` for a `{"prediction": "1", "confidence": 0.98}` document. + +If the teacher is just your own labels, with no relabeling or soft targets, +`fit(f1, f2, label, '{"register":"churn-v1"}')` trains and registers in one +call; `distill_predict` is the fuller path that adds teacher relabeling and +soft-label distillation. `student_kind` is `tree` (a single CART), `gbt` (a gradient-boosted forest with second-order leaves, which matches or beats tuned XGBoost on most diff --git a/website/src/content/docs/guides/operations.md b/website/src/content/docs/guides/operations.md index b02cfb9..f0e29b5 100644 --- a/website/src/content/docs/guides/operations.md +++ b/website/src/content/docs/guides/operations.md @@ -3,26 +3,30 @@ title: Operations description: The SQL functions sqlite-predict adds, and how they compose. --- -Serving is an **aggregate**: `forecast` and `detect_anomalies` are aggregate -functions over your own rows, like `sum()`. Evaluation and training are -**table-valued functions** over a read-only `SELECT`: `backtest`, `predict`, -and the distillers. Options are a trailing JSON object, e.g. -`'{"confidence_level":0.9}'`. +One rule covers the surface. The calls that work over your existing rows are +**aggregate functions**, like `sum()`: `forecast`, `detect_anomalies`, +`backtest`, and `fit`. Serving a trained model is a **scalar**, `predict`, one +prediction per row. Only the calls that run their own queries stay +**table-valued** over a read-only `SELECT`: `distill_predict`, +`distill_forecast`, and `predict_batch`. Options are a trailing JSON object, +e.g. `'{"confidence_level":0.9}'`. | Function | Question | Returns | | --- | --- | --- | | `forecast(ts, value, horizon [, options])` | Where is this metric going? | one JSON document per group: future rows with prediction intervals and a status | | `detect_anomalies(ts, value [, options])` | Which points are abnormal? | one JSON document per group: anomaly-scored rows with expected value and probability | -| `predict(train_query, apply_query [, options])` | Classify or regress unseen rows | a prediction and confidence per row | -| `backtest(query, horizon [, options])` | How accurate is the model here? | per-fold MAE / RMSE / MASE / sMAPE and interval coverage | +| `backtest(ts, value, horizon [, options])` | How accurate is the model here? | one JSON document per group: per-fold MAE / RMSE / MASE / sMAPE and coverage | +| `fit(f1, ..., fN, label [, options])` | Train a model on labeled rows | a registered model id, or a model blob | +| `predict(model, f1, ..., fN [, options])` | Classify or regress a row | a prediction, or a `{prediction, confidence}` document with `proba` | | `distill_predict(train_query [, options])` | Compress a teacher into a fast student | a registered native tabular model | | `distill_forecast(train_query [, options])` | Compress a forecast model into a student | a registered native forecast model | -| `forecast_rows(doc)` / `anomaly_rows(doc)` | Expand a document to typed rows | one row per forecast step / scored point | +| `predict_batch(train_query, apply_query [, options])` | Batched or in-context serving | a prediction and confidence per row | +| `forecast_rows(doc)` / `anomaly_rows(doc)` / `backtest_rows(doc)` | Expand a document to typed rows | one row per step / point / fold | -## One convention: aggregates serve, query TVFs evaluate +## One convention: aggregates compute over your rows, predict serves per row -The serving calls take your rows directly, so filtering, joins, bound -parameters, and `GROUP BY` series-splitting are ordinary SQL: +The aggregates take your rows directly, so filtering, joins, bound parameters, +and `GROUP BY` splitting are ordinary SQL: ```sql -- one series @@ -30,42 +34,40 @@ SELECT forecast(ts, value, 24) FROM readings; -- many series, one document each SELECT city, forecast(ts, value, 24) FROM readings GROUP BY city; -``` - -Rows are sorted by `ts` internally, so input order never matters. The -aggregate is a **pure function**: nothing is written, so it works on read-only -databases and inside views. Each group returns one JSON document; parse it in -your app or expand it with `forecast_rows()` / `anomaly_rows()`. See -the language [getting-started guides](../../getting-started/python/), which -show the pattern through SQLAlchemy, Drizzle, and Diesel. -The evaluation and training calls (`backtest`, `predict`, `distill_*`) stay -query-shaped: they take a read-only `SELECT` string, because they need to -re-run it across folds or split it into train and apply sets. +-- a model per segment falls out of GROUP BY +SELECT region, fit(tenure, spend, churned, '{"kind":"gbt"}') FROM history GROUP BY region; +``` -## Column inference +Rows are sorted by `ts` internally where it applies, so input order never +matters. `forecast`, `detect_anomalies`, and `backtest` are **pure functions**: +nothing is written, so they work on read-only databases and inside views. Each +returns one JSON document; parse it in your app or expand it with +`forecast_rows()` / `anomaly_rows()` / `backtest_rows()`. -`backtest()` infers the time column (an integer epoch or an ISO-8601 string) -and the value column from its query's output, or you can name them explicitly -with `time_col` / `value_col`. Pass `group_cols` to split one query into many -series keyed by `series_key`: +Serving a trained model is the `predict` scalar, so it drops into any `SELECT` +beside your other columns: ```sql -SELECT * FROM backtest( - 'SELECT created_at, confidence, hypothesis_id FROM hypotheses', - 12, '{"group_cols":["hypothesis_id"]}'); +SELECT id, predict('churn-v1', tenure, spend) AS churn FROM active; ``` -The aggregates need none of this: argument positions carry the columns and -`GROUP BY` carries the series split. +See the language [getting-started guides](../../getting-started/python/), which +show the pattern through SQLAlchemy, Drizzle, and Diesel. + +The remaining calls (`distill_predict`, `distill_forecast`, `predict_batch`) +stay query-shaped: they take read-only `SELECT` strings, because they re-run +across folds or split into train and apply sets. ## Per-series status Each series carries a `status`: `ok`, `truncated` (the context was capped by -`context_limit`), `insufficient_history`, or `non_numeric`. For the aggregates -it lives in the returned document (a degraded series comes back as a status -document with empty `rows`, not an error); for `backtest` it is the `status` -column. Forecasting needs a minimum of 8 points. +`context_limit`), `insufficient_history`, or `non_numeric`. For `forecast` and +`detect_anomalies` it lives in the returned document (a degraded series comes +back as a status document with empty `rows`, not an error); `backtest` carries a +`status` per fold in its expanded rows. (`fit` is an aggregate too, but it +returns a model id or blob, not a status document.) Forecasting needs a minimum +of 8 points. See the [Functions reference](../../reference/functions/) for every signature and the [Options reference](../../reference/options/) for the full option set. diff --git a/website/src/content/docs/index.mdx b/website/src/content/docs/index.mdx index 78c3f46..3eeb324 100644 --- a/website/src/content/docs/index.mdx +++ b/website/src/content/docs/index.mdx @@ -18,10 +18,12 @@ import { Card, CardGrid } from "@astrojs/starlight/components"; ## Prediction as a SQL primitive `forecast()`, `detect_anomalies()`, and `predict()` become functions you call -over your own rows. The serving functions are pure aggregates: they write -nothing, so they work in views, on read-only databases, and from any ORM. The -core is one small C99 file with no dependencies, so it runs in your app, on a -phone, in the browser, or in the per-database state an AI agent keeps. +over your own rows: `forecast` and `detect_anomalies` are aggregates that +compose with `WHERE`, joins, and `GROUP BY`; `predict` is a scalar that composes +with row-level `SELECT`, `WHERE`, and joins, so ORMs and query builders work +unchanged. The core is one small C99 file with no +dependencies, so it runs in your app, on a phone, in the browser, or in the +per-database state an AI agent keeps. ```sql -- forecast a metric 24 steps ahead: plain SQL supplies the rows diff --git a/website/src/content/docs/reference/functions.md b/website/src/content/docs/reference/functions.md index 352e439..cbfd3fd 100644 --- a/website/src/content/docs/reference/functions.md +++ b/website/src/content/docs/reference/functions.md @@ -5,10 +5,11 @@ description: Every SQL function sqlite-predict registers, with signatures. ## Aggregate functions -`forecast` and `detect_anomalies` are **aggregate functions**, like `sum()`: -your statement supplies the rows, so filtering, joins, bound parameters, and -`GROUP BY` series-splitting are ordinary SQL. Both are pure functions: nothing -is written, so they work on read-only databases and inside views. +`forecast`, `detect_anomalies`, `backtest`, and `fit` are **aggregate +functions**, like `sum()`: your statement supplies the rows, so filtering, +joins, bound parameters, and `GROUP BY` splitting are ordinary SQL. The first +three are pure (nothing is written, so they work on read-only databases and +inside views); `fit` optionally registers a model. ### `forecast(ts, value, horizon [, options])` @@ -37,9 +38,9 @@ SELECT city, forecast(ts, value, 24) FROM readings GROUP BY city; `model` is the resolved model id (useful with `'{"model":"auto"}'`); `status` is `ok`, `truncated`, `insufficient_history` (fewer than 8 points), or -`non_numeric`. A degraded -series returns a status document with empty `rows`; it does not fail the -statement. An aggregate over zero rows returns `NULL` (like `sum()`). +`non_numeric`. A degraded series returns a status document with empty `rows`; +it does not fail the statement. An aggregate over zero rows returns `NULL` +(like `sum()`). Passing a BigQuery-style query string, `forecast('SELECT …', 24)`, raises an error explaining that `forecast` is an aggregate over your rows. @@ -47,16 +48,44 @@ error explaining that `forecast` is an aggregate over your rows. ### `detect_anomalies(ts, value [, options])` Same document pattern and the same accepted timestamp forms; each element of -`rows` carries `ts`, `value`, -`forecast`, `lower_bound`, `upper_bound`, `is_anomaly`, -`anomaly_probability`. The interval fields are null during warmup and for -model `sub-pca`. +`rows` carries `ts`, `value`, `forecast`, `lower_bound`, `upper_bound`, +`is_anomaly`, `anomaly_probability`. The interval fields are null during warmup +and for model `sub-pca`. -### `forecast_rows(doc)` / `anomaly_rows(doc)` +### `backtest(ts, value, horizon [, options])` -Table-valued expansion of a document back into typed rows (the row fields -above plus `status`), for SQL-side consumption (app-side `JSON.parse` is the -other, equally supported route): +Rolling-origin evaluation of a forecast model over your rows: it repeatedly +hides the tail, forecasts it, and scores against the held-out actuals. Returns +one JSON document per group of per-fold results; expand with `backtest_rows()`. +The row fields are `series_key`, `fold`, `cutoff_timestamp`, `model`, `n`, +`mae`, `rmse`, `mase`, `smape`, `coverage`, `mean_interval_width`, `status`. + +```sql +SELECT avg(mae) FROM backtest_rows( + (SELECT backtest(ts, value, 6, '{"folds":20}') FROM readings)); +``` + +### `fit(f1, ..., fN, label [, options])` + +Trains a native tabular student over your rows. The features are the leading +positional arguments and the **label is the last argument** (there is no +`target` option). The optional trailing `options` is a TEXT JSON object, so a +trailing `{...}` argument is always read as options: a class **label must not be +a JSON-object string** (it would be consumed as options). Returns the model: +with `'{"register":"churn-v1"}'` it registers into `_predict_models` and returns +the id, otherwise it returns a model blob you can pass to `predict`. Options: +`kind` (`gbt` default, or `tree`), `task` (`classify`/`regress`, inferred from +the label), `register`. + +```sql +SELECT fit(tenure, spend, churned, '{"kind":"gbt","register":"churn-v1"}') FROM history; +``` + +### `forecast_rows(doc)` / `anomaly_rows(doc)` / `backtest_rows(doc)` + +Table-valued expansion of a forecast, anomaly, or backtest document back into +typed rows (the row fields above plus `status`), for SQL-side consumption +(app-side `JSON.parse` is the other, equally supported route): ```sql SELECT r.* FROM forecast_rows( @@ -66,39 +95,43 @@ SELECT r.* FROM forecast_rows( `forecast_rows(NULL)` yields zero rows; a document with empty `rows` yields a single status row. -## Table-valued functions +## Scalar prediction -Evaluation and training stay query-shaped: they take a read-only `SELECT` -string, because they need to re-run it across folds or split it into train and -apply sets. +### `predict(model, f1, ..., fN [, options])` -### `backtest(query, horizon [, options])` +Serves a native student one prediction per row, so it drops into any `SELECT` +beside your other columns and composes with `WHERE` and joins. `model` is a +registered id (from `fit` or `distill_predict`) or a `fit()` blob. Features are +positional and must match the model's feature count; a mismatch raises +`PREDICT_ERR_SCHEMA`. Returns the prediction: a class label for a `classify` +model, a numeric value for a `regress` model. For a classifier, +`'{"proba":true}'` returns a `{"prediction": "1", "confidence": 0.98}` JSON +document. -Rolling-origin evaluation of a forecast model. `query` is a read-only `SELECT` -of `(time, value)`, optionally with grouping columns. Columns: `series_key`, -`fold`, `cutoff_timestamp`, `model`, `n`, `mae`, `rmse`, `mase`, `smape`, -`coverage`, `mean_interval_width`, `status`. +```sql +SELECT id, predict('churn-v1', tenure, spend) AS churn FROM active; -### `predict(train_query, apply_query [, options])` +-- one-shot train + apply, no registration and no query strings: +WITH m(model) AS (SELECT fit(tenure, spend, churned) FROM history) +SELECT a.id, predict((SELECT model FROM m), a.tenure, a.spend) FROM active a; +``` + +For in-context models (`knn5-incontext`) or batched ONNX serving, use +`predict_batch`. -Learns from `train_query` (features plus a `target` column) and predicts the -target for every row of `apply_query`. Columns: `row_ref`, `prediction`, -`confidence`, `status`. +## Table-valued functions -The first column of `apply_query` is the row reference: it is echoed back as -`row_ref` so you can join predictions to your rows, and it is **never a -feature**. Every column after it is a feature and must match a `train_query` -feature column **by name**; an apply column with no matching train column is -an error. So `predict('SELECT f1, f2, label FROM t', 'SELECT id, f1, f2 FROM -u', '{"target":"label"}')` trains on `f1, f2` and predicts one row per `u` -row, keyed by `id`. +Training and batched serving stay query-shaped: they take read-only `SELECT` +strings, because they re-run across folds or split into train and apply sets. -To serve a distilled student, pass its id as the -`model` option and `NULL` as `train_query` (the student already learned): -`predict(NULL, 'SELECT id, f1, f2 FROM t', '{"model":"churn-v1"}')`. +### `predict_batch(train_query, apply_query [, options])` -The default in-context model caps training at 100,000 rows and 64 feature -columns; more raises `PREDICT_ERR_CONTEXT_TOO_LARGE` / `PREDICT_ERR_SCHEMA`. +The batched serving and in-context path (the former `predict` TVF). With a +`train_query` it runs the in-context `knn5-incontext` model zero-shot; with +`NULL` and a `'{"model":…}'` option it serves a registered ONNX model over the +`apply_query` rows in one batch. The first `apply_query` column is the +`row_ref`, echoed back and never a feature; the rest are features. Columns: +`row_ref`, `prediction`, `confidence`, `status`. ### `distill_predict(train_query [, options])` diff --git a/website/src/content/docs/reference/models.md b/website/src/content/docs/reference/models.md index 4e09a0b..e0fe541 100644 --- a/website/src/content/docs/reference/models.md +++ b/website/src/content/docs/reference/models.md @@ -27,8 +27,8 @@ bundled model ships in the zero-dependency core, no ONNX runtime required. | `model` | Kind | Notes | | --- | --- | --- | -| `knn5-incontext` | statistical | In-context k-nearest-neighbors (k=5). Default zero-setup baseline for `predict()`. | -| distilled students | native | Registered by `distill_predict` as `tree`, `gbt`, or `mlp`. Call by the `model_id` you gave them. | +| native students | native | Trained by `fit` (`gbt` or `tree`) or `distill_predict` (`tree`, `gbt`, or `mlp`). `fit` returns a model blob by default, or registers under your id when you pass `'{"register":"..."}'`; either way the `predict` scalar serves it per row. | +| `knn5-incontext` | statistical | In-context k-nearest-neighbors (k=5), a zero-setup baseline with no training step. Serve it through `predict_batch(train_sql, apply_sql)`. | ## Distilled forecast students @@ -41,6 +41,6 @@ row to another database and it works there. See [Distillation](../../guides/dist The default build is pure C. `make loadable-onnx` adds a `runtime='onnx'` path so `distill_forecast`/`distill_predict` can distill *from* a live foundation-model -teacher (Chronos for forecasting, a tabular FM for `predict()`). Serving the +teacher (Chronos for forecasting, a tabular FM for the tabular path). Serving the distilled student never needs that build. License-tagged teachers enforce `PREDICT_ERR_LICENSE` unless you pass `accept_license`. diff --git a/website/src/content/docs/reference/options.md b/website/src/content/docs/reference/options.md index 30a255f..0e993ec 100644 --- a/website/src/content/docs/reference/options.md +++ b/website/src/content/docs/reference/options.md @@ -39,25 +39,43 @@ The same aggregate rules apply. ## backtest -`confidence_level`, `interval_method`, `folds`, `gap`, and `context_limit` as -for `forecast`. `model` differs: it must be a statistical model id or `auto`, -and it defaults to `theta-classic`, not `auto`; a distilled student competes -inside `auto` but cannot be pinned here. Plus the query-shape keys (backtest -takes its data as a query, so columns must be resolvable): +An aggregate over your rows, like `forecast`. `confidence_level`, +`interval_method`, `folds`, `gap`, and `context_limit` as for `forecast`. +`model` differs: it must be a statistical model id or `auto`, and it defaults +to `theta-classic`, not `auto`; a distilled student competes inside `auto` but +cannot be pinned here. There are no column-naming keys: the argument positions +carry the columns and `GROUP BY` carries the series split. + +## fit + +Trains a native tabular student over your rows; the label is the last +positional argument, so there is no `target` option. | Key | Type | Default | Meaning | | --- | --- | --- | --- | -| `time_col` | string | inferred | Name of the time column. | -| `value_col` | string | inferred | Name of the value column. | -| `group_cols` | string[] | none | Split into series keyed by these columns. | +| `kind` | `gbt` \| `tree` | `gbt` | Student architecture. | +| `task` | `classify` \| `regress` | inferred | Inferred from the label: integer or text is classify, real is regress. | +| `register` | string | none | Register the model under this id and return the id; without it, `fit` returns a model blob. | ## predict +A scalar: `predict(model, f1, ..., fN [, options])`. `model` is a registered id +or a `fit()` blob; features are positional. + +| Key | Type | Default | Meaning | +| --- | --- | --- | --- | +| `proba` | 0 \| 1 | 0 | Return a `{"prediction": "1", "confidence": 0.98}` JSON document instead of the bare label. | + +## predict_batch + +The batched and in-context serving path, +`predict_batch(train_query, apply_query [, options])`. + | Key | Type | Default | Meaning | | --- | --- | --- | --- | -| `target` | string | required for in-context models | Column in `train_query` to learn. A served student (`train_query` = `NULL`) needs no target. | +| `target` | string | required for in-context models | Column in `train_query` to learn. A served model (`train_query` = `NULL`) needs no target. | | `task` | `classify` \| `regress` | inferred | Prediction task. | -| `model` | string | `knn5-incontext` | Model or distilled student id. | +| `model` | string | `knn5-incontext` | An in-context model, a registered native student id (trained by `distill_predict` or `fit`), or a registered onnx id. To serve an already-trained model (a native student or onnx), pass `train_query` = `NULL`; only in-context models take a `train_query`. | | `device` | `cpu` \| `gpu` | `cpu` | Inference device (onnx build). | | `precision` | string | model default | Inference precision (onnx build). | | `accept_license` | 0 \| 1 | 0 | Accept a license-tagged model. |