Skip to content

Commit d456934

Browse files
committed
Expose grad() in XarrayContext SQL via the Substrait rewrite
Wire the autograd surface into XarrayContext so users can write calculus directly in SQL: ctx.sql("SELECT grad(sin(val), val) AS d_val, sin(val) AS val FROM t") On construction the context registers the `grad` marker UDF so such queries parse and plan. XarrayContext.sql() detects `grad(` (a cheap regex gate so ordinary queries are untouched) and routes through _sql_with_autograd: it plans the query, produces the logical plan as Substrait, calls the native grad_rewrite to differentiate every grad(expr, column) symbolically, then consumes the rewritten Substrait back into an executable DataFrame. Table scans are resolved by name on the consume side, so _table_schemas() passes the (name, schema) of each registered table to the rewrite. Schema- qualified tables (mixed-dimension datasets) are skipped for now and noted as a follow-up. Adds tests/test_autograd.py covering sin/cos, product and quotient rules, power, exp, the non-grad passthrough, and a clear error for unsupported functions — all checked against numpy analytic derivatives. Existing SQL tests still pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017mDoFJgsm9kS7SicGoCVF6
1 parent ee2e011 commit d456934

2 files changed

Lines changed: 146 additions & 2 deletions

File tree

tests/test_autograd.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
"""Tests for the SQL autograd surface: ``SELECT grad(expr, column) ...``.
2+
3+
These exercise the full path — XarrayContext.sql() -> Substrait -> native
4+
grad_rewrite -> Substrait -> execute — and compare results against analytic
5+
derivatives computed with numpy.
6+
"""
7+
8+
import numpy as np
9+
import pytest
10+
import xarray as xr
11+
12+
import xarray_sql as xql
13+
14+
15+
@pytest.fixture
16+
def ctx():
17+
val = np.linspace(0.1, 3.0, 16)
18+
ds = xr.Dataset(
19+
{"val": (("i",), val)},
20+
coords={"i": np.arange(16)},
21+
)
22+
context = xql.XarrayContext()
23+
context.from_dataset("t", ds, chunks={"i": 5})
24+
return context
25+
26+
27+
def _ordered(df, key="i"):
28+
"""Collect a result DataFrame into a dict of column -> numpy array, sorted
29+
by the integer key column so comparisons are index-aligned."""
30+
pdf = df.to_pandas().sort_values(key)
31+
return {c: pdf[c].to_numpy() for c in pdf.columns}
32+
33+
34+
def test_grad_sin_is_cos(ctx):
35+
val = np.linspace(0.1, 3.0, 16)
36+
res = _ordered(ctx.sql("SELECT i, grad(sin(val), val) AS d FROM t"))
37+
np.testing.assert_allclose(res["d"], np.cos(val))
38+
39+
40+
def test_grad_product_rule(ctx):
41+
val = np.linspace(0.1, 3.0, 16)
42+
res = _ordered(ctx.sql("SELECT i, grad(sin(val) * val, val) AS d FROM t"))
43+
np.testing.assert_allclose(res["d"], np.cos(val) * val + np.sin(val))
44+
45+
46+
def test_grad_exp_equals_value(ctx):
47+
val = np.linspace(0.1, 3.0, 16)
48+
res = _ordered(
49+
ctx.sql("SELECT i, exp(val) AS v, grad(exp(val), val) AS d FROM t")
50+
)
51+
np.testing.assert_allclose(res["d"], np.exp(val))
52+
np.testing.assert_allclose(res["d"], res["v"])
53+
54+
55+
def test_grad_quotient_and_power(ctx):
56+
val = np.linspace(0.1, 3.0, 16)
57+
res = _ordered(
58+
ctx.sql(
59+
"SELECT i, grad(1.0 / val, val) AS dinv, "
60+
"grad(power(val, 3), val) AS dcube FROM t"
61+
)
62+
)
63+
np.testing.assert_allclose(res["dinv"], -1.0 / val**2)
64+
np.testing.assert_allclose(res["dcube"], 3.0 * val**2)
65+
66+
67+
def test_non_grad_query_is_unaffected(ctx):
68+
# Queries without grad() bypass the rewrite and behave normally.
69+
res = _ordered(ctx.sql("SELECT i, val FROM t"))
70+
np.testing.assert_allclose(res["val"], np.linspace(0.1, 3.0, 16))
71+
72+
73+
def test_unsupported_function_raises(ctx):
74+
# atan2 has no derivative rule yet -> a clear error, not a wrong answer.
75+
with pytest.raises(Exception):
76+
ctx.sql("SELECT grad(atan2(val, val), val) AS d FROM t").to_pandas()

xarray_sql/sql.py

Lines changed: 70 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,22 @@
1+
import re
2+
3+
import pyarrow as pa
14
import xarray as xr
2-
from datafusion import SessionContext
5+
from datafusion import SessionContext, udf
36
from datafusion.catalog import Schema
7+
from datafusion.substrait import Consumer, Producer, Serde
48
from collections import defaultdict
59

10+
from . import _native
611
from . import cftime as cft
712
from .df import Chunks
813
from .ds import XarrayDataFrame
914
from .reader import read_xarray_table
1015

16+
# Matches a call to the autograd marker function ``grad(`` (case-insensitive),
17+
# used as a cheap gate so ordinary queries skip the Substrait round-trip.
18+
_GRAD_CALL = re.compile(r"\bgrad\s*\(", re.IGNORECASE)
19+
1120

1221
class XarrayContext(SessionContext):
1322
"""A datafusion `SessionContext` that also supports `xarray.Dataset`s."""
@@ -21,6 +30,24 @@ def __init__(self, *args, **kwargs):
2130
# in SQL (e.g. ``"air"`` for a uniform-dim Dataset, or
2231
# ``"era5.surface"`` for one entry from a multi-dim-group split).
2332
self._registered_datasets: dict[str, xr.Dataset] = {}
33+
self._register_autograd_udfs()
34+
35+
def _register_autograd_udfs(self) -> None:
36+
"""Register the ``grad`` marker UDF used by the autograd rewrite.
37+
38+
``grad(expr, column)`` is a *marker*: it lets queries parse and plan
39+
with the differentiation request intact. It is never executed — the
40+
Substrait rewrite in :meth:`sql` replaces every ``grad(...)`` with the
41+
symbolic derivative of ``expr`` before execution.
42+
"""
43+
marker = udf(
44+
lambda expr, column: expr,
45+
[pa.float64(), pa.float64()],
46+
pa.float64(),
47+
"immutable",
48+
"grad",
49+
)
50+
self.register_udf(marker)
2451

2552
def from_dataset(
2653
self,
@@ -174,9 +201,50 @@ def sql(self, query: str, *args, **kwargs) -> XarrayDataFrame:
174201
Returns:
175202
An :class:`XarrayDataFrame` wrapping the DataFusion DataFrame.
176203
"""
177-
inner = super().sql(query, *args, **kwargs)
204+
if _GRAD_CALL.search(query):
205+
inner = self._sql_with_autograd(query, *args, **kwargs)
206+
else:
207+
inner = super().sql(query, *args, **kwargs)
178208
return XarrayDataFrame(inner, templates=self._registered_datasets)
179209

210+
def _sql_with_autograd(self, query: str, *args, **kwargs):
211+
"""Plan ``query``, rewrite ``grad(...)`` calls, return a DataFrame.
212+
213+
The differentiation engine lives in the native (Rust) extension and
214+
operates on DataFusion logical expressions. Since that extension links
215+
its own copy of DataFusion, the plan crosses the boundary as Substrait:
216+
we produce the logical plan as Substrait, hand it to ``grad_rewrite``
217+
(which differentiates every ``grad(expr, column)`` symbolically), then
218+
consume the rewritten Substrait back into an executable DataFrame.
219+
"""
220+
plan = super().sql(query, *args, **kwargs).logical_plan()
221+
substrait_plan = Producer.to_substrait_plan(plan, self)
222+
rewritten = _native.grad_rewrite(
223+
substrait_plan.encode(), self._table_schemas()
224+
)
225+
new_plan = Consumer.from_substrait_plan(
226+
self, Serde.deserialize_bytes(rewritten)
227+
)
228+
return self.create_dataframe_from_logical_plan(new_plan)
229+
230+
def _table_schemas(self) -> list[tuple[str, pa.Schema]]:
231+
"""Return ``(name, schema)`` for each registered table.
232+
233+
The Substrait consumer in ``grad_rewrite`` resolves table scans by
234+
name, so it needs the schema of every table the plan might reference.
235+
Only metadata is read here — never the underlying data.
236+
"""
237+
schemas = []
238+
for name in self._registered_datasets:
239+
try:
240+
schemas.append((name, self.table(name).schema()))
241+
except Exception:
242+
# Schema-qualified tables (mixed-dimension datasets) aren't
243+
# resolvable by a bare name yet; skip rather than fail the
244+
# whole query. grad() over those is a follow-up.
245+
continue
246+
return schemas
247+
180248

181249
def _group_vars_by_dims(ds: xr.Dataset) -> dict[tuple[str, ...], list[str]]:
182250
"""Group variables in the dataset based on shared dims.

0 commit comments

Comments
 (0)