Skip to main content

improv_core_model/
formula.rs

1//! The dimension-aware formula AST.
2//!
3//! Formulas are defined over measure *names/ids* and categories, not cell
4//! addresses. Dimension operations (BY / OVER / EXCEPT) are explicit so the
5//! compiler can statically check dimension alignment.
6
7use crate::ids::{CategoryId, MeasureId};
8use crate::value::Value;
9use serde::{Deserialize, Serialize};
10
11/// Identifies a built-in or user-defined function (SUM, AVG, IF, ...).
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
13pub struct FuncId(pub u32);
14
15/// How a measure reference is projected/aggregated across dimensions.
16///
17/// * `by`     — categories to keep (group by).
18/// * `over`   — categories to aggregate over (collapse).
19/// * `except` — categories to drop.
20#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
21pub struct DimensionSpec {
22    pub by: Vec<CategoryId>,
23    pub over: Vec<CategoryId>,
24    pub except: Vec<CategoryId>,
25}
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
28pub enum UnaryOp {
29    Neg,
30    Not,
31}
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
34pub enum BinaryOp {
35    Add,
36    Sub,
37    Mul,
38    Div,
39    And,
40    Or,
41    Eq,
42    Ne,
43    Lt,
44    Le,
45    Gt,
46    Ge,
47    /// `^`: power (right-associative; binds tighter than unary minus).
48    Pow,
49    /// `&`: join two values as text.
50    Concat,
51}
52
53/// A formula expression. Broadcasting over shared dimensions is implicit at the
54/// language level and made explicit by the compiler (Phase 1).
55#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
56pub enum Expr {
57    Literal(Value),
58    /// Reference another measure, with an optional dimension projection.
59    Ref(MeasureId, DimensionSpec),
60    UnaryOp(UnaryOp, Box<Expr>),
61    BinaryOp(BinaryOp, Box<Expr>, Box<Expr>),
62    Call(FuncId, Vec<Expr>),
63    /// A category named as a function argument (`POSITION(Time)`,
64    /// `PREV(x, Time)`); not a value on its own.
65    Category(CategoryId),
66}
67
68impl Expr {
69    /// Every measure this expression references, in traversal order (with
70    /// duplicates). Used to build the dependency graph.
71    pub fn referenced_measures(&self) -> Vec<MeasureId> {
72        let mut acc = Vec::new();
73        self.collect_refs(&mut acc);
74        acc
75    }
76
77    fn collect_refs(&self, acc: &mut Vec<MeasureId>) {
78        match self {
79            Expr::Literal(_) | Expr::Category(_) => {}
80            Expr::Ref(m, _) => acc.push(*m),
81            Expr::UnaryOp(_, e) => e.collect_refs(acc),
82            Expr::BinaryOp(_, l, r) => {
83                l.collect_refs(acc);
84                r.collect_refs(acc);
85            }
86            Expr::Call(_, args) => {
87                for a in args {
88                    a.collect_refs(acc);
89                }
90            }
91        }
92    }
93}
94
95#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
96pub struct Formula {
97    pub expr: Expr,
98}
99
100impl Formula {
101    pub fn new(expr: Expr) -> Self {
102        Formula { expr }
103    }
104
105    pub fn referenced_measures(&self) -> Vec<MeasureId> {
106        self.expr.referenced_measures()
107    }
108}
109
110#[cfg(test)]
111#[cfg_attr(coverage_nightly, coverage(off))]
112mod tests {
113    use super::*;
114
115    #[test]
116    fn dependency_extraction() {
117        // Revenue = Price[Product] * Quantity[Time, Product]
118        let f = Formula::new(Expr::BinaryOp(
119            BinaryOp::Mul,
120            Box::new(Expr::Ref(MeasureId(100), DimensionSpec::default())),
121            Box::new(Expr::Ref(MeasureId(101), DimensionSpec::default())),
122        ));
123        let mut deps = f.referenced_measures();
124        deps.sort();
125        assert_eq!(deps, vec![MeasureId(100), MeasureId(101)]);
126    }
127}