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    /// The unary function names this registry has a rule for, sorted.
126    ///
127    /// Read out of the registry rather than restated, so it cannot fall behind
128    /// what is implemented — and so a test asking "is every rule covered?" is
129    /// asking the engine instead of a second list that has to be maintained
130    /// alongside the first. Includes rules a caller registered.
131    pub fn unary_names(&self) -> Vec<String> {
132        let mut names: Vec<String> = self.unary.keys().cloned().collect();
133        names.sort();
134        names
135    }
136
137    /// Register (or override) a unary differentiation rule under `name`. The
138    /// name is matched case-insensitively.
139    pub fn register(&mut self, name: &str, rule: Rule) {
140        self.unary.insert(name.to_ascii_lowercase(), rule);
141    }
142
143    fn lookup(&self, name: &str) -> Option<&Rule> {
144        self.unary.get(name)
145    }
146}
147
148/// A *leaf rule*: the tangent seed for a column occurrence. Returns an error
149/// when the occurrence's identity against the differentiation variable can't be
150/// pinned syntactically (the ambiguity guard, F2).
151type Leaf<'a> = dyn Fn(&ColRef) -> Result<Expr> + 'a;
152
153/// Differentiate `expr` with respect to the column `wrt`.
154///
155/// Forward-mode with a one-hot seed: `1` on `wrt`, `0` on every other column.
156pub fn differentiate(
157    expr: &Expr,
158    wrt: &ColRef,
159    casing: IdentCasing,
160    reg: &RuleRegistry,
161) -> Result<Expr> {
162    let leaf = |c: &ColRef| match c.classify(wrt, casing) {
163        Match::Is => Ok(one()),
164        Match::Not => Ok(zero()),
165        Match::Ambiguous => Err(DiffError::AmbiguousColumn(format!(
166            "occurrence of `{}` cannot be matched against differentiation \
167             variable `{}` — fully qualify it",
168            c.display(),
169            wrt.display()
170        ))),
171    };
172    linearize(expr, &leaf, reg)
173}
174
175/// Forward-mode directional derivative: the tangent of `expr` given a tangent
176/// (`seeds`) for each seeded input column; unseeded columns are constant.
177///
178/// The marker form `jvp(expr, column, tangent)` seeds a single column; a
179/// multi-input directional derivative is a sum of `jvp` terms (design.md §3.6).
180pub fn jvp(
181    expr: &Expr,
182    seeds: &[(ColRef, Expr)],
183    casing: IdentCasing,
184    reg: &RuleRegistry,
185) -> Result<Expr> {
186    let leaf = |c: &ColRef| {
187        for (col, tangent) in seeds {
188            match c.classify(col, casing) {
189                Match::Is => return Ok(tangent.clone()),
190                Match::Ambiguous => {
191                    return Err(DiffError::AmbiguousColumn(format!(
192                        "occurrence of `{}` cannot be matched against seeded \
193                         column `{}` — fully qualify it",
194                        c.display(),
195                        col.display()
196                    )))
197                }
198                Match::Not => continue,
199            }
200        }
201        Ok(zero())
202    };
203    linearize(expr, &leaf, reg)
204}
205
206/// Push tangents from the leaves up through `expr` via the chain rule.
207fn linearize(expr: &Expr, leaf: &Leaf, reg: &RuleRegistry) -> Result<Expr> {
208    match expr {
209        // Leaves: the leaf rule decides a column's tangent.
210        Expr::Identifier(_) | Expr::CompoundIdentifier(_) => {
211            let cr = ColRef::from_expr(expr)
212                .ok_or_else(|| DiffError::Internal("column expr yielded no ColRef".into()))?;
213            leaf(&cr)
214        }
215
216        // Constants have zero tangent.
217        Expr::Value(_) => Ok(zero()),
218
219        // Parentheses are transparent to differentiation; the smart
220        // constructors re-introduce any precedence parentheses the result needs.
221        Expr::Nested(inner) => linearize(inner, leaf, reg),
222
223        // A cast to a numeric type is locally linear: tangent of cast(u) =
224        // cast(du) to the same type. A cast to a non-numeric type (VARCHAR,
225        // DATE, BOOLEAN, …) is not differentiable — differentiating through it
226        // would emit a nonsensical `CAST(1.0 AS VARCHAR)`, so it is a typed
227        // error rather than a silently-wrong derivative (principle 5).
228        Expr::Cast {
229            kind,
230            expr: inner,
231            data_type,
232            array,
233            format,
234        } => {
235            if !is_numeric_type(data_type) {
236                return Err(DiffError::NotImplemented(format!(
237                    "differentiation through a cast to non-numeric type `{data_type}` \
238                     is not supported"
239                )));
240            }
241            let du = linearize(inner, leaf, reg)?;
242            Ok(Expr::Cast {
243                kind: kind.clone(),
244                expr: Box::new(du),
245                data_type: data_type.clone(),
246                array: *array,
247                format: format.clone(),
248            })
249        }
250
251        // tangent of -u = -(du); unary plus is transparent.
252        Expr::UnaryOp {
253            op: UnaryOperator::Minus,
254            expr: inner,
255        } => Ok(neg(linearize(inner, leaf, reg)?)),
256        Expr::UnaryOp {
257            op: UnaryOperator::Plus,
258            expr: inner,
259        } => linearize(inner, leaf, reg),
260
261        Expr::BinaryOp { left, op, right } => linearize_binary(left, op, right, leaf, reg),
262
263        Expr::Function(f) => linearize_function(f, leaf, reg),
264
265        other => Err(DiffError::NotImplemented(format!(
266            "this expression cannot be differentiated: `{other}`. {SUPPORTED}; CASE, \
267             comparisons, subqueries, window functions, and string/temporal expressions are \
268             not differentiable"
269        ))),
270    }
271}
272
273/// Linearize a binary arithmetic expression via the sum/product/quotient rules.
274fn linearize_binary(
275    left: &Expr,
276    op: &BinaryOperator,
277    right: &Expr,
278    leaf: &Leaf,
279    reg: &RuleRegistry,
280) -> Result<Expr> {
281    let da = linearize(left, leaf, reg)?;
282    let db = linearize(right, leaf, reg)?;
283    match op {
284        // tangent of (a + b) = da + db
285        BinaryOperator::Plus => Ok(add(da, db)),
286        // tangent of (a - b) = da - db
287        BinaryOperator::Minus => Ok(sub(da, db)),
288        // tangent of (a * b) = da*b + a*db   (product rule)
289        BinaryOperator::Multiply => Ok(add(mul(da, right.clone()), mul(left.clone(), db))),
290        // tangent of (a / b) = (da*b - a*db) / b^2   (quotient rule)
291        BinaryOperator::Divide => {
292            let numerator = sub(mul(da, right.clone()), mul(left.clone(), db));
293            Ok(div(numerator, square(right.clone())))
294        }
295        other => Err(DiffError::NotImplemented(format!(
296            "the operator `{other}` is not differentiable. {SUPPORTED}"
297        ))),
298    }
299}
300
301/// Linearize a scalar-function call via the chain rule.
302fn linearize_function(f: &Function, leaf: &Leaf, reg: &RuleRegistry) -> Result<Expr> {
303    let name = simple_func_name(f).ok_or_else(|| {
304        DiffError::NotImplemented(format!(
305            "cannot differentiate the call `{f}`: only an unqualified function name has a \
306             differentiation rule (a schema-qualified or otherwise complex name is left alone)"
307        ))
308    })?;
309    let args = positional_args(f).ok_or_else(|| {
310        DiffError::NotImplemented(format!(
311            "function `{name}` has non-positional arguments, which are not differentiable"
312        ))
313    })?;
314
315    // `power(base, exponent)` / `pow(...)` is the one binary primitive.
316    if name == "power" || name == "pow" {
317        return linearize_power(&name, &args, leaf, reg);
318    }
319
320    if args.len() != 1 {
321        return Err(DiffError::NotImplemented(format!(
322            "no differentiation rule for `{name}` with {} arguments: the built-in function \
323             rules are unary, and `power` is the only two-argument rule",
324            args.len()
325        )));
326    }
327    let u = args[0];
328    let du = linearize(u, leaf, reg)?;
329    // Chain-rule short-circuit: a zero inner tangent kills the whole term.
330    if is_zero(&du) {
331        return Ok(zero());
332    }
333    let outer = reg.lookup(&name).ok_or_else(|| {
334        DiffError::NotImplemented(format!(
335            "no differentiation rule for function `{name}`. {SUPPORTED}. Register a custom \
336             rule with `Ddx::register(\"{name}\", ...)`"
337        ))
338    })?(u)?;
339    Ok(mul(outer, du))
340}
341
342/// Linearize `power(base, exponent)` (design.md §3.6).
343///
344/// * Constant exponent `c`: `c * base^(c-1) * d(base)`.
345/// * Constant base `a`: `a^u * ln(a) * d(u)`.
346/// * Both variable (`u^v`): not supported yet (needs the exp/log trick).
347fn linearize_power(name: &str, args: &[&Expr], leaf: &Leaf, reg: &RuleRegistry) -> Result<Expr> {
348    if args.len() != 2 {
349        return Err(DiffError::NotImplemented(format!(
350            "{name}() expects exactly two arguments"
351        )));
352    }
353    let base = args[0];
354    let exponent = args[1];
355    match (as_const(base), as_const(exponent)) {
356        // Constant exponent (covers x^2, x^0.5, x^-2, ...).
357        (_, Some(c)) => {
358            let dbase = linearize(base, leaf, reg)?;
359            if is_zero(&dbase) {
360                return Ok(zero());
361            }
362            // `finite_num` fails loud on a non-finite constant (e.g. an
363            // out-of-range literal `1e400` → inf), but *only here at emission* —
364            // after the zero short-circuit above — so a wrt-independent base
365            // still differentiates to `0` rather than erroring (#49/F1, #33).
366            let outer = mul(
367                finite_num(c)?,
368                func("power", vec![base.clone(), finite_num(c - 1.0)?]),
369            );
370            Ok(mul(outer, dbase))
371        }
372        // Constant base, variable exponent.
373        (Some(a), None) => {
374            let dexp = linearize(exponent, leaf, reg)?;
375            if is_zero(&dexp) {
376                return Ok(zero());
377            }
378            // The derivative is `a^u · ln(a) · du`; `ln(a)` is non-finite for a
379            // non-positive (or infinite) base — `finite_num` fails loud there
380            // rather than emit an `inf`/`NaN` token (#33).
381            let outer = mul(
382                func("power", vec![base.clone(), exponent.clone()]),
383                finite_num(a.ln())?,
384            );
385            Ok(mul(outer, dexp))
386        }
387        // General u^v — deferred (design.md §3.6 roadmap).
388        (None, None) => Err(DiffError::NotImplemented(
389            "cannot differentiate `power(base, exponent)` when both the base and the exponent \
390             depend on the differentiation variable; ddx handles it only when one side is a \
391             constant (e.g. `power(x, 2)` or `power(2, x)`). For the general u^v case, rewrite \
392             it as `exp(exponent * ln(base))` when the base is positive"
393                .into(),
394        )),
395    }
396}
397
398/// The lower-cased name of a function call — but only for an **unqualified**
399/// call (a single-identifier name), mirroring the marker path's strict
400/// `len() == 1` (F8). A schema-qualified call like `myschema.sin(x)` may be an
401/// unrelated user function, so it must not silently match the built-in `sin`
402/// rule ("tag explicitly, never infer" — principle 3; round-3 review #47).
403fn simple_func_name(f: &Function) -> Option<String> {
404    match f.name.0.as_slice() {
405        [ObjectNamePart::Identifier(id)] => Some(id.value.to_ascii_lowercase()),
406        _ => None,
407    }
408}
409
410/// The positional (unnamed) argument expressions of a function call, or `None`
411/// if it uses any non-positional argument form (named args, wildcards, a
412/// subquery).
413pub(crate) fn positional_args(f: &Function) -> Option<Vec<&Expr>> {
414    match &f.args {
415        FunctionArguments::List(list) => {
416            let mut out = Vec::with_capacity(list.args.len());
417            for arg in &list.args {
418                match arg {
419                    FunctionArg::Unnamed(FunctionArgExpr::Expr(e)) => out.push(e),
420                    _ => return None,
421                }
422            }
423            Some(out)
424        }
425        _ => None,
426    }
427}
428
429/// True if `dt` is a numeric type — the only kind of cast that is locally
430/// linear (and so differentiable). The list is exhaustive for the pinned
431/// `sqlparser` version; a `sqlparser` bump is already a breaking release of
432/// `ddx-core` (design.md §6, G2), at which point this is re-checked.
433pub(crate) fn is_numeric_type(dt: &DataType) -> bool {
434    matches!(
435        dt,
436        // Floating-point / fixed-point.
437        DataType::Numeric(_)
438            | DataType::Decimal(_)
439            | DataType::BigNumeric(_)
440            | DataType::BigDecimal(_)
441            | DataType::Dec(_)
442            | DataType::Float(_)
443            | DataType::FloatUnsigned(_)
444            | DataType::Float4
445            | DataType::Float32
446            | DataType::Float64
447            | DataType::Real
448            | DataType::RealUnsigned
449            | DataType::Float8
450            | DataType::Double(_)
451            | DataType::DoubleUnsigned(_)
452            | DataType::DoublePrecision
453            | DataType::DoublePrecisionUnsigned
454            // Integers (signed / unsigned / width-tagged aliases).
455            | DataType::TinyInt(_)
456            | DataType::TinyIntUnsigned(_)
457            | DataType::UTinyInt
458            | DataType::Int2(_)
459            | DataType::Int2Unsigned(_)
460            | DataType::SmallInt(_)
461            | DataType::SmallIntUnsigned(_)
462            | DataType::USmallInt
463            | DataType::MediumInt(_)
464            | DataType::MediumIntUnsigned(_)
465            | DataType::Int(_)
466            | DataType::Int4(_)
467            | DataType::Int8(_)
468            | DataType::Int16
469            | DataType::Int32
470            | DataType::Int64
471            | DataType::Int128
472            | DataType::Int256
473            | DataType::Integer(_)
474            | DataType::IntUnsigned(_)
475            | DataType::Int4Unsigned(_)
476            | DataType::IntegerUnsigned(_)
477            | DataType::HugeInt
478            | DataType::UHugeInt
479            | DataType::UInt8
480            | DataType::UInt16
481            | DataType::UInt32
482            | DataType::UInt64
483            | DataType::UInt128
484            | DataType::UInt256
485            | DataType::BigInt(_)
486            | DataType::BigIntUnsigned(_)
487            | DataType::UBigInt
488            | DataType::Int8Unsigned(_)
489            | DataType::Signed
490            | DataType::SignedInteger
491            | DataType::Unsigned
492            | DataType::UnsignedInteger
493    )
494}
495
496#[cfg(test)]
497mod tests {
498    use super::*;
499
500    #[test]
501    fn the_error_message_lists_exactly_the_rules_that_exist() {
502        // `SUPPORTED` is prose, and it is the only description of ddx's rule set
503        // most users will ever read — it is appended to every "cannot
504        // differentiate this" error. Nothing about a `HashMap` insert forces it
505        // to stay true, so adding a rule to `RuleRegistry::new` while leaving
506        // the sentence alone makes the engine's own error messages misreport
507        // what it supports, in the exact situation where the user is relying on
508        // them to decide what to do next.
509        //
510        // Kept as a constant rather than assembled at runtime because an error
511        // message is easier to read, grep and translate when it is one fixed
512        // string. This test is what makes the constant safe.
513        let registry = RuleRegistry::new();
514        let listed: Vec<String> = SUPPORTED
515            .split("unary calls to ")
516            .nth(1)
517            .expect("SUPPORTED must describe the unary rules")
518            .split(',')
519            .next()
520            .expect("the unary list is comma-delimited from the rest")
521            .split('/')
522            .map(|s| s.trim().to_string())
523            .collect();
524
525        let mut expected = registry.unary_names();
526        expected.sort();
527        let mut actual = listed;
528        actual.sort();
529        assert_eq!(
530            actual, expected,
531            "the rule set and the sentence users are shown have diverged; \
532             update SUPPORTED in this file to match the registry"
533        );
534    }
535}