Skip to main content

ddx_datafusion/
analyzer.rs

1// SPDX-FileCopyrightText: 2026 Alexander Merose <al@merose.com> & ddx Authors
2//
3// SPDX-License-Identifier: Apache-2.0
4
5//! The in-engine rewrite: `grad`/`jvp` markers removed from the bound plan.
6//!
7//! An [`AnalyzerRule`] that finds every `grad`/`jvp` marker in a bound
8//! [`LogicalPlan`], differentiates its argument through `ddx-core`, and splices
9//! the result back as a real DataFusion [`Expr`]. This is what makes bare
10//! `grad()` work with no wrapper, across both the SQL and DataFrame APIs.
11//!
12//! **It is a bridge, not a second rule engine.** All the calculus lives in
13//! `ddx-core`; this module only moves expressions across the boundary:
14//!
15//! ```text
16//!   DataFusion Expr --expr_to_sql--> sqlparser::ast::Expr   (same crate version!)
17//!                                          |
18//!                                    ddx-core differentiate
19//!                                          |
20//!   DataFusion Expr <----replan----- sqlparser::ast::Expr
21//! ```
22//!
23//! Both hops are type-level, with no SQL string in between — which only works
24//! because `ddx-core` and `datafusion` resolve the *identical* `sqlparser`
25//! version. `tests/sqlparser_pin.rs` enforces that.
26//!
27//! # Binding-awareness comes free here
28//!
29//! The plan is already bound when the rule sees it, so column references arrive
30//! qualified. That means the ambiguity guard the *text* rewrite needs — which
31//! exists because a pre-binding rewrite cannot tell `a.x` from `b.x` — simply
32//! never fires on this path.
33//!
34//! The claim survived a deliberate attempt to break it, and the reason is worth
35//! stating: the obvious attack is two bound columns that unparse to the same
36//! text, which needs an unaliased self-join — and DataFusion rejects that as
37//! ambiguous during planning, before this rule is ever handed the plan. So the
38//! guarantee rests on the planner having already refused the ambiguous cases,
39//! not merely on qualifiers being present.
40
41use std::sync::Arc;
42
43use datafusion::arrow::datatypes::DataType;
44use datafusion::common::config::ConfigOptions;
45use datafusion::common::tree_node::{Transformed, TreeNode, TreeNodeRecursion};
46use datafusion::common::DFSchema;
47use datafusion::error::{DataFusionError, Result};
48use datafusion::logical_expr::utils::{find_out_reference_exprs, merge_schema};
49use datafusion::logical_expr::{Expr, ExprSchemable, LogicalPlan, ScalarUDF};
50use datafusion::optimizer::analyzer::type_coercion::TypeCoercion;
51use datafusion::optimizer::AnalyzerRule;
52use datafusion::sql::unparser::Unparser;
53use ddx_core::sqlparser::ast as sql_ast;
54use ddx_core::{ColRef, Ddx};
55
56use crate::error::to_df_err;
57use crate::markers::{marker_kind, GRAD, JVP};
58use crate::replan::{functions_in, replan, ExprContext};
59
60/// The ddx analyzer rule: rewrites `grad`/`jvp` markers away before execution.
61///
62/// Install it with [`crate::install`], which also registers the marker UDFs so
63/// the calls parse in the first place.
64pub struct DdxAnalyzer {
65    ddx: Ddx,
66    exprs: ExprContext,
67}
68
69// `AnalyzerRule` requires `Debug`, but `Ddx` holds a rule registry of function
70// pointers and is deliberately not `Debug` itself. Print what is actually
71// useful in a plan dump — which rule this is — rather than nothing.
72impl std::fmt::Debug for DdxAnalyzer {
73    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74        f.debug_struct("DdxAnalyzer")
75            .field("rule", &"ddx_markers")
76            .finish_non_exhaustive()
77    }
78}
79
80impl Default for DdxAnalyzer {
81    fn default() -> Self {
82        Self::new()
83    }
84}
85
86impl DdxAnalyzer {
87    /// A rule driving the built-in rule set.
88    pub fn new() -> Self {
89        Self::with_engine(Ddx::for_datafusion())
90    }
91
92    /// A rule driving a caller-supplied engine — use this to pick up custom
93    /// differentiation rules registered via [`Ddx::register`].
94    pub fn with_engine(ddx: Ddx) -> Self {
95        Self::with_engine_and_functions(ddx, [])
96    }
97
98    /// As [`DdxAnalyzer::with_engine`], plus extra scalar functions that may
99    /// appear in a re-planned derivative.
100    ///
101    /// You rarely need this. A UDF from your *own marker body* is handled
102    /// automatically — it is harvested from the bound expression itself, so
103    /// `grad(my_udf(y) * x, x)` works with no setup regardless of when `my_udf`
104    /// was registered. What lands outside that is a UDF **emitted by a custom
105    /// differentiation rule** you registered with [`Ddx::register`]: that
106    /// function appears only in the *output*, so it cannot be harvested from the
107    /// input and must be declared here.
108    pub fn with_engine_and_functions(
109        ddx: Ddx,
110        functions: impl IntoIterator<Item = Arc<ScalarUDF>>,
111    ) -> Self {
112        DdxAnalyzer {
113            ddx,
114            exprs: ExprContext::new(functions),
115        }
116    }
117
118    /// Rewrite every marker in one expression, bottom-up.
119    ///
120    /// Bottom-up is what makes higher-order differentiation fall out for free:
121    /// the inner `grad` of `grad(grad(f, x), x)` is already an ordinary
122    /// expression by the time the outer one is differentiated.
123    fn rewrite_expr(
124        &self,
125        expr: Expr,
126        schema: &DFSchema,
127        options: &ConfigOptions,
128    ) -> Result<Transformed<Expr>> {
129        expr.transform_up(|e| {
130            let Expr::ScalarFunction(call) = &e else {
131                return Ok(Transformed::no(e));
132            };
133            let Some(kind) = marker_kind(call.func.name()) else {
134                return Ok(Transformed::no(e));
135            };
136            let derivative = self.differentiate_call(kind, &call.args, schema, options)?;
137            Ok(Transformed::yes(derivative))
138        })
139    }
140
141    /// Differentiate one marker call and return the replacement expression.
142    fn differentiate_call(
143        &self,
144        kind: &'static str,
145        args: &[Expr],
146        schema: &DFSchema,
147        options: &ConfigOptions,
148    ) -> Result<Expr> {
149        // One destructure carries the arity, so it is not restated as a count and
150        // there is no positional indexing below. DataFusion's own signature check
151        // (`Signature::any(2)` / `any(3)` on the marker UDFs) rejects a wrong
152        // arity during planning, before this rule runs, so the error here is a
153        // backstop rather than the message a user normally sees.
154        let (body_arg, wrt_arg, tangent_arg) = match (kind, args) {
155            (GRAD, [body, wrt]) => (body, wrt, None),
156            (JVP, [body, wrt, tangent]) => (body, wrt, Some(tangent)),
157            _ => {
158                return Err(DataFusionError::Plan(format!(
159                    "ddx: `{kind}` was called with {} arguments. \
160                     Write `grad(expr, column)` or `jvp(expr, column, tangent)`.",
161                    args.len()
162                )))
163            }
164        };
165
166        // Reject a correlated outer reference *before* unparsing, so the user
167        // gets the real constraint instead of a lie about their schema.
168        //
169        // `Expr::OuterReferenceColumn` does not survive the bridge: it unparses
170        // to an ordinary qualified column, and the derivative is then re-planned
171        // against the *inner* node's inputs, where by construction that column
172        // is absent. The resulting "No field named t.x" is true about the wrong
173        // thing — the column exists, it just isn't reachable from here.
174        //
175        // Failing loudly is correct — ddx never guesses a derivative. This only
176        // fixes what the failure blames. It is structurally loud, incidentally: the planner
177        // creates an `OuterReferenceColumn` only when the name does *not*
178        // resolve in the inner scope, so the unparsed text cannot silently
179        // rebind to an inner column of the same name.
180        if let Some(outer) = outer_reference_in(args) {
181            return Err(DataFusionError::Plan(format!(
182                "ddx: this `{kind}` marker is inside a correlated subquery and references \
183                 the outer column `{outer}`. The derivative is re-planned against the \
184                 subquery's own inputs, where an outer column is not in scope, so a \
185                 reference to one cannot be carried through.\n\n\
186                 Use `ddx_datafusion::ddx_sql(&ctx, sql)` instead: it rewrites the SQL text \
187                 before the query is planned, so it has no such limit."
188            )));
189        }
190
191        let body = to_sql_ast(body_arg)?;
192        let wrt = wrt_colref(kind, wrt_arg)?;
193
194        let derivative: sql_ast::Expr = match tangent_arg {
195            None => self.ddx.differentiate(&body, &wrt).map_err(to_df_err)?,
196            Some(tangent) => {
197                let tangent = to_sql_ast(tangent)?;
198                self.ddx.jvp(&body, &[(wrt, tangent)]).map_err(to_df_err)?
199            }
200        };
201
202        // Functions the user called are harvested from the marker's own body:
203        // anything that survives differentiation was necessarily in there, and
204        // a bound Expr carries the ScalarUDF itself. That is why a session UDF
205        // works here without the analyzer ever reaching the session registry.
206        let local = functions_in(args);
207        let replanned = replan(&self.exprs, options, local, derivative, schema)?;
208
209        // Force the replacement to the type the marker UDF declared
210        // (`Marker::return_type` → Float64). Two reasons, one of them a bug:
211        //
212        // 1. Correctness. The marker's declared type is already baked into
213        //    every ancestor node's cached schema. `LogicalPlan::map_children`
214        //    preserves a parent's `schema` field while swapping its input, so
215        //    only the rewritten node gets `recompute_schema` — if the
216        //    derivative planned to a different type (`x + x` on an Int64 column
217        //    is Int64), ancestors keep a stale Float64 and the optimizer's
218        //    invariant check fails with an internal error.
219        // 2. Policy. Derivatives are always emitted DOUBLE-typed, because
220        //    differentiation runs before binding — operand types are unknown —
221        //    and SQL integer division truncates on some engines but not others.
222        //    Without this, `grad(x*x, x)` over a BIGINT column returns Int64 —
223        //    quietly violating the invariant this crate documents.
224        //
225        // `cast_to` is a no-op when the type already matches, which is the
226        // common case.
227        replanned.cast_to(&DataType::Float64, schema).map_err(|e| {
228            DataFusionError::Plan(format!(
229                "ddx: the derivative of a `{kind}` argument could not be represented as \
230                     DOUBLE. Every derivative is emitted DOUBLE-typed so that integer \
231                     division cannot silently truncate it: {e}"
232            ))
233        })
234    }
235}
236
237impl AnalyzerRule for DdxAnalyzer {
238    fn name(&self) -> &str {
239        "ddx_markers"
240    }
241
242    fn analyze(&self, plan: LogicalPlan, config: &ConfigOptions) -> Result<LogicalPlan> {
243        // Skip the whole walk when the plan contains no marker. Same spirit as
244        // ddx-core's parse-free pre-gate: a query that
245        // never mentions ddx should not be touched, and must not be able to
246        // fail inside ddx.
247        if !plan_has_marker(&plan)? {
248            return Ok(plan);
249        }
250
251        let plan = self.rewrite_plan(plan, config)?;
252
253        // Re-run type coercion over the rewritten plan.
254        //
255        // `add_analyzer_rule` installs this rule to run AFTER DataFusion's own
256        // `TypeCoercion` pass, so the expression we splice in has never been
257        // coerced — nothing runs after us. That matters because ddx-core
258        // deliberately emits DOUBLE-typed literals and casts, so
259        // differentiating anything over an integer column yields
260        // mixed-type arithmetic: `grad(x / 2, x)` on a BIGINT column produced
261        // `Float64 / Int64`, which plans fine and then dies at execution with an
262        // Arrow error. Coercing here is what the engine would have done had the
263        // derivative been written by hand.
264        TypeCoercion::new().analyze(plan, config)
265    }
266}
267
268impl DdxAnalyzer {
269    /// Rewrite every marker in one plan (and in any plan embedded in its
270    /// expressions). No pre-gate and no type coercion — [`AnalyzerRule::analyze`]
271    /// wraps those around the outermost call.
272    fn rewrite_plan(&self, plan: LogicalPlan, options: &ConfigOptions) -> Result<LogicalPlan> {
273        // `transform_up_with_subqueries`, not `transform_up`: a subquery carries
274        // its own `LogicalPlan` inside an *expression*, and the plain walk
275        // visits only direct relational inputs. DataFusion already knows every
276        // expression variant that can carry one, so this delegates rather than
277        // re-deriving the list — a hand-rolled match over an upstream enum has
278        // to be re-audited on every bump, and ours was already one arm short
279        // (`Expr::SetComparison`, i.e. ANY/ALL/SOME, reached execution).
280        plan.transform_up_with_subqueries(|node| {
281            // A node's expressions are resolved against its *inputs*, not its
282            // own output schema — that is what binds the derivative's columns
283            // to the same columns the original expression used. Leaf nodes
284            // (which have no inputs) fall back to their own schema.
285            let schema = merged_input_schema(&node);
286
287            // Which names this node publishes to its parents, read off the
288            // node's own schema *before* the rewrite.
289            //
290            // A rewritten expression must keep its name only where a parent can
291            // refer to it by that name: an unaliased `AVG(grad(x*x, x))` derives
292            // the field `avg(grad(t.x * t.x,t.x))`, and the projection above it
293            // refers to exactly that string, so renaming it underneath leaves
294            // the parent dangling. Predicates, sort keys, and join conditions
295            // name nothing, and aliasing those would be at best noise and at
296            // worst harmful (an alias around a join key can defeat equijoin
297            // recognition).
298            //
299            // This asks the plan rather than matching on node variants. Upstream
300            // ships a `NamePreserver` for the same job, but it decides from an
301            // exclusion list of `LogicalPlan` variants; a schema lookup cannot go
302            // stale, because any node that publishes a name necessarily has that
303            // name in its schema.
304            let out_schema = Arc::clone(node.schema());
305
306            let node = node.map_expressions(|expr| {
307                let original_name = expr.schema_name().to_string();
308                let out = self.rewrite_expr(expr, &schema, options)?;
309                // Only a rewritten expression can have changed its name, so an
310                // untouched one skips the check entirely.
311                if !out.transformed || !out_schema.has_column_with_unqualified_name(&original_name)
312                {
313                    return Ok(out);
314                }
315                out.map_data(|e| e.alias_if_changed(original_name))
316            })?;
317
318            // `map_expressions` already ORs the per-expression transformed flags,
319            // so this is exactly "did any marker rewrite happen in this node".
320            if node.transformed {
321                // Field *names* can change even when the type doesn't, so the
322                // node's schema is rebuilt regardless.
323                node.map_data(LogicalPlan::recompute_schema)
324            } else {
325                Ok(node)
326            }
327        })
328        .map(|t| t.data)
329    }
330}
331
332/// The schema a node's expressions resolve against: all of its inputs merged.
333///
334/// The merge itself is upstream's — it is what DataFusion's own analyzer rules
335/// use, and it has a single-input fast path that skips the rebuild. Only the
336/// leaf case is ours: a node with no inputs (a `Values`, say) resolves its
337/// expressions against its own schema, where upstream would hand back an empty
338/// one.
339fn merged_input_schema(plan: &LogicalPlan) -> DFSchema {
340    let inputs = plan.inputs();
341    if inputs.is_empty() {
342        plan.schema().as_ref().clone()
343    } else {
344        merge_schema(&inputs)
345    }
346}
347
348/// Does this plan contain a ddx marker anywhere — including inside a plan
349/// embedded in one of its expressions?
350///
351/// This is a *gate*: a false negative skips the rewrite for the whole plan and
352/// the marker survives to execution. So it walks with `apply_with_subqueries`,
353/// the mirror of the `transform_up_with_subqueries` used for the rewrite — the
354/// two must agree on what "anywhere" means, and delegating to DataFusion is the
355/// only way to keep them agreeing across upstream changes.
356fn plan_has_marker(plan: &LogicalPlan) -> Result<bool> {
357    let mut found = false;
358    plan.apply_with_subqueries(|node| {
359        node.apply_expressions(|expr| {
360            expr.apply(|e| {
361                if let Expr::ScalarFunction(call) = e {
362                    if marker_kind(call.func.name()).is_some() {
363                        found = true;
364                        return Ok(TreeNodeRecursion::Stop);
365                    }
366                }
367                Ok(TreeNodeRecursion::Continue)
368            })
369        })?;
370        Ok(if found {
371            TreeNodeRecursion::Stop
372        } else {
373            TreeNodeRecursion::Continue
374        })
375    })?;
376    Ok(found)
377}
378
379/// The first correlated outer reference anywhere in `args`, if there is one.
380fn outer_reference_in(args: &[Expr]) -> Option<String> {
381    args.iter()
382        .flat_map(find_out_reference_exprs)
383        .find_map(|e| match e {
384            Expr::OuterReferenceColumn(_, col) => Some(col.flat_name()),
385            _ => None,
386        })
387}
388
389/// Unparse a bound DataFusion expression into the `sqlparser` AST `ddx-core`
390/// consumes. This is the load-bearing type identity: the
391/// output here *is* `ddx-core`'s input type, with no string in between.
392fn to_sql_ast(expr: &Expr) -> Result<sql_ast::Expr> {
393    Unparser::default().expr_to_sql(expr)
394}
395
396/// Read the differentiation variable off the marker's second argument.
397///
398/// **The `wrt` must go through the same unparser as the body.** It is tempting
399/// to build the `ColRef` directly from the bound [`Column`]'s `relation`/`name`
400/// strings, since the planner already resolved them — but that produces
401/// *unquoted* idents, while the body's occurrences of the very same column are
402/// unparsed by `Unparser`, whose `DefaultDialect` quotes any identifier
403/// containing an uppercase letter (or a keyword). `IdentCasing::FoldUnquoted`
404/// then folds the unquoted `wrt` to lowercase and preserves the quoted
405/// occurrence's case, they compare unequal, every occurrence classifies as
406/// `Match::Not`, and the derivative comes back a silent `0` for any capitalized
407/// column — a silently wrong answer, which ddx must never produce, and one that
408/// hits any Parquet or CSV schema with capitalized headers.
409///
410/// Unparsing the column keeps the qualifier the planner resolved (so this path
411/// stays binding-aware) *and* guarantees both sides share one quoting rule.
412fn wrt_colref(kind: &str, arg: &Expr) -> Result<ColRef> {
413    let unparsed = to_sql_ast(arg)?;
414    ColRef::from_wrt_arg(kind, &unparsed).map_err(to_df_err)
415}