Skip to main content

alphawinnow/
dialect.rs

1//! Generic, local-only target-dialect lowering and compatibility checks.
2
3use std::collections::{BTreeMap, BTreeSet};
4
5use serde::{Deserialize, Serialize};
6use thiserror::Error;
7
8use crate::{Expr, ExprKind, canonical, parse_expression};
9
10pub const DIALECT_SCHEMA: u32 = 1;
11
12#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
13pub struct UnaryScalarLowering {
14    pub replacement: String,
15    pub scalar: f64,
16}
17
18#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
19pub struct DialectSpec {
20    pub schema: u32,
21    pub name: String,
22    pub allowed_operators: BTreeSet<String>,
23    pub allowed_fields: BTreeSet<String>,
24    #[serde(default)]
25    pub unary_scalar_lowerings: BTreeMap<String, UnaryScalarLowering>,
26}
27
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29pub struct DialectArtifact {
30    pub schema: u32,
31    pub dialect: String,
32    pub source_expression: String,
33    pub compiled_expression: String,
34    pub lowerings_applied: Vec<String>,
35    pub operators: BTreeSet<String>,
36    pub fields: BTreeSet<String>,
37}
38
39#[derive(Debug, Error)]
40pub enum DialectError {
41    #[error("unsupported dialect schema {0}")]
42    Schema(u32),
43    #[error("dialect name and allowlists must be non-empty")]
44    Empty,
45    #[error("invalid lowering for `{0}`")]
46    Lowering(String),
47    #[error("target dialect does not allow operator `{0}`")]
48    Operator(String),
49    #[error("target dialect does not allow field `{0}`")]
50    Field(String),
51    #[error(transparent)]
52    Expression(#[from] crate::ExpressionError),
53}
54
55impl DialectSpec {
56    /// Validate the immutable target contract before compiling an expression.
57    ///
58    /// # Errors
59    /// Returns a schema, allowlist, or lowering diagnostic for an invalid
60    /// dialect artifact.
61    pub fn validate(&self) -> Result<(), DialectError> {
62        if self.schema != DIALECT_SCHEMA {
63            return Err(DialectError::Schema(self.schema));
64        }
65        if self.name.trim().is_empty()
66            || self.allowed_operators.is_empty()
67            || self.allowed_fields.is_empty()
68        {
69            return Err(DialectError::Empty);
70        }
71        for (source, lowering) in &self.unary_scalar_lowerings {
72            if source.trim().is_empty()
73                || lowering.replacement.trim().is_empty()
74                || !lowering.scalar.is_finite()
75                || !self.allowed_operators.contains(&lowering.replacement)
76            {
77                return Err(DialectError::Lowering(source.clone()));
78            }
79        }
80        Ok(())
81    }
82}
83
84/// Lower one expression and fail closed on unavailable target symbols.
85///
86/// # Errors
87/// Returns an expression, dialect, unavailable-operator, or unavailable-field
88/// diagnostic without emitting a partially compiled expression.
89pub fn compile_dialect(
90    source: &str,
91    dialect: &DialectSpec,
92) -> Result<DialectArtifact, DialectError> {
93    dialect.validate()?;
94    let expression = parse_expression(source)?;
95    let mut lowerings = Vec::new();
96    let compiled = lower(&expression, dialect, &mut lowerings)?;
97    let mut operators = BTreeSet::new();
98    let mut fields = BTreeSet::new();
99    collect(&compiled, &mut operators, &mut fields);
100    for operator in &operators {
101        if !dialect.allowed_operators.contains(operator) {
102            return Err(DialectError::Operator(operator.clone()));
103        }
104    }
105    for field in &fields {
106        if !dialect.allowed_fields.contains(field) {
107            return Err(DialectError::Field(field.clone()));
108        }
109    }
110    Ok(DialectArtifact {
111        schema: DIALECT_SCHEMA,
112        dialect: dialect.name.clone(),
113        source_expression: canonical(&expression),
114        compiled_expression: canonical(&compiled),
115        lowerings_applied: lowerings,
116        operators,
117        fields,
118    })
119}
120
121fn lower(
122    expression: &Expr,
123    dialect: &DialectSpec,
124    applied: &mut Vec<String>,
125) -> Result<Expr, DialectError> {
126    Ok(match expression {
127        Expr::Field { .. } | Expr::Scalar { .. } | Expr::Group { .. } | Expr::Bool { .. } => {
128            expression.clone()
129        }
130        Expr::UnaryCall {
131            op,
132            arg,
133            kwargs,
134            kind,
135        } => {
136            let arg = lower(arg, dialect, applied)?;
137            let kwargs = lower_kwargs(kwargs, dialect, applied)?;
138            if let Some(rule) = dialect.unary_scalar_lowerings.get(op) {
139                if !kwargs.is_empty() || *kind != ExprKind::Signal {
140                    return Err(DialectError::Lowering(op.clone()));
141                }
142                applied.push(format!("{op}->{}", rule.replacement));
143                Expr::VariadicCall {
144                    op: rule.replacement.clone(),
145                    args: vec![Expr::Scalar { value: rule.scalar }, arg],
146                    kwargs: BTreeMap::new(),
147                    kind: *kind,
148                }
149            } else {
150                Expr::UnaryCall {
151                    op: op.clone(),
152                    arg: Box::new(arg),
153                    kwargs,
154                    kind: *kind,
155                }
156            }
157        }
158        Expr::BinaryCall {
159            op,
160            left,
161            right,
162            kwargs,
163            kind,
164        } => Expr::BinaryCall {
165            op: op.clone(),
166            left: Box::new(lower(left, dialect, applied)?),
167            right: Box::new(lower(right, dialect, applied)?),
168            kwargs: lower_kwargs(kwargs, dialect, applied)?,
169            kind: *kind,
170        },
171        Expr::VariadicCall {
172            op,
173            args,
174            kwargs,
175            kind,
176        } => Expr::VariadicCall {
177            op: op.clone(),
178            args: args
179                .iter()
180                .map(|arg| lower(arg, dialect, applied))
181                .collect::<Result<_, _>>()?,
182            kwargs: lower_kwargs(kwargs, dialect, applied)?,
183            kind: *kind,
184        },
185    })
186}
187
188fn lower_kwargs(
189    kwargs: &BTreeMap<String, Expr>,
190    dialect: &DialectSpec,
191    applied: &mut Vec<String>,
192) -> Result<BTreeMap<String, Expr>, DialectError> {
193    kwargs
194        .iter()
195        .map(|(name, value)| Ok((name.clone(), lower(value, dialect, applied)?)))
196        .collect()
197}
198
199fn collect(expression: &Expr, operators: &mut BTreeSet<String>, fields: &mut BTreeSet<String>) {
200    match expression {
201        Expr::Field { name } => {
202            fields.insert(name.clone());
203        }
204        Expr::UnaryCall {
205            op, arg, kwargs, ..
206        } => {
207            operators.insert(op.clone());
208            collect(arg, operators, fields);
209            for value in kwargs.values() {
210                collect(value, operators, fields);
211            }
212        }
213        Expr::BinaryCall {
214            op,
215            left,
216            right,
217            kwargs,
218            ..
219        } => {
220            operators.insert(op.clone());
221            collect(left, operators, fields);
222            collect(right, operators, fields);
223            for value in kwargs.values() {
224                collect(value, operators, fields);
225            }
226        }
227        Expr::VariadicCall {
228            op, args, kwargs, ..
229        } => {
230            operators.insert(op.clone());
231            for value in args.iter().chain(kwargs.values()) {
232                collect(value, operators, fields);
233            }
234        }
235        Expr::Scalar { .. } | Expr::Group { .. } | Expr::Bool { .. } => {}
236    }
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242
243    fn dialect() -> DialectSpec {
244        DialectSpec {
245            schema: DIALECT_SCHEMA,
246            name: "fixture-v1".to_owned(),
247            allowed_operators: ["multiply", "rank", "ts_delta"]
248                .into_iter()
249                .map(str::to_owned)
250                .collect(),
251            allowed_fields: ["returns"].into_iter().map(str::to_owned).collect(),
252            unary_scalar_lowerings: BTreeMap::from([(
253                "negate".to_owned(),
254                UnaryScalarLowering {
255                    replacement: "multiply".to_owned(),
256                    scalar: -1.0,
257                },
258            )]),
259        }
260    }
261
262    #[test]
263    fn nested_unary_lowering_is_structural_and_auditable() {
264        let artifact = compile_dialect("rank(negate(ts_delta(returns, 5)))", &dialect()).unwrap();
265        assert_eq!(
266            artifact.compiled_expression,
267            "rank(multiply(-1, ts_delta(returns, 5)))"
268        );
269        assert_eq!(artifact.lowerings_applied, ["negate->multiply"]);
270    }
271
272    #[test]
273    fn unavailable_symbols_fail_closed() {
274        let error = compile_dialect("zscore(returns)", &dialect()).unwrap_err();
275        assert!(matches!(error, DialectError::Operator(value) if value == "zscore"));
276    }
277}