Skip to content

Commit c49003a

Browse files
committed
Support grad/jvp/vjp on schema-qualified tables
Mixed-dimension datasets register as schema-qualified tables (e.g. era5.surface / era5.time_x_level). The autograd rewrite consumes the plan in a throwaway context that registers an empty table per scanned name, but register_table("era5.time_x", ...) failed with "failed to resolve schema: era5" because the namespace did not exist. Add ensure_schema(): before registering each table, parse its name into a TableReference and, for qualified names, create the schema namespace (MemorySchemaProvider) in the default catalog if absent. The Python side already resolves qualified names via ctx.table(name).schema(); only the Rust rewrite context needed the namespace. Tests: a mixed-dimension fixture exercising grad on both the 2D surface and 3D atmosphere tables, against numpy analytic derivatives. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017mDoFJgsm9kS7SicGoCVF6
1 parent 6319a06 commit c49003a

3 files changed

Lines changed: 76 additions & 6 deletions

File tree

src/lib.rs

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,8 @@ use arrow::pyarrow::FromPyArrow;
5555
use async_stream::try_stream;
5656
use async_trait::async_trait;
5757
use datafusion::catalog::streaming::StreamingTable;
58-
use datafusion::catalog::Session;
59-
use datafusion::common::{DataFusionError, Result as DFResult, ScalarValue};
58+
use datafusion::catalog::{MemorySchemaProvider, Session};
59+
use datafusion::common::{DataFusionError, Result as DFResult, ScalarValue, TableReference};
6060
use datafusion::datasource::empty::EmptyTable;
6161
use datafusion::datasource::TableProvider;
6262
use datafusion::execution::TaskContext;
@@ -993,6 +993,22 @@ impl LazyArrowStreamTable {
993993
// Autograd: Substrait-level grad() rewrite
994994
// ============================================================================
995995

996+
/// Ensure a schema (namespace) exists in the context's catalog, creating an
997+
/// empty in-memory one if needed. Used so the rewrite context can register
998+
/// schema-qualified tables (e.g. `era5.surface`) that mixed-dimension datasets
999+
/// produce.
1000+
fn ensure_schema(ctx: &SessionContext, catalog: Option<&str>, schema: &str) -> DFResult<()> {
1001+
// A bare TableReference has no catalog; fall back to DataFusion's default.
1002+
let catalog_name = catalog.unwrap_or("datafusion");
1003+
let catalog = ctx
1004+
.catalog(catalog_name)
1005+
.ok_or_else(|| DataFusionError::Plan(format!("catalog '{catalog_name}' not found")))?;
1006+
if catalog.schema(schema).is_none() {
1007+
catalog.register_schema(schema, Arc::new(MemorySchemaProvider::new()))?;
1008+
}
1009+
Ok(())
1010+
}
1011+
9961012
/// Rewrite `grad(expr, column)` calls in a Substrait plan into their symbolic
9971013
/// derivatives.
9981014
///
@@ -1037,7 +1053,20 @@ fn grad_rewrite<'py>(
10371053
))
10381054
})?;
10391055
let provider = Arc::new(EmptyTable::new(Arc::new(schema)));
1040-
ctx.register_table(name.as_str(), provider).map_err(|e| {
1056+
1057+
// Schema-qualified names (e.g. "era5.surface", from a mixed-dimension
1058+
// dataset) need their namespace to exist before the table can be
1059+
// registered into this throwaway context.
1060+
let table_ref = TableReference::from(name.as_str());
1061+
if let Some(schema_name) = table_ref.schema() {
1062+
ensure_schema(&ctx, table_ref.catalog(), schema_name).map_err(|e| {
1063+
pyo3::exceptions::PyValueError::new_err(format!(
1064+
"grad_rewrite: failed to create schema for table '{name}': {e}"
1065+
))
1066+
})?;
1067+
}
1068+
1069+
ctx.register_table(table_ref, provider).map_err(|e| {
10411070
pyo3::exceptions::PyValueError::new_err(format!(
10421071
"grad_rewrite: failed to register table '{name}': {e}"
10431072
))

tests/test_autograd.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,46 @@ def test_vjp_reverse_pullback(ctx_xy):
133133
np.testing.assert_allclose(res["s"], 3.0 * (np.cos(x) * y))
134134

135135

136+
@pytest.fixture
137+
def ctx_mixed():
138+
# A mixed-dimension dataset registers as schema-qualified tables:
139+
# era5.time_x (surface, 2 dims)
140+
# era5.time_x_level (atmosphere, 3 dims)
141+
rng = np.random.default_rng(1)
142+
ds = xr.Dataset(
143+
{
144+
"sfc": (("time", "x"), rng.uniform(0.5, 2.5, (3, 4))),
145+
"atm": (("time", "x", "level"), rng.uniform(0.5, 2.5, (3, 4, 2))),
146+
},
147+
coords={"time": [0, 1, 2], "x": np.arange(4.0), "level": [0, 1]},
148+
)
149+
context = xql.XarrayContext()
150+
context.from_dataset("era5", ds, chunks={"time": 1})
151+
return context, ds
152+
153+
154+
def test_grad_on_qualified_surface_table(ctx_mixed):
155+
context, ds = ctx_mixed
156+
res = _ordered(
157+
context.sql(
158+
"SELECT time, x, sfc, grad(sin(sfc), sfc) AS d FROM era5.time_x"
159+
),
160+
key="sfc",
161+
)
162+
np.testing.assert_allclose(res["d"], np.cos(res["sfc"]))
163+
164+
165+
def test_grad_on_qualified_atmosphere_table(ctx_mixed):
166+
context, ds = ctx_mixed
167+
res = _ordered(
168+
context.sql(
169+
"SELECT atm, grad(power(atm, 2), atm) AS d FROM era5.time_x_level"
170+
),
171+
key="atm",
172+
)
173+
np.testing.assert_allclose(res["d"], 2.0 * res["atm"])
174+
175+
136176
def test_jvp_and_vjp_agree_for_unit_seed(ctx_xy):
137177
# Forward (unit tangent) and reverse (unit cotangent) coincide for a
138178
# scalar output -- both contract the same partial derivative.

xarray_sql/sql.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -251,11 +251,12 @@ def _table_schemas(self) -> list[tuple[str, pa.Schema]]:
251251
schemas = []
252252
for name in self._registered_datasets:
253253
try:
254+
# Names may be bare ("air") or schema-qualified ("era5.surface",
255+
# from a mixed-dimension dataset); both resolve here.
254256
schemas.append((name, self.table(name).schema()))
255257
except Exception:
256-
# Schema-qualified tables (mixed-dimension datasets) aren't
257-
# resolvable by a bare name yet; skip rather than fail the
258-
# whole query. grad() over those is a follow-up.
258+
# Be defensive: skip a table we can't introspect rather than
259+
# failing the whole query.
259260
continue
260261
return schemas
261262

0 commit comments

Comments
 (0)