ddx_datafusion/lib.rs
1// SPDX-FileCopyrightText: 2026 Alexander Merose <al@merose.com> & ddx Authors
2//
3// SPDX-License-Identifier: Apache-2.0
4
5//! `ddx-datafusion` — the DataFusion adapter for [ddx](https://github.com/xqlsystems/ddx).
6//!
7//! Write calculus directly in SQL and let DataFusion evaluate the derivative
8//! per row — the relational equivalent of `jax.vmap(jax.grad(f))`:
9//!
10//! ```sql
11//! SELECT i, grad(x * y, x) AS dfdx, grad(x * y, y) AS dfdy FROM g
12//! ```
13//!
14//! All the calculus lives in [`ddx_core`]; this crate only connects it to an
15//! engine. It offers the same rewrite by two routes:
16//!
17//! | | [`install`] (Path B) | [`ddx_sql`] (Path A) |
18//! |---|---|---|
19//! | How | in-engine `AnalyzerRule` on the bound plan | rewrite the SQL text first |
20//! | Call style | bare `grad()`, anywhere | `ddx_sql(&ctx, sql)` |
21//! | Works with | SQL **and** the DataFrame API | SQL strings |
22//! | Column identity | resolved by the planner | syntactic (guards may fire) |
23//! | Correlated subqueries | ✗ (loud error) | ✓ |
24//! | Errors surface at | `collect()` | the `ddx_sql` call |
25//!
26//! **Prefer [`install`].** Because it runs after binding, columns arrive
27//! already resolved, so the qualification-ambiguity errors a pre-binding text
28//! rewrite must raise simply cannot occur.
29//!
30//! **Reach for [`ddx_sql`] when a marker sits inside a correlated subquery.**
31//! That is the one query shape Path B genuinely cannot carry: the bridge
32//! re-plans the derivative against the subquery's own inputs, and an outer
33//! reference does not survive that. Path B detects it and says so.
34//!
35//! Recursive CTEs are *not* such a shape: `install` carries a marker in a
36//! recursive term perfectly well, because `LogicalPlan::RecursiveQuery` is an
37//! ordinary node with ordinary inputs.
38//!
39//! # Where the two paths genuinely differ
40//!
41//! They drive the same [`ddx_core`] engine, so they agree on the calculus. They
42//! do not always agree on **what may be the `wrt`**, because they disagree about
43//! what a "column" is:
44//!
45//! ```sql
46//! SELECT grad(sum(x) * sum(x), sum(x)) AS d FROM t
47//! ```
48//!
49//! Path A refuses this — syntactically `sum(x)` is a function call, not a bare
50//! column, and the `wrt` must be a bare column. Path B answers `2·sum(x)`,
51//! because by the time it sees the plan the planner has already lowered the
52//! aggregate to the bound column `sum(t.x)`, and differentiating with respect to
53//! a column is exactly what it does.
54//!
55//! **Path B's `wrt` is any column of the node's input schema, including
56//! planner-derived ones** — aggregate outputs, window outputs, computed aliases.
57//! That is deliberate. Differentiating with respect to a *computed alias* is
58//! already a supported and correct operation — `grad(s*s, s)` is `2s` — and an
59//! aggregate output is the same shape one level down, so refusing it would
60//! contradict the case ddx already accepts.
61//!
62//! # Path B
63//!
64//! ```
65//! # use datafusion::prelude::SessionContext;
66//! # #[tokio::main]
67//! # async fn main() -> datafusion::error::Result<()> {
68//! let ctx = SessionContext::new();
69//! ddx_datafusion::install(&ctx);
70//!
71//! ctx.sql("CREATE TABLE t AS VALUES (1.0), (2.0), (3.0)").await?.collect().await?;
72//!
73//! // bare grad() — no wrapper
74//! let df = ctx.sql("SELECT grad(column1 * column1, column1) AS d FROM t").await?;
75//! # let _ = df.collect().await?;
76//! # Ok(())
77//! # }
78//! ```
79//!
80//! # What it supports
81//!
82//! Whatever [`ddx_core`] supports: `+ - * /`; the unary chain rule for the trig
83//! / inverse-trig / exp / log / hyperbolic set plus `abs`; `power` with a
84//! constant base or exponent; higher-order via nesting; through-aggregate via
85//! linearity (`AVG(grad(loss, theta))`). Anything else is a typed error, never
86//! a silently-wrong number. Errors from the engine arrive
87//! as [`DataFusionError::External`] boxing a [`ddx_core::DiffError`], so you can
88//! downcast and match on the variant.
89//!
90//! [`DataFusionError::External`]: datafusion::error::DataFusionError::External
91
92#![forbid(unsafe_code)]
93
94mod analyzer;
95mod error;
96mod markers;
97mod replan;
98mod sql;
99
100use std::sync::Arc;
101
102use datafusion::prelude::SessionContext;
103
104pub use analyzer::DdxAnalyzer;
105pub use markers::{grad_udf, jvp_udf, GRAD, JVP};
106pub use sql::{ddx_sql, ddx_sql_with, rewrite_sql, rewrite_sql_with};
107
108/// The engine this adapter drives, re-exported so downstream code links the
109/// same version — and, through it, the same `sqlparser`.
110pub use ddx_core;
111
112/// Install ddx on `ctx`: register the `grad`/`jvp` marker UDFs and the analyzer
113/// rule that rewrites them away (Path B).
114///
115/// Both halves are required and neither is useful alone. The UDFs exist only so
116/// the marker calls *parse and plan*; the analyzer rule is what actually
117/// differentiates. Registering the UDFs without the rule would let a marker
118/// reach execution, where it deliberately errors.
119///
120/// ```
121/// # use datafusion::prelude::SessionContext;
122/// let ctx = SessionContext::new();
123/// ddx_datafusion::install(&ctx);
124/// ```
125pub fn install(ctx: &SessionContext) {
126 install_with(ctx, DdxAnalyzer::new());
127}
128
129/// [`install`] with a caller-configured analyzer — use this to pick up custom
130/// differentiation rules (see [`DdxAnalyzer::with_engine`]).
131///
132/// Your own UDFs need no registration with ddx: a function called inside a
133/// marker is read off the bound expression when the derivative is re-planned,
134/// so `grad(my_udf(y) * x, x)` works whenever `my_udf` was registered, before or
135/// after this call.
136pub fn install_with(ctx: &SessionContext, analyzer: DdxAnalyzer) {
137 ctx.register_udf(grad_udf());
138 ctx.register_udf(jvp_udf());
139 ctx.add_analyzer_rule(Arc::new(analyzer));
140}