Skip to content

Commit 6325292

Browse files
ahrzbclaude
authored andcommitted
feat: UDF protocol + in-place serving calls (DRAFT-22, step 1)
Transformer windows rewrite in place to __cf_tf{j}(id, features...) over a dedicated params join whose table (keys + __cf_est) is produced by the fit step — mid-expression use works, unseen group is the LEFT JOIN's NULL. New _udf.py protocol (declared takes/returns, scalar __call__ as the contract, looping apply_batch, DuckDB register); PythonUDF authoring in projection SQL resolves registry-then-scope behind the catalog guard. transform() is now register-and-run: the helper-column splicing block is gone. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent fe08b63 commit 6325292

6 files changed

Lines changed: 727 additions & 149 deletions

File tree

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
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).
Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
"""SQLProjection — projections over ``__THIS__``, fit once, serve row-at-a-time.
22
33
The fit half works today: ``fit(table)`` marginalizes every window aggregate
4-
into materialized params tables plus a rewritten ``serving_sql``. The serving
5-
half (``infer``/``infer_batch`` through Confit) is a later loop and raises
6-
``NotImplementedError``.
4+
into materialized params tables plus a rewritten ``serving_sql``; transformers
5+
and author UDFs become scalar calls that ``transform(table)`` serves by
6+
registering them on the connection. The serving half (``infer``/``infer_batch``
7+
through Confit) is a later loop and raises ``NotImplementedError``.
78
"""
89

910
from __future__ import annotations
@@ -13,17 +14,22 @@
1314
Marginalized,
1415
MarginalizeError,
1516
ParamsSpec,
16-
TransformSpec,
17+
UDFSpec,
1718
marginalize,
1819
)
1920
from sql_transform._projection import SQLProjection
21+
from sql_transform._udf import UDF, PythonTransform, PythonUDF, UDFError
2022

2123
__all__ = [
24+
"UDF",
2225
"FitStep",
2326
"Marginalized",
2427
"MarginalizeError",
2528
"ParamsSpec",
29+
"PythonTransform",
30+
"PythonUDF",
2631
"SQLProjection",
27-
"TransformSpec",
32+
"UDFError",
33+
"UDFSpec",
2834
"marginalize",
2935
]

0 commit comments

Comments
 (0)