|
| 1 | +--- |
| 2 | +id: DRAFT-22 |
| 3 | +title: "UDF protocol and Confit externs: serving transformers without special support" |
| 4 | +status: Draft |
| 5 | +type: spike |
| 6 | +created: 2026-07-29 |
| 7 | +--- |
| 8 | + |
| 9 | +## Where this came from |
| 10 | + |
| 11 | +Design dialogue with AmirHossein, 2026-07-29, right after loop 5 |
| 12 | +(transformers as UDAFs v0, PR #61). The question: **how do fitted |
| 13 | +transformers — the ones Confit has no special support for — serve through |
| 14 | +Confit?** Converged on a full API; parked until the go. |
| 15 | + |
| 16 | +Two constraints fixed the shape early: |
| 17 | + |
| 18 | +1. **The Python fallback is permanent infrastructure**, not a stopgap. |
| 19 | + There will always be a transformer we haven't optimized, and the |
| 20 | + fallback doubles as the oracle that optimized implementations gate |
| 21 | + against. |
| 22 | +2. **Transformers can sit mid-expression** (`sc(age) OVER () + 1`, a CASE |
| 23 | + arm, a WHERE clause — once the v0 bare-final-item refusals lift). Any |
| 24 | + pre-SQL/python/post-SQL sandwich dies with that; the opaque call has to |
| 25 | + be an expression node the engine evaluates in place. |
| 26 | + |
| 27 | +## Decision 1 — per-group weights are data, not closure state |
| 28 | + |
| 29 | +A fitted per-group transformer is a *dict of estimators keyed by partition |
| 30 | +key* — which is exactly what params tables already are. So the group lookup |
| 31 | +does NOT hide inside the callable; the estimator is a value fetched by the |
| 32 | +existing join, and the UDF is a **pure function of its arguments**: |
| 33 | + |
| 34 | +```sql |
| 35 | +-- SELECT sc(age) OVER (PARTITION BY country) + 1 AS z => serving_sql: |
| 36 | +SELECT __cf_tf0(p0.__cf_est, t.age) + 1 AS z |
| 37 | +FROM __THIS__ AS t |
| 38 | +LEFT JOIN __CF_PARAMS_0__ AS p0 ON (t.country IS NOT DISTINCT FROM p0.country) |
| 39 | +``` |
| 40 | + |
| 41 | +- Params table gains an instance-id column (`__cf_est: i64`); ids index the |
| 42 | + fitted-clone registry. Both travel together: params stay Arrow, the |
| 43 | + instance registry is the one pickle payload, the id is the link. |
| 44 | +- **One NULL story.** Unseen group -> LEFT JOIN misses -> NULL id -> NULL |
| 45 | + output. Same mechanism as every marginalized aggregate; no dict-miss |
| 46 | + convention. |
| 47 | +- **No key semantics inside the extern.** The engine's join machinery |
| 48 | + already owns `IS NOT DISTINCT FROM`, NULL keys, key expressions. The |
| 49 | + extern receives one nullable i64 + typed features. (The rejected |
| 50 | + alternative — lookup-in-closure — would force Confit's Rust side to |
| 51 | + re-implement join-key equality.) |
| 52 | +- **Desugaring becomes a column-width decision.** For a scaler, fit writes |
| 53 | + `mean_0, scale_0` into the same params table instead of an id, and the |
| 54 | + call site inlines to `(t.age - p0.mean_0) / p0.scale_0`. Same join, same |
| 55 | + NULL flow; the oracle-vs-optimized diff is confined to the projected |
| 56 | + expression. |
| 57 | + |
| 58 | +## Decision 2 — the UDF protocol (sql_transform), transformer as subclass |
| 59 | + |
| 60 | +The base concept is a *declared scalar UDF*; the fitted transformer is the |
| 61 | +subclass that adds instance dispatch: |
| 62 | + |
| 63 | +```python |
| 64 | +class UDF: # Confit's whole world |
| 65 | + name: str |
| 66 | + takes: tuple[str, ...] # Confit Ty vocabulary: "i1"|"i64"|"f64"|"str" |
| 67 | + returns: tuple[str, ...] |
| 68 | + def __call__(self, *args): ... # scalar semantics — THE contract |
| 69 | + def apply_batch(self, *cols: pa.Array) -> tuple[pa.Array, ...]: |
| 70 | + ... # default: loop __call__; override to vectorize |
| 71 | + |
| 72 | +class PythonUDF(UDF): # any plain function |
| 73 | + def __init__(self, name, fn, takes, returns): ... |
| 74 | + |
| 75 | +class PythonTransform(UDF): |
| 76 | + instances: dict[int, Any] # instance id -> fitted object with .transform |
| 77 | + # implicit leading arg: nullable i64 id, never written in `takes` |
| 78 | + # NULL id -> None (unseen group); id missing from instances -> TRAP |
| 79 | + # (params table and instances from different fits = broken artifact, not NULL) |
| 80 | +``` |
| 81 | + |
| 82 | +Rules: |
| 83 | + |
| 84 | +- **Declared types, no probing.** `takes`/`returns` are written in Confit's |
| 85 | + own `Ty` vocabulary so build-time checking is literal. Everything is |
| 86 | + nullable, always. Deletes sklearn-attribute sniffing; duck-typed |
| 87 | + transforms are first-class. `SQLProjection` generates declarations at |
| 88 | + `_prepare` (it owns sklearn: `takes` from the resolved query schema, |
| 89 | + `returns` from the fitted estimator) so declaration/instance drift is |
| 90 | + impossible there; hand-rolled users are kept honest by the runtime check. |
| 91 | +- **Two enforcement points.** Build: call-site argument expressions |
| 92 | + type-check against `takes`, each use of output slot j against |
| 93 | + `returns[j]` — named refusal before any row flows. Runtime: the |
| 94 | + trampoline checks each returned tuple against `returns` and traps on |
| 95 | + violation. |
| 96 | +- **Inputs: scalars for semantics, Arrow for bulk, dicts nowhere.** Row |
| 97 | + level takes positional Python scalars (None = NULL) — the boundary has no |
| 98 | + names and the row path is the latency path. Batch level takes pyarrow |
| 99 | + arrays — already the currency on every side (Confit statics/infer_arrow, |
| 100 | + DuckDB vectorized UDFs zero-copy), NULLs ride the validity bitmap. |
| 101 | +- **Default `apply_batch` loops the scalar protocol** — amortizes the |
| 102 | + boundary (one GIL crossing, one Arrow exchange per chunk) WITHOUT |
| 103 | + vectorizing the math. `est.transform` on an n-row block goes through |
| 104 | + different BLAS kernels than 1-row calls; looping keeps |
| 105 | + row ≡ batch ≡ oracle bit-exact. A `VectorizedTransform` override is the |
| 106 | + explicit opt-in (documented ulp caveat). |
| 107 | +- **Determinism is the contract.** Fit-time DuckDB, serve-time Confit, and |
| 108 | + the oracle all call the same object and are compared bit-exact; a UDF |
| 109 | + with randomness breaks the round-trip invariant unlocalizably. Docstring |
| 110 | + contract, not enforcement code. |
| 111 | +- **Scope fence: scalar UDFs only.** A user-defined aggregate over |
| 112 | + `__THIS__` is what transformers already are (fit/transform + window |
| 113 | + syntax). The protocol never grows an aggregation arm. |
| 114 | + |
| 115 | +## Decision 3 — the InferFn API |
| 116 | + |
| 117 | +```python |
| 118 | +fn = InferFn( |
| 119 | + serving_sql, # contains __cf_tf0(p0.__cf_est, t.age) calls |
| 120 | + row_tables={"__THIS__": Row}, |
| 121 | + static_tables=params, # id columns are ordinary Arrow data |
| 122 | + udfs=[PythonTransform("__cf_tf0", instances=..., takes=("f64",), returns=("f64",))], |
| 123 | + shape="map", |
| 124 | +) |
| 125 | +``` |
| 126 | + |
| 127 | +- Contract, restated with one parameter: **serve bit-for-bit identical to |
| 128 | + DuckDB *with these udfs registered* (`con.create_function(u.name, u)` |
| 129 | + each), or refuse.** Same two outcomes; the oracle stays runnable because |
| 130 | + the udfs list is exactly what you register in DuckDB. |
| 131 | +- Undeclared unknown function still refuses at build, verbatim. |
| 132 | +- Confit never sees sklearn: Arrow tables + declared callables only. |
| 133 | + Serialization stays on the Python side; the InferFn constructor args are |
| 134 | + the complete artifact description. |
| 135 | +- k-output call at the boundary -> `list[float] | None` field (marshaller |
| 136 | + assembles k slots); mid-expression use must index down to a scalar. |
| 137 | + Confit's IR stays scalar-only (`i1/i64/f64/str`) — outputs are k slots |
| 138 | + via out-param array, the existing `len_out` pattern. |
| 139 | + |
| 140 | +## Confit implementation route |
| 141 | + |
| 142 | +The cranelift backend already routes every nontrivial op through |
| 143 | +`extern "C"` helpers sharing code with the interpreter |
| 144 | +(`packages/confit/src/specializer/exec/cranelift.rs`) — a `*mut Cx` context |
| 145 | +pointer, trap flag checked after every fallible call. The UDF extern is |
| 146 | +**one more helper family in an existing pattern**: |
| 147 | + |
| 148 | +```rust |
| 149 | +extern "C" fn h_extern(p: *mut Cx, slot: i64, /* args by ABI */ out: *mut f64) -> i64; |
| 150 | +// Cx carries the slot table: boxed trampoline (GIL + __call__) or, later, a |
| 151 | +// native kernel. Python exception -> trap flag, like every fallible helper. |
| 152 | +``` |
| 153 | + |
| 154 | +Frontend: unknown function + matching udfs entry -> extern-call IR |
| 155 | +instruction with k outputs, type-checked like any other op. |
| 156 | + |
| 157 | +Engine bindings, one object, four targets: |
| 158 | + |
| 159 | +| engine | binding | |
| 160 | +|---|---| |
| 161 | +| DuckDB (batch path + oracle) | `con.create_function(u.name, u.apply_batch, type="arrow")` | |
| 162 | +| Confit interpreter | boxed closure calling `u.__call__` | |
| 163 | +| Confit cranelift | C-ABI trampoline over `u.__call__` | |
| 164 | +| future optimized | native kernel in the same slot (`NativeAffine(...)` etc.) | |
| 165 | + |
| 166 | +The oracle gate for optimized impls is "same query, same statics, swap the |
| 167 | +list entry." Bit-exact for scaler-class ops; matvec-class needs a |
| 168 | +**declared per-estimator ulp tolerance** (BLAS summation order is |
| 169 | +unpinnable) — written down per kind, not discovered per bug. |
| 170 | + |
| 171 | +## What this deletes / changes in sql_transform |
| 172 | + |
| 173 | +- `marginalize` emits the call **in place** (mid-expression works) instead |
| 174 | + of helper columns; the `TransformSpec` splicing block in |
| 175 | + `_projection.py::transform` deletes — batch transform becomes |
| 176 | + register-udfs-and-run. |
| 177 | +- The v0 refusals (bare final-level items only) lift with no serving-side |
| 178 | + design change. |
| 179 | +- Plain scalar UDFs in authoring SQL resolve exactly like transformers |
| 180 | + (registry, then caller scope, guarded by the `duckdb_functions()` |
| 181 | + catalog) — the only difference is scalar vs window call position. |
| 182 | + |
| 183 | +## Sequencing (when greenlit) |
| 184 | + |
| 185 | +1. SQL-call rewrite in `marginalize` + DuckDB UDF binding — batch path gets |
| 186 | + mid-expression transformers with zero Confit involvement. |
| 187 | +2. Confit `udfs=` API + extern helper family + trampoline. |
| 188 | +3. The params-join wiring Confit already needs regardless |
| 189 | + (`IS NOT DISTINCT FROM`, `ON ((1 = 1))`) — previously queued. |
| 190 | + |
| 191 | +## Rejected along the way |
| 192 | + |
| 193 | +- **Compile-out as the primary path** (desugar/ONNX): a fallback is |
| 194 | + mandatory anyway (coverage + oracle); ONNX kernels aren't sklearn-exact. |
| 195 | +- **The sandwich / staged serving plan**: mid-expression kills the single |
| 196 | + sandwich; the staged DAG generalization works but costs N boundary |
| 197 | + crossings, helper-schema soup, per-stage shape contracts. Named escape |
| 198 | + hatch if the extern loop stalls, nothing more. |
| 199 | +- **Group lookup inside the closure**: re-implements join-key semantics in |
| 200 | + Rust; two NULL conventions. |
| 201 | +- **Arity/type probing in Confit**: replaced by declared `takes`/`returns`. |
| 202 | +- **Affine probing** (recover W,b numerically, serve as matvec): a third |
| 203 | + "approximately" mode — exactly what the two-outcome contract forbids. |
0 commit comments