Skip to content

Commit ba030d1

Browse files
ahrzbclaude
authored andcommitted
refactor: extract the DuckDB engine into the confit package; monorepo
The serving engine gets a name and a package of its own. Confit: SQL plus static tables frozen at fit time are cooked once into a native function, then service is just bringing a portion up to heat. Layout — a uv + cargo workspace of two packages: packages/confit/ the serving engine, standalone and publishable src/{specializer,duckdb,error,types,schema}.rs confit/ python package -> confit._engine tests/ the 16 engine suites + the mined corpus packages/sql-transform/ authoring layer, depends on confit src/{datafusion,lookup,value}.rs sql_transform/ python package -> sql_transform._interpreter Only the DuckDB engine moved. The DataFusion engine and the codegen backend stay in sql-transform, which now links confit for the shared, semantics-free vocabulary (error/types/schema) and imports DuckDBInferFn from it. Coupling was already small: the specializer touched exactly four lines of crate-root code, and no confit test referenced sql_transform, so the split is a move rather than a disentangling. Notes: * confit's lib target is named _engine (maturin's convention here), so sql-transform renames the crate: `extern crate _engine as confit;`. * generator scripts had hardcoded src/ output paths that would have written into a directory that no longer exists — repointed. * the _interpreter.pyi stub for DuckDBInferFn was stale (it claimed infer() raised NotImplementedError); replaced with an accurate confit/_engine.pyi covering shape/output/backend/boundary, infer_rows and infer_arrow. * both native guards now watch the sources they actually depend on; sql-transform's also watches confit's, since it links it. * carries DRAFT-20 (the serving-pipelines-in-SQL design notes), parked earlier in the same session. Gate unchanged: cargo 168, pytest 835 passed + 13 xfailed, pre-commit clean, uv sync --locked clean. Verified confit imports and serves standalone without sql_transform entering sys.modules. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent fa0e551 commit ba030d1

140 files changed

Lines changed: 792 additions & 265 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,10 @@ wheels/
2424
# DuckDB source clone — reference corpus for the SQL specializer, not a dependency
2525
/duckdb/
2626

27-
# Compiled PyO3 extension module (built in-place by `maturin develop`)
28-
sql_transform/_interpreter*.so
29-
sql_transform/_interpreter*.pyd
30-
sql_transform/_interpreter*.pdb
27+
# Compiled PyO3 extension modules (built in-place by `maturin develop`)
28+
**/sql_transform/_interpreter*.so
29+
**/sql_transform/_interpreter*.pyd
30+
**/sql_transform/_interpreter*.pdb
31+
**/confit/_engine*.so
32+
**/confit/_engine*.pyd
33+
**/confit/_engine*.pdb

Cargo.lock

Lines changed: 16 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 6 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,10 @@
1-
[package]
2-
name = "sql-transform-interpreter"
1+
[workspace]
2+
members = ["packages/confit", "packages/sql-transform"]
3+
resolver = "2"
4+
5+
[workspace.package]
36
version = "0.1.0"
47
edition = "2021"
58

6-
[lib]
7-
name = "_interpreter"
8-
crate-type = ["cdylib"]
9-
10-
# extension-module is deliberately NOT a default feature: it suppresses the
11-
# libpython link, which is right for the wheel but breaks `cargo test`.
12-
# maturin enables it via [tool.maturin] features in pyproject.toml.
13-
[dependencies]
14-
cranelift-codegen = "0.126"
15-
cranelift-frontend = "0.126"
16-
cranelift-jit = "0.126"
17-
cranelift-module = "0.126"
9+
[workspace.dependencies]
1810
pyo3 = { version = "0.29", features = ["abi3-py314"] }
19-
regex = "1"
20-
unicode-segmentation = "1"
21-
sqlparser = "0.62"
22-
target-lexicon = "0.13.5"

README.md

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,22 +3,41 @@
33
Define ML feature transforms as SQL, fit once, then run them at batch or
44
low-latency single-row speed.
55

6+
## Packages
7+
8+
This repository is a workspace of two packages:
9+
10+
| package | what it is |
11+
|---|---|
12+
| [`packages/confit`](packages/confit) | **Confit** — the serving engine. SQL plus static tables frozen at fit time are partially evaluated, once, into a native function. Serves bit-exact with DuckDB or refuses at build time. Usable on its own. |
13+
| [`packages/sql-transform`](packages/sql-transform) | The authoring layer: `SQLTransform`, the DataFusion-differentiated engine, and the codegen backend. Depends on Confit for specialized serving. |
14+
615
## Installation
716

817
```bash
9-
pip install sql-transform
18+
pip install sql-transform # authoring + serving
19+
pip install confit # the serving engine alone
1020
```
1121

1222
### Development
1323

1424
```bash
1525
git clone https://github.com/ahrzb/sql-transforms.git
1626
cd sql-transforms
17-
mise run install # uv sync — installs deps and builds the Rust extension
27+
mise run install # uv sync — installs both packages and builds their Rust extensions
1828
```
1929

20-
The inference engine is a Rust/PyO3 module built by [maturin](https://www.maturin.rs/).
21-
After changing Rust code, rebuild it with `uv run maturin develop`.
30+
Each package ships a Rust/PyO3 extension built by
31+
[maturin](https://www.maturin.rs/)`confit._engine` and
32+
`sql_transform._interpreter`. After changing Rust code, rebuild with
33+
`uv run maturin develop` from inside that package's directory; the test suite
34+
also rebuilds automatically when a `.rs` file is newer than the built module.
35+
36+
Run the whole gate from the repository root:
37+
38+
```bash
39+
uv run pytest -q && cargo test --release
40+
```
2241

2342
## Quick Start
2443

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
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.

benchmarks/bench_serving.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,8 @@ def engine_run(engines):
150150
def refuse_debug_build():
151151
"""Abort unless the imported native extension is a release build.
152152
153-
tests/_native_guard.py rebuilds the cwd-local .pyd via plain `maturin
153+
packages/sql-transform/tests/_native_guard.py rebuilds the cwd-local .pyd
154+
via plain `maturin
154155
develop` (debug) whenever src/*.rs is newer; that shadows the venv's
155156
release wheel and silently inflates engine rows ~5x (measured 2026-07-26).
156157
"""

benchmarks/serving_scenarios/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ def rows_table(mod, rows: list[dict]) -> pa.Table:
6969

7070
def build_spec_fn(mod, statics: dict[str, pa.Table], output: str = "model"):
7171
"""The specializer serving fn (env knobs select backend/boundary)."""
72-
from sql_transform._interpreter import DuckDBInferFn
72+
from confit import DuckDBInferFn
7373

7474
return DuckDBInferFn(
7575
mod.SQL,

docs/known-limitations.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
This is the user-facing contract of the SQL specializer (`DuckDBInferFn`):
44
what it refuses to serve, **why**, and what you see when you hit a limit.
5-
Its executable twin is [`tests/test_known_limitations.py`](../tests/test_known_limitations.py)
5+
Its executable twin is [`packages/confit/tests/test_known_limitations.py`](../tests/test_known_limitations.py)
66
every limitation below is asserted there, so lifting one breaks a test and
77
forces this document to change with it.
88

@@ -105,7 +105,7 @@ wrong answer or require semantics we can't reproduce exactly:
105105
| `^` operator | It IS pow in DuckDB, but sqlparser's precedence differs from DuckDB's (`2*x^y` would parse as `(2*x)^y`). Mapping it computes the wrong tree silently. Use `pow()`. |
106106
| prefix `~`, `#`, `NOT GLOB` | Same class: precedence/parse divergences that would silently mis-associate. `xor()` covers bit-xor; `NOT (x GLOB p)` works. |
107107
| Regex reject list: `\B`, `\Q…\E`, `(?<name>…)`, duplicate group names, bounds > 1000, stacked quantifiers (`a*+`), `\u` escapes, negated Perl classes inside `[...]` | The RE2↔rust-regex differential battery (98 entries) proved these are the constructs where the engines disagree or DuckDB itself is broken (`\B` crashes DuckDB at runtime on non-ASCII). Everything else is byte-identical. |
108-
| Fuzzer-found regex rejects (TASK-54): `\1`–`\9` backrefs outside classes, the full stacked-quantifier grammar (`{2}*`, `?*`, `a???` — one lazy `?` is the only legal follower), nested repetition products > 1000, whitespace inside `{m, n}` bounds, class set-op lookalikes (`--`/`&&`/`~~`), non-POSIX `[` inside classes, Perl-class range endpoints (`[a-\d]`), capturing `(x){0}`, anchor-only multi-anchor patterns (DuckDB is SELF-inconsistent on these — its row path disagrees with its own constant fold), `$` anchors in non-final position (`'$hello'` — DuckDB's row path literal-optimizes the leading `$`+literal into a PREFIX match, matching "hello world", while its own constant fold matches normally; found by the standing fuzzer on seed 20260728), and counted repetitions over RE2's PROGRAM-SIZE budget (`(\p{L}){1,500}` is "pattern too large" in DuckDB while rust-regex serves it — rejected via a one-sided weight estimate that always fires before DuckDB's real budget; same seed, pins `pins-waveB/fuzzer-20260728.json`) | The standing differential fuzzer (`tests/test_duckdb_regexp_fuzz.py`, in the normal gate) found these 12 classes in its first 3k-case deep run — each one a silent-wrong-answer risk in rust-regex — then re-swept to ZERO divergences over 40k cases across 8 seeds. Pins: `pins-waveB/fuzzer-task54.json`. |
108+
| Fuzzer-found regex rejects (TASK-54): `\1`–`\9` backrefs outside classes, the full stacked-quantifier grammar (`{2}*`, `?*`, `a???` — one lazy `?` is the only legal follower), nested repetition products > 1000, whitespace inside `{m, n}` bounds, class set-op lookalikes (`--`/`&&`/`~~`), non-POSIX `[` inside classes, Perl-class range endpoints (`[a-\d]`), capturing `(x){0}`, anchor-only multi-anchor patterns (DuckDB is SELF-inconsistent on these — its row path disagrees with its own constant fold), `$` anchors in non-final position (`'$hello'` — DuckDB's row path literal-optimizes the leading `$`+literal into a PREFIX match, matching "hello world", while its own constant fold matches normally; found by the standing fuzzer on seed 20260728), and counted repetitions over RE2's PROGRAM-SIZE budget (`(\p{L}){1,500}` is "pattern too large" in DuckDB while rust-regex serves it — rejected via a one-sided weight estimate that always fires before DuckDB's real budget; same seed, pins `pins-waveB/fuzzer-20260728.json`) | The standing differential fuzzer (`packages/confit/tests/test_duckdb_regexp_fuzz.py`, in the normal gate) found these 12 classes in its first 3k-case deep run — each one a silent-wrong-answer risk in rust-regex — then re-swept to ZERO divergences over 40k cases across 8 seeds. Pins: `pins-waveB/fuzzer-task54.json`. |
109109
| `SIMILAR TO ... ESCAPE` | Not implemented in DuckDB itself. |
110110
| `* EXCLUDE (t.key)` on a USING join | DuckDB UNMERGES the coalesced column (it reappears at the right table's position) — measured, not modeled. Unqualified EXCLUDE works. |
111111
| `BETWEEN`/`IN` mixing non-numeric string literals with numbers | DuckDB converts at EXECUTION time (an empty input succeeds!); a bind-time conversion was measured to be over-eager. Numeric literals convert fine. |
@@ -131,7 +131,7 @@ These are served, but with a consciously chosen surface — know them:
131131
corpus only ever compares successful results, so texts never affect
132132
parity.
133133
- **Two known oracle divergences** (excluded from the corpus by name in
134-
`tests/test_corpus_replay.py::_KNOWN_DIVERGENT_SOURCES`): DuckDB
134+
`packages/confit/tests/test_corpus_replay.py::_KNOWN_DIVERGENT_SOURCES`): DuckDB
135135
behaviors that depend on column STATISTICS (e.g. ILIKE's NUL handling
136136
selects a different kernel depending on *sibling rows*). A row-at-a-time
137137
engine cannot reproduce statistics-dependent semantics even in
@@ -172,9 +172,9 @@ Three mechanisms, all in the normal test gate:
172172
1. **The corpus replay** (678 statements mined from DuckDB's test suite):
173173
every statement must match bit-for-bit, reject cleanly, or be a named
174174
divergence — a wrong answer anywhere fails the gate.
175-
2. **The executable twin** (`tests/test_known_limitations.py`): every
175+
2. **The executable twin** (`packages/confit/tests/test_known_limitations.py`): every
176176
limitation in this document is asserted; lifting one breaks a test.
177-
3. **The standing differential fuzzer** (`tests/test_duckdb_regexp_fuzz.py`):
177+
3. **The standing differential fuzzer** (`packages/confit/tests/test_duckdb_regexp_fuzz.py`):
178178
randomized DuckDB-vs-engine sweeps of the regex surface on every run
179179
(seed/size overridable for deep runs) — new divergences fail with the
180180
reproducing seed and SQL, and their fix lands as a reject-list entry

0 commit comments

Comments
 (0)