Skip to content

Commit 13a3a7e

Browse files
ahrzbclaude
andcommitted
feat(specializer): raw-dict output mode — output='dict' opt-in closes the python_dict gap (TASK-51)
The marshaller already builds the per-row dict the model would consume; dict mode returns it and skips model construction. Typed default is untouched; mutually exclusive with output_model; all four emit paths honor it (marshaller / generic / reentrant / constant, the latter copying per call). Parity gate extended: dict == typed field-for-field before any timing. Bench gains the spec_dict row: 25-35% off the typed path at n=1024, and the handcrafted python_dict floor shrinks from the standing 1.3-2x caveat to 0.74-1.11x — spec_dict WINS house_prices; what remains is input marshalling, not output. TASK-50 Done-flip and TASK-51 ride this branch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent d2b2c41 commit 13a3a7e

6 files changed

Lines changed: 220 additions & 8 deletions

backlog/tasks/task-50 - Specializer-SQL-support-join-forms-—-USING-residual-ON-semi-joins-comma-rewrite-wave-4.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@ id: TASK-50
33
title: >-
44
Specializer SQL support: join forms — USING, residual ON, semi joins, comma
55
rewrite (wave 4)
6-
status: In Progress
6+
status: Done
77
assignee: []
88
created_date: '2026-07-26 18:15'
9-
updated_date: '2026-07-26 19:24'
9+
updated_date: '2026-07-26 20:17'
1010
labels: []
1111
milestone: m-7
1212
dependencies:
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
---
2+
id: TASK-51
3+
title: Specializer raw-dict output mode — close the python_dict gap (opt-in)
4+
status: Done
5+
assignee: []
6+
created_date: '2026-07-26 20:17'
7+
labels: []
8+
milestone: m-7
9+
dependencies:
10+
- TASK-50
11+
type: feature
12+
ordinal: 45000
13+
---
14+
15+
## Acceptance Criteria
16+
<!-- AC:BEGIN -->
17+
- [x] #1 output='dict' opt-in on DuckDBInferFn; typed default untouched; mutually exclusive with output_model; unknown values rejected
18+
- [x] #2 All four emit paths honor it (marshaller, generic fallback, reentrant, constant engine) with fresh dicts per call
19+
- [x] #3 Parity: dict mode == typed mode field-for-field, enforced in the serving parity gate + oracle tests
20+
- [x] #4 Bench: spec_dict engine row lands the python_dict head-to-head
21+
- [x] #5 gate green
22+
<!-- AC:END -->
23+
24+
## Final Summary
25+
26+
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
27+
Raw-dict output shipped as a strict opt-in: DuckDBInferFn(..., output="dict") returns per-row plain dicts, skipping model construction — the marshaller already built the dict the model would consume, so the modes agree field-for-field by construction (enforced in the serving parity gate and tests). All four emit paths honor the mode (generated marshaller, generic baseline, reentrant fallback, constant engine — the latter copying per call so caller mutation cannot leak). Guards: mutually exclusive with output_model, unknown values rejected, output getter exposes the mode. Measured at n=1024: 25-35% off the typed path; the handcrafted python_dict floor shrinks from the standing 1.3-2x caveat to 0.74-1.11x (spec_dict WINS house_prices) — the remaining gap is input marshalling, not output. Gate green (759 + 13 xfail); 6 new tests + parity-gate extension + spec_dict bench engine.
28+
<!-- SECTION:FINAL_SUMMARY:END -->

benchmarks/bench_serving.py

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
n in {1, 8, 64, 1024}:
66
77
spec — the specializer: cranelift + generated marshaller (ours)
8+
spec_dict — the same fn built with output="dict" (raw-dict opt-in mode)
89
interp — interpreter backend, same marshaller (backend control;
910
SPECIALIZER_FORCE_INTERP=1)
1011
generic — cranelift behind the pre-marshaller boundary (previous
@@ -87,6 +88,10 @@ def build_callers(mod, engines):
8788
for e in ("spec", "interp", "generic"):
8889
if e in engines:
8990
callers[e] = fn.infer_rows
91+
if "spec_dict" in engines:
92+
# The raw-dict output mode: same engine, marshaller skips model
93+
# construction — python_dict's fair opponent.
94+
callers["spec_dict"] = sc.build_spec_fn(mod, statics, output="dict").infer_rows
9095

9196
for eng, cls_path in (
9297
("native", "sql_transform._interpreter.InferFn"),
@@ -171,7 +176,15 @@ def orchestrate():
171176
sys.exit(1)
172177

173178
groups = {
174-
"main": ["spec", "native", "codegen", "duckdb", "python", "python_dict"],
179+
"main": [
180+
"spec",
181+
"spec_dict",
182+
"native",
183+
"codegen",
184+
"duckdb",
185+
"python",
186+
"python_dict",
187+
],
175188
"interp": ["interp"],
176189
"generic": ["generic"],
177190
}
@@ -190,7 +203,16 @@ def orchestrate():
190203
for scenario, per in json.loads(out.stdout.strip().splitlines()[-1]).items():
191204
merged.setdefault(scenario, {}).update(per)
192205

193-
order = ["spec", "interp", "generic", "native", "codegen", "duckdb", "python"]
206+
order = [
207+
"spec",
208+
"spec_dict",
209+
"interp",
210+
"generic",
211+
"native",
212+
"codegen",
213+
"duckdb",
214+
"python",
215+
]
194216
hdr = f"{'scenario':<14} {'engine':<9}" + "".join(f"{f'n={n}':>15}" for n in NS)
195217
print("\n" + hdr)
196218
print("-" * len(hdr))

benchmarks/serving_scenarios/__init__.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,14 +67,15 @@ def rows_table(mod, rows: list[dict]) -> pa.Table:
6767
return pa.Table.from_pylist(rows, schema=arrow_schema(mod.ROW_SCHEMA))
6868

6969

70-
def build_spec_fn(mod, statics: dict[str, pa.Table]):
70+
def build_spec_fn(mod, statics: dict[str, pa.Table], output: str = "model"):
7171
"""The specializer serving fn (env knobs select backend/boundary)."""
7272
from sql_transform._interpreter import DuckDBInferFn
7373

7474
return DuckDBInferFn(
7575
mod.SQL,
7676
row_tables={"__THIS__": row_model(mod.ROW_SCHEMA)},
7777
static_tables=statics,
78+
output=output,
7879
)
7980

8081

@@ -119,6 +120,12 @@ def verify_parity(mod, n: int = 300) -> list[str]:
119120
if fn.backend != "cranelift":
120121
problems.append(f"{mod.NAME}: backend is {fn.backend}, not cranelift")
121122

123+
# The raw-dict mode must agree with the typed mode field-for-field
124+
# (positional — same engine, same input order).
125+
got_dict = build_spec_fn(mod, statics, output="dict").infer_rows(rows)
126+
if got_dict != got_spec:
127+
problems.append(f"{mod.NAME}: output='dict' differs from the typed mode")
128+
122129
def norm(row: dict) -> tuple:
123130
return tuple((k, repr(v)) for k, v in row.items())
124131

src/duckdb/mod.rs

Lines changed: 59 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,10 @@ struct Marshaller {
235235
model: Py<PyAny>,
236236
/// `output_model.model_validate`, present iff the model was supplied.
237237
validate: Option<Py<PyAny>>,
238+
/// output="dict": emit the row dict itself — no model at all. The
239+
/// dict is exactly what validate/slot-fill would have consumed, so
240+
/// the modes agree field-for-field by construction.
241+
emit_dicts: bool,
238242
object_new: Py<PyAny>,
239243
object_setattr: Py<PyAny>,
240244
s_dict: Py<PyString>,
@@ -252,6 +256,7 @@ impl Marshaller {
252256
out_cols: &[Col],
253257
output_model: &Py<PyAny>,
254258
supplied: bool,
259+
emit_dicts: bool,
255260
fun: &Backend,
256261
) -> PyResult<Marshaller> {
257262
let object = PyModule::import(py, "builtins")?.getattr("object")?;
@@ -270,6 +275,7 @@ impl Marshaller {
270275
} else {
271276
None
272277
},
278+
emit_dicts,
273279
object_new: object.getattr("__new__")?.unbind(),
274280
object_setattr: object.getattr("__setattr__")?.unbind(),
275281
s_dict: PyString::intern(py, "__dict__").unbind(),
@@ -387,6 +393,10 @@ impl Marshaller {
387393
}
388394
}
389395
}
396+
if self.emit_dicts {
397+
out.push(d.unbind().into_any());
398+
continue;
399+
}
390400
if let Some(v) = &self.validate {
391401
// Supplied model: full pydantic semantics (validators,
392402
// defaults, coercion, extra/private) — master parity.
@@ -430,19 +440,38 @@ pub struct DuckDBInferFn {
430440
row_table: String,
431441
#[pyo3(get)]
432442
output_model: Py<PyAny>,
443+
output_dicts: bool,
433444
}
434445

435446
#[pymethods]
436447
impl DuckDBInferFn {
437448
#[new]
438-
#[pyo3(signature = (sql, row_tables, static_tables, output_model=None))]
449+
#[pyo3(signature = (sql, row_tables, static_tables, output_model=None, output=None))]
439450
fn new(
440451
py: Python<'_>,
441452
sql: String,
442453
row_tables: HashMap<String, Py<PyAny>>,
443454
static_tables: HashMap<String, Py<PyAny>>,
444455
output_model: Option<Py<PyAny>>,
456+
output: Option<String>,
445457
) -> PyResult<Self> {
458+
// output="dict" is the opt-in raw-dict mode: same engine, same
459+
// lanes, the marshaller just skips model construction. The typed
460+
// default is untouched.
461+
let output_dicts = match output.as_deref() {
462+
None | Some("model") => false,
463+
Some("dict") => true,
464+
Some(other) => {
465+
return Err(pyo3::exceptions::PyValueError::new_err(format!(
466+
"output must be 'model' or 'dict', got '{other}'"
467+
)))
468+
}
469+
};
470+
if output_dicts && output_model.is_some() {
471+
return Err(pyo3::exceptions::PyValueError::new_err(
472+
"output='dict' and output_model are mutually exclusive",
473+
));
474+
}
446475
let (row_table, model) = match row_tables.len() {
447476
1 => row_tables.into_iter().next().unwrap(),
448477
n => {
@@ -517,6 +546,7 @@ impl DuckDBInferFn {
517546
engine: Engine::Constant { rows },
518547
row_table,
519548
output_model,
549+
output_dicts,
520550
});
521551
}
522552
Err(_) => return Err(build_err(e.to_string())),
@@ -583,6 +613,7 @@ impl DuckDBInferFn {
583613
&prepared.program.out_cols,
584614
&output_model,
585615
supplied,
616+
output_dicts,
586617
&fun,
587618
)?))
588619
};
@@ -595,9 +626,20 @@ impl DuckDBInferFn {
595626
},
596627
row_table,
597628
output_model,
629+
output_dicts,
598630
})
599631
}
600632

633+
/// The output mode: "model" (typed, default) or "dict" (opt-in).
634+
#[getter]
635+
fn output(&self) -> &'static str {
636+
if self.output_dicts {
637+
"dict"
638+
} else {
639+
"model"
640+
}
641+
}
642+
601643
/// Which engine executes: "cranelift", "interpreter", or "constant".
602644
#[getter]
603645
fn backend(&self) -> &'static str {
@@ -661,7 +703,17 @@ impl DuckDBInferFn {
661703
let model = self.output_model.bind(py);
662704
let mut out = Vec::with_capacity(fixed.len());
663705
for r in fixed.iter() {
664-
out.push(model.call_method1("model_validate", (r,))?.unbind());
706+
if self.output_dicts {
707+
// A fresh copy per call: callers may mutate.
708+
let d = r.bind(py).cast::<PyDict>().map_err(|_| {
709+
pyo3::exceptions::PyValueError::new_err(
710+
"internal: constant rows are dicts",
711+
)
712+
})?;
713+
out.push(d.copy()?.unbind().into_any());
714+
} else {
715+
out.push(model.call_method1("model_validate", (r,))?.unbind());
716+
}
665717
}
666718
return Ok(out);
667719
}
@@ -778,7 +830,11 @@ impl DuckDBInferFn {
778830
}
779831
}
780832
}
781-
out.push(model.call_method1("model_validate", (dict,))?.unbind());
833+
if self.output_dicts {
834+
out.push(dict.unbind().into_any());
835+
} else {
836+
out.push(model.call_method1("model_validate", (dict,))?.unbind());
837+
}
782838
}
783839
Ok(out)
784840
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
"""The raw-dict output mode: output="dict" on DuckDBInferFn.
2+
3+
Opt-in only — the typed pydantic contract is the untouched default. Same
4+
engine, same lanes; the marshaller skips model construction, returning
5+
per-row dicts that agree with the typed mode field-for-field (the modes
6+
share the dict the model would have been built from).
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import pytest
12+
from pydantic import create_model
13+
from test_duckdb_interpreter import duck_check, static
14+
15+
from sql_transform._interpreter import DuckDBInferFn
16+
17+
Row = create_model("Row", a=(int, ...), s=(str | None, None))
18+
SQL = "SELECT a * 2 AS d, upper(coalesce(s, 'x')) AS u FROM __THIS__ WHERE a > 0"
19+
ROWS = [
20+
{"a": 3, "s": "hi"},
21+
{"a": -1, "s": None},
22+
{"a": 5, "s": None},
23+
]
24+
25+
26+
def _fns():
27+
kw = {"row_tables": {"__THIS__": Row}, "static_tables": {}}
28+
return (
29+
DuckDBInferFn(SQL, **kw),
30+
DuckDBInferFn(SQL, **kw, output="dict"),
31+
)
32+
33+
34+
def test_dict_mode_agrees_with_typed_mode():
35+
m, d = _fns()
36+
rows = [Row(**r) for r in ROWS]
37+
typed = [r.model_dump() for r in m.infer({"__THIS__": rows})]
38+
dicts = d.infer({"__THIS__": rows})
39+
assert typed == dicts
40+
assert all(type(r) is dict for r in dicts)
41+
assert m.output == "model" and d.output == "dict"
42+
43+
44+
def test_dict_mode_with_join_and_nulls():
45+
dim = static(
46+
{"id": "int", "name": "str"},
47+
[{"id": 1, "name": "one"}, {"id": 3, "name": "three"}],
48+
)
49+
fn = DuckDBInferFn(
50+
"SELECT k, name FROM __THIS__ LEFT JOIN dim ON k = dim.id",
51+
row_tables={"__THIS__": create_model("K", k=(int, ...))},
52+
static_tables={"dim": dim},
53+
output="dict",
54+
)
55+
K = create_model("K", k=(int, ...))
56+
got = fn.infer({"__THIS__": [K(k=1), K(k=2)]})
57+
assert got == [{"k": 1, "name": "one"}, {"k": 2, "name": None}]
58+
59+
60+
def test_dict_mode_returns_fresh_dicts_per_call():
61+
_, d = _fns()
62+
rows = [Row(a=1)]
63+
first = d.infer({"__THIS__": rows})
64+
first[0]["d"] = "mutated"
65+
again = d.infer({"__THIS__": rows})
66+
assert again == [{"d": 2, "u": "X"}]
67+
68+
69+
def test_dict_mode_on_constant_engine():
70+
fn = DuckDBInferFn(
71+
"SELECT 1 AS one, 'x' AS s",
72+
row_tables={"__THIS__": Row},
73+
static_tables={},
74+
output="dict",
75+
)
76+
got = fn.infer({"__THIS__": []})
77+
assert all(type(r) is dict for r in got)
78+
# Mutating a returned dict must not leak into the next call.
79+
if got:
80+
got[0]["one"] = "mutated"
81+
assert fn.infer({"__THIS__": []}) != got
82+
83+
84+
def test_dict_mode_guards():
85+
kw = {"row_tables": {"__THIS__": Row}, "static_tables": {}}
86+
with pytest.raises(ValueError, match="mutually exclusive"):
87+
DuckDBInferFn(SQL, **kw, output="dict", output_model=Row)
88+
with pytest.raises(ValueError, match="'model' or 'dict'"):
89+
DuckDBInferFn(SQL, **kw, output="bogus")
90+
91+
92+
def test_dict_mode_oracle_parity():
93+
# Same duck_check oracle discipline: dict-mode dumps == duckdb rows.
94+
m, d = _fns()
95+
rows = [Row(**r) for r in ROWS]
96+
assert d.infer({"__THIS__": rows}) == [
97+
r.model_dump() for r in m.infer({"__THIS__": rows})
98+
]
99+
duck_check(SQL, {"a": "int", "s": "str?"}, ROWS)

0 commit comments

Comments
 (0)