|
| 1 | +# UDF protocol + in-place serving calls (DRAFT-22, step 1 of 3) |
| 2 | + |
| 3 | +Full design: `backlog/drafts/draft-22 - UDF-protocol-and-Confit-externs-serving-transformers-without-special-support.md`. |
| 4 | +This loop is the Python half — it fixes the serving_sql shape Confit's |
| 5 | +extern loop (step 2) will consume, with zero Confit involvement. |
| 6 | + |
| 7 | +## What changes |
| 8 | + |
| 9 | +**Transformer calls rewrite in place, mid-expression included.** The v0 |
| 10 | +"top-level select item only" refusal lifts. A transformer window node |
| 11 | +anywhere in a final-level item becomes a scalar call: |
| 12 | + |
| 13 | +```sql |
| 14 | +SELECT sc(age) OVER (PARTITION BY country) + 1 AS z FROM __THIS__ |
| 15 | +-- serving_sql: |
| 16 | +SELECT (__cf_tf0(__cf_p0.__cf_est, __cf_t.age) + 1) AS z |
| 17 | +FROM __THIS__ AS __cf_t |
| 18 | +LEFT JOIN __CF_PARAMS_0__ AS __cf_p0 |
| 19 | + ON (__cf_t.country IS NOT DISTINCT FROM __cf_p0.country) |
| 20 | +``` |
| 21 | + |
| 22 | +**Per-group weights are params data.** Each transformer application gets |
| 23 | +its own params join (never shared with aggregate joins — dedup is a later |
| 24 | +optimization). Its `__CF_PARAMS_{n}__` table is `(join keys, __cf_est)` |
| 25 | +where `__cf_est` indexes the fitted clones — produced by the `kind="fit"` |
| 26 | +plan step (which is now *named* `__CF_PARAMS_{n}__`: all params tables are |
| 27 | +params tables; some come from SQL collapse, some from fitting). Unseen |
| 28 | +group -> LEFT JOIN miss -> NULL id -> NULL output; one NULL story. |
| 29 | + |
| 30 | +**The UDF protocol** (`sql_transform/_udf.py`): declared name + |
| 31 | +`takes`/`returns` in the engine type vocabulary (`"i1"|"i64"|"f64"|"str"`), |
| 32 | +scalar `__call__` as THE semantic contract (plain Python values, None for |
| 33 | +NULL), `apply_batch` over pyarrow whose default loops the scalar form |
| 34 | +(amortizes the boundary without vectorizing the math — batch stays |
| 35 | +bit-identical to row), `register(con)` binds to DuckDB. Determinism is a |
| 36 | +documented contract. Scalar UDFs only — a custom aggregate is what |
| 37 | +transformers already are. |
| 38 | + |
| 39 | +- `PythonUDF(name, fn, takes, returns)` — any plain function, authorable |
| 40 | + in projection SQL (`SELECT myfn(age) ...`), resolved like transformers |
| 41 | + (registry then caller scope), guarded by the DuckDB function catalog. |
| 42 | + Exactly one return. |
| 43 | +- `PythonTransform(name, instances, takes, returns)` — the fitted |
| 44 | + artifact: implicit leading nullable-i64 id; NULL id -> None; id missing |
| 45 | + from `instances` -> raise (broken artifact, not NULL); runtime check of |
| 46 | + each result against `returns`. |
| 47 | + |
| 48 | +**Width comes from the fitted estimator** (probed once at fit). Width 1 |
| 49 | +registers as `DOUBLE` — a width-1 transform IS scalar-valued, so |
| 50 | +mid-expression arithmetic just works. Width k registers as `DOUBLE[]` — |
| 51 | +bare-item use yields a list column; arithmetic on it fails in DuckDB's |
| 52 | +binder with a typed error at transform time. |
| 53 | + |
| 54 | +**`SQLProjection.transform()` becomes register-and-run.** The helper-column |
| 55 | +splicing block deletes; `TransformSpec` deletes (replaced by `UDFSpec(name, |
| 56 | +step, transformer)`), `Marginalized.transforms` becomes `udfs` plus |
| 57 | +`scalar_udfs` (author UDF names to register at fit and serving). |
| 58 | + |
| 59 | +## Refusals (new and kept) |
| 60 | + |
| 61 | +- kept: non-final-level transformer; ORDER BY / frames / FILTER / DISTINCT |
| 62 | + / IGNORE NULLS on a transformer window; bundle rules (one arg, named |
| 63 | + struct_pack, `fn(*)` = zero args); namespaced transformer. |
| 64 | +- new: a transformer call inside a window aggregate's arguments; an |
| 65 | + unknown scalar function with no UDF in registry/scope; a UDF whose |
| 66 | + declared name or arity disagrees with the call site; a UDF inside a |
| 67 | + subquery (later loop); fitting a transformer on an empty training set. |
| 68 | + |
| 69 | +## Gate |
| 70 | + |
| 71 | +Unchanged in spirit: SQL columns bit-exact vs DuckDB at threads=1; |
| 72 | +transformer columns vs the independent clone-per-group sklearn reference; |
| 73 | +author-UDF queries round-trip exactly (fit+transform == original SQL with |
| 74 | +the same UDF registered — the invariant extends verbatim since the UDF is |
| 75 | +deterministic and identical on both sides). |
0 commit comments