Skip to main content

ddx_core/
ddx.rs

1// SPDX-FileCopyrightText: 2026 Alexander Merose <al@merose.com> & ddx Authors
2//
3// SPDX-License-Identifier: Apache-2.0
4
5//! The public entry point: the [`Ddx`] object.
6//!
7//! The surface is an object rather than free functions so the user rule
8//! registry and the dialect/identifier-folding policy have a home — no global
9//! state, no later API break (design.md §3.2, F9).
10
11use sqlparser::ast::Expr;
12use sqlparser::dialect::Dialect;
13use sqlparser::parser::Parser;
14
15use crate::colref::{ColRef, IdentCasing};
16use crate::engine::{differentiate, jvp, Rule, RuleRegistry};
17use crate::error::{DiffError, Result};
18use crate::rewrite::{self, Explanation};
19
20/// The ddx v1 differentiation engine.
21///
22/// Holds the (extensible) rule registry and the identifier-folding policy.
23/// Construct one, optionally register custom rules, then drive it with
24/// [`Ddx::rewrite_sql`] (the whole marker path) or the lower-level
25/// [`Ddx::differentiate`] / [`Ddx::jvp`] (used by the DataFusion Path B bridge,
26/// design.md §3.3).
27///
28/// # Example
29///
30/// ```
31/// use ddx_core::Ddx;
32/// use ddx_core::sqlparser::dialect::GenericDialect;
33///
34/// let ddx = Ddx::new();
35/// let out = ddx
36///     .rewrite_sql("SELECT grad(sin(x), x) AS d FROM t", &GenericDialect {})
37///     .unwrap();
38/// assert_eq!(out, "SELECT (cos(x)) AS d FROM t");
39/// ```
40#[derive(Clone)]
41pub struct Ddx {
42    rules: RuleRegistry,
43    casing: IdentCasing,
44}
45
46impl Default for Ddx {
47    fn default() -> Self {
48        Self::new()
49    }
50}
51
52impl Ddx {
53    /// A new engine with the built-in rule set and the generic
54    /// (`FoldUnquoted`) identifier policy — the DataFusion/Postgres rule
55    /// (unquoted identifiers fold to lowercase, quoted keep case).
56    pub fn new() -> Self {
57        Ddx {
58            rules: RuleRegistry::new(),
59            casing: IdentCasing::FoldUnquoted,
60        }
61    }
62
63    /// The DataFusion-flavored engine (`FoldUnquoted`). Pair with
64    /// `GenericDialect`/DataFusion's dialect when calling [`Ddx::rewrite_sql`].
65    pub fn for_datafusion() -> Self {
66        Self::with_casing(IdentCasing::FoldUnquoted)
67    }
68
69    /// The DuckDB-flavored engine (`FoldAll` — DuckDB folds quoted identifiers
70    /// too). Pair with `DuckDbDialect` when calling [`Ddx::rewrite_sql`].
71    pub fn for_duckdb() -> Self {
72        Self::with_casing(IdentCasing::FoldAll)
73    }
74
75    /// A new engine with the built-in rules and an explicit identifier policy.
76    pub fn with_casing(casing: IdentCasing) -> Self {
77        Ddx {
78            rules: RuleRegistry::new(),
79            casing,
80        }
81    }
82
83    /// The unary function names this engine can differentiate, sorted.
84    ///
85    /// Read from the rule registry, so it reflects what is implemented rather
86    /// than a list kept in step by hand — and it includes anything registered
87    /// through [`Ddx::register`].
88    pub fn unary_rule_names(&self) -> Vec<String> {
89        self.rules.unary_names()
90    }
91
92    /// The identifier-folding policy this engine compares columns under.
93    pub fn casing(&self) -> IdentCasing {
94        self.casing
95    }
96
97    /// Register (or override) a user differentiation rule for a unary function
98    /// `name`: the rule supplies `f'(u)` and the engine applies the chain rule
99    /// (design.md §3.2).
100    pub fn register(&mut self, name: &str, rule: Rule) {
101        self.rules.register(name, rule);
102    }
103
104    /// The whole marker path: rewrite every `grad`/`jvp` call in `sql` to
105    /// derivative SQL and return the rewritten text. A statement with no marker
106    /// is returned byte-identical (it is never even parsed, design.md §3.2).
107    ///
108    /// `dialect` is used to *parse* the marker-bearing statement; the
109    /// identifier-folding policy used to *match* columns is this engine's
110    /// (`casing`) — pair them (e.g. `Ddx::for_duckdb()` with `DuckDbDialect`).
111    pub fn rewrite_sql(&self, sql: &str, dialect: &dyn Dialect) -> Result<String> {
112        rewrite::rewrite_sql(sql, dialect, self.casing, &self.rules)
113    }
114
115    /// Preview what [`Ddx::rewrite_sql`] would do to `sql` — every `grad`/`jvp`
116    /// marker and the derivative SQL it becomes, plus the fully rewritten
117    /// statement — *without* running anything. The returned [`Explanation`] is
118    /// inspectable field-by-field, and prints a readable summary via `Display`,
119    /// so it doubles as a quick interactive "what will this do?" for a REPL or
120    /// notebook.
121    ///
122    /// ```
123    /// use ddx_core::Ddx;
124    /// use ddx_core::sqlparser::dialect::GenericDialect;
125    ///
126    /// let ddx = Ddx::new();
127    /// let ex = ddx
128    ///     .explain("SELECT grad(sin(x), x) AS d FROM t", &GenericDialect {})
129    ///     .unwrap();
130    /// assert_eq!(ex.rewritten, "SELECT (cos(x)) AS d FROM t");
131    /// assert_eq!(ex.steps.len(), 1);
132    /// assert_eq!(ex.steps[0].marker, "grad(sin(x), x)");
133    /// assert_eq!(ex.steps[0].derivative, "(cos(x))");
134    /// println!("{ex}"); // human-readable, inspect-before-you-run summary
135    /// ```
136    pub fn explain(&self, sql: &str, dialect: &dyn Dialect) -> Result<Explanation> {
137        rewrite::explain_sql(sql, dialect, self.casing, &self.rules)
138    }
139
140    /// Differentiate an AST expression with respect to `wrt`. The lower-level
141    /// entry the DataFusion bridge drives (design.md §3.3, Path B).
142    pub fn differentiate(&self, e: &Expr, wrt: &ColRef) -> Result<Expr> {
143        differentiate(e, wrt, self.casing, &self.rules)
144    }
145
146    /// Forward-mode directional derivative: seed a tangent on each column in
147    /// `seeds` and push it through `e` (design.md §3.6).
148    ///
149    /// (The design sketch names a `HashMap<ColRef, Expr>`; a slice of pairs is
150    /// used instead because `ColRef` equality is dialect-dependent — folding
151    /// makes it a poor hash key — so an explicit, ordered seed list is clearer
152    /// and preserves match order.)
153    pub fn jvp(&self, e: &Expr, seeds: &[(ColRef, Expr)]) -> Result<Expr> {
154        jvp(e, seeds, self.casing, &self.rules)
155    }
156
157    /// The "calculus compiler" escape hatch: differentiate the scalar
158    /// expression `expr` (SQL text) with respect to the column `wrt` (a bare
159    /// column name), returning the derivative as SQL text — for embedding an
160    /// update rule where a marker can't reach (design.md §3.6).
161    pub fn differentiate_sql(
162        &self,
163        expr: &str,
164        wrt: &str,
165        dialect: &dyn Dialect,
166    ) -> Result<String> {
167        let parsed = parse_expr(expr, dialect)?;
168        let wrt_expr = parse_expr(wrt, dialect)?;
169        let wrt_col = ColRef::from_wrt_arg("differentiate_sql", &wrt_expr)?;
170        let derivative = self.differentiate(&parsed, &wrt_col)?;
171        Ok(derivative.to_string())
172    }
173}
174
175/// Parse a single scalar expression from text under `dialect`.
176fn parse_expr(text: &str, dialect: &dyn Dialect) -> Result<Expr> {
177    Parser::new(dialect)
178        .try_with_sql(text)
179        .and_then(|mut p| p.parse_expr())
180        .map_err(|e| DiffError::Parse(format!("failed to parse expression `{text}`: {e}")))
181}