Skip to main content

ddx_core/
engine.rs

1// SPDX-FileCopyrightText: 2026 Alexander Merose <al@merose.com> & ddx Authors
2//
3// SPDX-License-Identifier: Apache-2.0
4
5//! The differentiation engine: forward-mode linearization over
6//! `sqlparser::ast::Expr`, with a name-keyed, user-extensible rule registry.
7//!
8//! The approach mirrors JAX's per-primitive rule registry: every expression
9//! node has a differentiation rule and the chain rule composes them as the tree
10//! is walked. Because each row of a relational table is an independent
11//! evaluation point, differentiating a column expression and letting the engine
12//! evaluate it per row is the relational equivalent of `jax.vmap(jax.grad(f))`
13//! (design.md §1). Both [`differentiate`] (one partial derivative) and [`jvp`]
14//! (a directional derivative) are thin wrappers over [`linearize`] that differ
15//! only in their *leaf rule* — the tangent assigned to each column.
16
17use std::collections::HashMap;
18use std::f64::consts::{LN_10, LN_2};
19use std::sync::Arc;
20
21use sqlparser::ast::{
22    BinaryOperator, DataType, Expr, Function, FunctionArg, FunctionArgExpr, FunctionArguments,
23    ObjectNamePart, UnaryOperator,
24};
25
26use crate::colref::{ColRef, IdentCasing, Match};
27use crate::constructors::{
28    add, as_const, div, finite_num, func, func1, is_zero, mul, neg, num, one, sign, square, sub,
29    zero,
30};
31use crate::error::{DiffError, Result};
32
33/// A one-line summary of what v1 differentiates, appended to "unsupported"
34/// errors so a user reading the message learns what *is* supported and can act.
35const SUPPORTED: &str = "ddx differentiates the operators + - * /, unary calls to \
36sin/cos/tan/asin/acos/atan/exp/ln/log2/log10/sqrt/sinh/cosh/tanh/abs, power(...) with a \
37constant base or exponent, casts to a numeric type, and column/literal leaves";
38
39/// A differentiation rule for a unary primitive `f(u)`: given the argument
40/// expression `u`, it returns the *outer* derivative `f'(u)`. The engine
41/// multiplies by `du` (the chain rule) itself, so a user rule supplies only
42/// the local factor (design.md §3.2).
43pub type Rule = Arc<dyn Fn(&Expr) -> Result<Expr> + Send + Sync>;
44
45/// A registry of differentiation rules, keyed by (lower-cased) function name.
46///
47/// Built-ins populate it; users extend it with [`RuleRegistry::register`]
48/// (design.md §3.2 — the "extensible rule registry" decision).
49#[derive(Clone)]
50pub struct RuleRegistry {
51    unary: HashMap<String, Rule>,
52}
53
54impl Default for RuleRegistry {
55    fn default() -> Self {
56        Self::new()
57    }
58}
59
60/// Wrap an infallible closure as a [`Rule`].
61fn rule(f: impl Fn(&Expr) -> Expr + Send + Sync + 'static) -> Rule {
62    Arc::new(move |u| Ok(f(u)))
63}
64
65impl RuleRegistry {
66    /// A registry populated with the built-in v1 rule set: `+ - * /`, the unary
67    /// chain rule for the trig / inverse-trig / exp / log / hyperbolic set plus
68    /// `abs`, and `power` with a constant base or exponent (design.md §3.6).
69    pub fn new() -> Self {
70        let mut unary: HashMap<String, Rule> = HashMap::new();
71
72        // Trigonometric.
73        unary.insert("sin".into(), rule(|u| func1("cos", u.clone())));
74        unary.insert("cos".into(), rule(|u| neg(func1("sin", u.clone()))));
75        unary.insert(
76            "tan".into(),
77            rule(|u| div(one(), square(func1("cos", u.clone())))),
78        );
79        // Inverse trigonometric.
80        unary.insert(
81            "asin".into(),
82            rule(|u| div(one(), func1("sqrt", sub(one(), square(u.clone()))))),
83        );
84        unary.insert(
85            "acos".into(),
86            rule(|u| neg(div(one(), func1("sqrt", sub(one(), square(u.clone())))))),
87        );
88        unary.insert(
89            "atan".into(),
90            rule(|u| div(one(), add(one(), square(u.clone())))),
91        );
92        // Exponential / logarithmic.
93        unary.insert("exp".into(), rule(|u| func1("exp", u.clone())));
94        unary.insert("ln".into(), rule(|u| div(one(), u.clone())));
95        unary.insert(
96            "log2".into(),
97            rule(|u| div(one(), mul(u.clone(), num(LN_2)))),
98        );
99        unary.insert(
100            "log10".into(),
101            rule(|u| div(one(), mul(u.clone(), num(LN_10)))),
102        );
103        unary.insert(
104            "sqrt".into(),
105            rule(|u| div(one(), mul(num(2.0), func1("sqrt", u.clone())))),
106        );
107        // Hyperbolic.
108        unary.insert("sinh".into(), rule(|u| func1("cosh", u.clone())));
109        unary.insert("cosh".into(), rule(|u| func1("sinh", u.clone())));
110        unary.insert(
111            "tanh".into(),
112            rule(|u| sub(one(), square(func1("tanh", u.clone())))),
113        );
114        // Piecewise-linear: d/du |u| = sign(u), emitted as a portable CASE that
115        // pins abs'(0) = 0 on every engine (design.md §5, F12). It deliberately
116        // does NOT emit signum()/sign(): DuckDB has no signum (only sign),
117        // DataFusion has no sign (only signum), and signum(0) = 1 — so a bare
118        // builtin would be non-portable AND violate the pinned convention. Note
119        // this pins ddx's own convention; jax.grad(abs)(0) uses a different one.
120        unary.insert("abs".into(), rule(|u| sign(u.clone())));
121
122        RuleRegistry { unary }
123    }
124
125    /// Register (or override) a unary differentiation rule under `name`. The
126    /// name is matched case-insensitively.
127    pub fn register(&mut self, name: &str, rule: Rule) {
128        self.unary.insert(name.to_ascii_lowercase(), rule);
129    }
130
131    fn lookup(&self, name: &str) -> Option<&Rule> {
132        self.unary.get(name)
133    }
134}
135
136/// A *leaf rule*: the tangent seed for a column occurrence. Returns an error
137/// when the occurrence's identity against the differentiation variable can't be
138/// pinned syntactically (the ambiguity guard, F2).
139type Leaf<'a> = dyn Fn(&ColRef) -> Result<Expr> + 'a;
140
141/// Differentiate `expr` with respect to the column `wrt`.
142///
143/// Forward-mode with a one-hot seed: `1` on `wrt`, `0` on every other column.
144pub fn differentiate(
145    expr: &Expr,
146    wrt: &ColRef,
147    casing: IdentCasing,
148    reg: &RuleRegistry,
149) -> Result<Expr> {
150    let leaf = |c: &ColRef| match c.classify(wrt, casing) {
151        Match::Is => Ok(one()),
152        Match::Not => Ok(zero()),
153        Match::Ambiguous => Err(DiffError::AmbiguousColumn(format!(
154            "occurrence of `{}` cannot be matched against differentiation \
155             variable `{}` — fully qualify it",
156            c.display(),
157            wrt.display()
158        ))),
159    };
160    linearize(expr, &leaf, reg)
161}
162
163/// Forward-mode directional derivative: the tangent of `expr` given a tangent
164/// (`seeds`) for each seeded input column; unseeded columns are constant.
165///
166/// The marker form `jvp(expr, column, tangent)` seeds a single column; a
167/// multi-input directional derivative is a sum of `jvp` terms (design.md §3.6).
168pub fn jvp(
169    expr: &Expr,
170    seeds: &[(ColRef, Expr)],
171    casing: IdentCasing,
172    reg: &RuleRegistry,
173) -> Result<Expr> {
174    let leaf = |c: &ColRef| {
175        for (col, tangent) in seeds {
176            match c.classify(col, casing) {
177                Match::Is => return Ok(tangent.clone()),
178                Match::Ambiguous => {
179                    return Err(DiffError::AmbiguousColumn(format!(
180                        "occurrence of `{}` cannot be matched against seeded \
181                         column `{}` — fully qualify it",
182                        c.display(),
183                        col.display()
184                    )))
185                }
186                Match::Not => continue,
187            }
188        }
189        Ok(zero())
190    };
191    linearize(expr, &leaf, reg)
192}
193
194/// Push tangents from the leaves up through `expr` via the chain rule.
195fn linearize(expr: &Expr, leaf: &Leaf, reg: &RuleRegistry) -> Result<Expr> {
196    match expr {
197        // Leaves: the leaf rule decides a column's tangent.
198        Expr::Identifier(_) | Expr::CompoundIdentifier(_) => {
199            let cr = ColRef::from_expr(expr)
200                .ok_or_else(|| DiffError::Internal("column expr yielded no ColRef".into()))?;
201            leaf(&cr)
202        }
203
204        // Constants have zero tangent.
205        Expr::Value(_) => Ok(zero()),
206
207        // Parentheses are transparent to differentiation; the smart
208        // constructors re-introduce any precedence parentheses the result needs.
209        Expr::Nested(inner) => linearize(inner, leaf, reg),
210
211        // A cast to a numeric type is locally linear: tangent of cast(u) =
212        // cast(du) to the same type. A cast to a non-numeric type (VARCHAR,
213        // DATE, BOOLEAN, …) is not differentiable — differentiating through it
214        // would emit a nonsensical `CAST(1.0 AS VARCHAR)`, so it is a typed
215        // error rather than a silently-wrong derivative (principle 5).
216        Expr::Cast {
217            kind,
218            expr: inner,
219            data_type,
220            array,
221            format,
222        } => {
223            if !is_numeric_type(data_type) {
224                return Err(DiffError::NotImplemented(format!(
225                    "differentiation through a cast to non-numeric type `{data_type}` \
226                     is not supported"
227                )));
228            }
229            let du = linearize(inner, leaf, reg)?;
230            Ok(Expr::Cast {
231                kind: kind.clone(),
232                expr: Box::new(du),
233                data_type: data_type.clone(),
234                array: *array,
235                format: format.clone(),
236            })
237        }
238
239        // tangent of -u = -(du); unary plus is transparent.
240        Expr::UnaryOp {
241            op: UnaryOperator::Minus,
242            expr: inner,
243        } => Ok(neg(linearize(inner, leaf, reg)?)),
244        Expr::UnaryOp {
245            op: UnaryOperator::Plus,
246            expr: inner,
247        } => linearize(inner, leaf, reg),
248
249        Expr::BinaryOp { left, op, right } => linearize_binary(left, op, right, leaf, reg),
250
251        Expr::Function(f) => linearize_function(f, leaf, reg),
252
253        other => Err(DiffError::NotImplemented(format!(
254            "this expression cannot be differentiated: `{other}`. {SUPPORTED}; CASE, \
255             comparisons, subqueries, window functions, and string/temporal expressions are \
256             not differentiable"
257        ))),
258    }
259}
260
261/// Linearize a binary arithmetic expression via the sum/product/quotient rules.
262fn linearize_binary(
263    left: &Expr,
264    op: &BinaryOperator,
265    right: &Expr,
266    leaf: &Leaf,
267    reg: &RuleRegistry,
268) -> Result<Expr> {
269    let da = linearize(left, leaf, reg)?;
270    let db = linearize(right, leaf, reg)?;
271    match op {
272        // tangent of (a + b) = da + db
273        BinaryOperator::Plus => Ok(add(da, db)),
274        // tangent of (a - b) = da - db
275        BinaryOperator::Minus => Ok(sub(da, db)),
276        // tangent of (a * b) = da*b + a*db   (product rule)
277        BinaryOperator::Multiply => Ok(add(mul(da, right.clone()), mul(left.clone(), db))),
278        // tangent of (a / b) = (da*b - a*db) / b^2   (quotient rule)
279        BinaryOperator::Divide => {
280            let numerator = sub(mul(da, right.clone()), mul(left.clone(), db));
281            Ok(div(numerator, square(right.clone())))
282        }
283        other => Err(DiffError::NotImplemented(format!(
284            "the operator `{other}` is not differentiable. {SUPPORTED}"
285        ))),
286    }
287}
288
289/// Linearize a scalar-function call via the chain rule.
290fn linearize_function(f: &Function, leaf: &Leaf, reg: &RuleRegistry) -> Result<Expr> {
291    let name = simple_func_name(f).ok_or_else(|| {
292        DiffError::NotImplemented(format!(
293            "cannot differentiate the call `{f}`: only an unqualified function name has a \
294             differentiation rule (a schema-qualified or otherwise complex name is left alone)"
295        ))
296    })?;
297    let args = positional_args(f).ok_or_else(|| {
298        DiffError::NotImplemented(format!(
299            "function `{name}` has non-positional arguments, which are not differentiable"
300        ))
301    })?;
302
303    // `power(base, exponent)` / `pow(...)` is the one binary primitive.
304    if name == "power" || name == "pow" {
305        return linearize_power(&name, &args, leaf, reg);
306    }
307
308    if args.len() != 1 {
309        return Err(DiffError::NotImplemented(format!(
310            "no differentiation rule for `{name}` with {} arguments: the built-in function \
311             rules are unary, and `power` is the only two-argument rule",
312            args.len()
313        )));
314    }
315    let u = args[0];
316    let du = linearize(u, leaf, reg)?;
317    // Chain-rule short-circuit: a zero inner tangent kills the whole term.
318    if is_zero(&du) {
319        return Ok(zero());
320    }
321    let outer = reg.lookup(&name).ok_or_else(|| {
322        DiffError::NotImplemented(format!(
323            "no differentiation rule for function `{name}`. {SUPPORTED}. Register a custom \
324             rule with `Ddx::register(\"{name}\", ...)`"
325        ))
326    })?(u)?;
327    Ok(mul(outer, du))
328}
329
330/// Linearize `power(base, exponent)` (design.md §3.6).
331///
332/// * Constant exponent `c`: `c * base^(c-1) * d(base)`.
333/// * Constant base `a`: `a^u * ln(a) * d(u)`.
334/// * Both variable (`u^v`): not supported yet (needs the exp/log trick).
335fn linearize_power(name: &str, args: &[&Expr], leaf: &Leaf, reg: &RuleRegistry) -> Result<Expr> {
336    if args.len() != 2 {
337        return Err(DiffError::NotImplemented(format!(
338            "{name}() expects exactly two arguments"
339        )));
340    }
341    let base = args[0];
342    let exponent = args[1];
343    match (as_const(base), as_const(exponent)) {
344        // Constant exponent (covers x^2, x^0.5, x^-2, ...).
345        (_, Some(c)) => {
346            let dbase = linearize(base, leaf, reg)?;
347            if is_zero(&dbase) {
348                return Ok(zero());
349            }
350            // `finite_num` fails loud on a non-finite constant (e.g. an
351            // out-of-range literal `1e400` → inf), but *only here at emission* —
352            // after the zero short-circuit above — so a wrt-independent base
353            // still differentiates to `0` rather than erroring (#49/F1, #33).
354            let outer = mul(
355                finite_num(c)?,
356                func("power", vec![base.clone(), finite_num(c - 1.0)?]),
357            );
358            Ok(mul(outer, dbase))
359        }
360        // Constant base, variable exponent.
361        (Some(a), None) => {
362            let dexp = linearize(exponent, leaf, reg)?;
363            if is_zero(&dexp) {
364                return Ok(zero());
365            }
366            // The derivative is `a^u · ln(a) · du`; `ln(a)` is non-finite for a
367            // non-positive (or infinite) base — `finite_num` fails loud there
368            // rather than emit an `inf`/`NaN` token (#33).
369            let outer = mul(
370                func("power", vec![base.clone(), exponent.clone()]),
371                finite_num(a.ln())?,
372            );
373            Ok(mul(outer, dexp))
374        }
375        // General u^v — deferred (design.md §3.6 roadmap).
376        (None, None) => Err(DiffError::NotImplemented(
377            "cannot differentiate `power(base, exponent)` when both the base and the exponent \
378             depend on the differentiation variable; ddx handles it only when one side is a \
379             constant (e.g. `power(x, 2)` or `power(2, x)`). For the general u^v case, rewrite \
380             it as `exp(exponent * ln(base))` when the base is positive"
381                .into(),
382        )),
383    }
384}
385
386/// The lower-cased name of a function call — but only for an **unqualified**
387/// call (a single-identifier name), mirroring the marker path's strict
388/// `len() == 1` (F8). A schema-qualified call like `myschema.sin(x)` may be an
389/// unrelated user function, so it must not silently match the built-in `sin`
390/// rule ("tag explicitly, never infer" — principle 3; round-3 review #47).
391fn simple_func_name(f: &Function) -> Option<String> {
392    match f.name.0.as_slice() {
393        [ObjectNamePart::Identifier(id)] => Some(id.value.to_ascii_lowercase()),
394        _ => None,
395    }
396}
397
398/// The positional (unnamed) argument expressions of a function call, or `None`
399/// if it uses any non-positional argument form (named args, wildcards, a
400/// subquery).
401pub(crate) fn positional_args(f: &Function) -> Option<Vec<&Expr>> {
402    match &f.args {
403        FunctionArguments::List(list) => {
404            let mut out = Vec::with_capacity(list.args.len());
405            for arg in &list.args {
406                match arg {
407                    FunctionArg::Unnamed(FunctionArgExpr::Expr(e)) => out.push(e),
408                    _ => return None,
409                }
410            }
411            Some(out)
412        }
413        _ => None,
414    }
415}
416
417/// True if `dt` is a numeric type — the only kind of cast that is locally
418/// linear (and so differentiable). The list is exhaustive for the pinned
419/// `sqlparser` version; a `sqlparser` bump is already a breaking release of
420/// `ddx-core` (design.md §6, G2), at which point this is re-checked.
421pub(crate) fn is_numeric_type(dt: &DataType) -> bool {
422    matches!(
423        dt,
424        // Floating-point / fixed-point.
425        DataType::Numeric(_)
426            | DataType::Decimal(_)
427            | DataType::BigNumeric(_)
428            | DataType::BigDecimal(_)
429            | DataType::Dec(_)
430            | DataType::Float(_)
431            | DataType::FloatUnsigned(_)
432            | DataType::Float4
433            | DataType::Float32
434            | DataType::Float64
435            | DataType::Real
436            | DataType::RealUnsigned
437            | DataType::Float8
438            | DataType::Double(_)
439            | DataType::DoubleUnsigned(_)
440            | DataType::DoublePrecision
441            | DataType::DoublePrecisionUnsigned
442            // Integers (signed / unsigned / width-tagged aliases).
443            | DataType::TinyInt(_)
444            | DataType::TinyIntUnsigned(_)
445            | DataType::UTinyInt
446            | DataType::Int2(_)
447            | DataType::Int2Unsigned(_)
448            | DataType::SmallInt(_)
449            | DataType::SmallIntUnsigned(_)
450            | DataType::USmallInt
451            | DataType::MediumInt(_)
452            | DataType::MediumIntUnsigned(_)
453            | DataType::Int(_)
454            | DataType::Int4(_)
455            | DataType::Int8(_)
456            | DataType::Int16
457            | DataType::Int32
458            | DataType::Int64
459            | DataType::Int128
460            | DataType::Int256
461            | DataType::Integer(_)
462            | DataType::IntUnsigned(_)
463            | DataType::Int4Unsigned(_)
464            | DataType::IntegerUnsigned(_)
465            | DataType::HugeInt
466            | DataType::UHugeInt
467            | DataType::UInt8
468            | DataType::UInt16
469            | DataType::UInt32
470            | DataType::UInt64
471            | DataType::UInt128
472            | DataType::UInt256
473            | DataType::BigInt(_)
474            | DataType::BigIntUnsigned(_)
475            | DataType::UBigInt
476            | DataType::Int8Unsigned(_)
477            | DataType::Signed
478            | DataType::SignedInteger
479            | DataType::Unsigned
480            | DataType::UnsignedInteger
481    )
482}