Skip to content

Commit fe08b63

Browse files
ahrzbclaude
authored andcommitted
docs: DRAFT-22 — UDF protocol + Confit externs for serving transformers
Converged design dialogue 2026-07-29: per-group weights as params data (instance ids over the existing join), a declared scalar-UDF protocol with PythonTransform as subclass, InferFn udfs= API, extern helper family in Confit. Parked until the go. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent eeaf547 commit fe08b63

1 file changed

Lines changed: 203 additions & 0 deletions

File tree

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

Comments
 (0)