|
| 1 | +--- |
| 2 | +id: DRAFT-20 |
| 3 | +title: Serving pipelines in SQL — marginalized aggregates, fitted artifacts, and the leakage question |
| 4 | +status: Draft |
| 5 | +type: spike |
| 6 | +created: 2026-07-28 |
| 7 | +--- |
| 8 | + |
| 9 | +## Where this came from |
| 10 | + |
| 11 | +Design dialogue with AmirHossein, 2026-07-28. Stated goal: **express the entire |
| 12 | +serving pipeline in SQL.** Not implemented, not scheduled — parked so the |
| 13 | +conclusions survive. |
| 14 | + |
| 15 | +## The architecture we converged on |
| 16 | + |
| 17 | +**Aggregates over `__THIS__` are the static half of a binding-time analysis.** |
| 18 | +The user writes one text; a training-side preprocess marginalizes every |
| 19 | +aggregate over `__THIS__` into a params table and rewrites it to a join. |
| 20 | +`PARTITION BY g` becomes the join key. AmirHossein's plan; pipelines appear as |
| 21 | +UDAFs at the training end. |
| 22 | + |
| 23 | +```sql |
| 24 | +-- one text |
| 25 | +SELECT (age - avg(age) OVER (PARTITION BY country)) / stddev(age) AS age_z FROM __THIS__ |
| 26 | +-- serving form after rewrite (already compilable today) |
| 27 | +SELECT (age - p.mean) / p.scale FROM __THIS__ LEFT JOIN params p USING (country) |
| 28 | +``` |
| 29 | + |
| 30 | +Consequences: |
| 31 | + |
| 32 | +- **Most sklearn transformers become unnecessary.** StandardScaler, MinMax, |
| 33 | + SimpleImputer, target encoding, one-hot domains are all `avg`/`stddev`/ |
| 34 | + `median`/`DISTINCT`/`GROUP BY`. No importer, no `State<T>` surface — the |
| 35 | + state struct is what the *rewriter emits*, not a user-facing concept. |
| 36 | +- **A fitted transformer is a static table that gets joined.** Per-group |
| 37 | + families (per-country PCA) are the general case, not a special case — the |
| 38 | + group is the join key, and multi-column keys already work. |
| 39 | +- **Params must be pivoted WIDE**, not tidy/long: a long params table would |
| 40 | + need `SUM(...) GROUP BY` to form a dot product, and serving rejects |
| 41 | + aggregation by design. |
| 42 | +- **The engine's no-aggregation rule stops being a limitation** and becomes the |
| 43 | + type system: the rewrite's output is aggregate-free by construction, so |
| 44 | + anything the specializer refuses is a bug in the rewrite. |
| 45 | +- **The payoff is training/serving skew elimination by construction**, not |
| 46 | + latency — one text, mechanically split. |
| 47 | + |
| 48 | +## What actually needs building (small) |
| 49 | + |
| 50 | +Everything reduces to **two instructions plus a params-materialization library**: |
| 51 | + |
| 52 | +``` |
| 53 | +matvec → linear, logistic, PCA, any projection (+ link fn) |
| 54 | +tree_ensemble → GBDT / RF / extra trees |
| 55 | +everything else → desugar over existing lanes; ZERO engine change |
| 56 | +``` |
| 57 | + |
| 58 | +Suggested order: (1) params-as-static-tables + desugar catalogue — no engine |
| 59 | +change, covers most real pipelines; (2) `matvec` — unlocks linear/logistic, |
| 60 | +the most-served model class, plus PCA; (3) `tree_ensemble`, sklearn-only, |
| 61 | +when something real needs it. |
| 62 | + |
| 63 | +Sizing (my estimate, from the wave work): a bit-exact tree ensemble for ONE |
| 64 | +model family ≈ 1.5–2 weeks. The tree walk itself is a day. The cost is the |
| 65 | +six-file instruction tax, the importer (per format), and the semantics pins — |
| 66 | +float32-vs-f64 threshold comparison, `<` vs `<=` ties, per-node missing |
| 67 | +direction, link/objective, base_score. Cost scales with *formats supported*, |
| 68 | +not tree count. XGBoost +1wk, LightGBM (categorical bitset splits) +1–2wk; |
| 69 | +that way lies a model-format zoo. |
| 70 | + |
| 71 | +## Bit-exactness, inverted |
| 72 | + |
| 73 | +- Scalers / imputers / encoders / trees: **bit-exact is reachable**. Tree walks |
| 74 | + are comparisons plus a fixed-order sum. |
| 75 | +- Anything matmul-shaped (PCA, linear): **not bit-exact with sklearn** — numpy |
| 76 | + dispatches to BLAS, whose summation order varies by build, threading, and |
| 77 | + size. Needs a documented own-order + tolerance tier, loudly opt-in. It would |
| 78 | + be the first approximate answer this engine ever serves. |
| 79 | + |
| 80 | +Oracle split: feature SQL keeps DuckDB (existing machinery, unchanged, and |
| 81 | +functions defined AS their expansion stay verifiable — pin whether DuckDB |
| 82 | +`CREATE MACRO` takes struct args). Model scoring's oracle becomes |
| 83 | +sklearn/XGBoost. Closing that seam would mean shipping a DuckDB extension with |
| 84 | +the same functions — roughly a doubling of the work. |
| 85 | + |
| 86 | +## THE OPEN QUESTION — leakage (AmirHossein: "very tricky, brainstorm at some point") |
| 87 | + |
| 88 | +**Cross-fitting breaks the one-text-one-rewrite symmetry**, which is the |
| 89 | +property everything else rests on: |
| 90 | + |
| 91 | +``` |
| 92 | +serving: LEFT JOIN params USING (country) |
| 93 | +training: LEFT JOIN params_by_fold USING (country, fold) -- fold ∉ serving |
| 94 | +``` |
| 95 | + |
| 96 | +The marginalizing rewrite would no longer be the same transformation in both |
| 97 | +phases. Sub-questions for that session: |
| 98 | + |
| 99 | +1. How does the preprocessor learn **which columns are targets**? `avg(age)` is |
| 100 | + safe; `avg(target)` leaks. SQL carries no such metadata today. |
| 101 | +2. Refuse, or cross-fit automatically? The design makes the leaky version the |
| 102 | + easiest thing to write. |
| 103 | +3. Smoothed target encoding (m-estimate / prior) means the marginalized |
| 104 | + aggregate has **hyperparameters** — it isn't a pure `avg`. |
| 105 | +4. High-cardinality group keys: a group of size 1 is the row's own target. |
| 106 | + |
| 107 | +## Other open items |
| 108 | + |
| 109 | +- Unknown group at serving is unavoidable (a country unseen in training). The |
| 110 | + join type is the policy — LEFT → null lanes, INNER → row drops, COALESCE to a |
| 111 | + global row → fallback. Must be an explicit per-table choice. |
| 112 | +- `PARTITION BY g` requires `g` to be a serving input field — a build-time |
| 113 | + proof that should name the missing column. |
| 114 | +- `avg(x)` silently means training-time, never request-batch. Safe (serving |
| 115 | + sees one row) but a real trap; wants an explain output. |
| 116 | +- Params-table size ceiling for high-cardinality keys (statics are frozen in |
| 117 | + memory). |
| 118 | +- Tfidf vocabularies fall on the blob side of the line, same as matrices/trees. |
| 119 | + |
| 120 | +## The line |
| 121 | + |
| 122 | +State-as-struct for fixed-arity scalar params. Opaque frozen blob plus a |
| 123 | +dedicated instruction for anything whose size scales with model complexity — |
| 124 | +matrices, ensembles, vocabularies. A GBDT as struct lanes would be ~150k |
| 125 | +compile-time lanes; its structure *is* control flow, so it belongs in the |
| 126 | +compiled code, not a data field. |
| 127 | + |
| 128 | +End state: the deployable artifact is SQL text + Arrow params tables, no Python |
| 129 | +at serving. Given the WASM spike ran near-native under Go (wazero) and Java |
| 130 | +(chicory, with its opt-in compiler), the same artifact serves from a JVM or Go |
| 131 | +service with nothing reimplemented. |
0 commit comments