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}