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 identifier-folding policy this engine compares columns under.
84    pub fn casing(&self) -> IdentCasing {
85        self.casing
86    }
87
88    /// Register (or override) a user differentiation rule for a unary function
89    /// `name`: the rule supplies `f'(u)` and the engine applies the chain rule
90    /// (design.md §3.2).
91    pub fn register(&mut self, name: &str, rule: Rule) {
92        self.rules.register(name, rule);
93    }
94
95    /// The whole marker path: rewrite every `grad`/`jvp` call in `sql` to
96    /// derivative SQL and return the rewritten text. A statement with no marker
97    /// is returned byte-identical (it is never even parsed, design.md §3.2).
98    ///
99    /// `dialect` is used to *parse* the marker-bearing statement; the
100    /// identifier-folding policy used to *match* columns is this engine's
101    /// (`casing`) — pair them (e.g. `Ddx::for_duckdb()` with `DuckDbDialect`).
102    pub fn rewrite_sql(&self, sql: &str, dialect: &dyn Dialect) -> Result<String> {
103        rewrite::rewrite_sql(sql, dialect, self.casing, &self.rules)
104    }
105
106    /// Preview what [`Ddx::rewrite_sql`] would do to `sql` — every `grad`/`jvp`
107    /// marker and the derivative SQL it becomes, plus the fully rewritten
108    /// statement — *without* running anything. The returned [`Explanation`] is
109    /// inspectable field-by-field, and prints a readable summary via `Display`,
110    /// so it doubles as a quick interactive "what will this do?" for a REPL or
111    /// notebook.
112    ///
113    /// ```
114    /// use ddx_core::Ddx;
115    /// use ddx_core::sqlparser::dialect::GenericDialect;
116    ///
117    /// let ddx = Ddx::new();
118    /// let ex = ddx
119    ///     .explain("SELECT grad(sin(x), x) AS d FROM t", &GenericDialect {})
120    ///     .unwrap();
121    /// assert_eq!(ex.rewritten, "SELECT (cos(x)) AS d FROM t");
122    /// assert_eq!(ex.steps.len(), 1);
123    /// assert_eq!(ex.steps[0].marker, "grad(sin(x), x)");
124    /// assert_eq!(ex.steps[0].derivative, "(cos(x))");
125    /// println!("{ex}"); // human-readable, inspect-before-you-run summary
126    /// ```
127    pub fn explain(&self, sql: &str, dialect: &dyn Dialect) -> Result<Explanation> {
128        rewrite::explain_sql(sql, dialect, self.casing, &self.rules)
129    }
130
131    /// Differentiate an AST expression with respect to `wrt`. The lower-level
132    /// entry the DataFusion bridge drives (design.md §3.3, Path B).
133    pub fn differentiate(&self, e: &Expr, wrt: &ColRef) -> Result<Expr> {
134        differentiate(e, wrt, self.casing, &self.rules)
135    }
136
137    /// Forward-mode directional derivative: seed a tangent on each column in
138    /// `seeds` and push it through `e` (design.md §3.6).
139    ///
140    /// (The design sketch names a `HashMap<ColRef, Expr>`; a slice of pairs is
141    /// used instead because `ColRef` equality is dialect-dependent — folding
142    /// makes it a poor hash key — so an explicit, ordered seed list is clearer
143    /// and preserves match order.)
144    pub fn jvp(&self, e: &Expr, seeds: &[(ColRef, Expr)]) -> Result<Expr> {
145        jvp(e, seeds, self.casing, &self.rules)
146    }
147
148    /// The "calculus compiler" escape hatch: differentiate the scalar
149    /// expression `expr` (SQL text) with respect to the column `wrt` (a bare
150    /// column name), returning the derivative as SQL text — for embedding an
151    /// update rule where a marker can't reach (design.md §3.6).
152    pub fn differentiate_sql(
153        &self,
154        expr: &str,
155        wrt: &str,
156        dialect: &dyn Dialect,
157    ) -> Result<String> {
158        let parsed = parse_expr(expr, dialect)?;
159        let wrt_expr = parse_expr(wrt, dialect)?;
160        let wrt_col = ColRef::from_wrt_arg("differentiate_sql", &wrt_expr)?;
161        let derivative = self.differentiate(&parsed, &wrt_col)?;
162        Ok(derivative.to_string())
163    }
164}
165
166/// Parse a single scalar expression from text under `dialect`.
167fn parse_expr(text: &str, dialect: &dyn Dialect) -> Result<Expr> {
168    Parser::new(dialect)
169        .try_with_sql(text)
170        .and_then(|mut p| p.parse_expr())
171        .map_err(|e| DiffError::Parse(format!("failed to parse expression `{text}`: {e}")))
172}