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}