Skip to content

Commit 14b2697

Browse files
alxmrsclaude
andcommitted
Differentiate grad() as a SQL rewrite, dropping the Substrait bridge
Make grad()/jvp()/vjp() work inside any query shape (recursive CTEs, DML, subqueries) by rewriting the calls as SQL text before planning, rather than round-tripping the logical plan through Substrait (which could not represent those shapes). Closes the gap tracked in #197. XarrayContext.sql() now hands a query containing a marker to the native rewrite_grad_sql, which parses the statement with sqlparser, differentiates each marker call with the existing engine, and renders the derivative back into the SQL in place. Because it runs before planning, every query shape the parser accepts is supported, and the result is ordinary SQL the stock datafusion-python context plans and executes directly. This removes the Substrait round-trip entirely: the datafusion-substrait and prost dependencies, the grad_rewrite/_sql_with_autograd/_table_schemas plumbing, the marker-UDF registration, and the protoc steps in CI. Unlike the FFI alternative, it needs no datafusion fork and no custom datafusion-python wheel. The grad surface is unchanged (same SQL, same results); marker arguments use unqualified column names, matching existing usage, since differentiation is syntactic and runs before binding. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 7bade4d commit 14b2697

9 files changed

Lines changed: 254 additions & 242 deletions

File tree

.github/workflows/ci-build.yml

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -31,13 +31,6 @@ jobs:
3131

3232
- uses: dtolnay/rust-toolchain@stable
3333

34-
# The `substrait` crate (a datafusion-substrait dependency) generates
35-
# code from .proto files at build time and requires protoc.
36-
- name: Install Protoc
37-
uses: arduino/setup-protoc@v3
38-
with:
39-
repo-token: ${{ secrets.GITHUB_TOKEN }}
40-
4134
- name: Setup sccache
4235
uses: mozilla-actions/sccache-action@v0.0.9
4336

.github/workflows/ci-rust.yml

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,6 @@ jobs:
2727
with:
2828
components: clippy
2929

30-
# The `substrait` crate (a datafusion-substrait dependency) generates
31-
# code from .proto files at build time and requires protoc.
32-
- name: Install Protoc
33-
uses: arduino/setup-protoc@v3
34-
with:
35-
repo-token: ${{ secrets.GITHUB_TOKEN }}
36-
3730
- name: Setup sccache
3831
uses: mozilla-actions/sccache-action@v0.0.9
3932

.github/workflows/ci.yml

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -43,13 +43,6 @@ jobs:
4343

4444
- uses: dtolnay/rust-toolchain@stable
4545

46-
# The `substrait` crate (a datafusion-substrait dependency) generates
47-
# code from .proto files at build time and requires protoc.
48-
- name: Install Protoc
49-
uses: arduino/setup-protoc@v3
50-
with:
51-
repo-token: ${{ secrets.GITHUB_TOKEN }}
52-
5346
- name: Setup sccache
5447
uses: mozilla-actions/sccache-action@v0.0.9
5548

.github/workflows/publish.yml

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -54,13 +54,6 @@ jobs:
5454

5555
- uses: dtolnay/rust-toolchain@stable
5656

57-
# The `substrait` crate (a datafusion-substrait dependency) generates
58-
# code from .proto files at build time and requires protoc.
59-
- name: Install Protoc
60-
uses: arduino/setup-protoc@v3
61-
with:
62-
repo-token: ${{ secrets.GITHUB_TOKEN }}
63-
6457
- name: Setup sccache
6558
uses: mozilla-actions/sccache-action@v0.0.9
6659

Cargo.toml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,7 @@ async-stream = "0.3"
2323
async-trait = "0.1"
2424
datafusion = { version = "54.0.0" }
2525
datafusion-ffi = { version = "54.0.0" }
26-
datafusion-substrait = { version = "52.0.0" }
27-
prost = "0.14"
26+
sqlparser = { version = "0.59", features = ["visitor"] }
2827
futures = { version = "0.3" }
2928
# `abi3-py310` builds against CPython's stable ABI, so a single wheel per
3029
# platform works on all CPython >= 3.10 (matching `requires-python`). This

src/autograd.rs

Lines changed: 187 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,16 +54,23 @@
5454
use std::any::Any;
5555
use std::collections::HashMap;
5656
use std::f64::consts::{LN_10, LN_2};
57+
use std::ops::ControlFlow;
58+
use std::sync::Arc;
5759

58-
use datafusion::arrow::datatypes::DataType;
60+
use datafusion::arrow::datatypes::{DataType, Field};
5961
use datafusion::common::tree_node::{Transformed, TreeNode};
60-
use datafusion::common::{DataFusionError, Result, ScalarValue};
62+
use datafusion::common::{DFSchema, DataFusionError, Result, ScalarValue, TableReference};
6163
use datafusion::functions::math::expr_fn;
6264
use datafusion::logical_expr::expr::ScalarFunction;
6365
use datafusion::logical_expr::{
6466
lit, BinaryExpr, Cast, ColumnarValue, Expr, LogicalPlan, Operator, ScalarFunctionArgs,
6567
ScalarUDF, ScalarUDFImpl, Signature, Volatility,
6668
};
69+
use datafusion::prelude::SessionContext;
70+
use datafusion::sql::unparser::expr_to_sql;
71+
use sqlparser::ast::{Expr as SqlExpr, Visit, VisitMut, Visitor, VisitorMut};
72+
use sqlparser::dialect::GenericDialect;
73+
use sqlparser::parser::Parser;
6774

6875
// ---------------------------------------------------------------------------
6976
// Constant helpers and the 0/1-folding builders
@@ -522,6 +529,142 @@ fn rewrite_vjp(args: &[Expr]) -> Result<Expr> {
522529
Ok(mul(args[2].clone(), derivative))
523530
}
524531

532+
// ---------------------------------------------------------------------------
533+
// SQL source-to-source rewrite
534+
// ---------------------------------------------------------------------------
535+
536+
/// Rewrite every `grad`/`jvp`/`vjp` call in a SQL statement into its symbolic
537+
/// derivative, returning the rewritten SQL text.
538+
///
539+
/// Unlike a logical-plan rewrite, this is a pure source-to-source transform run
540+
/// *before* the query is planned, so it works for any query shape the SQL parser
541+
/// accepts — recursive CTEs, DML, and subqueries included. Each marker call is
542+
/// parsed into a DataFusion [`Expr`], differentiated by the engine in this
543+
/// module, and rendered back to SQL in place. Columns are taken from the call's
544+
/// own identifiers (all treated as `Float64`; types don't affect the symbolic
545+
/// result), so no catalog or table schema is needed.
546+
pub fn rewrite_grad_in_sql(sql: &str) -> Result<String> {
547+
let dialect = GenericDialect {};
548+
let mut statements = Parser::parse_sql(&dialect, sql)
549+
.map_err(|e| DataFusionError::Plan(format!("grad: failed to parse SQL: {e}")))?;
550+
551+
// A throwaway context that only needs the marker UDFs registered so the
552+
// calls parse into `ScalarFunction` nodes the engine can dispatch on.
553+
let ctx = SessionContext::new();
554+
ctx.register_udf(grad_marker());
555+
ctx.register_udf(jvp_marker());
556+
ctx.register_udf(vjp_marker());
557+
558+
let mut rewriter = GradSqlRewriter { ctx: &ctx };
559+
for stmt in &mut statements {
560+
if let ControlFlow::Break(msg) = stmt.visit(&mut rewriter) {
561+
return Err(DataFusionError::Plan(msg));
562+
}
563+
}
564+
565+
Ok(statements
566+
.iter()
567+
.map(ToString::to_string)
568+
.collect::<Vec<_>>()
569+
.join("; "))
570+
}
571+
572+
/// True if `name` is one of the autograd marker functions (case-insensitive).
573+
fn is_marker_name(name: &str) -> bool {
574+
matches!(name.to_lowercase().as_str(), "grad" | "jvp" | "vjp")
575+
}
576+
577+
/// Walks a SQL AST and replaces each `grad`/`jvp`/`vjp` call with its derivative.
578+
struct GradSqlRewriter<'a> {
579+
ctx: &'a SessionContext,
580+
}
581+
582+
impl VisitorMut for GradSqlRewriter<'_> {
583+
type Break = String;
584+
585+
fn pre_visit_expr(&mut self, expr: &mut SqlExpr) -> ControlFlow<String> {
586+
let is_marker = matches!(
587+
expr,
588+
SqlExpr::Function(f) if is_marker_name(&f.name.to_string())
589+
);
590+
if !is_marker {
591+
return ControlFlow::Continue(());
592+
}
593+
match self.rewrite_call(expr) {
594+
Ok(()) => ControlFlow::Continue(()),
595+
Err(e) => ControlFlow::Break(e),
596+
}
597+
}
598+
}
599+
600+
impl GradSqlRewriter<'_> {
601+
/// Differentiate a single marker call in place. The replacement is wrapped
602+
/// in parentheses so it keeps the call's precedence in the surrounding SQL.
603+
fn rewrite_call(&self, expr: &mut SqlExpr) -> std::result::Result<(), String> {
604+
let schema = call_schema(expr)?;
605+
let text = expr.to_string();
606+
let parsed = self
607+
.ctx
608+
.parse_sql_expr(&text, &schema)
609+
.map_err(|e| format!("grad: failed to parse '{text}': {e}"))?;
610+
let derivative = rewrite_grad_in_expr(parsed)
611+
.map_err(|e| format!("grad: failed to differentiate '{text}': {e}"))?
612+
.data;
613+
let rendered = expr_to_sql(&derivative)
614+
.map_err(|e| format!("grad: failed to render derivative for '{text}': {e}"))?;
615+
*expr = SqlExpr::Nested(Box::new(rendered));
616+
Ok(())
617+
}
618+
}
619+
620+
/// Build a `Float64` schema covering every column identifier referenced inside a
621+
/// marker call, so the call's argument expression can be parsed standalone.
622+
fn call_schema(call: &SqlExpr) -> std::result::Result<DFSchema, String> {
623+
let mut collector = ColumnCollector::default();
624+
let _ = call.visit(&mut collector);
625+
let fields = collector
626+
.cols
627+
.into_iter()
628+
.map(|(qualifier, name)| {
629+
let qualifier = qualifier.map(TableReference::bare);
630+
(
631+
qualifier,
632+
Arc::new(Field::new(name, DataType::Float64, true)),
633+
)
634+
})
635+
.collect();
636+
DFSchema::new_with_metadata(fields, HashMap::new())
637+
.map_err(|e| format!("grad: failed to build schema for differentiation: {e}"))
638+
}
639+
640+
/// Collects the (optional qualifier, name) of every column identifier in a SQL
641+
/// expression tree.
642+
#[derive(Default)]
643+
struct ColumnCollector {
644+
cols: Vec<(Option<String>, String)>,
645+
}
646+
647+
impl Visitor for ColumnCollector {
648+
type Break = ();
649+
650+
fn pre_visit_expr(&mut self, expr: &SqlExpr) -> ControlFlow<()> {
651+
let pair = match expr {
652+
SqlExpr::Identifier(ident) => Some((None, ident.value.clone())),
653+
SqlExpr::CompoundIdentifier(parts) => parts.last().map(|last| {
654+
let qualifier = (parts.len() >= 2).then(|| parts[parts.len() - 2].value.clone());
655+
(qualifier, last.value.clone())
656+
}),
657+
_ => None,
658+
};
659+
if let Some(pair) = pair {
660+
if !self.cols.contains(&pair) {
661+
self.cols.push(pair);
662+
}
663+
}
664+
ControlFlow::Continue(())
665+
}
666+
}
667+
525668
// ---------------------------------------------------------------------------
526669
// Tests
527670
// ---------------------------------------------------------------------------
@@ -650,4 +793,46 @@ mod tests {
650793
let rev = rewrite_vjp(&[f, col("x"), one()]).unwrap();
651794
assert_eq!(fwd, rev);
652795
}
796+
797+
#[test]
798+
fn sql_rewrite_replaces_grad_call() {
799+
// grad(sin(x), x) -> cos(x); the surrounding SELECT is preserved.
800+
let out = rewrite_grad_in_sql("SELECT grad(sin(x), x) AS d FROM t").unwrap();
801+
assert_eq!(out, "SELECT (cos(x)) AS d FROM t");
802+
}
803+
804+
#[test]
805+
fn sql_rewrite_leaves_non_grad_queries_intact() {
806+
// A query with no marker is still parsed and re-emitted unchanged in
807+
// meaning (the caller only invokes the rewrite when a marker is present).
808+
let out = rewrite_grad_in_sql("SELECT a + b FROM t").unwrap();
809+
assert_eq!(out, "SELECT a + b FROM t");
810+
}
811+
812+
#[test]
813+
fn sql_rewrite_fires_inside_recursive_cte() {
814+
// The #197 capability: a marker inside a recursive term is rewritten,
815+
// a query shape the Substrait bridge could never carry. d/dx(x*x) = x+x.
816+
let out = rewrite_grad_in_sql(
817+
"WITH RECURSIVE r AS (SELECT 1.0 AS x UNION ALL \
818+
SELECT x - grad(x * x, x) FROM r WHERE x < 10) SELECT x FROM r",
819+
)
820+
.unwrap();
821+
assert!(out.contains("(x + x)"), "unexpected rewrite: {out}");
822+
assert!(
823+
!out.to_lowercase().contains("grad("),
824+
"marker left behind: {out}"
825+
);
826+
}
827+
828+
#[test]
829+
fn sql_rewrite_handles_nested_higher_order_grad() {
830+
// grad(grad(power(x, 3), x), x) -> d2/dx2 (x^3) = 6x; bottom-up so the
831+
// inner call is differentiated before the outer one.
832+
let out = rewrite_grad_in_sql("SELECT grad(grad(power(x, 3), x), x) AS d FROM t").unwrap();
833+
assert!(
834+
!out.to_lowercase().contains("grad("),
835+
"marker left behind: {out}"
836+
);
837+
}
653838
}

0 commit comments

Comments
 (0)