Skip to content

Commit c251982

Browse files
ahrzbclaude
authored andcommitted
feat: udfs= surface — pyo3 trampoline, wide list boundary, .pyi contract
DuckDBInferFn(..., udfs=[...]) accepts duck-typed protocol objects (name/ takes/returns, scalar __call__, an instances attribute marking the implicit leading i64 id), builds ExternSpecs for prepare and boxed GIL trampolines for both backends. Width-k bare items emit one list|None field at every boundary (marshaller, generic, infer_arrow as pa.list_). Constant fallback is off under declared udfs. Contract parameterized, not weakened: bit-exact vs DuckDB with the same udfs registered, or refuse. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent f21cf2c commit c251982

5 files changed

Lines changed: 891 additions & 94 deletions

File tree

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
# Confit: params-join wiring + UDF externs (DRAFT-22, steps 2+3)
2+
3+
Full design: `backlog/drafts/draft-22 - UDF-protocol-and-Confit-externs-serving-transformers-without-special-support.md`
4+
(+ its addendum and DRAFT-23). Scope per AmirHossein: **PythonTransform tier
5+
only** — the opaque-callable fallback/oracle; native `confit.udfs.*` families
6+
are the next loop (DRAFT-23).
7+
8+
## What Confit gains
9+
10+
**Step 3 — the marginalizer's join shape builds.**
11+
12+
- `IS NOT DISTINCT FROM` join keys against static build sides: a NULL key is
13+
an ordinary key value (one bucket) — encoded as a (validity, payload) pair
14+
in the probe map, on both the build and probe sides.
15+
- The keyless always-true LEFT JOIN (`ON ((1 = 1))`) against a one-row
16+
static.
17+
- Both under "map"/"filter" shapes: these joins are 0-or-1 by the params
18+
contract, and the LEFT-join rule of the exactly-one proof covers them.
19+
20+
**Step 2 — declared UDF externs.**
21+
22+
- New program header `extern @N: name(tys) -> (tys)` + `ecall` instruction.
23+
Operand layout: one (validity, payload) pair per declared param in; a
24+
whole-call validity (the NULL list, distinct from a list of NULLs) plus a
25+
(validity, payload) pair per declared return out.
26+
- Both backends route through one shared `call_extern` that enforces the
27+
declared return shape — wrong length/type or a raised exception is a
28+
named trap. The cranelift path is an `extern "C"` helper over the same
29+
function (the no-drift rule).
30+
- `DuckDBInferFn(..., udfs=[...])`: duck-typed protocol objects (`name`,
31+
`takes`, `returns`, scalar `__call__`; an `instances` attribute marks the
32+
implicit leading nullable-i64 instance id). The trampoline attaches to
33+
Python per call, converts NULL-masked scalars both ways under the
34+
declared types.
35+
- Width-1 calls are ordinary scalar expressions (mid-expression works).
36+
Width-k calls are bare SELECT items emitting ONE `list | None` field —
37+
assembled at every boundary (row marshaller, generic path, `infer_arrow`
38+
as `pa.list_`), NULL list when the id/whole-call is NULL. Mid-expression
39+
width-k refuses by name.
40+
- The contract is parameterized, not weakened: bit-exact vs DuckDB *with
41+
the same udfs registered* (`create_function`), or refuse. Unknown
42+
functions without a matching declaration refuse exactly as before; the
43+
static-only constant fallback is off when udfs are declared.
44+
45+
## Gate
46+
47+
- Rust: 189 tests including the DRAFT-22 serving-shape end-to-end, IR
48+
round-trip/verify for `extern`/`ecall`, and the cranelift-vs-interpreter
49+
random-IR differential.
50+
- Python: `tests/test_params_joins.py` (INDF/keyless differentials vs
51+
DuckDB) and `tests/test_udfs.py` (marginalizer shape, wide list fields,
52+
arrow boundary, both backends agree, trap/refusal surface) — all
53+
differential against DuckDB with the same objects registered.
54+
55+
## Deliberately out (next loops)
56+
57+
- Native typed entries (`confit.udfs.PCA/StandardScaler/TreeEnsemble`) —
58+
DRAFT-23, behind the same extern slots.
59+
- `SQLProjection.infer/infer_batch` wiring through this surface (the
60+
integration loop; needs this PR + sql-transform's step 1, both merged).
61+
- Vectorized `apply_batch` binding for `infer_arrow` (today: per-row calls
62+
through the scalar protocol — correct, boundary-amortization later).

packages/confit/confit/_engine.pyi

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,9 @@ class DuckDBInferFn:
1010
"""SQL specialized against frozen static tables, served bit-exact with DuckDB.
1111
1212
Construction either succeeds with a function that matches DuckDB
13-
bit-for-bit, or raises `ValueError` naming the unsupported construct. There
14-
is no third mode.
13+
bit-for-bit — *with the declared `udfs` registered*, when there are any —
14+
or raises `ValueError` naming the unsupported construct. There is no
15+
third mode.
1516
"""
1617

1718
output_model: type[BaseModel] | None
@@ -21,11 +22,27 @@ class DuckDBInferFn:
2122
sql: str,
2223
row_tables: dict[str, type[BaseModel]],
2324
static_tables: dict[str, pa.Table],
25+
udfs: list[Any] | None = None,
2426
output_model: type[BaseModel] | None = None,
2527
output: str | None = None,
2628
shape: str | None = None,
2729
) -> None:
28-
"""`output`: "model" (typed, default) or "dict".
30+
"""`udfs`: declared opaque scalar functions the SQL may call
31+
(DRAFT-22). Each object carries `name: str`, `takes: tuple[str, ...]`
32+
and `returns: tuple[str, ...]` in the engine type vocabulary
33+
("i1" | "i64" | "f64" | "str"), and a scalar `__call__(*args)`
34+
receiving one plain Python value per argument (None for NULL) and
35+
returning a tuple matching `returns`, or None for an all-NULL
36+
result. An object with an `instances` attribute is a fitted
37+
transformer: its implicit leading argument is a nullable BIGINT
38+
instance id (never written in `takes`). Width-1 calls are scalar
39+
expressions; width-k calls are bare SELECT items emitting one
40+
`list | None` field. The oracle statement is parameterized, not
41+
weakened: the engine matches DuckDB running the same SQL with these
42+
same objects registered via `create_function`. UDFs must be
43+
deterministic.
44+
45+
`output`: "model" (typed, default) or "dict".
2946
3047
`shape`: the output-multiplicity contract, proven at build time --
3148
"map" (exactly one row out per row in; rejects WHERE and inner joins),

packages/confit/src/duckdb/arrow.rs

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -230,15 +230,50 @@ fn bitmap(oks: impl Iterator<Item = bool>, n: usize) -> (Vec<u8>, usize) {
230230
(bits, nulls)
231231
}
232232

233-
/// Engine output -> pa.Table, one `Array.from_buffers` per column.
234-
pub fn emit(py: Python<'_>, out_cols: &[Col], st: &RunState) -> PyResult<Py<PyAny>> {
233+
/// Engine output -> pa.Table, one `Array.from_buffers` per scalar field; a
234+
/// wide UDF field assembles as `pa.array` of `None | [components]` (the
235+
/// python-list path — the wide boundary is transformer output, not the
236+
/// scalar hot lane).
237+
pub fn emit(
238+
py: Python<'_>,
239+
out_cols: &[Col],
240+
plan: &[super::EmitField],
241+
st: &RunState,
242+
) -> PyResult<Py<PyAny>> {
235243
let pa = PyModule::import(py, "pyarrow")?;
236244
let from_buffers = pa.getattr("Array")?.getattr("from_buffers")?;
237245
let py_buffer = pa.getattr("py_buffer")?;
238246
let n = st.emitted;
239-
let mut arrays = Vec::with_capacity(out_cols.len());
240-
let mut names = Vec::with_capacity(out_cols.len());
241-
for (c, oc) in out_cols.iter().zip(&st.out) {
247+
let mut arrays = Vec::with_capacity(plan.len());
248+
let mut names = Vec::with_capacity(plan.len());
249+
for field in plan {
250+
let (c, oc) = match field {
251+
super::EmitField::Scalar(i) => (&out_cols[*i], &st.out[*i]),
252+
super::EmitField::Wide {
253+
name,
254+
valid,
255+
first,
256+
width,
257+
} => {
258+
names.push(name.clone());
259+
let elem = match out_cols[*first].ty.ty {
260+
crate::specializer::ir::Ty::I1 => pa.call_method0("bool_")?,
261+
crate::specializer::ir::Ty::I64 => pa.call_method0("int64")?,
262+
crate::specializer::ir::Ty::F64 => pa.call_method0("float64")?,
263+
crate::specializer::ir::Ty::Str => pa.call_method0("large_string")?,
264+
};
265+
let list_ty = pa.call_method1("list_", (elem,))?;
266+
let mut values = Vec::with_capacity(n);
267+
for r in 0..n {
268+
values.push(super::wide_py(py, st, *valid, *first, *width, r)?);
269+
}
270+
let vals = PyList::new(py, values)?;
271+
let kw = pyo3::types::PyDict::new(py);
272+
kw.set_item("type", list_ty)?;
273+
arrays.push(pa.call_method("array", (vals,), Some(&kw))?);
274+
continue;
275+
}
276+
};
242277
names.push(c.name.clone());
243278
let (dtype, validity, bufs): (Bound<'_, PyAny>, (Vec<u8>, usize), Vec<Py<PyAny>>) =
244279
match oc {

0 commit comments

Comments
 (0)