Skip to content

Commit 6030a55

Browse files
ahrzbclaude
authored andcommitted
feat(specializer): row-shape contract flag — shape='map' | 'filter' | 'many' (TASK-58)
User-requested serving fence ahead of stage B: shape='map' statically PROVES exactly one output row per input row (out[i] <-> in[i]) at build time — rejects WHERE, INNER joins (key misses drop), and static-only constant queries, each named; LEFT joins and scalar projections serve (unique keys are already the map contract). shape='filter' (default) is byte-identical to before; shape='many' is reserved and will be the ONLY shape under which stage-B multiplicity constructs build. Introspectable via fn.shape. Doc note in known-limitations §2. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent bfe711e commit 6030a55

5 files changed

Lines changed: 203 additions & 1 deletion

File tree

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
---
2+
id: TASK-58
3+
title: 'Row-shape contract flag: shape="map" | "filter" | "many" on DuckDBInferFn'
4+
status: In Progress
5+
assignee: []
6+
created_date: '2026-07-28 01:50'
7+
labels:
8+
- specializer
9+
- api
10+
dependencies: []
11+
type: feature
12+
ordinal: 52000
13+
---
14+
15+
## Description
16+
17+
<!-- SECTION:DESCRIPTION:BEGIN -->
18+
User-requested (2026-07-28 morning) safety fence ahead of stage-B: serving paths need a BUILD-TIME guarantee that a query is a true projection (exactly one output row per input row, out[i] <-> in[i]).
19+
20+
API: DuckDBInferFn(..., shape="filter") default = today's 0..1 behavior, unchanged. shape="map" = static proof of exactly-1: rejects WHERE (can drop), INNER JOIN (key miss drops), the static-only constant path (output unrelated to input rows), and every stage-B form; allows scalar exprs + LEFT JOIN (unique keys are already enforced). shape="many" = reserved now (named rejection pointing at stage-B), becomes the ONLY way multiplicity constructs build once stage-B lands.
21+
22+
The proof is static (query shape), not a runtime row-count assertion. Rejection messages name the blocking construct ("shape='map': WHERE clause can drop rows").
23+
<!-- SECTION:DESCRIPTION:END -->
24+
25+
## Acceptance Criteria
26+
<!-- AC:BEGIN -->
27+
- [ ] #1 shape param accepted with 'filter' default; invalid values are named ValueErrors
28+
- [ ] #2 shape='map' statically rejects WHERE, INNER JOIN, and constant-path queries with messages naming the construct; serves scalar projections and LEFT JOINs
29+
- [ ] #3 shape='many' is a named reserved rejection until stage-B
30+
- [ ] #4 Default behavior byte-identical to today (full gate green, corpus 529 unchanged)
31+
- [ ] #5 Tests cover accept/reject matrix; docs note in known-limitations SS2
32+
- [ ] #6 PR opened
33+
<!-- AC:END -->

docs/known-limitations.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,17 @@ rejected, permanently by design:
3636

3737
## 2. Out of scope for row-serving (by decision, not difficulty)
3838

39+
**The row-shape contract** (TASK-58): `DuckDBInferFn(..., shape=...)`
40+
declares how many output rows each input row may produce, checked at
41+
build time. `"filter"` (the default) is the engine's native 0..1;
42+
`"map"` statically PROVES exactly-one (`out[i] ↔ in[i]`, the strict
43+
serving guarantee) by rejecting anything that can drop a row — a WHERE
44+
clause, an INNER join (key misses drop), a static-tables-only constant
45+
query; `"many"` (0..N) is reserved for join multiplicity (stage B) and
46+
will be the only shape under which duplicate-key joins, cross/inequality
47+
joins, and self-joins ever build — multiplicity can never sneak into a
48+
serving path by default.
49+
3950
The engine serves **row-at-a-time feature transforms**. Whole-relation
4051
constructs are out of scope because their output shape is not
4152
one-row-in/one-row-out:

src/duckdb/mod.rs

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -495,20 +495,41 @@ pub struct DuckDBInferFn {
495495
#[pyo3(get)]
496496
output_model: Py<PyAny>,
497497
output_dicts: bool,
498+
shape_map: bool,
498499
}
499500

500501
#[pymethods]
501502
impl DuckDBInferFn {
502503
#[new]
503-
#[pyo3(signature = (sql, row_tables, static_tables, output_model=None, output=None))]
504+
#[pyo3(signature = (sql, row_tables, static_tables, output_model=None, output=None, shape=None))]
504505
fn new(
505506
py: Python<'_>,
506507
sql: String,
507508
row_tables: HashMap<String, Py<PyAny>>,
508509
static_tables: HashMap<String, Py<PyAny>>,
509510
output_model: Option<Py<PyAny>>,
510511
output: Option<String>,
512+
shape: Option<String>,
511513
) -> PyResult<Self> {
514+
// The row-shape contract (TASK-58): "filter" (default) is today's
515+
// 0..1 rows out per row in; "map" statically PROVES exactly-one
516+
// (out[i] <-> in[i]) or refuses at build; "many" is reserved for
517+
// join multiplicity (stage B) and is the only shape under which
518+
// those constructs will ever build.
519+
let strict_map = match shape.as_deref() {
520+
None | Some("filter") => false,
521+
Some("map") => true,
522+
Some("many") => {
523+
return Err(pyo3::exceptions::PyValueError::new_err(
524+
"shape='many' is reserved for join multiplicity (stage B — not built yet)",
525+
))
526+
}
527+
Some(other) => {
528+
return Err(pyo3::exceptions::PyValueError::new_err(format!(
529+
"shape must be 'map', 'filter', or 'many', got '{other}'"
530+
)))
531+
}
532+
};
512533
// output="dict" is the opt-in raw-dict mode: same engine, same
513534
// lanes, the marshaller just skips model construction. The typed
514535
// default is untouched.
@@ -653,6 +674,14 @@ impl DuckDBInferFn {
653674
Err(e @ (PrepareError::Unsupported(_) | PrepareError::Parse(_))) => {
654675
match eval_static_only(py, &sql, &static_tables) {
655676
Ok((rows, fields)) => {
677+
if strict_map {
678+
// Fixed rows regardless of input — the exact
679+
// opposite of out[i] <-> in[i].
680+
return Err(pyo3::exceptions::PyValueError::new_err(
681+
"shape='map': a static-tables-only query emits fixed \
682+
rows unrelated to the input rows",
683+
));
684+
}
656685
let output_model = match output_model {
657686
Some(m) => m,
658687
None => model_from_fields(py, fields)?,
@@ -662,13 +691,21 @@ impl DuckDBInferFn {
662691
row_table,
663692
output_model,
664693
output_dicts,
694+
shape_map: false,
665695
});
666696
}
667697
Err(_) => return Err(build_err(e.to_string())),
668698
}
669699
}
670700
Err(e) => return Err(build_err(e.to_string())),
671701
};
702+
if strict_map {
703+
if let Some(blocker) = &prepared.one_row_blocker {
704+
return Err(pyo3::exceptions::PyValueError::new_err(format!(
705+
"shape='map': {blocker}"
706+
)));
707+
}
708+
}
672709

673710
// Program statics and StaticSpecs are both indexed by join id.
674711
let mut data = Vec::with_capacity(prepared.statics.len());
@@ -742,9 +779,20 @@ impl DuckDBInferFn {
742779
row_table,
743780
output_model,
744781
output_dicts,
782+
shape_map: strict_map,
745783
})
746784
}
747785

786+
/// The row-shape contract: "map" (proven exactly-1) or "filter" (0..1).
787+
#[getter]
788+
fn shape(&self) -> &'static str {
789+
if self.shape_map {
790+
"map"
791+
} else {
792+
"filter"
793+
}
794+
}
795+
748796
/// The output mode: "model" (typed, default) or "dict" (opt-in).
749797
#[getter]
750798
fn output(&self) -> &'static str {

src/specializer/mod.rs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,12 @@ pub struct StaticSpec {
4242
pub struct Prepared {
4343
pub program: ir::Program,
4444
pub statics: Vec<StaticSpec>,
45+
/// `None` when the query provably emits EXACTLY one output row per
46+
/// input row (out[i] <-> in[i]); otherwise names the first construct
47+
/// that can drop a row. The static proof behind `shape="map"`
48+
/// (TASK-58): no WHERE, and every join is LEFT (unique keys are
49+
/// already the map contract, so LEFT never drops or duplicates).
50+
pub one_row_blocker: Option<String>,
4551
}
4652

4753
/// STAGE 1 for the v0 ribbon: SQL text + the dynamic table's name and schema
@@ -72,6 +78,7 @@ pub fn prepare_opaque(
7278
) -> Result<Prepared, PrepareError> {
7379
let (rel, joins, out_cols, regexes) =
7480
frontend::frontend(sql, this_name, in_cols, opaque, structs, statics)?;
81+
let one_row_blocker = one_row_blocker(&rel, &joins, statics);
7582
let mut program = lower::lower(&rel, &joins, statics, in_cols, out_cols, regexes, "run")?;
7683
// Block-splitting lowerings mint ids out of text order; renumber so
7784
// every prepared program is exactly canonical (parse(print(p)) == p).
@@ -110,5 +117,36 @@ pub fn prepare_opaque(
110117
Ok(Prepared {
111118
program,
112119
statics: specs,
120+
one_row_blocker,
113121
})
114122
}
123+
124+
/// The static exactly-one-row proof behind `shape="map"`: a Filter node or
125+
/// a non-LEFT join can drop input rows; everything else the engine serves
126+
/// is row-preserving (unique join keys are already the map contract, so a
127+
/// LEFT join never drops or duplicates).
128+
fn one_row_blocker(
129+
rel: &plan::Rel,
130+
joins: &[plan::JoinSpec],
131+
statics: &[plan::StaticTable],
132+
) -> Option<String> {
133+
fn has_filter(r: &plan::Rel) -> bool {
134+
match r {
135+
plan::Rel::Filter { .. } => true,
136+
plan::Rel::Project { input, .. } => has_filter(input),
137+
plan::Rel::Scan => false,
138+
}
139+
}
140+
if has_filter(rel) {
141+
return Some("a WHERE clause can drop rows".into());
142+
}
143+
for j in joins {
144+
if j.kind != plan::JoinKind::Left {
145+
return Some(format!(
146+
"INNER JOIN '{}' drops rows on a key miss (use LEFT JOIN)",
147+
statics[j.table].name
148+
));
149+
}
150+
}
151+
None
152+
}

tests/test_shape_contract.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
"""The row-shape contract flag (TASK-58): shape="map" | "filter" | "many".
2+
3+
"map" is a STATIC build-time proof of exactly one output row per input row
4+
(out[i] <-> in[i]) — the serving-path guarantee. "filter" is the default
5+
0..1 behavior, byte-identical to before the flag existed. "many" is
6+
reserved for join multiplicity (stage B) and is the only shape under which
7+
those constructs will ever build.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
import pyarrow as pa
13+
import pytest
14+
from pydantic import create_model
15+
16+
from sql_transform._interpreter import DuckDBInferFn
17+
18+
T = create_model("T", a=(int, ...), s=(str | None, None))
19+
DIM = pa.table({"id": [1, 2], "v": [10, 20]})
20+
21+
22+
def build(sql, shape=None, statics=None):
23+
kwargs = {"output": "dict"}
24+
if shape is not None:
25+
kwargs["shape"] = shape
26+
return DuckDBInferFn(
27+
sql, row_tables={"__THIS__": T}, static_tables=statics or {}, **kwargs
28+
)
29+
30+
31+
def test_map_serves_projections_and_left_joins():
32+
fn = build("SELECT a + 1 AS b, upper(s) AS u FROM __THIS__", shape="map")
33+
assert fn.shape == "map"
34+
got = fn.infer({"__THIS__": [T(a=1, s="x"), T(a=2, s=None)]})
35+
assert [r["b"] for r in got] == [2, 3] # exactly one out per in, in order
36+
37+
fn = build(
38+
"SELECT a, v FROM __THIS__ LEFT JOIN d ON a = d.id",
39+
shape="map",
40+
statics={"d": DIM},
41+
)
42+
got = fn.infer({"__THIS__": [T(a=1), T(a=99)]})
43+
assert [r["v"] for r in got] == [10, None] # a miss maps, never drops
44+
45+
46+
def test_map_rejects_row_dropping_constructs():
47+
with pytest.raises(ValueError, match="shape='map'.*WHERE"):
48+
build("SELECT a FROM __THIS__ WHERE a > 0", shape="map")
49+
with pytest.raises(ValueError, match="shape='map'.*INNER JOIN 'd'"):
50+
build(
51+
"SELECT a, v FROM __THIS__ JOIN d ON a = d.id",
52+
shape="map",
53+
statics={"d": DIM},
54+
)
55+
# A static-only query emits fixed rows unrelated to the input.
56+
with pytest.raises(ValueError, match="shape='map'.*static-tables-only"):
57+
build("SELECT max(id) FROM d", shape="map", statics={"d": DIM})
58+
59+
60+
def test_filter_default_unchanged():
61+
for kwargs in [{}, {"shape": "filter"}]:
62+
fn = build("SELECT a FROM __THIS__ WHERE a > 1", **kwargs)
63+
assert fn.shape == "filter"
64+
got = fn.infer({"__THIS__": [T(a=1), T(a=2)]})
65+
assert [r["a"] for r in got] == [2]
66+
67+
68+
def test_many_is_reserved_and_bad_values_are_named():
69+
with pytest.raises(ValueError, match="stage B"):
70+
build("SELECT a FROM __THIS__", shape="many")
71+
with pytest.raises(ValueError, match="must be 'map', 'filter', or 'many'"):
72+
build("SELECT a FROM __THIS__", shape="projection")

0 commit comments

Comments
 (0)