Skip to content

Commit 182f74e

Browse files
ahrzbclaude
andcommitted
feat: SQLProjection.infer/infer_batch through Confit (DRAFT-22 closed)
One artifact, two bindings: transform() stays the DuckDB oracle; infer/ infer_batch lazily prepare a DuckDBInferFn from the same serving_sql + params + UDF objects (udfs=, shape="map"). Serving row model derives from the training table's arrow schema. backend/boundary/output_model delegate; unfitted use refuses by name. Gate: infer_batch == transform value-for- value across aggregates, transformers (width 1 and 2), author UDFs, chains, unseen-group NULLs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent c251982 commit 182f74e

5 files changed

Lines changed: 229 additions & 27 deletions

File tree

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# SQLProjection serving through Confit (DRAFT-22, the integration loop)
2+
3+
The last leg of DRAFT-22: `infer`/`infer_batch` stop raising and bind the
4+
fitted artifact to Confit. Requires steps 1-3 (PRs #62, #63), both merged.
5+
6+
## What
7+
8+
One artifact, two bindings:
9+
10+
```python
11+
p = SQLProjection(sql, transformers={...}).fit(train)
12+
p.transform(table) # DuckDB batch: register UDFs, run serving_sql
13+
p.infer(row) # Confit row-at-a-time: the same three pieces —
14+
p.infer_batch(rows) # serving_sql + params tables + UDF objects —
15+
# passed to DuckDBInferFn(udfs=..., shape="map")
16+
```
17+
18+
- `_serving_fn()` prepares the `DuckDBInferFn` lazily after fit and caches
19+
it; refit invalidates. `backend`/`boundary`/`output_model` delegate.
20+
- The serving **row model derives from the training table's arrow schema**
21+
(real types, guaranteed), not from `this_model` (which may carry
22+
`object` fields and stays authoritative only for names/order at
23+
marginalize time). Unmappable columns become opaque fields — Confit
24+
accepts them unless the SQL references them. Consequence (v0 contract):
25+
serving rows have the training table's shape.
26+
- `shape="map"`: every serving query is provably one-row-per-row (LEFT
27+
params joins only), so `infer` returns exactly one typed instance.
28+
29+
## Gate
30+
31+
`_serving_test.py`: for every admitted family (aggregates keyed/keyless,
32+
transformer width-1 partitioned, width-2 global with struct bundle, author
33+
UDFs, CTE chains, unseen-group NULL), `infer_batch(train rows)` equals
34+
`transform(train)` value-for-value — the Confit binding vs the DuckDB
35+
oracle binding of the same artifact. Dict rows and model rows agree.
36+
37+
## Deliberately out
38+
39+
- Pickle/serialization of the fitted projection (artifact = serving_sql +
40+
Arrow params + UDF objects; the pickle boundary is a later decision).
41+
- Native `confit.udfs.*` entries (DRAFT-23) — swap into the same `udfs=`
42+
list when they land.
43+
- Serving-row schema narrowed to referenced columns only.

packages/sql-transform/sql_transform/__init__.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
"""SQLProjection — projections over ``__THIS__``, fit once, serve row-at-a-time.
22
3-
The fit half works today: ``fit(table)`` marginalizes every window aggregate
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``.
3+
``fit(table)`` marginalizes every window aggregate into materialized params
4+
tables plus a rewritten ``serving_sql``; transformers fit per group into
5+
``PythonTransform`` UDFs. One artifact, two bindings: ``transform(table)``
6+
runs it through DuckDB (batch, the oracle), ``infer``/``infer_batch`` run it
7+
through Confit (row-at-a-time, bit-exact with the DuckDB path).
88
"""
99

1010
from __future__ import annotations

packages/sql-transform/sql_transform/_projection.py

Lines changed: 64 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
"""SQLProjection — projections over ``__THIS__``, fit once, serve row-at-a-time.
22
3-
The fit half works: window aggregates over ``__THIS__`` are marginalized into
3+
The fit half: window aggregates over ``__THIS__`` are marginalized into
44
materialized params tables plus a rewritten ``serving_sql`` (see
5-
``_marginalize``). Transformers and author UDFs serve as scalar calls in that
6-
SQL — ``transform`` registers them on the connection and runs; there is no
7-
post-processing. The serving half (``infer``/``infer_batch`` through Confit)
8-
is a later loop and still raises ``NotImplementedError``.
5+
``_marginalize``); transformers fit per group into ``PythonTransform`` UDFs.
6+
The serving half is two bindings of the same artifact: ``transform`` runs
7+
``serving_sql`` through DuckDB with the UDFs registered (batch, the oracle),
8+
and ``infer``/``infer_batch`` run it through Confit's ``DuckDBInferFn`` with
9+
the same UDF objects passed as ``udfs=`` (row-at-a-time, bit-exact with the
10+
DuckDB path by Confit's contract).
911
"""
1012

1113
from __future__ import annotations
@@ -32,7 +34,28 @@ def _feature_matrix(table: pa.Table, cols: list[str]):
3234
return mat
3335

3436

35-
_TODO = "SQLProjection serving is a later loop; {} has no implementation yet"
37+
def _model_from_arrow(schema: pa.Schema) -> type[BaseModel]:
38+
"""The serving row model, derived from the training table's schema (real
39+
types, guaranteed — a declared ``this_model`` may carry ``object``
40+
fields). Unmappable types become opaque ``object`` fields: Confit
41+
accepts those unless the SQL references them."""
42+
import pydantic
43+
44+
fields: dict[str, Any] = {}
45+
for f in schema:
46+
t = f.type
47+
if pa.types.is_floating(t):
48+
p: type = float
49+
elif pa.types.is_integer(t):
50+
p = int
51+
elif pa.types.is_boolean(t):
52+
p = bool
53+
elif pa.types.is_string(t) or pa.types.is_large_string(t):
54+
p = str
55+
else:
56+
p = object
57+
fields[f.name] = (p | None if p is not object else Any, None)
58+
return pydantic.create_model("Row", **fields)
3659

3760

3861
class SQLProjection:
@@ -90,6 +113,8 @@ def _resolve(name: str):
90113
self._marginalized = marginalize(sql, self._columns, _resolve)
91114
self._params: dict[str, pa.Table] | None = None
92115
self._udfs: dict[str, PythonTransform] | None = None
116+
self._row_model: type[BaseModel] | None = None
117+
self._fn: Any = None # confit.DuckDBInferFn, built lazily post-fit
93118

94119
@classmethod
95120
def from_file(cls, path: str) -> SQLProjection:
@@ -147,6 +172,8 @@ def fit(self, table: pa.Table, /) -> SQLProjection:
147172
self._params = {spec.name: materialized[spec.name] for spec in m.params}
148173
finally:
149174
con.close()
175+
self._row_model = _model_from_arrow(table.schema)
176+
self._fn = None # refit invalidates the prepared serving function
150177
return self
151178

152179
def _fit_step(self, step, table: pa.Table) -> tuple[pa.Table, PythonTransform]:
@@ -257,20 +284,47 @@ def udfs(self) -> dict[str, UDF]:
257284
out.update(self._udfs)
258285
return out
259286

287+
def _serving_fn(self):
288+
"""The Confit binding of the fitted artifact, prepared once: the same
289+
``serving_sql``, params tables, and UDF objects the DuckDB path uses —
290+
Confit's contract makes the two bit-exact or refuses by name."""
291+
if self._params is None or self._udfs is None or self._row_model is None:
292+
raise MarginalizeError("not fitted: call fit(table) first")
293+
if self._fn is None:
294+
from confit import DuckDBInferFn
295+
296+
m = self._marginalized
297+
udfs = [self.transformers[n] for n in m.scalar_udfs]
298+
udfs += list(self._udfs.values())
299+
self._fn = DuckDBInferFn(
300+
m.serving_sql,
301+
row_tables={"__THIS__": self._row_model},
302+
static_tables=dict(self._params),
303+
udfs=udfs,
304+
shape="map",
305+
)
306+
return self._fn
307+
260308
@property
261309
def backend(self) -> str:
262310
"""Execution backend: "cranelift", "interpreter", or "constant"."""
263-
raise NotImplementedError(_TODO.format("backend"))
311+
return self._serving_fn().backend
264312

265313
@property
266314
def boundary(self) -> str:
267315
"""Boundary path: "marshaller", "generic", or "constant"."""
268-
raise NotImplementedError(_TODO.format("boundary"))
316+
return self._serving_fn().boundary
317+
318+
@property
319+
def output_model(self) -> type[BaseModel]:
320+
"""The typed output row model ``infer`` returns instances of."""
321+
return self._serving_fn().output_model
269322

270323
def infer(self, row: dict[str, Any] | BaseModel, /) -> BaseModel:
271324
"""Single-row inference; returns the typed output model instance."""
272-
raise NotImplementedError(_TODO.format("infer"))
325+
(out,) = self._serving_fn().infer_rows([row]) # shape="map": exactly one
326+
return out
273327

274328
def infer_batch(self, rows: list[dict[str, Any] | BaseModel], /) -> list[BaseModel]:
275329
"""Many-rows inference; returns typed output model instances."""
276-
raise NotImplementedError(_TODO.format("infer_batch"))
330+
return self._serving_fn().infer_rows(rows)

packages/sql-transform/sql_transform/_projection_test.py

Lines changed: 7 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -501,19 +501,14 @@ def test_template_input_is_a_later_loop():
501501
SQLProjection(t"SELECT 1 AS x FROM __THIS__")
502502

503503

504-
@pytest.mark.parametrize("method", ["infer", "infer_batch"])
505-
def test_serving_methods_still_raise(method):
504+
@pytest.mark.parametrize("attr", ["infer", "infer_batch", "backend", "boundary"])
505+
def test_serving_surface_requires_fit(attr):
506+
# The serving half is live (see _serving_test.py); unfitted use refuses.
506507
p = SQLProjection("SELECT age + 1 AS b FROM __THIS__")
507-
with pytest.raises(NotImplementedError, match=method):
508-
getattr(p, method)(None)
509-
510-
511-
@pytest.mark.parametrize("prop", ["backend", "boundary"])
512-
def test_serving_properties_still_raise(prop):
513-
p = SQLProjection("SELECT age + 1 AS b FROM __THIS__")
514-
assert isinstance(inspect.getattr_static(SQLProjection, prop), property)
515-
with pytest.raises(NotImplementedError, match=prop):
516-
getattr(p, prop)
508+
with pytest.raises(MarginalizeError, match="not fitted"):
509+
v = getattr(p, attr)
510+
if callable(v):
511+
v(None)
517512

518513

519514
def test_signatures_are_stable():
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
"""The serving gate: infer/infer_batch (Confit) == transform (DuckDB).
2+
3+
Both bindings run the same artifact — serving_sql, params tables, UDF
4+
objects — so their outputs must agree value-for-value on every admitted
5+
query. ``transform`` is the DuckDB oracle side; ``infer_batch`` is the
6+
row-at-a-time path this project exists for.
7+
"""
8+
9+
import pyarrow as pa
10+
import pytest
11+
from sklearn.decomposition import PCA
12+
from sklearn.pipeline import Pipeline
13+
from sklearn.preprocessing import StandardScaler
14+
15+
from sql_transform import MarginalizeError, PythonUDF, SQLProjection
16+
17+
TRAIN = pa.table(
18+
{
19+
"country": ["US", "US", None, "DE", None, "DE", "FR"],
20+
"age": [40.0, 30.0, 20.0, 25.0, 50.0, 45.0, 35.0],
21+
"fare": [7, 8, 6, 9, 10, 11, 5],
22+
"name": ["x", "y", "z", "w", "v", "u", "t"],
23+
}
24+
)
25+
26+
27+
def serve_gate(sql: str, table: pa.Table = TRAIN, **kwargs) -> SQLProjection:
28+
"""Fit, then assert the Confit row path equals the DuckDB batch path."""
29+
p = SQLProjection(sql, **kwargs).fit(table)
30+
want = p.transform(table).to_pylist()
31+
got = [r.model_dump() for r in p.infer_batch(table.to_pylist())]
32+
assert got == want, f"\n{got}\n!=\n{want}"
33+
return p
34+
35+
36+
def test_aggregates_only():
37+
p = serve_gate(
38+
"SELECT age - avg(age) OVER (PARTITION BY country) AS d, name FROM __THIS__"
39+
)
40+
assert p.backend in ("cranelift", "interpreter")
41+
assert p.boundary == "marshaller"
42+
43+
44+
def test_keyless_aggregate_and_int_column():
45+
serve_gate("SELECT fare - avg(fare) OVER () AS d, name FROM __THIS__")
46+
47+
48+
def test_transformer_partitioned_width1():
49+
sc = StandardScaler()
50+
p = serve_gate(
51+
"SELECT sc(age) OVER (PARTITION BY country) * 10 + 1 AS z, name FROM __THIS__",
52+
transformers={"sc": sc},
53+
)
54+
out = p.infer({"country": "US", "age": 33.0, "fare": 1, "name": "q"})
55+
assert isinstance(out.z, float)
56+
57+
58+
def test_transformer_global_width2_list_field():
59+
embed = Pipeline([("s", StandardScaler()), ("p", PCA(n_components=2))])
60+
p = serve_gate(
61+
"SELECT embed(struct_pack(a := age, f := fare)) OVER () AS e, name"
62+
" FROM __THIS__",
63+
transformers={"embed": embed},
64+
)
65+
out = p.infer({"country": "US", "age": 33.0, "fare": 4, "name": "q"})
66+
assert isinstance(out.e, list) and len(out.e) == 2
67+
68+
69+
def test_unseen_group_is_null_row_at_a_time():
70+
sc = StandardScaler()
71+
p = serve_gate(
72+
"SELECT sc(age) OVER (PARTITION BY country) AS z, name FROM __THIS__",
73+
transformers={"sc": sc},
74+
)
75+
out = p.infer({"country": "JP", "age": 1.0, "fare": 1, "name": "q"})
76+
assert out.z is None
77+
78+
79+
def test_author_udf_serves():
80+
halve = PythonUDF(
81+
"halve", lambda x: None if x is None else x / 2.0, ("f64",), ("f64",)
82+
)
83+
serve_gate(
84+
"SELECT halve(age) - avg(halve(age)) OVER () AS d, name FROM __THIS__",
85+
transformers={"halve": halve},
86+
)
87+
88+
89+
def test_chain_with_transformer():
90+
sc = StandardScaler()
91+
serve_gate(
92+
"WITH a AS (SELECT age * 2 AS age2, country, name FROM __THIS__)"
93+
" SELECT sc(age2) OVER (PARTITION BY country) - 1 AS z, name FROM a",
94+
transformers={"sc": sc},
95+
)
96+
97+
98+
def test_model_rows_and_dict_rows_agree():
99+
p = serve_gate("SELECT age - avg(age) OVER () AS d FROM __THIS__")
100+
row = {"country": "US", "age": 33.0, "fare": 1, "name": "q"}
101+
model_row = p._row_model(**row)
102+
assert p.infer(row).model_dump() == p.infer(model_row).model_dump()
103+
104+
105+
def test_not_fitted_refuses():
106+
p = SQLProjection("SELECT age - avg(age) OVER () AS d FROM __THIS__")
107+
with pytest.raises(MarginalizeError, match="not fitted"):
108+
p.infer({"age": 1.0})
109+
with pytest.raises(MarginalizeError, match="not fitted"):
110+
_ = p.backend

0 commit comments

Comments
 (0)