1use 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 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
84pub 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}