Skip to main content

improv_engine/
compiler.rs

1//! The formula compiler: `core_model::Formula` -> `TypedExpr` -> `PlanNode`.
2//!
3//! Phase 1 of `AGENT_STEERING.md`. Two passes:
4//!   1. `infer` — walk the AST, infer value type + dimension for each node,
5//!      resolving measure references against the model's measure metadata.
6//!      Catches structural errors (dimension mismatch) and type errors.
7//!   2. `build_plan` — lower the typed tree to a `PlanNode` graph, inserting
8//!      `Join` nodes to align operand dimensions and `Aggregate` nodes for
9//!      aggregation functions.
10//!
11//! IMPLEMENTED. This file defines the public API contract and both passes.
12
13use crate::plan::{PlanNode, PlanNodeKind};
14use crate::typed::{Bound, Dim, ResolvedDimSpec, TypeInfo, TypedExpr, TypedExprKind};
15use improv_core_model::functions::{self, Arg, Kind, Ret};
16use improv_core_model::{
17    BinaryOp, Category, CategoryId, DimensionSpec, Expr, Formula, FuncId, Item, ItemId, Measure,
18    MeasureId, UnaryOp, ValueType,
19};
20use std::collections::HashMap;
21
22#[derive(Debug, thiserror::Error, PartialEq)]
23pub enum CompileError {
24    #[error("unknown measure referenced in formula: {0:?}")]
25    UnknownMeasure(MeasureId),
26    #[error("unknown function: {0:?}")]
27    UnknownFunction(improv_core_model::FuncId),
28    #[error("type mismatch: {0}")]
29    TypeMismatch(String),
30    #[error("dimension mismatch: {0}")]
31    DimensionMismatch(String),
32    #[error("unsupported: {0}")]
33    Unsupported(String),
34}
35
36pub type Result<T> = std::result::Result<T, CompileError>;
37
38/// Read-only context for compilation: the model's measures, and its
39/// categories (their items' order and names) when the caller has a model.
40pub struct CompileContext<'m> {
41    pub measures: &'m HashMap<MeasureId, Measure>,
42    /// `None` without a model: positional forms (`PREV`, `POSITION`,
43    /// ordered reductions) then order items by id.
44    pub categories: Option<&'m HashMap<CategoryId, Category>>,
45    /// Item names, for `@ITEM`.
46    pub items: Option<&'m HashMap<ItemId, Item>>,
47    /// The model's named LAMBDAs, expanded where called.
48    pub lambdas: Option<&'m improv_core_model::Model>,
49}
50
51impl<'m> CompileContext<'m> {
52    /// A context with measures only (scalar formulas, type checks).
53    pub fn new(measures: &'m HashMap<MeasureId, Measure>) -> Self {
54        CompileContext {
55            measures,
56            categories: None,
57            items: None,
58            lambdas: None,
59        }
60    }
61
62    /// A context over a whole model.
63    pub fn for_model(model: &'m improv_core_model::Model) -> Self {
64        CompileContext {
65            measures: &model.measures,
66            categories: Some(&model.categories),
67            items: Some(&model.items),
68            lambdas: (!model.lambdas.is_empty()).then_some(model),
69        }
70    }
71
72    /// Category `c`'s items in order (empty when unknown).
73    pub fn items_of(&self, c: CategoryId) -> Vec<ItemId> {
74        self.categories
75            .and_then(|cs| cs.get(&c))
76            .map(|c| c.items.clone())
77            .unwrap_or_default()
78    }
79
80    /// The name of category `c`, for messages.
81    pub fn category_name(&self, c: CategoryId) -> String {
82        self.categories
83            .and_then(|cs| cs.get(&c))
84            .map_or_else(|| format!("{c:?}"), |c| c.name.0.clone())
85    }
86}
87
88/// Compile a formula for `target` measure into an operator plan.
89pub fn compile_formula(
90    ctx: &CompileContext,
91    target: MeasureId,
92    formula: &Formula,
93) -> Result<PlanNode> {
94    if let Some(args) = top_fixpoint(&formula.expr) {
95        return compile_fixpoint(ctx, target, args);
96    }
97    if formula.referenced_measures().contains(&target) {
98        return compile_recurrence(ctx, target, &formula.expr);
99    }
100    let typed = infer(ctx, target, &formula.expr)?;
101    build_plan(ctx, &typed)
102}
103
104/// The arguments of a formula that is a `FIXPOINT` call.
105fn top_fixpoint(e: &Expr) -> Option<&[Expr]> {
106    match e {
107        Expr::Call(f, args) if f.0 == dim::FIXPOINT => Some(args),
108        _ => None,
109    }
110}
111
112/// The most rounds a `FIXPOINT` may run: its `max_iter` is at most this.
113pub const FIXPOINT_MAX_ITER: u32 = 10_000;
114
115/// `FIXPOINT(init, LAMBDA(r, body), [max_iter = 100], [tolerance = 1e-10])`,
116/// a measure's whole formula; the options are constant numbers. The engine
117/// solves it host-side (`session::Engine`): r = init, then r = body(r) until
118/// Σ|body(r) − r| ≤ tolerance or max_iter rounds pass (see
119/// [`fixpoint_parts`] for r's type and categories).
120fn compile_fixpoint(ctx: &CompileContext, target: MeasureId, args: &[Expr]) -> Result<PlanNode> {
121    let usage = || {
122        CompileError::Unsupported(
123            "FIXPOINT takes (init, LAMBDA(r, body), [max_iter], [tolerance])".into(),
124        )
125    };
126    let [init, body, opts @ ..] = args else {
127        return Err(usage());
128    };
129    if opts.len() > 2 {
130        return Err(usage());
131    }
132    let mut read = init.referenced_measures();
133    read.extend(body.referenced_measures());
134    if read.contains(&target) {
135        return Err(CompileError::Unsupported(
136            "FIXPOINT may not read its own measure: the iterate is the LAMBDA's parameter".into(),
137        ));
138    }
139    let opt = |i: usize, default: f64, what: &str| -> Result<f64> {
140        let Some(e) = opts.get(i) else {
141            return Ok(default);
142        };
143        match crate::scalar::eval(e, &HashMap::new()) {
144            Ok(Some(v)) => v.as_num(),
145            _ => None,
146        }
147        .ok_or_else(|| {
148            CompileError::TypeMismatch(format!("FIXPOINT's {what} must be a constant number"))
149        })
150    };
151    let max_iter = opt(0, 100.0, "max_iter")?;
152    if !(0.0..=f64::from(FIXPOINT_MAX_ITER)).contains(&max_iter) || max_iter.fract() != 0.0 {
153        return Err(CompileError::Unsupported(format!(
154            "FIXPOINT's max_iter must be a whole number from 0 to {FIXPOINT_MAX_ITER}"
155        )));
156    }
157    let tolerance = opt(1, 1e-10, "tolerance")?;
158    // NaN is not a tolerance either.
159    if tolerance.is_nan() || tolerance < 0.0 {
160        return Err(CompileError::Unsupported(
161            "FIXPOINT's tolerance must be 0 or more".into(),
162        ));
163    }
164    let (_, _, ty) = fixpoint_parts(ctx, target, init, body, functions::lambda_slot(0, 0))?;
165    Ok(PlanNode {
166        kind: PlanNodeKind::Fixpoint {
167            init: init.clone(),
168            body: body.clone(),
169            max_iter: max_iter as u32,
170            tolerance,
171        },
172        ty,
173    })
174}
175
176/// A FIXPOINT's two plans: the first iterate, and the body reading the
177/// iterate as input measure `r` (the LAMBDA's parameter renamed). The
178/// iterate has init's type, and its categories are init's plus any the body
179/// adds: `FIXPOINT(1 / @ITEMCOUNT(Src), LAMBDA(r, … SUMPRODUCT(r, M OVER
180/// Src) …))` iterates over Src. Init then broadcasts onto every item
181/// combination of them, as `FILL` does. The body must keep the iterate's
182/// categories and type.
183pub(crate) fn fixpoint_parts(
184    ctx: &CompileContext,
185    target: MeasureId,
186    init: &Expr,
187    body: &Expr,
188    r: MeasureId,
189) -> Result<(PlanNode, PlanNode, TypeInfo)> {
190    let body = rename_ref(body, functions::lambda_slot(0, 0), r);
191    let t0 = infer(ctx, target, init)?;
192    let mut dim = t0.ty.dim.clone();
193    loop {
194        let mut measures = ctx.measures.clone();
195        measures.insert(
196            r,
197            Measure {
198                id: r,
199                name: improv_core_model::Name("r".into()),
200                value_type: t0.ty.value_type,
201                categories: dim.categories.clone(),
202                kind: improv_core_model::MeasureKind::Input,
203                description: None,
204            },
205        );
206        let bctx = CompileContext {
207            measures: &measures,
208            categories: ctx.categories,
209            items: ctx.items,
210            lambdas: ctx.lambdas,
211        };
212        let tb = infer(&bctx, target, &body).map_err(|e| match e {
213            CompileError::DimensionMismatch(s) if dim.is_scalar() => {
214                CompileError::DimensionMismatch(format!(
215                    "{s} (FIXPOINT's r ranges over the initial value's categories, here none: \
216                     start from one over them, SEQUENCE(T, x, 0) for x at every item of T)"
217                ))
218            }
219            e => e,
220        })?;
221        if tb.ty.dim != dim {
222            if !dim.is_subset_of(&tb.ty.dim) {
223                return Err(CompileError::DimensionMismatch(format!(
224                    "FIXPOINT: the body ranges over {} but the iterate over {}: the body must \
225                     keep the iterate's categories",
226                    dims_text(ctx, &tb.ty.dim),
227                    dims_text(ctx, &dim)
228                )));
229            }
230            dim = tb.ty.dim;
231            continue;
232        }
233        if tb.ty.value_type != t0.ty.value_type && !is_error_only(&t0) {
234            return Err(CompileError::TypeMismatch(format!(
235                "FIXPOINT: the initial value is {:?} but the body yields {:?}",
236                t0.ty.value_type, tb.ty.value_type
237            )));
238        }
239        if ctx.categories.is_none() && !dim.is_scalar() {
240            return Err(CompileError::Unsupported(
241                "FIXPOINT needs the model's categories".into(),
242            ));
243        }
244        let ty = TypeInfo {
245            value_type: tb.ty.value_type,
246            dim: dim.clone(),
247        };
248        let first = if t0.ty.dim == dim {
249            t0
250        } else {
251            let grid = dim
252                .categories
253                .iter()
254                .map(|c| (*c, ctx.items_of(*c)))
255                .collect();
256            TypedExpr {
257                ty: TypeInfo {
258                    value_type: t0.ty.value_type,
259                    dim,
260                },
261                kind: TypedExprKind::Coalesce {
262                    args: vec![t0],
263                    grid: Some(grid),
264                },
265            }
266        };
267        return Ok((build_plan(ctx, &first)?, build_plan(&bctx, &tb)?, ty));
268    }
269}
270
271/// `e` with every reference to measure `from` made a reference to `to`.
272fn rename_ref(e: &Expr, from: MeasureId, to: MeasureId) -> Expr {
273    match e {
274        Expr::Ref(r, s) if *r == from => Expr::Ref(to, s.clone()),
275        Expr::UnaryOp(o, a) => Expr::UnaryOp(*o, Box::new(rename_ref(a, from, to))),
276        Expr::BinaryOp(o, a, b) => Expr::BinaryOp(
277            *o,
278            Box::new(rename_ref(a, from, to)),
279            Box::new(rename_ref(b, from, to)),
280        ),
281        Expr::Call(f, a) => Expr::Call(*f, a.iter().map(|x| rename_ref(x, from, to)).collect()),
282        other => other.clone(),
283    }
284}
285
286/// The shape a new derived measure `target` named `name` takes from its
287/// formula: its value type and its categories, as the engine computes them
288/// (an `OVER` drops categories, `@ITEM` is text, `DATE` a date). A formula
289/// that reads the measure itself (a `PREV` recurrence) is typed with a
290/// provisional measure over the categories of the other measures it reads.
291pub fn derived_shape(
292    model: &improv_core_model::Model,
293    target: MeasureId,
294    name: &str,
295    formula: &Formula,
296) -> Result<(ValueType, Vec<CategoryId>)> {
297    let ctx = CompileContext::for_model(model);
298    if let Some(args) = top_fixpoint(&formula.expr) {
299        let p = compile_fixpoint(&ctx, target, args)?;
300        return Ok((p.ty.value_type, p.ty.dim.categories));
301    }
302    if !formula.referenced_measures().contains(&target) {
303        let t = infer(&ctx, target, &formula.expr)?;
304        return Ok((t.ty.value_type, t.ty.dim.categories));
305    }
306    // A recurrence: the measure's categories are its inputs' union.
307    let mut cats: Vec<CategoryId> = Vec::new();
308    for m in formula.referenced_measures() {
309        if let Some(measure) = model.measures.get(&m).filter(|_| m != target) {
310            cats.extend(measure.categories.iter().copied());
311        }
312    }
313    let cats = Dim::of(cats).categories;
314    let mut provisional = model.clone();
315    let existing = model.measures.get(&target).map(|m| m.value_type);
316    for vt in existing.into_iter().chain([
317        ValueType::Number,
318        ValueType::Text,
319        ValueType::Boolean,
320        ValueType::DateTime,
321    ]) {
322        provisional.measures.insert(
323            target,
324            Measure {
325                id: target,
326                name: improv_core_model::Name(name.to_string()),
327                value_type: vt,
328                categories: cats.clone(),
329                kind: improv_core_model::MeasureKind::Derived(formula.clone()),
330                description: None,
331            },
332        );
333        let pctx = CompileContext::for_model(&provisional);
334        match compile_recurrence(&pctx, target, &formula.expr) {
335            Ok(_) => return Ok((vt, cats)),
336            Err(CompileError::TypeMismatch(_)) => continue,
337            Err(e) => return Err(e),
338        }
339    }
340    Err(CompileError::TypeMismatch(format!(
341        "{name}'s formula has no consistent type"
342    )))
343}
344
345/// `SCAN(init, x OVER T, body)` / `REDUCE`: the fold is a recurrence along
346/// T (the accumulator is the previous item's value, `init` before the
347/// first; the value is `x` at this item), and REDUCE keeps its last value.
348/// Only as the whole formula: define the fold as its own measure.
349fn compile_fold(ctx: &CompileContext, last: bool, args: &[Expr]) -> Result<PlanNode> {
350    let (init, x, body) = match args {
351        [i, Expr::Ref(x, spec), b] if spec.over.len() == 1 => (i, (*x, spec.over[0]), b),
352        _ => {
353            return Err(CompileError::Unsupported(
354                "a fold needs (init, x OVER Category, LAMBDA(acc, v, body))".into(),
355            ))
356        }
357    };
358    let (xid, cat) = x;
359    let m = ctx
360        .measures
361        .get(&xid)
362        .ok_or(CompileError::UnknownMeasure(xid))?;
363    if !m.categories.contains(&cat) {
364        return Err(CompileError::DimensionMismatch(format!(
365            "{} does not range over {}",
366            m.name.0,
367            ctx.category_name(cat)
368        )));
369    }
370    // The placeholders of this lambda (one fold per formula, so k = 0).
371    let (acc, val) = (functions::lambda_slot(0, 0), functions::lambda_slot(0, 1));
372    let body = rename_ref(body, val, xid);
373    crate::scalar::check(&body).map_err(|e| {
374        CompileError::Unsupported(format!("a fold's LAMBDA body must be scalar: {e}"))
375    })?;
376    crate::scalar::check(init).map_err(|e| {
377        CompileError::Unsupported(format!("a fold's initial value must be scalar: {e}"))
378    })?;
379    // Type: the accumulator has the initial value's type.
380    let init_t = infer(ctx, xid, init)?;
381    let mut measures = ctx.measures.clone();
382    measures.insert(
383        acc,
384        Measure {
385            id: acc,
386            value_type: init_t.ty.value_type,
387            ..m.clone()
388        },
389    );
390    let tctx = CompileContext {
391        measures: &measures,
392        categories: ctx.categories,
393        items: ctx.items,
394        lambdas: ctx.lambdas,
395    };
396    let typed = infer(&tctx, xid, &body)?;
397    if typed.ty.value_type != init_t.ty.value_type && !is_error_only(&init_t) {
398        return Err(CompileError::TypeMismatch(format!(
399            "a fold's accumulator starts as {:?} but the LAMBDA yields {:?}",
400            init_t.ty.value_type, typed.ty.value_type
401        )));
402    }
403    let mut ids: Vec<MeasureId> = body.referenced_measures();
404    ids.extend(init.referenced_measures());
405    ids.retain(|i| *i != acc);
406    ids.sort();
407    ids.dedup();
408    let own = Dim::of(m.categories.clone());
409    let mut refs = Vec::new();
410    for id in ids {
411        let r = infer_ref(ctx, id, &DimensionSpec::default())?;
412        if !r.ty.dim.is_subset_of(&own) {
413            return Err(CompileError::DimensionMismatch(format!(
414                "a fold over {} may only read measures within its categories",
415                m.name.0
416            )));
417        }
418        refs.push((id, build_plan(ctx, &r)?));
419    }
420    let ty = TypeInfo {
421        value_type: typed.ty.value_type,
422        dim: own.clone(),
423    };
424    let scan = PlanNode {
425        kind: PlanNodeKind::Recurrence {
426            body,
427            refs,
428            slots: vec![(acc, 1, Some(init.clone()))],
429            cat,
430            order: ctx.items_of(cat),
431        },
432        ty: ty.clone(),
433    };
434    if !last {
435        return Ok(scan);
436    }
437    let kept: Vec<CategoryId> = own
438        .categories
439        .iter()
440        .copied()
441        .filter(|c| *c != cat)
442        .collect();
443    Ok(PlanNode {
444        kind: PlanNodeKind::Aggregate {
445            inputs: vec![scan],
446            params: vec![],
447            pred: None,
448            group_by: kept.clone(),
449            func: FuncId(907), // LAST
450            order: vec![(cat, ctx.items_of(cat))],
451            window: None,
452            per_item: false,
453            into: Vec::new(),
454        },
455        ty: TypeInfo {
456            value_type: ty.value_type,
457            dim: Dim::of(kept),
458        },
459    })
460}
461
462/// The first placeholder id for a recurrence's own earlier values.
463const SLOT_BASE: u32 = u32::MAX - 1024;
464
465/// A formula that reads its own measure through `PREV(M, T, n, [default])`
466/// (`Bal = PREV(Bal, Time, 1, Open) + Flow`): an ordered scan along `T`.
467/// Every self-reference must be such a `PREV` along one category; the rest
468/// of the formula must be scalar (no aggregation, no other positional
469/// function), and its references must together range over the measure's
470/// categories (they supply the cells).
471fn compile_recurrence(ctx: &CompileContext, target: MeasureId, expr: &Expr) -> Result<PlanNode> {
472    use improv_core_model::Value;
473    let m = ctx
474        .measures
475        .get(&target)
476        .ok_or(CompileError::UnknownMeasure(target))?;
477    let bad = |why: &str| {
478        CompileError::Unsupported(format!(
479            "{} refers to itself; {why} (write PREV({0}, Category, n, default))",
480            m.name.0
481        ))
482    };
483    let mut slots: Vec<(MeasureId, i64, Option<Expr>)> = Vec::new();
484    let mut cat: Option<CategoryId> = None;
485    // Replace each PREV(target, T, n, d) with a placeholder reference.
486    fn rewrite(
487        e: &Expr,
488        target: MeasureId,
489        slots: &mut Vec<(MeasureId, i64, Option<Expr>)>,
490        cat: &mut Option<CategoryId>,
491        bad: &dyn Fn(&str) -> CompileError,
492    ) -> Result<Expr> {
493        Ok(match e {
494            Expr::Call(f, args)
495                if f.0 == dim::PREV
496                    && matches!(args.first(), Some(Expr::Ref(r, _)) if *r == target) =>
497            {
498                let Some(Expr::Category(c)) = args.get(1) else {
499                    return Err(bad("PREV needs a category"));
500                };
501                if cat.is_some_and(|x| x != *c) {
502                    return Err(bad("every PREV of it must run along the same category"));
503                }
504                *cat = Some(*c);
505                let n = match args.get(2) {
506                    None => 1,
507                    Some(Expr::Literal(improv_core_model::Value::Number(n)))
508                        if n.fract() == 0.0 && *n >= 1.0 =>
509                    {
510                        *n as i64
511                    }
512                    Some(_) => return Err(bad("PREV of itself needs a whole number of items ≥ 1")),
513                };
514                let default = args.get(3).cloned();
515                if default
516                    .as_ref()
517                    .is_some_and(|d| d.referenced_measures().contains(&target))
518                {
519                    return Err(bad("the default must not refer to it"));
520                }
521                let id = MeasureId(SLOT_BASE + slots.len() as u32);
522                slots.push((id, n, default));
523                Expr::Ref(id, DimensionSpec::default())
524            }
525            Expr::Ref(r, _) if *r == target => {
526                return Err(bad("only PREV may read its earlier values"))
527            }
528            Expr::UnaryOp(op, a) => {
529                Expr::UnaryOp(*op, Box::new(rewrite(a, target, slots, cat, bad)?))
530            }
531            Expr::BinaryOp(op, a, b) => Expr::BinaryOp(
532                *op,
533                Box::new(rewrite(a, target, slots, cat, bad)?),
534                Box::new(rewrite(b, target, slots, cat, bad)?),
535            ),
536            Expr::Call(f, args) => Expr::Call(
537                *f,
538                args.iter()
539                    .map(|a| rewrite(a, target, slots, cat, bad))
540                    .collect::<Result<_>>()?,
541            ),
542            other => other.clone(),
543        })
544    }
545    let body = rewrite(expr, target, &mut slots, &mut cat, &bad)?;
546    let cat = cat.ok_or_else(|| bad("only PREV may read its earlier values"))?;
547    if !m.categories.contains(&cat) {
548        return Err(bad("PREV must run along one of its own categories"));
549    }
550    crate::scalar::check(&body).map_err(|e| bad(&format!("the rest must be scalar: {e}")))?;
551    for (_, _, d) in &slots {
552        if let Some(d) = d {
553            crate::scalar::check(d)
554                .map_err(|e| bad(&format!("the default must be scalar: {e}")))?;
555        }
556    }
557    // Type the body with each placeholder standing for the measure itself.
558    let mut measures = ctx.measures.clone();
559    for (id, _, _) in &slots {
560        measures.insert(
561            *id,
562            Measure {
563                id: *id,
564                ..m.clone()
565            },
566        );
567    }
568    let typed_ctx = CompileContext {
569        measures: &measures,
570        categories: ctx.categories,
571        items: ctx.items,
572        lambdas: ctx.lambdas,
573    };
574    let typed = infer(&typed_ctx, target, &body)?;
575    if typed.ty.value_type != m.value_type {
576        return Err(CompileError::TypeMismatch(format!(
577            "{} is {:?} but its formula yields {:?}",
578            m.name.0, m.value_type, typed.ty.value_type
579        )));
580    }
581    // The other measures the body and defaults read, each a plain input to
582    // align on the measure's categories.
583    let mut ids: Vec<MeasureId> = body.referenced_measures();
584    for (_, _, d) in &slots {
585        ids.extend(d.iter().flat_map(|d| d.referenced_measures()));
586    }
587    ids.retain(|i| i.0 < SLOT_BASE);
588    ids.sort();
589    ids.dedup();
590    let mut refs = Vec::new();
591    let mut dim = Dim::scalar();
592    for id in ids {
593        let r = infer_ref(ctx, id, &DimensionSpec::default())?;
594        if !r.ty.dim.is_subset_of(&Dim::of(m.categories.clone())) {
595            return Err(bad("it may only read measures within its own categories"));
596        }
597        dim = dim.union(&r.ty.dim);
598        refs.push((id, build_plan(ctx, &r)?));
599    }
600    if dim != Dim::of(m.categories.clone()) {
601        return Err(bad(
602            "the measures it reads must together range over all of its categories",
603        ));
604    }
605    let _ = Value::Number(0.0);
606    Ok(PlanNode {
607        kind: PlanNodeKind::Recurrence {
608            body,
609            refs,
610            slots,
611            cat,
612            order: ctx.items_of(cat),
613        },
614        ty: TypeInfo {
615            value_type: m.value_type,
616            dim,
617        },
618    })
619}
620
621/// Whether `f` is an aggregation (`SUM(x OVER T)`), per the registry.
622pub(crate) fn is_aggregation(f: FuncId) -> bool {
623    functions::is_aggregation(f)
624}
625
626/// The number of arguments a scalar (non-aggregating, non-lazy) built-in
627/// takes when that number is fixed; `None` for an unknown id. Kept for
628/// callers that only need "is this an element-wise built-in".
629pub fn scalar_arity(f: FuncId) -> Option<usize> {
630    functions::by_id(f)
631        .filter(|d| d.kind == Kind::Scalar)
632        .map(|d| d.params.len())
633}
634
635/// The lazy functions' ids (see `functions::CORE`).
636pub(crate) mod lazy {
637    pub const IF: u32 = 30;
638    pub const IFS: u32 = 31;
639    pub const SWITCH: u32 = 32;
640    pub const CHOOSE: u32 = 33;
641    pub const IFERROR: u32 = 34;
642    pub const IFNA: u32 = 35;
643    pub const CLEARERROR: u32 = 36;
644    pub const CLEARZERO: u32 = 37;
645    pub const COALESCE: u32 = 41;
646    /// `FILL` sits with the dimensional functions (it reads categories).
647    pub const FILL: u32 = 919;
648}
649
650/// Pass 1: type + dimension inference.
651#[allow(
652    clippy::only_used_in_recursion,
653    reason = "`target` is threaded for future error context and recursive calls"
654)]
655pub fn infer(ctx: &CompileContext, target: MeasureId, expr: &Expr) -> Result<TypedExpr> {
656    // A named LAMBDA call types as its expansion.
657    if let Expr::Call(f, _) = expr {
658        if functions::named_lambda_of(*f).is_some() {
659            let model = ctx.lambdas.ok_or(CompileError::UnknownFunction(*f))?;
660            let e = model
661                .expand_lambdas(expr)
662                .map_err(CompileError::Unsupported)?;
663            return infer(ctx, target, &e);
664        }
665    }
666    match expr {
667        Expr::Literal(v) => {
668            let value_type = v.type_of().ok_or_else(|| {
669                CompileError::TypeMismatch("error literal has no value type".into())
670            })?;
671            Ok(TypedExpr {
672                kind: TypedExprKind::Literal(v.clone()),
673                ty: TypeInfo {
674                    value_type,
675                    dim: Dim::scalar(),
676                },
677            })
678        }
679        Expr::Ref(mid, spec) => {
680            let typed = infer_ref(ctx, *mid, spec)?;
681            // Outside an aggregation nothing collapses the categories a
682            // `[…]` list leaves out: the cells would keep them while the type
683            // said otherwise. Refuse, and say how to collapse one.
684            let m = &ctx.measures[mid];
685            if let Some(c) = m
686                .categories
687                .iter()
688                .find(|c| !typed.ty.dim.categories.contains(c))
689            {
690                let c = ctx.category_name(*c);
691                return Err(CompileError::DimensionMismatch(format!(
692                    "{0}[…] leaves out category {c}, which only an aggregation \
693                     can collapse: write SUM({0} OVER {c}) (or AVERAGE/MIN/MAX)",
694                    m.name.0
695                )));
696            }
697            Ok(typed)
698        }
699        Expr::Category(c) => Err(CompileError::TypeMismatch(format!(
700            "category {} is not a value; use it as a function argument (POSITION({0}))",
701            ctx.category_name(*c)
702        ))),
703        Expr::UnaryOp(op, e) => {
704            let inner = infer(ctx, target, e)?;
705            let want = match op {
706                UnaryOp::Neg => ValueType::Number,
707                UnaryOp::Not => ValueType::Boolean,
708            };
709            if inner.ty.value_type != want {
710                return Err(CompileError::TypeMismatch(format!(
711                    "unary {op:?} expects {want:?}, got {:?}",
712                    inner.ty.value_type
713                )));
714            }
715            let ty = inner.ty.clone();
716            Ok(TypedExpr {
717                kind: TypedExprKind::UnaryOp(*op, Box::new(inner)),
718                ty,
719            })
720        }
721        Expr::BinaryOp(op, l, r) => {
722            let lt = infer(ctx, target, l)?;
723            let rt = infer(ctx, target, r)?;
724            let value_type = binop_result_type(*op, &lt, &rt)?;
725
726            // Broadcastable: one dim is a subset of the other; or they share
727            // a category, a join over it (`A[R, K] * B[K, C]` spans R × K × C,
728            // so `SUM(AB OVER K)` is the matrix product); or one side reads
729            // no measure (positions and literals are not data, so
730            // `POSITION(R) * 10 + POSITION(C)` spans R × C). Two measures
731            // with no category in common are refused as a likely mistake.
732            let shares = lt
733                .ty
734                .dim
735                .categories
736                .iter()
737                .any(|c| rt.ty.dim.categories.contains(c));
738            let dim = if lt.ty.dim.is_subset_of(&rt.ty.dim) {
739                rt.ty.dim.clone()
740            } else if rt.ty.dim.is_subset_of(&lt.ty.dim) {
741                lt.ty.dim.clone()
742            } else if shares
743                || l.referenced_measures().is_empty()
744                || r.referenced_measures().is_empty()
745            {
746                lt.ty.dim.union(&rt.ty.dim)
747            } else {
748                return Err(CompileError::DimensionMismatch(format!(
749                    "{} ranges over {} and {} over {}: they share no category, so no \
750                     cell has both (neither operand dim is a subset of the other). Reduce \
751                     one first, e.g. SUM({} OVER {}), or give them a category in common",
752                    operand_name(ctx, l),
753                    dims_text(ctx, &lt.ty.dim),
754                    operand_name(ctx, r),
755                    dims_text(ctx, &rt.ty.dim),
756                    operand_name(ctx, r),
757                    rt.ty
758                        .dim
759                        .categories
760                        .first()
761                        .map_or_else(String::new, |c| ctx.category_name(*c)),
762                )));
763            };
764            Ok(TypedExpr {
765                kind: TypedExprKind::BinaryOp(*op, Box::new(lt), Box::new(rt)),
766                ty: TypeInfo { value_type, dim },
767            })
768        }
769        Expr::Call(func, args) if !is_aggregation(*func) => {
770            let def = functions::by_id(*func).ok_or(CompileError::UnknownFunction(*func))?;
771            if !def.accepts(args.len()) {
772                return Err(CompileError::Unsupported(format!(
773                    "{} expects {}, got {} argument(s)",
774                    def.name,
775                    def.signature(),
776                    args.len()
777                )));
778            }
779            if def.id.0 == dim::SCAN || def.id.0 == dim::REDUCE {
780                let plan = compile_fold(ctx, def.id.0 == dim::REDUCE, args)?;
781                let ty = plan.ty.clone();
782                return Ok(TypedExpr {
783                    kind: TypedExprKind::Plan(Box::new(plan)),
784                    ty,
785                });
786            }
787            if def.id.0 == dim::FIXPOINT {
788                return Err(CompileError::Unsupported(
789                    "FIXPOINT must be a measure's whole formula: define it as its own measure"
790                        .into(),
791                ));
792            }
793            if let Some(t) = infer_dimensional(ctx, target, def, args)? {
794                return Ok(t);
795            }
796            if def.kind == Kind::Lazy {
797                return infer_lazy(ctx, target, def, args);
798            }
799            let typed_args: Vec<TypedExpr> = args
800                .iter()
801                .map(|a| infer(ctx, target, a))
802                .collect::<Result<_>>()?;
803            for (i, a) in typed_args.iter().enumerate() {
804                check_arg(def, i, a)?;
805            }
806            let dim = union_dim(ctx, def.name, typed_args.iter().map(|a| &a.ty.dim))?;
807            let Ret::Fixed(value_type) = def.ret else {
808                unreachable!("only lazy functions unify their return type")
809            };
810            Ok(TypedExpr {
811                kind: TypedExprKind::Call(*func, typed_args),
812                ty: TypeInfo { value_type, dim },
813            })
814        }
815        Expr::Call(func, args) => infer_aggregate(ctx, target, *func, args),
816    }
817}
818
819/// The ids of the dimensional functions (see `functions/dimensional.rs`).
820mod dim {
821    pub const POSITION: u32 = 900;
822    pub const ITEM: u32 = 901;
823    pub const ID: u32 = 902;
824    pub const ITEMCOUNT: u32 = 903;
825    pub const PREV: u32 = 904;
826    pub const NEXT: u32 = 905;
827    pub const SCAN: u32 = 911;
828    pub const REDUCE: u32 = 912;
829    pub const RELABEL: u32 = 920;
830    pub const SWAP: u32 = 921;
831    pub const FIXPOINT: u32 = 922;
832}
833
834/// `POSITION`, `@ITEM`, `@ID`, `@ITEMCOUNT`, `PREV` and `NEXT`, which read
835/// the categories; `None` for any other function.
836fn infer_dimensional(
837    ctx: &CompileContext,
838    target: MeasureId,
839    def: &functions::FnDef,
840    args: &[Expr],
841) -> Result<Option<TypedExpr>> {
842    let cat = |e: &Expr| match e {
843        Expr::Category(c) => Ok(*c),
844        _ => Err(CompileError::TypeMismatch(format!(
845            "{} takes a category name ({})",
846            def.name,
847            def.signature()
848        ))),
849    };
850    let items_of = |c: CategoryId| {
851        let items = ctx.items_of(c);
852        if items.is_empty() && ctx.categories.is_none() {
853            return Err(CompileError::Unsupported(format!(
854                "{} needs the model's categories",
855                def.name
856            )));
857        }
858        Ok(items)
859    };
860    let name_of = |i: ItemId| {
861        ctx.items
862            .and_then(|m| m.get(&i))
863            .map_or_else(|| format!("{}", i.0), |it| it.name.0.clone())
864    };
865    let items_node = |c: CategoryId, vals: Vec<(ItemId, improv_core_model::Value)>, vt| TypedExpr {
866        kind: TypedExprKind::Items(c, vals),
867        ty: TypeInfo {
868            value_type: vt,
869            dim: Dim::of(vec![c]),
870        },
871    };
872    use improv_core_model::Value;
873    Ok(Some(match def.id.0 {
874        dim::POSITION => {
875            let c = cat(&args[0])?;
876            let vals = items_of(c)?
877                .into_iter()
878                .enumerate()
879                .map(|(i, it)| (it, Value::Number(i as f64 + 1.0)))
880                .collect();
881            items_node(c, vals, ValueType::Number)
882        }
883        dim::ITEM | dim::ID => {
884            let c = cat(&args[0])?;
885            let vals = items_of(c)?
886                .into_iter()
887                .map(|it| (it, Value::Text(name_of(it))))
888                .collect();
889            items_node(c, vals, ValueType::Text)
890        }
891        dim::ITEMCOUNT => {
892            let c = cat(&args[0])?;
893            let n = items_of(c)?.len();
894            TypedExpr {
895                kind: TypedExprKind::Literal(Value::Number(n as f64)),
896                ty: TypeInfo {
897                    value_type: ValueType::Number,
898                    dim: Dim::scalar(),
899                },
900            }
901        }
902        dim::PREV | dim::NEXT => {
903            let input = infer(ctx, target, &args[0])?;
904            let c = cat(&args[1])?;
905            if !input.ty.dim.categories.contains(&c) {
906                return Err(CompileError::DimensionMismatch(format!(
907                    "{}: the value does not range over {}",
908                    def.name,
909                    ctx.category_name(c)
910                )));
911            }
912            let n = match args.get(2) {
913                None => 1,
914                Some(Expr::Literal(Value::Number(n))) if n.fract() == 0.0 && *n >= 0.0 => *n as i64,
915                Some(Expr::UnaryOp(UnaryOp::Neg, inner)) if matches!(**inner, Expr::Literal(Value::Number(n)) if n.fract() == 0.0) =>
916                {
917                    let Expr::Literal(Value::Number(n)) = **inner else {
918                        unreachable!()
919                    };
920                    -(n as i64)
921                }
922                Some(_) => {
923                    return Err(CompileError::Unsupported(format!(
924                        "{} takes a whole number of items as its third argument",
925                        def.name
926                    )))
927                }
928            };
929            let default = match args.get(3) {
930                Some(d) => {
931                    let d = infer(ctx, target, d)?;
932                    if d.ty.value_type != input.ty.value_type && !is_error_only(&d) {
933                        return Err(CompileError::TypeMismatch(format!(
934                            "{} default must be {:?}, got {:?}",
935                            def.name, input.ty.value_type, d.ty.value_type
936                        )));
937                    }
938                    if !d.ty.dim.is_subset_of(&input.ty.dim) {
939                        return Err(CompileError::DimensionMismatch(format!(
940                            "{} default ranges over more categories than the value",
941                            def.name
942                        )));
943                    }
944                    Some(Box::new(d))
945                }
946                None => None,
947            };
948            let ty = input.ty.clone();
949            TypedExpr {
950                kind: TypedExprKind::Shift {
951                    input: Box::new(input),
952                    cat: c,
953                    offset: if def.id.0 == dim::PREV { -n } else { n },
954                    default,
955                },
956                ty,
957            }
958        }
959        dim::RELABEL | dim::SWAP => {
960            let input = infer(ctx, target, &args[0])?;
961            let (a, b) = (cat(&args[1])?, cat(&args[2])?);
962            let has = |c| input.ty.dim.categories.contains(&c);
963            let swap = def.id.0 == dim::SWAP;
964            let (an, bn) = (ctx.category_name(a), ctx.category_name(b));
965            if a == b {
966                return Err(CompileError::DimensionMismatch(format!(
967                    "{}: {an} is both categories",
968                    def.name
969                )));
970            }
971            if !has(a) || swap && !has(b) {
972                let missing = if has(a) { &bn } else { &an };
973                return Err(CompileError::DimensionMismatch(format!(
974                    "{}: the value does not range over {missing}",
975                    def.name
976                )));
977            }
978            if !swap && has(b) {
979                return Err(CompileError::DimensionMismatch(format!(
980                    "RELABEL: the value already ranges over {bn}; SWAP exchanges two \
981                     categories it has"
982                )));
983            }
984            // Items correspond by name; one with no namesake maps nowhere.
985            let pairs = |from: CategoryId, to: CategoryId| -> Result<Vec<(ItemId, ItemId)>> {
986                let names: HashMap<String, ItemId> =
987                    items_of(to)?.into_iter().map(|i| (name_of(i), i)).collect();
988                Ok(items_of(from)?
989                    .into_iter()
990                    .filter_map(|i| names.get(&name_of(i)).map(|j| (i, *j)))
991                    .collect())
992            };
993            let mut maps = vec![(a, b, pairs(a, b)?)];
994            if swap {
995                maps.push((b, a, pairs(b, a)?));
996            }
997            let mut cats = input.ty.dim.categories.clone();
998            if !swap {
999                cats.retain(|c| *c != a);
1000                cats.push(b);
1001            }
1002            let ty = TypeInfo {
1003                value_type: input.ty.value_type,
1004                dim: Dim::of(cats),
1005            };
1006            TypedExpr {
1007                kind: TypedExprKind::Remap {
1008                    input: Box::new(input),
1009                    maps,
1010                },
1011                ty,
1012            }
1013        }
1014        _ => return Ok(None),
1015    }))
1016}
1017
1018/// A reduction `AGG(args…)`: the registry's reduced arguments (measure
1019/// references carrying the `OVER` categories) are aligned on their full
1020/// coordinate; the others are parameters with one value per group.
1021fn infer_aggregate(
1022    ctx: &CompileContext,
1023    target: MeasureId,
1024    func: FuncId,
1025    args: &[Expr],
1026) -> Result<TypedExpr> {
1027    let def = functions::by_id(func).ok_or(CompileError::UnknownFunction(func))?;
1028    let mut pred = None;
1029    let mut window = None;
1030    let mut into: Vec<CategoryId> = Vec::new();
1031    let mut rest: Vec<&Expr> = Vec::new();
1032    for a in args {
1033        match a {
1034            Expr::Call(id, k) if *id == functions::INTO => {
1035                for c in k {
1036                    match c {
1037                        Expr::Category(c) if !into.contains(c) => into.push(*c),
1038                        _ => return Err(CompileError::Unsupported("malformed INTO".into())),
1039                    }
1040                }
1041                if into.is_empty() {
1042                    return Err(CompileError::Unsupported("malformed INTO".into()));
1043                }
1044            }
1045            Expr::Call(id, p) if *id == functions::WHERE => {
1046                let t = infer(ctx, target, &p[0])?;
1047                if t.ty.value_type != ValueType::Boolean {
1048                    return Err(CompileError::TypeMismatch(format!(
1049                        "WHERE needs a Boolean condition, got {:?}",
1050                        t.ty.value_type
1051                    )));
1052                }
1053                pred = Some(Box::new(t));
1054            }
1055            Expr::Call(id, b) if *id == functions::WINDOW => {
1056                window = Some((bound(&b[0])?, bound(&b[1])?));
1057            }
1058            other => rest.push(other),
1059        }
1060    }
1061    if !def.accepts(rest.len()) {
1062        return Err(CompileError::Unsupported(format!(
1063            "{} expects {}, got {} argument(s)",
1064            def.name,
1065            def.signature(),
1066            rest.len()
1067        )));
1068    }
1069    let mut inputs = Vec::new();
1070    let mut params = Vec::new();
1071    // Over::Leading: the registry lists (y, x, options…); every further
1072    // reduced argument checks as x, and the options follow from position
1073    // `min`.
1074    let leading = match def.over {
1075        functions::Over::Leading(min) => Some((
1076            min,
1077            rest.iter()
1078                .enumerate()
1079                .filter(|(i, a)| def.reduced(*i, a))
1080                .count(),
1081        )),
1082        _ => None,
1083    };
1084    let slot = |i: usize| match leading {
1085        Some((min, n)) if i >= n => i - n + min,
1086        Some((min, _)) if i >= min => min - 1,
1087        _ => i,
1088    };
1089    for (i, a) in rest.iter().enumerate() {
1090        let si = slot(i);
1091        if def.reduced(i, a) {
1092            let t = match a {
1093                // The aggregation collapses what the `[…]` list leaves out.
1094                Expr::Ref(mid, spec) => infer_ref(ctx, *mid, spec)?,
1095                _ => {
1096                    return Err(CompileError::Unsupported(format!(
1097                        "{} argument {} must be a measure reference",
1098                        def.name,
1099                        i + 1
1100                    )))
1101                }
1102            };
1103            check_arg(def, si, &t)?;
1104            inputs.push(t);
1105        } else {
1106            let t = infer(ctx, target, a)?;
1107            check_arg(def, si, &t)?;
1108            params.push(t);
1109        }
1110    }
1111    let first = inputs.first().ok_or_else(|| {
1112        CompileError::Unsupported(format!("{} needs a measure OVER a category", def.name))
1113    })?;
1114    // All reduced measures collapse the same categories. Their other
1115    // categories broadcast: the rows join on what they share, and the result
1116    // keeps the union (`SUMPRODUCT(r, M OVER Src)` with r over Src and M over
1117    // Src × Dst is the product of M and r, per Dst).
1118    let over = aggregation_over(ctx, first);
1119    let mut full = first_full_dim(ctx, first);
1120    let mut kept = first.ty.dim.clone();
1121    for t in &inputs[1..] {
1122        // Nothing reduced (no measure has the OVER category): no broadcast,
1123        // or a typo'd category would silently cross the measures.
1124        if over.is_empty() && first_full_dim(ctx, t) != full {
1125            return Err(CompileError::DimensionMismatch(format!(
1126                "{}: the measures reduced together must range over the same categories, \
1127                 but one ranges over {} and another over {}, and neither is reduced",
1128                def.name,
1129                dims_text(ctx, &full),
1130                dims_text(ctx, &first_full_dim(ctx, t)),
1131            )));
1132        }
1133        if aggregation_over(ctx, t) != over {
1134            return Err(CompileError::DimensionMismatch(format!(
1135                "{}: the measures reduced together must reduce the same categories, \
1136                 but one is reduced over {} and another over {}",
1137                def.name,
1138                dims_text(ctx, &Dim::of(over.clone())),
1139                dims_text(ctx, &Dim::of(aggregation_over(ctx, t))),
1140            )));
1141        }
1142        full = full.union(&first_full_dim(ctx, t));
1143        kept = kept.union(&t.ty.dim);
1144    }
1145    // No OVER category (`the sum of Quantity` in the CNL) collapses
1146    // nothing: each group is one cell.
1147    match (def.kind == Kind::Array, into.first()) {
1148        (true, None) => {
1149            return Err(CompileError::Unsupported(format!(
1150                "{} returns a list: say where it lands, {}(… INTO Category)",
1151                def.name, def.name
1152            )))
1153        }
1154        (false, Some(_)) => {
1155            return Err(CompileError::Unsupported(format!(
1156                "{} returns one value; INTO is for list results",
1157                def.name
1158            )))
1159        }
1160        _ => {}
1161    }
1162    for &k in &into {
1163        if full.categories.contains(&k) {
1164            return Err(CompileError::DimensionMismatch(format!(
1165                "{}: INTO {} must be a category the measure does not range over",
1166                def.name,
1167                ctx.category_name(k)
1168            )));
1169        }
1170    }
1171    let mut kept: Vec<CategoryId> = kept.categories;
1172    kept.extend(into.iter().copied());
1173    if window.is_some() || def.kind == Kind::PerItem {
1174        if over.len() != 1 {
1175            return Err(CompileError::Unsupported(format!(
1176                "{} over a window or per item takes exactly one OVER category",
1177                def.name
1178            )));
1179        }
1180        kept.push(over[0]);
1181    }
1182    let dim = Dim::of(kept);
1183    if let Some(p) = &pred {
1184        if !p.ty.dim.is_subset_of(&full) {
1185            return Err(CompileError::DimensionMismatch(format!(
1186                "{}: the WHERE condition ranges over categories the measure does not",
1187                def.name
1188            )));
1189        }
1190    }
1191    // A parameter may range over categories the measure lacks (a lookup:
1192    // `XLOOKUP(Grade, Key OVER Band, Rate)` with Grade per Employee); the
1193    // result gains them, and each group reads the whole table. It must not
1194    // range over a reduced category.
1195    let mut dim = dim;
1196    for p in &params {
1197        if p.ty.dim.categories.iter().any(|c| over.contains(c)) {
1198            return Err(CompileError::DimensionMismatch(format!(
1199                "{}: a parameter must not range over the reduced categories",
1200                def.name
1201            )));
1202        }
1203        dim = dim.union(&p.ty.dim);
1204    }
1205    let value_type = match def.ret {
1206        Ret::Fixed(v) => v,
1207        Ret::Unify => first.ty.value_type,
1208        // Argument i's type: a reduced input or a parameter, by position.
1209        Ret::Arg(i) => {
1210            let (mut ins, mut ps) = (inputs.iter(), params.iter());
1211            (0..rest.len())
1212                .map(|j| {
1213                    if def.reduced(j, rest[j]) {
1214                        ins.next()
1215                    } else {
1216                        ps.next()
1217                    }
1218                })
1219                .nth(i)
1220                .flatten()
1221                .map_or(first.ty.value_type, |t| t.ty.value_type)
1222        }
1223    };
1224    Ok(TypedExpr {
1225        kind: TypedExprKind::Aggregate {
1226            func,
1227            inputs,
1228            params,
1229            over,
1230            pred,
1231            window,
1232            into,
1233        },
1234        ty: TypeInfo { value_type, dim },
1235    })
1236}
1237
1238/// A reduced reference's dimension before the reduction: its own plus the
1239/// categories it collapses.
1240fn first_full_dim(ctx: &CompileContext, t: &TypedExpr) -> Dim {
1241    t.ty.dim.union(&Dim::of(aggregation_over(ctx, t)))
1242}
1243
1244/// A window bound literal (see `functions::WINDOW`).
1245fn bound(e: &Expr) -> Result<Bound> {
1246    use improv_core_model::Value;
1247    match e {
1248        Expr::Literal(Value::Text(t)) if t == "FIRST" => Ok(Bound::First),
1249        Expr::Literal(Value::Text(t)) if t == "LAST" => Ok(Bound::Last),
1250        Expr::Literal(Value::Number(n)) if n.fract() == 0.0 => Ok(Bound::Offset(*n as i64)),
1251        _ => Err(CompileError::Unsupported("malformed window bound".into())),
1252    }
1253}
1254
1255/// Argument `i` of `def` must have the parameter's type (`Any` takes all).
1256fn check_arg(def: &functions::FnDef, i: usize, a: &TypedExpr) -> Result<()> {
1257    let want = match def.param(i) {
1258        Arg::Any => return Ok(()),
1259        Arg::Number => ValueType::Number,
1260        Arg::Text => ValueType::Text,
1261        Arg::Boolean => ValueType::Boolean,
1262        Arg::Date => ValueType::DateTime,
1263        // A date, or a number read as a spreadsheet serial date.
1264        Arg::DateLike if a.ty.value_type == ValueType::Number => return Ok(()),
1265        Arg::DateLike => ValueType::DateTime,
1266        Arg::Category => {
1267            return Err(CompileError::TypeMismatch(format!(
1268                "{} argument {} must be a category name ({})",
1269                def.name,
1270                i + 1,
1271                def.signature()
1272            )))
1273        }
1274    };
1275    // An error-only argument (`NA()`) fits any parameter.
1276    if a.ty.value_type == want || is_error_only(a) {
1277        return Ok(());
1278    }
1279    Err(CompileError::TypeMismatch(format!(
1280        "{} argument {} must be {want:?}, got {:?} ({})",
1281        def.name,
1282        i + 1,
1283        a.ty.value_type,
1284        def.signature()
1285    )))
1286}
1287
1288/// `[Time, Product]` (or `no category`) for an error message.
1289fn dims_text(ctx: &CompileContext, d: &Dim) -> String {
1290    if d.categories.is_empty() {
1291        return "no category".into();
1292    }
1293    let names: Vec<String> = d.categories.iter().map(|c| ctx.category_name(*c)).collect();
1294    format!("[{}]", names.join(", "))
1295}
1296
1297/// An operand as the user wrote it, for an error message: a measure's
1298/// name, else "this expression".
1299fn operand_name(ctx: &CompileContext, e: &Expr) -> String {
1300    match e {
1301        Expr::Ref(id, _) => ctx
1302            .measures
1303            .get(id)
1304            .map_or_else(|| "this expression".into(), |m| m.name.0.clone()),
1305        _ => "this expression".into(),
1306    }
1307}
1308
1309/// The union of broadcastable dims: each must be a subset of the union so far
1310/// or contain it, so the dataflow join is well defined.
1311fn union_dim<'a>(
1312    ctx: &CompileContext,
1313    what: &str,
1314    dims: impl IntoIterator<Item = &'a Dim>,
1315) -> Result<Dim> {
1316    let mut dim = Dim::scalar();
1317    for d in dims {
1318        if !(dim.is_subset_of(d) || d.is_subset_of(&dim)) {
1319            return Err(CompileError::DimensionMismatch(format!(
1320                "{what} arguments not broadcastable: one ranges over {}, another over {}; \
1321                 an argument's categories must contain or be contained in the others'",
1322                dims_text(ctx, &dim),
1323                dims_text(ctx, d)
1324            )));
1325        }
1326        dim = dim.union(d);
1327    }
1328    Ok(dim)
1329}
1330
1331/// An expression that can only ever be an error (`NA()`, `ERROR("#NUM!")`):
1332/// its declared type unifies with anything.
1333fn is_error_only(e: &TypedExpr) -> bool {
1334    match &e.kind {
1335        TypedExprKind::Call(f, _) => matches!(f.0, 53 | 54),
1336        TypedExprKind::Literal(v) => v.is_error(),
1337        _ => false,
1338    }
1339}
1340
1341fn call(id: u32, args: Vec<Expr>) -> Expr {
1342    Expr::Call(FuncId(id), args)
1343}
1344
1345fn na() -> Expr {
1346    call(53, vec![])
1347}
1348
1349fn value_error() -> Expr {
1350    call(
1351        54,
1352        vec![Expr::Literal(improv_core_model::Value::Text(
1353            "#VALUE!".into(),
1354        ))],
1355    )
1356}
1357
1358/// Type a lazy function. `IF` is the primitive; the others are rewritten into
1359/// nested `IF`s first:
1360///
1361/// * `IFS(t1, v1, t2, v2, …)` → `IF(t1, v1, IF(t2, v2, … NA()))`
1362/// * `SWITCH(e, m1, r1, …, [d])` → `IF(e == m1, r1, … d or NA())`
1363/// * `CHOOSE(i, v1, …)` → `IF(INT(i) == 1, v1, … #VALUE!)`
1364/// * `IFERROR(v, a)` → `IF(ISERROR(v), a, v)`, `IFNA` with `ISNA`
1365/// * `CLEARERROR(v, [a])` → `IF(ISERROR(v), a, v)`, or no cell without `a`
1366/// * `CLEARZERO(v, [a])` → `IF(v == 0, a, v)`, or no cell without `a`
1367fn infer_lazy(
1368    ctx: &CompileContext,
1369    target: MeasureId,
1370    def: &functions::FnDef,
1371    args: &[Expr],
1372) -> Result<TypedExpr> {
1373    if def.id.0 == lazy::IF {
1374        return infer_if(ctx, target, args);
1375    }
1376    if def.id.0 == lazy::COALESCE || def.id.0 == lazy::FILL {
1377        return infer_coalesce(ctx, target, def, args);
1378    }
1379    infer(ctx, target, &desugar_lazy(def.id, args)?)
1380}
1381
1382/// `COALESCE(a, …, default)` and `FILL(x, default)`: the first argument with
1383/// a cell, over an outer union of the arguments' cells (no `IF` or join can
1384/// say "a cell where any of them has one").
1385///
1386/// The arguments' categories must nest (`union_dim`); the result ranges over
1387/// the largest. `COALESCE` has a cell wherever an argument over all of the
1388/// result's categories has one; an argument over fewer (a literal, a
1389/// per-Person default under a Person × Year result) broadcasts onto those
1390/// cells and adds none. `FILL` has a cell at every item combination of x's
1391/// categories, and its default may not range beyond them.
1392fn infer_coalesce(
1393    ctx: &CompileContext,
1394    target: MeasureId,
1395    def: &functions::FnDef,
1396    args: &[Expr],
1397) -> Result<TypedExpr> {
1398    let typed: Vec<TypedExpr> = args
1399        .iter()
1400        .map(|a| infer(ctx, target, a))
1401        .collect::<Result<_>>()?;
1402    let mut value_type = None;
1403    for t in typed.iter().filter(|t| !is_error_only(t)) {
1404        match value_type {
1405            None => value_type = Some(t.ty.value_type),
1406            Some(v) if v != t.ty.value_type => {
1407                return Err(CompileError::TypeMismatch(format!(
1408                    "{} arguments must have the same type, got {v:?} and {:?}",
1409                    def.name, t.ty.value_type
1410                )))
1411            }
1412            Some(_) => {}
1413        }
1414    }
1415    let value_type = value_type.unwrap_or(typed[0].ty.value_type);
1416    let dim = union_dim(ctx, def.name, typed.iter().map(|a| &a.ty.dim))?;
1417    let grid = if def.id.0 == lazy::FILL {
1418        if dim != typed[0].ty.dim {
1419            return Err(CompileError::DimensionMismatch(format!(
1420                "FILL's default ranges over {}, beyond x's {}",
1421                dims_text(ctx, &dim),
1422                dims_text(ctx, &typed[0].ty.dim)
1423            )));
1424        }
1425        if ctx.categories.is_none() && !dim.is_scalar() {
1426            return Err(CompileError::Unsupported(
1427                "FILL needs the model's categories".into(),
1428            ));
1429        }
1430        Some(
1431            dim.categories
1432                .iter()
1433                .map(|c| (*c, ctx.items_of(*c)))
1434                .collect(),
1435        )
1436    } else {
1437        None
1438    };
1439    Ok(TypedExpr {
1440        kind: TypedExprKind::Coalesce { args: typed, grid },
1441        ty: TypeInfo { value_type, dim },
1442    })
1443}
1444
1445/// The nested-`IF` form of a lazy call other than `IF` itself (see
1446/// [`infer_lazy`]). Shared with the scalar evaluator so both agree.
1447pub(crate) fn desugar_lazy(id: FuncId, args: &[Expr]) -> Result<Expr> {
1448    let if_ = |c: Expr, a: Expr, b: Option<Expr>| {
1449        let mut v = vec![c, a];
1450        v.extend(b);
1451        call(lazy::IF, v)
1452    };
1453    let not = |e: Expr| Expr::UnaryOp(UnaryOp::Not, Box::new(e));
1454    Ok(match id.0 {
1455        lazy::IFS => {
1456            if !args.len().is_multiple_of(2) {
1457                return Err(CompileError::Unsupported(
1458                    "IFS takes test/value pairs".into(),
1459                ));
1460            }
1461            args.chunks(2)
1462                .rev()
1463                .fold(na(), |acc, p| if_(p[0].clone(), p[1].clone(), Some(acc)))
1464        }
1465        lazy::SWITCH => {
1466            let (e, rest) = args.split_first().expect("SWITCH has an expression");
1467            let (pairs, default) = if rest.len() % 2 == 1 {
1468                (&rest[..rest.len() - 1], rest.last().cloned())
1469            } else {
1470                (rest, None)
1471            };
1472            pairs
1473                .chunks(2)
1474                .rev()
1475                .fold(default.unwrap_or_else(na), |acc, p| {
1476                    let test =
1477                        Expr::BinaryOp(BinaryOp::Eq, Box::new(e.clone()), Box::new(p[0].clone()));
1478                    if_(test, p[1].clone(), Some(acc))
1479                })
1480        }
1481        lazy::CHOOSE => {
1482            let (i, vals) = args.split_first().expect("CHOOSE has an index");
1483            let idx = call(204, vec![i.clone()]); // INT
1484            vals.iter()
1485                .enumerate()
1486                .rev()
1487                .fold(value_error(), |acc, (k, v)| {
1488                    let n = Expr::Literal(improv_core_model::Value::Number(k as f64 + 1.0));
1489                    let test = Expr::BinaryOp(BinaryOp::Eq, Box::new(idx.clone()), Box::new(n));
1490                    if_(test, v.clone(), Some(acc))
1491                })
1492        }
1493        lazy::IFERROR | lazy::IFNA => {
1494            let test = call(
1495                if id.0 == lazy::IFERROR { 50 } else { 52 },
1496                vec![args[0].clone()],
1497            );
1498            if_(test, args[1].clone(), Some(args[0].clone()))
1499        }
1500        lazy::CLEARERROR => {
1501            let test = call(50, vec![args[0].clone()]);
1502            match args.get(1) {
1503                Some(alt) => if_(test, alt.clone(), Some(args[0].clone())),
1504                None => if_(not(test), args[0].clone(), None),
1505            }
1506        }
1507        lazy::CLEARZERO => {
1508            let zero = Expr::Literal(improv_core_model::Value::Number(0.0));
1509            let test = Expr::BinaryOp(BinaryOp::Eq, Box::new(args[0].clone()), Box::new(zero));
1510            match args.get(1) {
1511                Some(alt) => if_(test, alt.clone(), Some(args[0].clone())),
1512                None => if_(not(test), args[0].clone(), None),
1513            }
1514        }
1515        _ => return Err(CompileError::UnknownFunction(id)),
1516    })
1517}
1518
1519/// `IF(test, then, [else])`: the test must be Boolean; the branches unify
1520/// (an error-only branch takes the other's type). A cell exists where the
1521/// test does and the selected branch does; where the test is an error, the
1522/// result is that error. The result ranges over the union of the three
1523/// dims, and the test, joined with at least one branch, must span it (that
1524/// branch supplies the coordinates of the categories the test lacks).
1525fn infer_if(ctx: &CompileContext, target: MeasureId, args: &[Expr]) -> Result<TypedExpr> {
1526    let typed: Vec<TypedExpr> = args
1527        .iter()
1528        .map(|a| infer(ctx, target, a))
1529        .collect::<Result<_>>()?;
1530    let (test, branches) = typed.split_first().expect("IF has a test");
1531    if test.ty.value_type != ValueType::Boolean && !is_error_only(test) {
1532        return Err(CompileError::TypeMismatch(format!(
1533            "IF test must be Boolean, got {:?}",
1534            test.ty.value_type
1535        )));
1536    }
1537    let value_type = match branches {
1538        [a] => a.ty.value_type,
1539        [a, b] if is_error_only(a) => b.ty.value_type,
1540        [a, b] if is_error_only(b) || a.ty.value_type == b.ty.value_type => a.ty.value_type,
1541        [a, b] => {
1542            return Err(CompileError::TypeMismatch(format!(
1543                "IF branches must have the same type, got {:?} and {:?}",
1544                a.ty.value_type, b.ty.value_type
1545            )))
1546        }
1547        _ => unreachable!("arity checked by the registry"),
1548    };
1549    let dim = union_dim(ctx, "IF", typed.iter().map(|a| &a.ty.dim))?;
1550    let spans = |b: &TypedExpr| test.ty.dim.union(&b.ty.dim) == dim;
1551    if test.ty.dim != dim && !branches.iter().any(spans) {
1552        return Err(CompileError::DimensionMismatch(format!(
1553            "IF ranges over {}, but neither the test with the then-branch nor with the \
1554             else-branch covers it",
1555            dims_text(ctx, &dim)
1556        )));
1557    }
1558    Ok(TypedExpr {
1559        kind: TypedExprKind::Call(FuncId(lazy::IF), typed),
1560        ty: TypeInfo { value_type, dim },
1561    })
1562}
1563
1564/// A measure reference's type: the measure's categories, kept by `by`,
1565/// minus `except` and `over`. [`infer`] adds the bare-reference check.
1566fn infer_ref(ctx: &CompileContext, mid: MeasureId, spec: &DimensionSpec) -> Result<TypedExpr> {
1567    let measure = ctx
1568        .measures
1569        .get(&mid)
1570        .ok_or(CompileError::UnknownMeasure(mid))?;
1571    let result: Vec<_> = measure
1572        .categories
1573        .iter()
1574        .copied()
1575        .filter(|c| spec.by.is_empty() || spec.by.contains(c))
1576        .filter(|c| !spec.except.contains(c))
1577        .filter(|c| !spec.over.contains(c))
1578        .collect();
1579    Ok(TypedExpr {
1580        kind: TypedExprKind::Ref(
1581            mid,
1582            ResolvedDimSpec {
1583                by: spec.by.clone(),
1584                over: spec.over.clone(),
1585                except: spec.except.clone(),
1586            },
1587        ),
1588        ty: TypeInfo {
1589            value_type: measure.value_type,
1590            dim: Dim::of(result),
1591        },
1592    })
1593}
1594
1595/// Categories an aggregation collapses: the arg ref's `over`, restricted to
1596/// the measure's own categories (an `OVER` category the measure lacks
1597/// collapses nothing).
1598fn aggregation_over(ctx: &CompileContext, arg: &TypedExpr) -> Vec<CategoryId> {
1599    match &arg.kind {
1600        TypedExprKind::Ref(mid, spec) => {
1601            let own = ctx.measures.get(mid).map(|m| &m.categories);
1602            spec.over
1603                .iter()
1604                .copied()
1605                .filter(|c| own.is_some_and(|o| o.contains(c)))
1606                .collect()
1607        }
1608        _ => Vec::new(),
1609    }
1610}
1611
1612fn binop_result_type(op: BinaryOp, l: &TypedExpr, r: &TypedExpr) -> Result<ValueType> {
1613    use BinaryOp::*;
1614    let (lt, rt) = (l.ty.value_type, r.ty.value_type);
1615    use ValueType::{DateTime as D, Number as N};
1616    match (op, lt, rt) {
1617        // Date arithmetic, in days: date ± days is a date, date − date days.
1618        (Add, D, N) | (Add, N, D) | (Sub, D, N) => return Ok(D),
1619        (Sub, D, D) => return Ok(N),
1620        _ => {}
1621    }
1622    match op {
1623        Add | Sub | Mul | Div | Pow => {
1624            require(
1625                lt == ValueType::Number && rt == ValueType::Number,
1626                op,
1627                "Number",
1628            )?;
1629            Ok(ValueType::Number)
1630        }
1631        // `&` joins any two values as text.
1632        Concat => Ok(ValueType::Text),
1633        And | Or => {
1634            require(
1635                lt == ValueType::Boolean && rt == ValueType::Boolean,
1636                op,
1637                "Boolean",
1638            )?;
1639            Ok(ValueType::Boolean)
1640        }
1641        Eq | Ne | Lt | Le | Gt | Ge => {
1642            require(lt == rt, op, "matching operand types")?;
1643            Ok(ValueType::Boolean)
1644        }
1645    }
1646}
1647
1648fn require(ok: bool, op: BinaryOp, want: &str) -> Result<()> {
1649    if ok {
1650        Ok(())
1651    } else {
1652        Err(CompileError::TypeMismatch(format!(
1653            "{op:?} requires {want}"
1654        )))
1655    }
1656}
1657
1658/// Pass 2: lower a typed expression to a plan.
1659#[allow(
1660    clippy::only_used_in_recursion,
1661    reason = "`ctx` kept in the signature for symmetry and future func-registry lookups"
1662)]
1663pub fn build_plan(ctx: &CompileContext, typed: &TypedExpr) -> Result<PlanNode> {
1664    let ty = typed.ty.clone();
1665    let kind = match &typed.kind {
1666        TypedExprKind::Literal(v) => PlanNodeKind::Literal(v.clone()),
1667        TypedExprKind::Ref(mid, _spec) => {
1668            // A bare ref lowers to its input collection. `infer` already removed
1669            // any `over` categories from the ref's dim, so a bare ref never
1670            // self-aggregates here; `Call` emits the Aggregate node.
1671            PlanNodeKind::InputMeasure(*mid)
1672        }
1673        TypedExprKind::UnaryOp(op, e) => PlanNodeKind::MapUnary(*op, Box::new(build_plan(ctx, e)?)),
1674        TypedExprKind::BinaryOp(op, l, r) => {
1675            let lp = build_plan(ctx, l)?;
1676            let rp = build_plan(ctx, r)?;
1677            // A scalar literal operand broadcasts over the other operand and is
1678            // folded into the map by the dataflow builder — no Join needed.
1679            let is_scalar_lit =
1680                |e: &TypedExpr| matches!(e.kind, TypedExprKind::Literal(_)) && e.ty.dim.is_scalar();
1681            if l.ty.dim == r.ty.dim || is_scalar_lit(l) || is_scalar_lit(r) {
1682                PlanNodeKind::MapBinary(*op, Box::new(lp), Box::new(rp))
1683            } else {
1684                // Dims differ: insert a Join on the shared (intersection)
1685                // categories to broadcast the two operands onto the union dim,
1686                // then map element-wise. The Join carries both operands; each
1687                // MapBinary child then ranges over the union (result) dim.
1688                let join_keys: Vec<_> =
1689                    l.ty.dim
1690                        .categories
1691                        .iter()
1692                        .copied()
1693                        .filter(|c| r.ty.dim.categories.contains(c))
1694                        .collect();
1695                let (lp_aligned, rp_aligned) = split_join(lp, rp, join_keys, &ty);
1696                PlanNodeKind::MapBinary(*op, Box::new(lp_aligned), Box::new(rp_aligned))
1697            }
1698        }
1699        TypedExprKind::Call(func, args) if func.0 == lazy::IF => {
1700            let mut plans = args
1701                .iter()
1702                .map(|a| build_plan(ctx, a))
1703                .collect::<Result<Vec<_>>>()?
1704                .into_iter();
1705            let test = plans.next().expect("IF has a test");
1706            let then = plans.next().expect("IF has a then-branch");
1707            let otherwise = plans.next();
1708            // The branch that, with the test, spans the result: its coordinates
1709            // fill in the categories the test lacks. None when the test spans.
1710            let spanning = if test.ty.dim == ty.dim {
1711                None
1712            } else if test.ty.dim.union(&then.ty.dim) == ty.dim {
1713                Some(0)
1714            } else {
1715                Some(1)
1716            };
1717            PlanNodeKind::If {
1718                test: Box::new(test),
1719                then: Box::new(then),
1720                otherwise: otherwise.map(Box::new),
1721                spanning,
1722            }
1723        }
1724        TypedExprKind::Call(func, args) if !is_aggregation(*func) => {
1725            // Scalar built-in: lower each arg; the dataflow builder aligns
1726            // multi-arg calls by key (join) and applies the function.
1727            let arg_plans = args
1728                .iter()
1729                .map(|a| build_plan(ctx, a))
1730                .collect::<Result<Vec<_>>>()?;
1731            PlanNodeKind::FuncCall {
1732                func: *func,
1733                args: arg_plans,
1734            }
1735        }
1736        TypedExprKind::Call(func, _) => return Err(CompileError::UnknownFunction(*func)),
1737        TypedExprKind::Aggregate {
1738            func,
1739            inputs,
1740            params,
1741            over,
1742            pred,
1743            window,
1744            into,
1745        } => {
1746            let per_item = functions::by_id(*func).is_some_and(|d| d.kind == Kind::PerItem);
1747            let group_by = if !into.is_empty() {
1748                // The landing categories are added back per result.
1749                ty.dim
1750                    .categories
1751                    .iter()
1752                    .copied()
1753                    .filter(|c| !into.contains(c))
1754                    .collect()
1755            } else if window.is_some() || per_item {
1756                // The reduced category is kept in the result but grouped
1757                // across.
1758                ty.dim
1759                    .categories
1760                    .iter()
1761                    .copied()
1762                    .filter(|c| !over.contains(c))
1763                    .collect()
1764            } else {
1765                ty.dim.categories.clone()
1766            };
1767            PlanNodeKind::Aggregate {
1768                inputs: inputs
1769                    .iter()
1770                    .map(|a| build_plan(ctx, a))
1771                    .collect::<Result<_>>()?,
1772                params: params
1773                    .iter()
1774                    .map(|a| build_plan(ctx, a))
1775                    .collect::<Result<_>>()?,
1776                pred: pred
1777                    .as_ref()
1778                    .map(|p| build_plan(ctx, p).map(Box::new))
1779                    .transpose()?,
1780                group_by,
1781                func: *func,
1782                order: over.iter().map(|c| (*c, ctx.items_of(*c))).collect(),
1783                window: *window,
1784                per_item,
1785                into: into.iter().map(|k| (*k, ctx.items_of(*k))).collect(),
1786            }
1787        }
1788        TypedExprKind::Items(c, vals) => PlanNodeKind::Items(*c, vals.clone()),
1789        TypedExprKind::Coalesce { args, grid } => PlanNodeKind::Coalesce {
1790            args: args
1791                .iter()
1792                .map(|a| build_plan(ctx, a))
1793                .collect::<Result<_>>()?,
1794            grid: grid.clone(),
1795        },
1796        TypedExprKind::Remap { input, maps } => PlanNodeKind::Remap {
1797            input: Box::new(build_plan(ctx, input)?),
1798            maps: maps.clone(),
1799        },
1800        TypedExprKind::Plan(p) => return Ok((**p).clone()),
1801        TypedExprKind::Shift {
1802            input,
1803            cat,
1804            offset,
1805            default,
1806        } => PlanNodeKind::Shift {
1807            input: Box::new(build_plan(ctx, input)?),
1808            cat: *cat,
1809            offset: *offset,
1810            order: ctx.items_of(*cat),
1811            default: default
1812                .as_ref()
1813                .map(|d| build_plan(ctx, d).map(Box::new))
1814                .transpose()?,
1815        },
1816    };
1817    Ok(PlanNode { kind, ty })
1818}
1819
1820/// Build the aligned operand pair for a dimension-broadcasting MapBinary: a
1821/// `Join` node (aligned to the union dim) is the left operand; the right
1822/// operand references the same join by carrying the right side. Both children
1823/// carry the union `TypeInfo` so downstream sees a single aligned collection.
1824fn split_join(
1825    lp: PlanNode,
1826    rp: PlanNode,
1827    join_keys: Vec<improv_core_model::CategoryId>,
1828    union_ty: &TypeInfo,
1829) -> (PlanNode, PlanNode) {
1830    let right_leaf = rp.clone();
1831    let join = PlanNode {
1832        kind: PlanNodeKind::Join {
1833            left: Box::new(lp),
1834            right: Box::new(rp),
1835            join_keys,
1836        },
1837        ty: union_ty.clone(),
1838    };
1839    (join, right_leaf)
1840}
1841
1842#[cfg(test)]
1843#[cfg_attr(coverage_nightly, coverage(off))]
1844mod tests {
1845    use super::*;
1846    use improv_core_model::{CategoryId, DimensionSpec, ItemId, MeasureKind, Name, Value};
1847
1848    const TIME: CategoryId = CategoryId(1);
1849    const PRODUCT: CategoryId = CategoryId(2);
1850    const REGION: CategoryId = CategoryId(3);
1851    const PRICE: MeasureId = MeasureId(100); // over [Product]
1852    const QTY: MeasureId = MeasureId(101); // over [Time, Product]
1853    const HEADCOUNT: MeasureId = MeasureId(102); // over [Region]
1854
1855    fn num_measure(id: MeasureId, name: &str, cats: Vec<CategoryId>) -> Measure {
1856        Measure {
1857            id,
1858            name: Name(name.into()),
1859            value_type: ValueType::Number,
1860            categories: cats,
1861            kind: MeasureKind::Input,
1862            description: None,
1863        }
1864    }
1865
1866    fn fixture() -> HashMap<MeasureId, Measure> {
1867        let mut m = HashMap::new();
1868        m.insert(PRICE, num_measure(PRICE, "Price", vec![PRODUCT]));
1869        m.insert(QTY, num_measure(QTY, "Quantity", vec![TIME, PRODUCT]));
1870        m.insert(HEADCOUNT, num_measure(HEADCOUNT, "Headcount", vec![REGION]));
1871        m
1872    }
1873
1874    fn refr(id: MeasureId) -> Expr {
1875        Expr::Ref(id, DimensionSpec::default())
1876    }
1877
1878    // 1. Revenue = Price[Product] * Quantity[Time,Product]
1879    #[test]
1880    fn revenue_mul_infers_union_dim_and_joins() {
1881        let measures = fixture();
1882        let ctx = CompileContext::new(&measures);
1883        let expr = Expr::BinaryOp(BinaryOp::Mul, Box::new(refr(PRICE)), Box::new(refr(QTY)));
1884
1885        let typed = infer(&ctx, MeasureId(200), &expr).expect("infer");
1886        assert_eq!(typed.ty.value_type, ValueType::Number);
1887        assert_eq!(typed.ty.dim, Dim::of(vec![TIME, PRODUCT]), "union dim");
1888
1889        let plan = build_plan(&ctx, &typed).expect("plan");
1890        match plan.kind {
1891            PlanNodeKind::MapBinary(BinaryOp::Mul, left, _right) => match left.kind {
1892                PlanNodeKind::Join { join_keys, .. } => {
1893                    assert_eq!(join_keys, vec![PRODUCT], "join on shared Product");
1894                }
1895                other => panic!("expected Join feeding MapBinary, got {other:?}"),
1896            },
1897            other => panic!("expected MapBinary(Mul), got {other:?}"),
1898        }
1899    }
1900
1901    // 2. Disjoint dims (Product-only vs Region-only), neither a subset -> error.
1902    #[test]
1903    fn disjoint_dims_error() {
1904        let measures = fixture();
1905        let ctx = CompileContext::new(&measures);
1906        let expr = Expr::BinaryOp(
1907            BinaryOp::Add,
1908            Box::new(refr(PRICE)),     // [Product]
1909            Box::new(refr(HEADCOUNT)), // [Region]
1910        );
1911        let err = infer(&ctx, MeasureId(200), &expr).unwrap_err();
1912        assert!(matches!(err, CompileError::DimensionMismatch(_)), "{err:?}");
1913    }
1914
1915    // 3. SUM(Revenue OVER Time): {Time,Product} -> {Product}, Aggregate plan.
1916    #[test]
1917    fn sum_over_time_reduces_dim() {
1918        let measures = fixture();
1919        let ctx = CompileContext::new(&measures);
1920        // Quantity ref aggregating OVER Time.
1921        let arg = Expr::Ref(
1922            QTY,
1923            DimensionSpec {
1924                over: vec![TIME],
1925                ..Default::default()
1926            },
1927        );
1928        let expr = Expr::Call(FuncId(1), vec![arg]); // SUM
1929
1930        let typed = infer(&ctx, MeasureId(200), &expr).expect("infer");
1931        assert_eq!(typed.ty.dim, Dim::of(vec![PRODUCT]), "Time collapsed");
1932
1933        let plan = build_plan(&ctx, &typed).expect("plan");
1934        match plan.kind {
1935            PlanNodeKind::Aggregate { group_by, func, .. } => {
1936                assert_eq!(group_by, vec![PRODUCT]);
1937                assert_eq!(func, FuncId(1));
1938            }
1939            other => panic!("expected Aggregate, got {other:?}"),
1940        }
1941    }
1942
1943    // 3b. A bare `Quantity[Product]` would leave Time in the cells but not in
1944    // the type, so it is refused; a list naming every category is fine, and
1945    // inside an aggregation the list's left-out categories are collapsed.
1946    #[test]
1947    fn by_list_must_not_drop_categories_outside_an_aggregation() {
1948        let measures = fixture();
1949        let ctx = CompileContext::new(&measures);
1950        let by = |cats: Vec<CategoryId>| {
1951            Expr::Ref(
1952                QTY,
1953                DimensionSpec {
1954                    by: cats,
1955                    ..Default::default()
1956                },
1957            )
1958        };
1959        let err = infer(&ctx, MeasureId(200), &by(vec![PRODUCT])).unwrap_err();
1960        match err {
1961            CompileError::DimensionMismatch(msg) => {
1962                assert!(msg.contains("SUM(Quantity OVER"), "{msg}")
1963            }
1964            other => panic!("expected DimensionMismatch, got {other:?}"),
1965        }
1966        let full = infer(&ctx, MeasureId(200), &by(vec![TIME, PRODUCT])).expect("full list");
1967        assert_eq!(full.ty.dim, Dim::of(vec![TIME, PRODUCT]));
1968        let agg = Expr::Call(
1969            FuncId(1),
1970            vec![Expr::Ref(
1971                QTY,
1972                DimensionSpec {
1973                    by: vec![PRODUCT],
1974                    over: vec![TIME],
1975                    except: vec![],
1976                },
1977            )],
1978        );
1979        let typed = infer(&ctx, MeasureId(200), &agg).expect("aggregated");
1980        assert_eq!(typed.ty.dim, Dim::of(vec![PRODUCT]));
1981    }
1982
1983    // 4. Unknown measure ref errors with UnknownMeasure.
1984    #[test]
1985    fn unknown_measure_errors() {
1986        let measures = fixture();
1987        let ctx = CompileContext::new(&measures);
1988        let err = infer(&ctx, MeasureId(200), &refr(MeasureId(999))).unwrap_err();
1989        assert_eq!(err, CompileError::UnknownMeasure(MeasureId(999)));
1990    }
1991
1992    // Extra coverage: literal, unary Neg, type mismatch, unknown func,
1993    // compile_formula end-to-end.
1994    #[test]
1995    fn literal_and_unary_and_type_errors() {
1996        let measures = fixture();
1997        let ctx = CompileContext::new(&measures);
1998
1999        let lit = infer(&ctx, MeasureId(200), &Expr::Literal(Value::Number(3.0))).unwrap();
2000        assert_eq!(lit.ty.value_type, ValueType::Number);
2001        assert!(lit.ty.dim.is_scalar());
2002        assert!(matches!(
2003            build_plan(&ctx, &lit).unwrap().kind,
2004            PlanNodeKind::Literal(Value::Number(_))
2005        ));
2006
2007        let neg = Expr::UnaryOp(UnaryOp::Neg, Box::new(refr(PRICE)));
2008        let t = infer(&ctx, MeasureId(200), &neg).unwrap();
2009        assert_eq!(t.ty.dim, Dim::of(vec![PRODUCT]));
2010        assert!(matches!(
2011            build_plan(&ctx, &t).unwrap().kind,
2012            PlanNodeKind::MapUnary(UnaryOp::Neg, _)
2013        ));
2014
2015        // Not on a Number -> type mismatch.
2016        let bad = Expr::UnaryOp(UnaryOp::Not, Box::new(refr(PRICE)));
2017        assert!(matches!(
2018            infer(&ctx, MeasureId(200), &bad),
2019            Err(CompileError::TypeMismatch(_))
2020        ));
2021
2022        // Unknown function id.
2023        let uf = Expr::Call(FuncId(999), vec![refr(PRICE)]);
2024        assert_eq!(
2025            infer(&ctx, MeasureId(200), &uf),
2026            Err(CompileError::UnknownFunction(FuncId(999)))
2027        );
2028
2029        // End-to-end through compile_formula.
2030        let f = Formula::new(Expr::BinaryOp(
2031            BinaryOp::Mul,
2032            Box::new(refr(PRICE)),
2033            Box::new(refr(QTY)),
2034        ));
2035        let plan = compile_formula(&ctx, MeasureId(200), &f).unwrap();
2036        assert!(matches!(
2037            plan.kind,
2038            PlanNodeKind::MapBinary(BinaryOp::Mul, ..)
2039        ));
2040    }
2041
2042    // Scalar FuncCall: ABS(Price) types as Number with the arg's dim and
2043    // lowers to PlanNodeKind::FuncCall.
2044    #[test]
2045    fn scalar_abs_infers_and_lowers() {
2046        let measures = fixture();
2047        let ctx = CompileContext::new(&measures);
2048        let expr = Expr::Call(FuncId(10), vec![refr(PRICE)]); // ABS(Price[Product])
2049
2050        let typed = infer(&ctx, MeasureId(200), &expr).expect("infer");
2051        assert_eq!(typed.ty.value_type, ValueType::Number);
2052        assert_eq!(typed.ty.dim, Dim::of(vec![PRODUCT]), "dim follows the arg");
2053
2054        let plan = build_plan(&ctx, &typed).expect("plan");
2055        match plan.kind {
2056            PlanNodeKind::FuncCall { func, args } => {
2057                assert_eq!(func, FuncId(10));
2058                assert_eq!(args.len(), 1);
2059                assert!(matches!(args[0].kind, PlanNodeKind::InputMeasure(PRICE)));
2060            }
2061            other => panic!("expected FuncCall, got {other:?}"),
2062        }
2063    }
2064
2065    // 2-arg scalar MIN2 unions dims; wrong-arity and wrong-type error.
2066    #[test]
2067    fn scalar_min2_union_dim_and_errors() {
2068        let measures = fixture();
2069        let ctx = CompileContext::new(&measures);
2070        // MIN2(Price[Product], Quantity[Time,Product]) -> [Time,Product]
2071        let ok = Expr::Call(FuncId(20), vec![refr(PRICE), refr(QTY)]);
2072        let typed = infer(&ctx, MeasureId(200), &ok).expect("infer");
2073        assert_eq!(typed.ty.dim, Dim::of(vec![TIME, PRODUCT]), "union dim");
2074
2075        // Wrong arity: ABS with 2 args.
2076        let bad_arity = Expr::Call(FuncId(10), vec![refr(PRICE), refr(QTY)]);
2077        assert!(matches!(
2078            infer(&ctx, MeasureId(200), &bad_arity),
2079            Err(CompileError::Unsupported(_))
2080        ));
2081
2082        // Wrong type: ABS of a comparison (Boolean) result.
2083        let cmp = Expr::BinaryOp(
2084            BinaryOp::Gt,
2085            Box::new(refr(PRICE)),
2086            Box::new(Expr::Literal(Value::Number(1.0))),
2087        );
2088        let bad_type = Expr::Call(FuncId(10), vec![cmp]);
2089        assert!(matches!(
2090            infer(&ctx, MeasureId(200), &bad_type),
2091            Err(CompileError::TypeMismatch(_))
2092        ));
2093
2094        // Unknown function id (in no registry entry).
2095        let unknown = Expr::Call(FuncId(999), vec![refr(PRICE)]);
2096        assert_eq!(
2097            infer(&ctx, MeasureId(200), &unknown),
2098            Err(CompileError::UnknownFunction(FuncId(999)))
2099        );
2100    }
2101
2102    fn with_flag() -> (HashMap<MeasureId, Measure>, MeasureId) {
2103        let mut measures = fixture();
2104        let flag = MeasureId(103);
2105        measures.insert(
2106            flag,
2107            Measure {
2108                value_type: ValueType::Boolean,
2109                ..num_measure(flag, "Flag", vec![PRODUCT])
2110            },
2111        );
2112        (measures, flag)
2113    }
2114
2115    fn bin(op: BinaryOp, l: Expr, r: Expr) -> Expr {
2116        Expr::BinaryOp(op, Box::new(l), Box::new(r))
2117    }
2118
2119    /// A model over Time×Product with items, for the positional forms.
2120    fn model_with_items() -> improv_core_model::Model {
2121        let mut m = improv_core_model::Model::new();
2122        m.add_category(TIME, "Time");
2123        m.add_category(PRODUCT, "Product");
2124        m.add_category(REGION, "Region");
2125        m.add_item(ItemId(10), TIME, "2025");
2126        m.add_item(ItemId(11), TIME, "2026");
2127        m.add_item(ItemId(20), PRODUCT, "A");
2128        for (id, meas) in fixture() {
2129            m.measures.insert(id, meas);
2130        }
2131        m
2132    }
2133
2134    fn parse(m: &improv_core_model::Model, text: &str) -> Expr {
2135        improv_core_model::parse_expr(m, text).unwrap().expr
2136    }
2137
2138    /// The dimensional functions' and general aggregations' compile rules.
2139    #[test]
2140    fn dimensional_and_aggregate_rules() {
2141        let m = model_with_items();
2142        let ctx = CompileContext::for_model(&m);
2143        let ty = |t: &str| infer(&ctx, MeasureId(200), &parse(&m, t));
2144        assert_eq!(ty("POSITION(Time)").unwrap().ty.dim, Dim::of(vec![TIME]));
2145        assert_eq!(ty("@ID(Time)").unwrap().ty.value_type, ValueType::Text);
2146        assert_eq!(ty("@ITEMCOUNT(Time)").unwrap().ty.dim, Dim::scalar());
2147        // PREV: the value must range over the category; n whole; default typed.
2148        assert!(matches!(
2149            ty("PREV(Price, Time)"),
2150            Err(CompileError::DimensionMismatch(_))
2151        ));
2152        assert!(matches!(
2153            ty("PREV(Quantity, Time, 1.5)"),
2154            Err(CompileError::Unsupported(_))
2155        ));
2156        assert!(matches!(
2157            ty("PREV(Quantity, Time, Price)"),
2158            Err(CompileError::Unsupported(_))
2159        ));
2160        assert!(ty("PREV(Quantity, Time, -1)").is_ok());
2161        assert!(matches!(
2162            ty("PREV(Quantity, Time, 1, \"x\")"),
2163            Err(CompileError::TypeMismatch(_))
2164        ));
2165        assert!(ty("PREV(Quantity, Time, 1, NA())").is_ok());
2166        assert!(matches!(
2167            ty("PREV(Price + Quantity * 0, Time, 1, Headcount)"),
2168            Err(CompileError::DimensionMismatch(_))
2169        ));
2170        // A category used as a value.
2171        assert!(matches!(
2172            infer(&ctx, MeasureId(200), &Expr::Category(TIME)),
2173            Err(CompileError::TypeMismatch(_))
2174        ));
2175        // Without the model's categories, the positional forms refuse.
2176        let bare = CompileContext::new(&m.measures);
2177        assert!(matches!(
2178            infer(&bare, MeasureId(200), &parse(&m, "POSITION(Time)")),
2179            Err(CompileError::Unsupported(_))
2180        ));
2181        // Aggregations: WHERE must be boolean and within the measure's
2182        // categories; a window takes one category; parameters stay within the
2183        // kept categories; reduced arguments share their categories.
2184        assert!(matches!(
2185            ty("SUM(Quantity OVER Time WHERE Quantity)"),
2186            Err(CompileError::TypeMismatch(_))
2187        ));
2188        assert!(matches!(
2189            ty("SUM(Quantity OVER Time WHERE Headcount > 1)"),
2190            Err(CompileError::DimensionMismatch(_))
2191        ));
2192        let w = Expr::Call(
2193            FuncId(1),
2194            vec![
2195                Expr::Ref(
2196                    QTY,
2197                    DimensionSpec {
2198                        over: vec![TIME, PRODUCT],
2199                        ..Default::default()
2200                    },
2201                ),
2202                Expr::Call(
2203                    functions::WINDOW,
2204                    vec![Expr::Literal(improv_core_model::Value::Number(0.0)); 2],
2205                ),
2206            ],
2207        );
2208        assert!(matches!(
2209            infer(&ctx, MeasureId(200), &w),
2210            Err(CompileError::Unsupported(_))
2211        ));
2212        let bad_bound = Expr::Call(
2213            FuncId(1),
2214            vec![
2215                Expr::Ref(
2216                    QTY,
2217                    DimensionSpec {
2218                        over: vec![TIME],
2219                        ..Default::default()
2220                    },
2221                ),
2222                Expr::Call(functions::WINDOW, vec![lit(0.5), lit(0.0)]),
2223            ],
2224        );
2225        assert!(matches!(
2226            infer(&ctx, MeasureId(200), &bad_bound),
2227            Err(CompileError::Unsupported(_))
2228        ));
2229        assert!(matches!(
2230            ty("TEXTJOIN(@ITEM(Time), TRUE, Quantity OVER Time)"),
2231            Err(CompileError::DimensionMismatch(_))
2232        ));
2233        // TEXTJOIN's text parameter must be text.
2234        assert!(matches!(
2235            ty("TEXTJOIN(1, TRUE, Quantity OVER Time)"),
2236            Err(CompileError::TypeMismatch(_))
2237        ));
2238        // An aggregation with too many arguments (built by hand: the parser
2239        // refuses it already), and a reduced argument that is not a reference.
2240        let extra = Expr::Call(
2241            FuncId(906),
2242            vec![
2243                Expr::Ref(
2244                    QTY,
2245                    DimensionSpec {
2246                        over: vec![TIME],
2247                        ..Default::default()
2248                    },
2249                ),
2250                lit(1.0),
2251            ],
2252        );
2253        assert!(matches!(
2254            infer(&ctx, MeasureId(200), &extra),
2255            Err(CompileError::Unsupported(_))
2256        ));
2257        let not_ref = Expr::Call(FuncId(906), vec![lit(1.0)]);
2258        assert!(matches!(
2259            infer(&ctx, MeasureId(200), &not_ref),
2260            Err(CompileError::Unsupported(_))
2261        ));
2262        // Reduced arguments with different categories (two-measure case).
2263        let two = Expr::Call(
2264            FuncId(906),
2265            vec![Expr::Ref(
2266                QTY,
2267                DimensionSpec {
2268                    over: vec![TIME],
2269                    ..Default::default()
2270                },
2271            )],
2272        );
2273        assert!(infer(&ctx, MeasureId(200), &two).is_ok());
2274        // The window and WHERE lower into the plan.
2275        let t = ty("SUM(Quantity OVER Time[FIRST..LAST] WHERE Quantity > 0)").unwrap();
2276        assert_eq!(t.ty.dim, Dim::of(vec![TIME, PRODUCT]));
2277        let plan = build_plan(&ctx, &t).unwrap();
2278        assert!(matches!(
2279            plan.kind,
2280            PlanNodeKind::Aggregate {
2281                window: Some(_),
2282                pred: Some(_),
2283                ..
2284            }
2285        ));
2286        let t = ty("Quantity - PREV(Quantity, Time, 1, 0)").unwrap();
2287        assert!(build_plan(&ctx, &t).is_ok());
2288    }
2289
2290    /// Recurrence shapes: valid ones compile to a scan; the rest explain why
2291    /// not.
2292    #[test]
2293    fn recurrence_rules() {
2294        let mut m = model_with_items();
2295        let bal = MeasureId(300);
2296        m.add_measure(num_measure(bal, "Bal", vec![TIME, PRODUCT]));
2297        m.add_measure(num_measure(MeasureId(301), "Flag", vec![TIME, PRODUCT]));
2298        m.measures.get_mut(&MeasureId(301)).unwrap().value_type = ValueType::Boolean;
2299        let compile = |m: &improv_core_model::Model, t: &str| {
2300            let e = parse(m, t);
2301            compile_formula(&CompileContext::for_model(m), bal, &Formula::new(e))
2302        };
2303        let ok = compile(&m, "PREV(Bal, Time, 1, Price) + Quantity").unwrap();
2304        assert!(matches!(ok.kind, PlanNodeKind::Recurrence { .. }));
2305        assert!(compile(&m, "PREV(Bal, Time) + PREV(Bal, Time, 2, 0) + Quantity").is_ok());
2306        for (text, why) in [
2307            ("Bal + 1", "only PREV"),
2308            ("PREV(Bal, Time, 0) + Quantity", "whole number of items"),
2309            (
2310                "PREV(Bal, Time, 1, Bal) + Quantity",
2311                "default must not refer",
2312            ),
2313            (
2314                "PREV(Bal, Time) + PREV(Bal, Product) + Quantity",
2315                "same category",
2316            ),
2317            (
2318                "PREV(Bal, Time) + SUM(Quantity OVER Time)",
2319                "must be scalar",
2320            ),
2321            (
2322                "PREV(Bal, Time, 1, SUM(Price OVER Product)) + Quantity",
2323                "default must be scalar",
2324            ),
2325            ("PREV(Bal, Time) + Price", "together range over"),
2326            ("PREV(Bal, Time) + Headcount", "dimension mismatch"),
2327        ] {
2328            let e = compile(&m, text).unwrap_err().to_string();
2329            assert!(e.contains(why), "{text}: {e}");
2330        }
2331        let e = compile(&m, "IF(Flag, PREV(Bal, Time), 1 > 0)").unwrap_err();
2332        assert!(matches!(e, CompileError::TypeMismatch(_)), "{e:?}");
2333        // PREV along a category the measure lacks.
2334        let mut r = m.clone();
2335        r.measures.get_mut(&bal).unwrap().categories = vec![TIME];
2336        r.measures.get_mut(&QTY).unwrap();
2337        let e = compile_formula(
2338            &CompileContext::for_model(&r),
2339            bal,
2340            &Formula::new(parse(&r, "PREV(Bal, Product) + 1")),
2341        )
2342        .unwrap_err()
2343        .to_string();
2344        assert!(e.contains("own categories"), "{e}");
2345        // derived_shape: a recurrence takes its inputs' categories and the
2346        // type that compiles; a plain formula the engine's.
2347        let f = Formula::new(parse(&m, "PREV(Bal, Time, 1, 0) + Quantity"));
2348        let mut fresh = m.clone();
2349        fresh.measures.remove(&bal);
2350        let pm = fresh.with_provisional(bal, "Bal");
2351        let f2 = Formula::new(parse(&pm, "PREV(Bal, Time, 1, 0) + Quantity"));
2352        assert_eq!(
2353            derived_shape(&fresh, bal, "Bal", &f2).unwrap(),
2354            (ValueType::Number, vec![TIME, PRODUCT])
2355        );
2356        let g = Formula::new(parse(&m, "@ITEM(Time)"));
2357        assert_eq!(
2358            derived_shape(&m, MeasureId(400), "G", &g).unwrap(),
2359            (ValueType::Text, vec![TIME])
2360        );
2361        let _ = f;
2362        let bad = Formula::new(parse(&pm, "PREV(Bal, Time, 1, \"x\") & Bal"));
2363        assert!(derived_shape(&fresh, bal, "Bal", &bad).is_err());
2364    }
2365
2366    /// Dimension errors name the measures and categories and say why, never
2367    /// raw ids.
2368    #[test]
2369    fn dimension_errors_are_readable() {
2370        let m = model_with_items();
2371        let ctx = CompileContext::for_model(&m);
2372        for (text, want) in [
2373            ("Headcount + Price", "share no category"),
2374            (
2375                "ROUND(Headcount, Price)",
2376                "not broadcastable: one ranges over",
2377            ),
2378            ("CORREL(Price, Headcount OVER Time)", "but one ranges over"),
2379        ] {
2380            let e = infer(&ctx, MeasureId(200), &parse(&m, text))
2381                .unwrap_err()
2382                .to_string();
2383            assert!(e.contains(want), "{text}: {e}");
2384            assert!(!e.contains("CategoryId("), "{text}: {e}");
2385        }
2386    }
2387
2388    /// A fold's compile rules.
2389    #[test]
2390    fn fold_rules() {
2391        let m = model_with_items();
2392        let ctx = CompileContext::for_model(&m);
2393        let ty = |t: &str| infer(&ctx, MeasureId(200), &parse(&m, t));
2394        assert_eq!(
2395            ty("SCAN(0, Quantity OVER Time, LAMBDA(a, v, a + v))")
2396                .unwrap()
2397                .ty
2398                .dim,
2399            Dim::of(vec![TIME, PRODUCT])
2400        );
2401        assert_eq!(
2402            ty("REDUCE(0, Quantity OVER Time, LAMBDA(a, v, a + v))")
2403                .unwrap()
2404                .ty
2405                .dim,
2406            Dim::of(vec![PRODUCT])
2407        );
2408        for (text, why) in [
2409            (
2410                "SCAN(0, Price OVER Time, LAMBDA(a, v, a + v))",
2411                "does not range over",
2412            ),
2413            (
2414                "SCAN(\"x\", Quantity OVER Time, LAMBDA(a, v, v))",
2415                "starts as",
2416            ),
2417            (
2418                "SCAN(0, Quantity OVER Time, LAMBDA(a, v, a + SUM(Price OVER Product)))",
2419                "must be scalar",
2420            ),
2421            (
2422                "SCAN(SUM(Price OVER Product), Quantity OVER Time, LAMBDA(a, v, a))",
2423                "initial value must be scalar",
2424            ),
2425            (
2426                "SCAN(0, Quantity OVER Time, LAMBDA(a, v, a + Headcount))",
2427                "dimension mismatch",
2428            ),
2429        ] {
2430            let e = ty(text).unwrap_err().to_string();
2431            assert!(e.contains(why), "{text}: {e}");
2432        }
2433        let bad = Expr::Call(FuncId(911), vec![lit(0.0)]);
2434        assert!(matches!(
2435            infer(&ctx, MeasureId(200), &bad),
2436            Err(CompileError::Unsupported(_))
2437        ));
2438    }
2439
2440    /// Date arithmetic types: date ± number is a date, date − date a number,
2441    /// anything else with a date an error.
2442    #[test]
2443    fn date_arithmetic_types() {
2444        let mut measures = fixture();
2445        let when = MeasureId(110);
2446        measures.insert(
2447            when,
2448            Measure {
2449                value_type: ValueType::DateTime,
2450                ..num_measure(when, "When", vec![PRODUCT])
2451            },
2452        );
2453        let ctx = CompileContext::new(&measures);
2454        let ty = |e: &Expr| infer(&ctx, MeasureId(200), e).map(|t| t.ty.value_type);
2455        assert_eq!(
2456            ty(&bin(BinaryOp::Add, refr(when), lit(1.0))),
2457            Ok(ValueType::DateTime)
2458        );
2459        assert_eq!(
2460            ty(&bin(BinaryOp::Add, lit(1.0), refr(when))),
2461            Ok(ValueType::DateTime)
2462        );
2463        assert_eq!(
2464            ty(&bin(BinaryOp::Sub, refr(when), lit(1.0))),
2465            Ok(ValueType::DateTime)
2466        );
2467        assert_eq!(
2468            ty(&bin(BinaryOp::Sub, refr(when), refr(when))),
2469            Ok(ValueType::Number)
2470        );
2471        assert!(ty(&bin(BinaryOp::Mul, refr(when), lit(2.0))).is_err());
2472        assert!(ty(&bin(BinaryOp::Sub, lit(1.0), refr(when))).is_err());
2473    }
2474
2475    /// IF's type and dimension rules, and the lazy desugarings' arity rules.
2476    #[test]
2477    fn if_family_type_and_dimension_rules() {
2478        let (measures, flag) = with_flag();
2479        let ctx = CompileContext::new(&measures);
2480        let infer_ = |e: &Expr| infer(&ctx, MeasureId(200), e);
2481        let call = |id: u32, args: Vec<Expr>| Expr::Call(FuncId(id), args);
2482        let na = || call(53, vec![]);
2483        let text = |s: &str| Expr::Literal(Value::Text(s.into()));
2484        // The test must be Boolean; NA() as a test is accepted (it errors).
2485        assert!(matches!(
2486            infer_(&call(30, vec![refr(PRICE), lit(1.0)])),
2487            Err(CompileError::TypeMismatch(_))
2488        ));
2489        assert!(infer_(&call(30, vec![na(), lit(1.0)])).is_ok());
2490        // Branches unify; an error-only branch takes the other's type.
2491        assert!(matches!(
2492            infer_(&call(30, vec![refr(flag), lit(1.0), text("x")])),
2493            Err(CompileError::TypeMismatch(_))
2494        ));
2495        let t = infer_(&call(30, vec![refr(flag), na(), text("x")])).unwrap();
2496        assert_eq!(t.ty.value_type, ValueType::Text);
2497        let t = infer_(&call(30, vec![refr(flag), text("x"), na()])).unwrap();
2498        assert_eq!(t.ty.value_type, ValueType::Text);
2499        // Spanning: Flag over Product, Quantity over Time×Product spans via
2500        // either branch; Headcount over Region cannot broadcast at all.
2501        let t = infer_(&call(30, vec![refr(flag), lit(0.0), refr(QTY)])).unwrap();
2502        assert_eq!(t.ty.dim, Dim::of(vec![TIME, PRODUCT]));
2503        let plan = build_plan(&ctx, &t).unwrap();
2504        assert!(matches!(
2505            plan.kind,
2506            PlanNodeKind::If {
2507                spanning: Some(1),
2508                ..
2509            }
2510        ));
2511        let t = infer_(&call(30, vec![refr(flag), refr(QTY), lit(0.0)])).unwrap();
2512        assert!(matches!(
2513            build_plan(&ctx, &t).unwrap().kind,
2514            PlanNodeKind::If {
2515                spanning: Some(0),
2516                ..
2517            }
2518        ));
2519        assert!(matches!(
2520            infer_(&call(30, vec![refr(flag), refr(HEADCOUNT), lit(0.0)])),
2521            Err(CompileError::DimensionMismatch(_))
2522        ));
2523        // A test over Time with branches over Time×Product and
2524        // Time×Product×Region spans through the wider (else) branch.
2525        let mut m2 = measures.clone();
2526        let wide = MeasureId(105);
2527        m2.insert(wide, num_measure(wide, "W", vec![TIME, PRODUCT, REGION]));
2528        let tflag = MeasureId(106);
2529        m2.insert(
2530            tflag,
2531            Measure {
2532                value_type: ValueType::Boolean,
2533                ..num_measure(tflag, "TF", vec![TIME])
2534            },
2535        );
2536        let ctx2 = CompileContext::new(&m2);
2537        let e = call(30, vec![refr(tflag), refr(QTY), refr(wide)]);
2538        let t = infer(&ctx2, MeasureId(200), &e).unwrap();
2539        assert!(matches!(
2540            build_plan(&ctx2, &t).unwrap().kind,
2541            PlanNodeKind::If {
2542                spanning: Some(1),
2543                ..
2544            }
2545        ));
2546        // IFS needs pairs; SWITCH with and without a default; arity errors.
2547        assert!(matches!(
2548            infer_(&call(31, vec![refr(flag), lit(1.0), refr(flag)])),
2549            Err(CompileError::Unsupported(_))
2550        ));
2551        assert!(infer_(&call(32, vec![refr(PRICE), lit(1.0), text("a"), text("z")])).is_ok());
2552        assert!(infer_(&call(32, vec![refr(PRICE), lit(1.0), text("a")])).is_ok());
2553        assert!(matches!(
2554            infer_(&call(312, vec![refr(PRICE)])),
2555            Err(CompileError::Unsupported(_))
2556        ));
2557        assert_eq!(
2558            infer_(&call(999, vec![])),
2559            Err(CompileError::UnknownFunction(FuncId(999)))
2560        );
2561        // A Text argument where a number is wanted; an error-only argument fits.
2562        assert!(matches!(
2563            infer_(&call(10, vec![text("x")])),
2564            Err(CompileError::TypeMismatch(_))
2565        ));
2566        assert!(infer_(&call(10, vec![na()])).is_ok());
2567        assert!(matches!(
2568            infer_(&call(22, vec![refr(PRICE), refr(HEADCOUNT)])),
2569            Err(CompileError::DimensionMismatch(_))
2570        ));
2571        // desugar_lazy on a non-lazy id.
2572        assert_eq!(
2573            desugar_lazy(FuncId(10), &[]),
2574            Err(CompileError::UnknownFunction(FuncId(10)))
2575        );
2576        // NOT on a number.
2577        assert!(matches!(
2578            infer_(&Expr::UnaryOp(UnaryOp::Not, Box::new(refr(PRICE)))),
2579            Err(CompileError::TypeMismatch(_))
2580        ));
2581    }
2582
2583    fn lit(n: f64) -> Expr {
2584        Expr::Literal(Value::Number(n))
2585    }
2586
2587    #[test]
2588    fn infer_rejects_ill_typed_and_unbroadcastable_exprs() {
2589        let (measures, flag) = with_flag();
2590        let ctx = CompileContext::new(&measures);
2591        let err = |e: &Expr| infer(&ctx, MeasureId(200), e).unwrap_err();
2592        let msg = |e: &Expr| err(e).to_string();
2593
2594        let e = Expr::Literal(Value::Error(improv_core_model::ValueError::new(
2595            improv_core_model::ValueErrorKind::DivisionByZero,
2596        )));
2597        assert!(msg(&e).contains("error literal"));
2598        // Arithmetic needs Numbers; logic needs Booleans.
2599        assert!(msg(&bin(BinaryOp::Add, refr(flag), lit(1.0))).contains("requires Number"));
2600        assert!(msg(&bin(BinaryOp::And, refr(flag), refr(PRICE))).contains("requires Boolean"));
2601        assert!(msg(&bin(BinaryOp::Or, refr(PRICE), refr(flag))).contains("requires Boolean"));
2602        // Scalar call: a bad arg propagates; disjoint dims don't broadcast.
2603        assert_eq!(
2604            err(&Expr::Call(FuncId(10), vec![refr(MeasureId(999))])),
2605            CompileError::UnknownMeasure(MeasureId(999))
2606        );
2607        assert!(matches!(
2608            err(&Expr::Call(FuncId(20), vec![refr(PRICE), refr(HEADCOUNT)])),
2609            CompileError::DimensionMismatch(_)
2610        ));
2611        // Aggregations take their registry arity, of measure references.
2612        assert!(msg(&Expr::Call(FuncId(1), vec![refr(PRICE), refr(PRICE)])).contains("expects"));
2613        assert!(msg(&Expr::Call(FuncId(1), vec![refr(flag)])).contains("must be Number"));
2614        // AND(flag OVER …) takes booleans; SUM(flag, …) does not.
2615        assert!(msg(&Expr::Call(FuncId(22), vec![refr(flag)]))
2616            .contains("SUM argument 1 must be Number"));
2617    }
2618
2619    #[test]
2620    fn infer_accepts_logic_broadcast_calls_and_folds_literals() {
2621        let (measures, flag) = with_flag();
2622        let ctx = CompileContext::new(&measures);
2623        let ok = |e: &Expr| infer(&ctx, MeasureId(200), e).unwrap();
2624
2625        let t = ok(&bin(BinaryOp::And, refr(flag), refr(flag)));
2626        assert_eq!(t.ty.value_type, ValueType::Boolean);
2627        // MIN2(Quantity[Time,Product], Price[Product]) broadcasts to the union.
2628        let t = ok(&Expr::Call(FuncId(20), vec![refr(QTY), refr(PRICE)]));
2629        assert_eq!(t.ty.dim, Dim::of(vec![TIME, PRODUCT]));
2630        // An aggregation reduces measure references only.
2631        let e = infer(
2632            &ctx,
2633            MeasureId(200),
2634            &Expr::Call(FuncId(1), vec![bin(BinaryOp::Mul, refr(PRICE), lit(2.0))]),
2635        );
2636        assert!(matches!(e, Err(CompileError::Unsupported(_))));
2637        // Without OVER, nothing collapses (each cell is its own group).
2638        let t = infer(
2639            &ctx,
2640            MeasureId(200),
2641            &Expr::Call(FuncId(1), vec![refr(PRICE)]),
2642        )
2643        .unwrap();
2644        assert_eq!(t.ty.dim, Dim::of(vec![PRODUCT]));
2645        // A scalar literal on the left folds into the map: no Join.
2646        let typed = ok(&bin(BinaryOp::Mul, lit(2.0), refr(QTY)));
2647        match build_plan(&ctx, &typed).unwrap().kind {
2648            PlanNodeKind::MapBinary(BinaryOp::Mul, l, r) => {
2649                assert!(matches!(l.kind, PlanNodeKind::Literal(_)));
2650                assert_eq!(r.kind, PlanNodeKind::InputMeasure(QTY));
2651            }
2652            other => panic!("expected MapBinary, got {other:?}"),
2653        }
2654    }
2655}