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}