|
| 1 | +--- |
| 2 | +id: DRAFT-23 |
| 3 | +title: "Native UDF families: typed confit.udfs.* entries with extracted state" |
| 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 DRAFT-22 step 1 |
| 12 | +landed (PR #62) and while the Confit extern substrate was being built. The |
| 13 | +shape is his: for known estimator families the *udf entry itself* goes |
| 14 | +native — the SQL never changes. This is also the design half of the parked |
| 15 | +"optimized sklearn transforms" milestone (milestone creation still gated on |
| 16 | +his go). |
| 17 | + |
| 18 | +## The design |
| 19 | + |
| 20 | +The `udfs=` list is a **typed registry**. Same SQL text under every entry: |
| 21 | + |
| 22 | +```python |
| 23 | +DuckDBInferFn( |
| 24 | + 'SELECT pca(__cf_p0.__cf_est, __cf_t.age, __cf_t.fare) AS e, ...' |
| 25 | + ' FROM __THIS__ AS __cf_t LEFT JOIN __CF_PARAMS_0__ AS __cf_p0 ON ((...))', |
| 26 | + static_tables={"__CF_PARAMS_0__": "<country, __cf_est>"}, |
| 27 | + udfs=[confit.udfs.PCA( |
| 28 | + "pca", n_components=2, |
| 29 | + instances={0: {"mean": [37.2, 8.1], "components": [[...], [...]]}, |
| 30 | + 1: {...}}, # plain arrays, extracted at fit — no pickle |
| 31 | + )], |
| 32 | +) |
| 33 | + |
| 34 | +# trees: fit step is an importer; keyless => literal id, no join at all |
| 35 | +DuckDBInferFn( |
| 36 | + 'SELECT score(0, __cf_t.age, __cf_t.fare) AS s, ...', |
| 37 | + udfs=[confit.udfs.TreeEnsemble("score", instances={0: "<trees as data>"})], |
| 38 | +) |
| 39 | +``` |
| 40 | + |
| 41 | +| entry | implementation | state | parity tier | |
| 42 | +|---|---|---|---| |
| 43 | +| `PythonTransform` | GIL trampoline -> `est.transform` | pickle | the oracle, always available | |
| 44 | +| `confit.udfs.StandardScaler` | native affine kernel | arrays | bit-exact target | |
| 45 | +| `confit.udfs.PCA` | native matvec kernel | arrays | toleranced (BLAS order unpinnable) | |
| 46 | +| `confit.udfs.TreeEnsemble` | native tree walk | tree tables | bit-exact reachable (compares + fixed-order sums) | |
| 47 | + |
| 48 | +Rules: |
| 49 | + |
| 50 | +- **Hyperparams live on the entry** (`n_components=2`); fitted state per |
| 51 | + instance id. The entry is self-describing: `takes`/`returns` are |
| 52 | + *derived* by the class (PCA with n_components=2 knows returns is 2 wide), |
| 53 | + not hand-declared. |
| 54 | +- **One SQL text across tiers.** Fallback -> optimized is swapping the list |
| 55 | + entry. The gate is "same query, same statics, swap the entry, compare" |
| 56 | + — no query rewrite to account for, so a disagreement is always the |
| 57 | + kernel's fault. Tolerance declared per family (table above), never |
| 58 | + discovered per bug. |
| 59 | +- **Family recognition in `SQLProjection._prepare`, conservative:** if the |
| 60 | + fitted estimator (or every step of a fitted Pipeline) maps to a native |
| 61 | + family, emit the typed entry with extracted state; anything unrecognized |
| 62 | + falls through to `PythonTransform`. Recognition is per-estimator-class; |
| 63 | + the fallback existing is what lets recognition stay conservative. |
| 64 | +- **No pickle for known families** — instances are arrays/tables, |
| 65 | + JSON/Arrow-serializable. This is what unlocks non-Python hosts (the |
| 66 | + wazero/chicory WASM path): a Go/Java service can serve per-country PCA |
| 67 | + because the entry is data plus a kernel that ships inside the engine. |
| 68 | + `PythonTransform` can never cross that boundary; native entries can. |
| 69 | +- **Engine side:** each family is a new `ExternImpl` variant behind the |
| 70 | + same extern slot the DRAFT-22 substrate provides (Cx slot table: |
| 71 | + PyTrampoline | NativeKernel). Frontend/type-checking unchanged. |
| 72 | +- **DuckDB batch side:** each `confit.udfs.*` class also registers a plain |
| 73 | + Python implementation of the same math (`create_function`), so the batch |
| 74 | + path and the differential gate run without the Rust engine in the loop. |
| 75 | + |
| 76 | +## Sequencing |
| 77 | + |
| 78 | +1. DRAFT-22 substrate lands (extern slots + PythonTransform trampoline + |
| 79 | + params-join wiring — in flight). |
| 80 | +2. `confit.udfs.StandardScaler` first (affine, bit-exact target — proves |
| 81 | + the swap-the-entry gate), then `PCA` (tolerance story), then |
| 82 | + `TreeEnsemble` (importer fit step, `reads = ()`). |
| 83 | +3. Interpretability follow-up, later: named per-component output columns. |
| 84 | + |
| 85 | +## Rejected along the way |
| 86 | + |
| 87 | +- **Desugar-into-SQL-expressions** for known families (DRAFT-22's original |
| 88 | + "column-width decision"): a query rewrite between tiers means the gate |
| 89 | + compares two different texts — weaker isolation, and wide params columns |
| 90 | + can't carry matrices/trees anyway. Superseded by typed entries. |
| 91 | +- **Pickled sklearn as the serving artifact for known families**: keeps |
| 92 | + Python in the serve path and locks out non-Python hosts; it remains the |
| 93 | + catch-all tier only. |
0 commit comments