Skip to main content

improv_core_model/
lib.rs

1//! Improv core model: categories, items, measures, coordinates, formulas.
2//!
3//! GUI-free, storage-free. This is the multidimensional "cube": each measure is
4//! a tensor indexed by a subset of categories; a coordinate names one cell.
5//!
6//! Structure follows the project steering doc (AGENT_STEERING.md / IMPROV.txt).
7#![cfg_attr(coverage_nightly, feature(coverage_attribute))]
8// The attribute is only used inside #[cfg(test)] modules, so non-test
9// coverage builds would otherwise warn that the feature is unused.
10#![cfg_attr(coverage_nightly, allow(unused_features))]
11
12pub mod extfn_def;
13pub mod formula;
14pub mod functions;
15pub mod ids;
16pub mod locale;
17pub mod parser;
18pub mod schedule;
19pub mod value;
20
21pub use extfn_def::{ExternalFn, Language};
22pub use formula::{BinaryOp, DimensionSpec, Expr, Formula, FuncId, UnaryOp};
23pub use ids::{CategoryId, ItemId, MeasureId, Name, ScenarioId, ViewId};
24pub use locale::Locale;
25pub use parser::{
26    parse_definition, parse_expr, parse_formula, Definition, FormulaText, LambdaText, ParseError,
27};
28pub use value::{Value, ValueError, ValueErrorKind, ValueType};
29
30use serde::{Deserialize, Serialize};
31use std::collections::{BTreeMap, BTreeSet, HashMap};
32
33/// A dimension of the model (e.g. Time, Product, Region).
34#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
35pub struct Category {
36    pub id: CategoryId,
37    pub name: Name,
38    pub items: Vec<ItemId>,
39}
40
41/// A member of a category (e.g. 2025, "Widget A").
42#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
43pub struct Item {
44    pub id: ItemId,
45    pub category: CategoryId,
46    pub name: Name,
47}
48
49/// A coordinate maps categories to items: one cell of a measure's tensor.
50///
51/// `BTreeMap` gives a stable, ordered key (important for hashing/serialization
52/// and, later, for deriving a fixed differential-dataflow key).
53#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
54pub struct Coordinate {
55    pub dims: BTreeMap<CategoryId, ItemId>,
56}
57
58impl Coordinate {
59    pub fn new() -> Self {
60        Coordinate {
61            dims: BTreeMap::new(),
62        }
63    }
64
65    pub fn from_pairs(pairs: impl IntoIterator<Item = (CategoryId, ItemId)>) -> Self {
66        Coordinate {
67            dims: pairs.into_iter().collect(),
68        }
69    }
70
71    pub fn get(&self, cat: CategoryId) -> Option<ItemId> {
72        self.dims.get(&cat).copied()
73    }
74
75    pub fn with(mut self, cat: CategoryId, item: ItemId) -> Self {
76        self.dims.insert(cat, item);
77        self
78    }
79
80    /// The set of categories this coordinate is defined over.
81    pub fn categories(&self) -> impl Iterator<Item = CategoryId> + '_ {
82        self.dims.keys().copied()
83    }
84}
85
86impl Default for Coordinate {
87    fn default() -> Self {
88        Self::new()
89    }
90}
91
92/// A measure is either raw input data or derived from a formula.
93#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
94pub enum MeasureKind {
95    Input,
96    Derived(Formula),
97}
98
99/// A named variable defined over one or more categories, e.g. `Revenue[Time, Product]`.
100#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
101pub struct Measure {
102    pub id: MeasureId,
103    pub name: Name,
104    pub value_type: ValueType,
105    /// Categories this measure is indexed by (its tensor dimensions).
106    pub categories: Vec<CategoryId>,
107    pub kind: MeasureKind,
108    pub description: Option<String>,
109}
110
111impl Measure {
112    pub fn is_input(&self) -> bool {
113        matches!(self.kind, MeasureKind::Input)
114    }
115    pub fn is_derived(&self) -> bool {
116        matches!(self.kind, MeasureKind::Derived(_))
117    }
118}
119
120/// The whole model: the multidimensional cube plus its raw input data.
121#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
122pub struct Model {
123    pub categories: HashMap<CategoryId, Category>,
124    pub items: HashMap<ItemId, Item>,
125    pub measures: HashMap<MeasureId, Measure>,
126    /// Raw input data: `(measure, coordinate) -> value`. Only `Input` measures
127    /// have entries here; derived measures are computed by the engine.
128    ///
129    /// Serialized as a sequence because JSON object keys must be strings and
130    /// this map is keyed by a `(MeasureId, Coordinate)` tuple.
131    #[serde(with = "inputs_as_seq")]
132    pub inputs: HashMap<(MeasureId, Coordinate), Value>,
133    /// Metadata marking which input measures are backed by an external SQL
134    /// query (Phase 7). The measure's `kind` stays `Input` — to the engine an
135    /// SQL measure is an ordinary input whose cells are (re)populated by
136    /// refreshing the query, so the deterministic core gains no SQL path. This
137    /// map records *how* to refresh; the actual query run lives in
138    /// `improv_storage_sql`.
139    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
140    pub sql_sources: HashMap<MeasureId, SqlSource>,
141    /// Saved views: named pivot layouts over the model (parity with
142    /// Improv/Quantrix "multiple views per model"). A view is presentation, not
143    /// modeling — it does not change measures or data.
144    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
145    pub views: HashMap<ViewId, View>,
146    /// External-language function definitions (Phase 6) available to formulas
147    /// via `CALL(name, ...)`, keyed by name. Plain data; the `improv_extfn`
148    /// crate evaluates them. Marked non-deterministic-source-adjacent: the
149    /// engine treats a call as pure per the author's purity assertion.
150    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
151    pub external_fns: HashMap<String, ExternalFn>,
152    /// Measures computed by calling an external function per coordinate over
153    /// argument measures (Phase 6). The measure's `kind` stays `Input`; a
154    /// host-side refresh (in `improv_engine::external`) runs the function and
155    /// populates its cells — the engine's dataflow gains no external-call path,
156    /// so its determinism is preserved.
157    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
158    pub external_calls: HashMap<MeasureId, ExternalCall>,
159    /// What-if **scenarios**: named sets of input-cell overrides layered on the
160    /// base model. First-class in the Improv sense — a scenario changes only
161    /// which *input* values are fed to the engine, so evaluating "under" a
162    /// scenario stays fully deterministic (the engine sees ordinary inputs).
163    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
164    pub scenarios: HashMap<ScenarioId, Scenario>,
165    /// Named LAMBDAs (Excel's Name Manager functions): `Margin = LAMBDA(p,
166    /// c, (p - c) / p)`, called as `Margin(Price, Cost)` in any formula. A
167    /// call is stored by the lambda's id ([`functions::named_lambda`]) and
168    /// expanded when compiled, so editing the lambda changes every use.
169    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
170    pub lambdas: BTreeMap<u32, NamedLambda>,
171}
172
173/// A named LAMBDA: its parameters and its body, in which parameter `i` is
174/// the reference [`functions::lambda_param`]`(i)`, or, where the body uses
175/// it as a category (`MAX(x OVER c)`), the category
176/// [`functions::lambda_category`]`(i)`. A body reads only its parameters
177/// (no measures), so a call depends on just its arguments; it may call
178/// lambdas defined before it, never itself.
179#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
180pub struct NamedLambda {
181    pub name: Name,
182    pub params: Vec<String>,
183    pub body: Expr,
184}
185
186/// How a named LAMBDA's body uses a parameter, which says what a call
187/// passes for it ([`NamedLambda::param_kinds`]).
188#[derive(Debug, Clone, Copy, PartialEq, Eq)]
189pub enum LambdaParam {
190    /// A value: any expression.
191    Value,
192    /// Reduced over a category (`MAX(x OVER c)`): a measure reference.
193    Reduced,
194    /// A category (`OVER c`, `POSITION(c)`): a category name.
195    Category,
196}
197
198impl NamedLambda {
199    /// What each parameter takes, from how the body uses it. An error names
200    /// a parameter used both as a value and as a category.
201    pub fn param_kinds(&self) -> Result<Vec<LambdaParam>, String> {
202        let mut uses = vec![ParamUse::default(); self.params.len()];
203        param_uses(&self.body, &mut uses);
204        self.params
205            .iter()
206            .zip(uses)
207            .map(|(p, u)| match u {
208                ParamUse {
209                    value: true,
210                    category: true,
211                    ..
212                } => Err(format!(
213                    "LAMBDA parameter {p} is used both as a value and as a category"
214                )),
215                ParamUse { category: true, .. } => Ok(LambdaParam::Category),
216                ParamUse { reduced: true, .. } => Ok(LambdaParam::Reduced),
217                _ => Ok(LambdaParam::Value),
218            })
219            .collect()
220    }
221}
222
223/// How a body uses one parameter (see [`NamedLambda::param_kinds`]).
224#[derive(Debug, Clone, Copy, Default)]
225struct ParamUse {
226    value: bool,
227    reduced: bool,
228    category: bool,
229}
230
231fn param_uses(e: &Expr, uses: &mut [ParamUse]) {
232    let cat = |c: &CategoryId, uses: &mut [ParamUse]| {
233        if let Some(u) = functions::lambda_category_of(*c).and_then(|i| uses.get_mut(i)) {
234            u.category = true;
235        }
236    };
237    match e {
238        Expr::Ref(id, s) => {
239            for c in s.by.iter().chain(&s.over).chain(&s.except) {
240                cat(c, uses);
241            }
242            if let Some(u) = functions::lambda_param_of(*id).and_then(|i| uses.get_mut(i)) {
243                u.value = true;
244                u.reduced |= *s != DimensionSpec::default();
245            }
246        }
247        Expr::Category(c) => cat(c, uses),
248        Expr::UnaryOp(_, a) => param_uses(a, uses),
249        Expr::BinaryOp(_, a, b) => {
250            param_uses(a, uses);
251            param_uses(b, uses);
252        }
253        Expr::Call(_, args) => args.iter().for_each(|a| param_uses(a, uses)),
254        Expr::Literal(_) => {}
255    }
256}
257
258/// A what-if scenario: a named overlay of input-cell overrides on the base
259/// model. Applying it produces a model whose `inputs` are the base inputs with
260/// these overrides taking precedence; derived measures then recompute normally.
261#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
262pub struct Scenario {
263    pub id: ScenarioId,
264    pub name: Name,
265    /// Input-cell overrides: `(measure, coordinate) -> value`. Only `Input`
266    /// measures are meaningful here; an override on a derived measure is
267    /// ignored by the engine (derived cells are computed).
268    #[serde(with = "inputs_as_seq")]
269    pub overrides: HashMap<(MeasureId, Coordinate), Value>,
270}
271
272/// A measure defined as `func(arg_measures...)` where `func` is a registered
273/// external function. Evaluated host-side per coordinate.
274#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
275pub struct ExternalCall {
276    /// Name of the external function (a key in `Model.external_fns`).
277    pub func: String,
278    /// The measures supplying the function's positional arguments, aligned to
279    /// the function's declared `arg_types`.
280    pub arg_measures: Vec<MeasureId>,
281    /// When this measure should be refreshed (default `Manual`). Advisory
282    /// metadata a scheduler consults; see `RefreshPolicy`.
283    #[serde(default)]
284    pub refresh_policy: RefreshPolicy,
285}
286/// Where a matrix sits on a view's canvas: position of its top-left corner and
287/// its size, in abstract layout units (the GUI treats them as egui logical
288/// points; nothing here depends on egui — `core_model` is GUI-free).
289///
290/// `Default` is a sane starting rectangle, not a zero one: a zero-sized matrix
291/// is never what a caller wants, and a legacy view deserialized without
292/// geometry has to land somewhere visible.
293#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
294pub struct CanvasRect {
295    pub x: f32,
296    pub y: f32,
297    pub w: f32,
298    pub h: f32,
299}
300
301impl Default for CanvasRect {
302    fn default() -> Self {
303        // ponytail: fixed default size; make it measure-aware (columns x rows)
304        // if auto-layout of a freshly placed matrix matters.
305        CanvasRect {
306            x: 0.0,
307            y: 0.0,
308            w: 560.0,
309            h: 420.0,
310        }
311    }
312}
313
314/// One matrix on a view's canvas: a measure, that matrix's own pivot layout,
315/// and where it sits (`rect`). A view holds several of these — Quantrix's
316/// free-form canvas, where each matrix is independently pivoted (see
317/// `docs/reviews/2026-09-22-gui-reconstruction-plan.md` Step 3).
318#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
319pub struct MatrixPlacement {
320    pub measure: MeasureId,
321    /// This matrix's axis permutation; see `View::axis_order`.
322    #[serde(default)]
323    pub axis_order: Vec<CategoryId>,
324    /// Categories stacked on this matrix's row axis.
325    #[serde(default = "one")]
326    pub n_rows: usize,
327    /// Categories stacked on this matrix's column axis.
328    #[serde(default = "one")]
329    pub n_cols: usize,
330    /// Pinned page items for this matrix.
331    #[serde(default)]
332    pub page_items: Vec<(CategoryId, ItemId)>,
333    /// This matrix's own filters.
334    #[serde(default)]
335    pub filters: Vec<Filter>,
336    /// Canvas geometry.
337    #[serde(default)]
338    pub rect: CanvasRect,
339}
340
341impl MatrixPlacement {
342    /// A placement of `measure` with default layout and geometry.
343    pub fn new(measure: MeasureId) -> Self {
344        MatrixPlacement {
345            measure,
346            axis_order: Vec::new(),
347            n_rows: 1,
348            n_cols: 1,
349            page_items: Vec::new(),
350            filters: Vec::new(),
351            rect: CanvasRect::default(),
352        }
353    }
354
355    /// Does `item` in `category` pass THIS matrix's filters? Categories without
356    /// a filter always pass. Same rule as `View::allows`, applied per matrix.
357    pub fn allows(&self, category: CategoryId, item: ItemId) -> bool {
358        match self.filters.iter().find(|f| f.category == category) {
359            Some(f) => f.items.contains(&item),
360            None => true,
361        }
362    }
363}
364
365/// A saved canvas layout: one *primary* matrix described by the flat fields
366/// below, plus any number of `placements` (additional matrices), each with its
367/// own measure, pivot and geometry.
368///
369/// The flat fields (`measure`, `axis_order`, `n_rows`, `n_cols`, `page_items`,
370/// `filters`) and `rect` ARE the primary matrix — not a cache of
371/// `placements[0]`. `placements` holds only the *extra* matrices, so every
372/// matrix is stored exactly once and there is no way for two copies of the same
373/// matrix to disagree. Use `View::matrices()` to iterate all of them uniformly.
374///
375/// This is what keeps pre-canvas saved views loading unchanged: a view written
376/// before canvases has no `placements`, defaults to none, and is therefore a
377/// one-matrix canvas whose single matrix is exactly what it always was.
378/// Reusable across sessions; loading a view reproduces a layout without
379/// touching formulas or data.
380#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
381pub struct View {
382    pub id: ViewId,
383    pub name: Name,
384    /// The primary matrix's measure.
385    pub measure: MeasureId,
386    /// A permutation of the measure's categories: the first `n_rows` are
387    /// stacked on rows (outer→inner), the next `n_cols` on columns, the rest
388    /// are pages. Empty = use the measure's natural order. (Pre-stacking views
389    /// have no `n_rows`/`n_cols`; they default to 1/1 = one category per axis.)
390    #[serde(default)]
391    pub axis_order: Vec<CategoryId>,
392    /// How many leading `axis_order` categories are stacked on the row axis.
393    #[serde(default = "one")]
394    pub n_rows: usize,
395    /// How many `axis_order` categories (after the rows) are stacked on the
396    /// column axis.
397    #[serde(default = "one")]
398    pub n_cols: usize,
399    /// For each page (extra) dimension, the pinned item.
400    #[serde(default)]
401    pub page_items: Vec<(CategoryId, ItemId)>,
402    /// Per-category filters: restrict a dimension to this subset of items.
403    /// A category absent here is unfiltered (all items shown).
404    #[serde(default)]
405    pub filters: Vec<Filter>,
406    /// Where the primary matrix sits on the canvas. (Pre-canvas views have no
407    /// geometry; they default to `CanvasRect::default()`.)
408    #[serde(default)]
409    pub rect: CanvasRect,
410    /// Additional matrices on this canvas, beyond the primary one described by
411    /// the fields above. Empty (the default, and what every pre-canvas saved
412    /// view deserializes to) = a single-matrix view.
413    #[serde(default)]
414    pub placements: Vec<MatrixPlacement>,
415}
416
417/// Serde default for `n_rows`/`n_cols` on `View` and `MatrixPlacement`: one
418/// category per axis.
419fn one() -> usize {
420    1
421}
422
423/// Restrict a category to a subset of its items in a view.
424#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
425pub struct Filter {
426    pub category: CategoryId,
427    /// Items to KEEP. An empty list means "no items pass" (an explicit empty
428    /// filter); omit the `Filter` entirely to show all items.
429    pub items: Vec<ItemId>,
430}
431
432impl View {
433    /// Build a view from its matrices: the first is the primary one (the flat
434    /// fields), the rest become `placements`.
435    pub fn from_matrices(
436        id: ViewId,
437        name: Name,
438        primary: MatrixPlacement,
439        extras: Vec<MatrixPlacement>,
440    ) -> Self {
441        View {
442            id,
443            name,
444            measure: primary.measure,
445            axis_order: primary.axis_order,
446            n_rows: primary.n_rows,
447            n_cols: primary.n_cols,
448            page_items: primary.page_items,
449            filters: primary.filters,
450            rect: primary.rect,
451            placements: extras,
452        }
453    }
454
455    /// The primary matrix, as a `MatrixPlacement` (the flat fields viewed
456    /// uniformly).
457    pub fn primary(&self) -> MatrixPlacement {
458        MatrixPlacement {
459            measure: self.measure,
460            axis_order: self.axis_order.clone(),
461            n_rows: self.n_rows,
462            n_cols: self.n_cols,
463            page_items: self.page_items.clone(),
464            filters: self.filters.clone(),
465            rect: self.rect,
466        }
467    }
468
469    /// Every matrix on this canvas, primary first. Always non-empty.
470    pub fn matrices(&self) -> Vec<MatrixPlacement> {
471        let mut v = Vec::with_capacity(1 + self.placements.len());
472        v.push(self.primary());
473        v.extend(self.placements.iter().cloned());
474        v
475    }
476
477    /// Does `item` in `category` pass the PRIMARY matrix's filters? Categories
478    /// without a filter always pass.
479    ///
480    /// Unchanged semantics from before canvases: filtering is per matrix, and
481    /// this is the primary matrix's filter set (identical to the whole view's,
482    /// for a single-matrix view). For another matrix use
483    /// `MatrixPlacement::allows`.
484    pub fn allows(&self, category: CategoryId, item: ItemId) -> bool {
485        self.primary().allows(category, item)
486    }
487}
488
489/// How to refresh an SQL-backed input measure: the query and how its columns
490/// map to the measure's dimensions + value. A live-query measure marked here
491/// is nondeterministic (its data comes from outside); determinism tests treat
492/// only measures WITHOUT an entry here as pure.
493#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
494pub struct SqlSource {
495    /// The `SELECT` to run on refresh.
496    pub query: String,
497    /// Result column names, in the same order as the measure's `categories`,
498    /// whose distinct values are the items of each dimension.
499    pub dimension_columns: Vec<String>,
500    /// Result column holding the numeric value.
501    pub value_column: String,
502    /// When this measure should be refreshed. Defaults to `Manual` (only when
503    /// asked). This is *policy metadata*; a caller (CLI `refresh-all`, a future
504    /// scheduler) decides when to honor it — the deterministic engine core is
505    /// untouched either way.
506    #[serde(default)]
507    pub refresh_policy: RefreshPolicy,
508}
509
510/// When an external-sourced measure (SQL or external-function) should refresh.
511#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
512#[serde(rename_all = "snake_case")]
513pub enum RefreshPolicy {
514    /// Only when explicitly asked (the safe default).
515    #[default]
516    Manual,
517    /// Whenever the model is loaded/opened by a tool that honors the policy.
518    OnLoad,
519    /// At most every `secs` seconds (advisory; the honoring tool tracks time).
520    Interval { secs: u64 },
521}
522
523/// serde adapter: (de)serialize the tuple-keyed `inputs` map as a `Vec` of
524/// `(key, value)` pairs so it survives JSON (and any string-keyed format).
525/// `body` with parameter `i` replaced by `args[i]`: a value parameter by the
526/// argument; a reduced one (`x OVER c`) by the argument's measure reference,
527/// carrying the body's `OVER`; a category placeholder by the argument's
528/// category.
529fn substitute_params(body: &Expr, args: &[Expr]) -> Result<Expr, String> {
530    let cat = |c: &CategoryId| match functions::lambda_category_of(*c) {
531        None => Ok(*c),
532        Some(i) => match args.get(i) {
533            Some(Expr::Category(a)) => Ok(*a),
534            _ => Err(format!("argument {} must be a category", i + 1)),
535        },
536    };
537    let cats = |v: &[CategoryId]| v.iter().map(cat).collect::<Result<Vec<_>, String>>();
538    let sub = |e: &Expr| substitute_params(e, args);
539    Ok(match body {
540        Expr::Ref(id, spec) => {
541            let spec = DimensionSpec {
542                by: cats(&spec.by)?,
543                over: cats(&spec.over)?,
544                except: cats(&spec.except)?,
545            };
546            match functions::lambda_param_of(*id).and_then(|i| Some((i, args.get(i)?))) {
547                None => Expr::Ref(*id, spec),
548                Some((_, a)) if spec == DimensionSpec::default() => a.clone(),
549                Some((_, Expr::Ref(m, a))) => {
550                    let or = |b: Vec<CategoryId>, a: &[CategoryId]| {
551                        if b.is_empty() {
552                            a.to_vec()
553                        } else {
554                            b
555                        }
556                    };
557                    Expr::Ref(
558                        *m,
559                        DimensionSpec {
560                            by: or(spec.by, &a.by),
561                            over: or(spec.over, &a.over),
562                            except: or(spec.except, &a.except),
563                        },
564                    )
565                }
566                Some((i, _)) => {
567                    return Err(format!(
568                        "argument {} is reduced over a category, so it must be a measure",
569                        i + 1
570                    ))
571                }
572            }
573        }
574        Expr::Category(c) => Expr::Category(cat(c)?),
575        Expr::Call(f, a) => Expr::Call(*f, a.iter().map(sub).collect::<Result<_, _>>()?),
576        Expr::UnaryOp(o, a) => Expr::UnaryOp(*o, Box::new(sub(a)?)),
577        Expr::BinaryOp(o, a, b) => Expr::BinaryOp(*o, Box::new(sub(a)?), Box::new(sub(b)?)),
578        Expr::Literal(_) => body.clone(),
579    })
580}
581
582/// Whether `e` calls one of the named LAMBDAs `ids`.
583fn calls_lambda(e: &Expr, ids: &BTreeSet<u32>) -> bool {
584    match e {
585        Expr::Call(f, a) => {
586            functions::named_lambda_of(*f).is_some_and(|i| ids.contains(&i))
587                || a.iter().any(|x| calls_lambda(x, ids))
588        }
589        Expr::UnaryOp(_, a) => calls_lambda(a, ids),
590        Expr::BinaryOp(_, a, b) => calls_lambda(a, ids) || calls_lambda(b, ids),
591        Expr::Literal(_) | Expr::Ref(..) | Expr::Category(_) => false,
592    }
593}
594
595mod inputs_as_seq {
596    use super::{Coordinate, MeasureId, Value};
597    use serde::{Deserialize, Deserializer, Serialize, Serializer};
598    use std::collections::HashMap;
599
600    type Map = HashMap<(MeasureId, Coordinate), Value>;
601
602    pub fn serialize<S: Serializer>(map: &Map, s: S) -> Result<S::Ok, S::Error> {
603        let v: Vec<(&(MeasureId, Coordinate), &Value)> = map.iter().collect();
604        v.serialize(s)
605    }
606
607    pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Map, D::Error> {
608        let v: Vec<((MeasureId, Coordinate), Value)> = Vec::deserialize(d)?;
609        Ok(v.into_iter().collect())
610    }
611}
612
613impl Model {
614    pub fn new() -> Self {
615        Model::default()
616    }
617
618    /// This model plus a provisional input measure `id` named `name` (when
619    /// no measure has the name), so a definition of a new measure can refer
620    /// to itself (`Bal = PREV(Bal, Time, 1, 0) + Flow`) while it is parsed.
621    pub fn with_provisional(&self, id: MeasureId, name: &str) -> std::borrow::Cow<'_, Model> {
622        if self.measure_by_name(name).is_some() {
623            return std::borrow::Cow::Borrowed(self);
624        }
625        let mut m = self.clone();
626        m.add_measure(Measure {
627            id,
628            name: Name(name.to_string()),
629            value_type: ValueType::Number,
630            categories: Vec::new(),
631            kind: MeasureKind::Input,
632            description: None,
633        });
634        std::borrow::Cow::Owned(m)
635    }
636
637    /// The named LAMBDA called `name` (case-insensitive), with its id.
638    pub fn lambda_by_name(&self, name: &str) -> Option<(u32, &NamedLambda)> {
639        self.lambdas
640            .iter()
641            .find(|(_, l)| l.name.0.eq_ignore_ascii_case(name))
642            .map(|(id, l)| (*id, l))
643    }
644
645    /// What calls named LAMBDA `id`, directly or through other lambdas: the
646    /// other lambdas' ids and the derived measures, each sorted.
647    pub fn lambda_users(&self, id: u32) -> (Vec<u32>, Vec<MeasureId>) {
648        let mut hit = BTreeSet::from([id]);
649        loop {
650            let before = hit.len();
651            for (lid, l) in &self.lambdas {
652                if !hit.contains(lid) && calls_lambda(&l.body, &hit) {
653                    hit.insert(*lid);
654                }
655            }
656            if hit.len() == before {
657                break;
658            }
659        }
660        let measures = self
661            .measures
662            .values()
663            .filter(|m| matches!(&m.kind, MeasureKind::Derived(f) if calls_lambda(&f.expr, &hit)))
664            .map(|m| m.id)
665            .collect::<BTreeSet<_>>();
666        hit.remove(&id);
667        (hit.into_iter().collect(), measures.into_iter().collect())
668    }
669
670    /// `e` with every named-LAMBDA call replaced by the lambda's body, its
671    /// parameters bound to the call's arguments (nested calls too). Errors
672    /// name an unknown lambda or a wrong argument count.
673    pub fn expand_lambdas(&self, e: &Expr) -> Result<Expr, String> {
674        self.expand_at(e, 0)
675    }
676
677    fn expand_at(&self, e: &Expr, depth: usize) -> Result<Expr, String> {
678        // Lambdas only call earlier ones, so depth is bounded by their
679        // number; the guard catches a hand-edited cycle.
680        if depth > self.lambdas.len() + 1 {
681            return Err("named LAMBDAs call each other in a cycle".into());
682        }
683        Ok(match e {
684            Expr::Call(f, args) => {
685                let args = args
686                    .iter()
687                    .map(|a| self.expand_at(a, depth))
688                    .collect::<Result<Vec<_>, _>>()?;
689                match functions::named_lambda_of(*f) {
690                    None => Expr::Call(*f, args),
691                    Some(id) => {
692                        let l = self
693                            .lambdas
694                            .get(&id)
695                            .ok_or_else(|| format!("named LAMBDA {id} does not exist"))?;
696                        if l.params.len() != args.len() {
697                            return Err(format!(
698                                "{} takes {} argument(s), got {}",
699                                l.name.0,
700                                l.params.len(),
701                                args.len()
702                            ));
703                        }
704                        let body = substitute_params(&l.body, &args)
705                            .map_err(|e| format!("{}: {e}", l.name.0))?;
706                        self.expand_at(&body, depth + 1)?
707                    }
708                }
709            }
710            Expr::UnaryOp(o, a) => Expr::UnaryOp(*o, Box::new(self.expand_at(a, depth)?)),
711            Expr::BinaryOp(o, a, b) => Expr::BinaryOp(
712                *o,
713                Box::new(self.expand_at(a, depth)?),
714                Box::new(self.expand_at(b, depth)?),
715            ),
716            other => other.clone(),
717        })
718    }
719
720    /// Add the named LAMBDAs stored as `(id, definition)` rows, each a JSON
721    /// [`NamedLambda`] or `Name = LAMBDA(…)` text. Text calls other lambdas
722    /// by name, and a redefined lambda may call one with a higher id, so the
723    /// text rows are parsed in passes until all are in. An error names the
724    /// row that never parsed.
725    pub fn load_lambdas(&mut self, rows: &[(u32, String)]) -> Result<(), String> {
726        let mut todo = Vec::new();
727        for (id, text) in rows {
728            match serde_json::from_str::<NamedLambda>(text) {
729                Ok(l) => {
730                    self.lambdas.insert(*id, l);
731                }
732                Err(_) => todo.push((*id, text)),
733            }
734        }
735        while !todo.is_empty() {
736            let before = todo.len();
737            let mut err = None;
738            todo.retain(|(id, text)| match parse_definition(self, text) {
739                Ok(Definition::Lambda(lt)) => {
740                    self.lambdas.insert(*id, lt.lambda);
741                    false
742                }
743                Ok(_) => {
744                    err = Some(format!("lambda {id}: {text:?} is not a LAMBDA"));
745                    true
746                }
747                Err(e) => {
748                    err.get_or_insert(format!("lambda {id}: {e}"));
749                    true
750                }
751            });
752            if todo.len() == before {
753                return Err(err.unwrap_or_default());
754            }
755        }
756        Ok(())
757    }
758
759    // --- builders (return the new id for convenience) ---
760
761    pub fn add_category(&mut self, id: CategoryId, name: impl Into<String>) {
762        self.categories.insert(
763            id,
764            Category {
765                id,
766                name: Name(name.into()),
767                items: Vec::new(),
768            },
769        );
770    }
771
772    pub fn add_item(&mut self, id: ItemId, category: CategoryId, name: impl Into<String>) {
773        self.items.insert(
774            id,
775            Item {
776                id,
777                category,
778                name: Name(name.into()),
779            },
780        );
781        if let Some(c) = self.categories.get_mut(&category) {
782            if !c.items.contains(&id) {
783                c.items.push(id);
784            }
785        }
786    }
787
788    pub fn add_measure(&mut self, m: Measure) {
789        self.measures.insert(m.id, m);
790    }
791
792    pub fn set_input(&mut self, measure: MeasureId, coord: Coordinate, value: Value) {
793        self.inputs.insert((measure, coord), value);
794    }
795
796    pub fn input(&self, measure: MeasureId, coord: &Coordinate) -> Option<&Value> {
797        self.inputs.get(&(measure, coord.clone()))
798    }
799
800    /// The transitive closure of every measure `roots` depends on: for each
801    /// derived measure, its formula's `referenced_measures()`; for an
802    /// external-call measure (`Model.external_calls`), its `arg_measures`.
803    /// Includes `roots` themselves. Storage-free, engine-free — a `Model`
804    /// loader (e.g. `storage_mentat::ModelStore::load_partial`) uses this to
805    /// load only the input cells an operation over `roots` actually needs,
806    /// instead of the whole model (see
807    /// `.agent/steering/AGENT_OUT_OF_CORE_DESIGN.md` §4).
808    pub fn measure_dependency_closure(
809        &self,
810        roots: &[MeasureId],
811    ) -> std::collections::HashSet<MeasureId> {
812        let mut seen: std::collections::HashSet<MeasureId> = roots.iter().copied().collect();
813        let mut frontier: Vec<MeasureId> = roots.to_vec();
814        while let Some(m) = frontier.pop() {
815            let mut deps: Vec<MeasureId> = Vec::new();
816            if let Some(measure) = self.measures.get(&m) {
817                if let MeasureKind::Derived(f) = &measure.kind {
818                    deps.extend(f.referenced_measures());
819                }
820            }
821            if let Some(call) = self.external_calls.get(&m) {
822                deps.extend(call.arg_measures.iter().copied());
823            }
824            for d in deps {
825                if seen.insert(d) {
826                    frontier.push(d);
827                }
828            }
829        }
830        seen
831    }
832
833    /// Look up a measure by its human name.
834    pub fn measure_by_name(&self, name: &str) -> Option<&Measure> {
835        self.measures.values().find(|m| m.name.0 == name)
836    }
837
838    /// Look up a category by its human name.
839    pub fn category_by_name(&self, name: &str) -> Option<&Category> {
840        self.categories.values().find(|c| c.name.0 == name)
841    }
842
843    /// Save (or replace) a view.
844    pub fn add_view(&mut self, v: View) {
845        self.views.insert(v.id, v);
846    }
847
848    /// Look up a view by its human name.
849    pub fn view_by_name(&self, name: &str) -> Option<&View> {
850        self.views.values().find(|v| v.name.0 == name)
851    }
852
853    /// Register a what-if scenario.
854    pub fn add_scenario(&mut self, s: Scenario) {
855        self.scenarios.insert(s.id, s);
856    }
857
858    /// Look up a scenario by its human name.
859    pub fn scenario_by_name(&self, name: &str) -> Option<&Scenario> {
860        self.scenarios.values().find(|s| s.name.0 == name)
861    }
862
863    /// Produce a model with a scenario's input overrides applied on top of the
864    /// base inputs (overrides win). Structure, formulas, and every other input
865    /// are unchanged — only the overridden `(measure, coordinate)` cells differ,
866    /// so the returned model evaluates deterministically like any other. The
867    /// scenario set itself is cleared on the overlay (an overlay is a concrete
868    /// what-if world, not a base to re-branch). Returns the base model unchanged
869    /// if no scenario by that id exists.
870    pub fn with_scenario(&self, id: ScenarioId) -> Model {
871        let mut m = self.clone();
872        m.scenarios.clear();
873        if let Some(s) = self.scenarios.get(&id) {
874            for (key, val) in &s.overrides {
875                m.inputs.insert(key.clone(), val.clone());
876            }
877        }
878        m
879    }
880}
881
882#[cfg(test)]
883#[cfg_attr(coverage_nightly, coverage(off))]
884mod tests {
885    use super::*;
886
887    // A tiny Time x Product revenue model, matching the steering doc's example.
888    fn time_product_model() -> Model {
889        let mut m = Model::new();
890        let (time, product) = (CategoryId(1), CategoryId(2));
891        m.add_category(time, "Time");
892        m.add_category(product, "Product");
893        m.add_item(ItemId(10), time, "2025");
894        m.add_item(ItemId(11), time, "2026");
895        m.add_item(ItemId(20), product, "Widget A");
896        m.add_item(ItemId(21), product, "Widget B");
897
898        m.add_measure(Measure {
899            id: MeasureId(100),
900            name: Name("Price".into()),
901            value_type: ValueType::Number,
902            categories: vec![product],
903            kind: MeasureKind::Input,
904            description: Some("Unit price per product".into()),
905        });
906        m.set_input(
907            MeasureId(100),
908            Coordinate::from_pairs([(product, ItemId(20))]),
909            Value::Number(10.0),
910        );
911        m
912    }
913
914    #[test]
915    fn coordinate_is_order_independent() {
916        let a = Coordinate::from_pairs([(CategoryId(1), ItemId(10)), (CategoryId(2), ItemId(20))]);
917        let b = Coordinate::from_pairs([(CategoryId(2), ItemId(20)), (CategoryId(1), ItemId(10))]);
918        assert_eq!(a, b, "BTreeMap key is insertion-order independent");
919        assert_eq!(a.get(CategoryId(1)), Some(ItemId(10)));
920    }
921
922    #[test]
923    fn model_build_and_lookup() {
924        let m = time_product_model();
925        assert_eq!(m.category_by_name("Time").unwrap().items.len(), 2);
926        assert!(m.measure_by_name("Price").unwrap().is_input());
927        let coord = Coordinate::from_pairs([(CategoryId(2), ItemId(20))]);
928        assert_eq!(m.input(MeasureId(100), &coord), Some(&Value::Number(10.0)));
929    }
930
931    #[test]
932    fn model_round_trips_through_json() {
933        let m = time_product_model();
934        let json = serde_json::to_string(&m).expect("serialize");
935        let back: Model = serde_json::from_str(&json).expect("deserialize");
936        assert_eq!(m, back, "model survives a JSON round trip");
937    }
938
939    #[test]
940    fn views_and_filters() {
941        let mut m = time_product_model();
942        let (time, product) = (CategoryId(1), CategoryId(2));
943        // A view of Price pivoted with Product on rows, filtered to WidgetA.
944        m.add_view(View {
945            id: ViewId(1),
946            name: Name("By Product".into()),
947            measure: MeasureId(100),
948            axis_order: vec![product, time],
949            n_rows: 1,
950            n_cols: 1,
951            page_items: vec![],
952            filters: vec![Filter {
953                category: product,
954                items: vec![ItemId(20)], // WidgetA only
955            }],
956            rect: Default::default(),
957            placements: vec![],
958        });
959        let v = m.view_by_name("By Product").expect("view");
960        assert_eq!(v.axis_order, vec![product, time]);
961        // Filter: WidgetA passes, WidgetB does not; unfiltered Time passes all.
962        assert!(v.allows(product, ItemId(20)));
963        assert!(!v.allows(product, ItemId(21)));
964        assert!(v.allows(time, ItemId(10)));
965
966        // Views survive a JSON round trip.
967        let json = serde_json::to_string(&m).unwrap();
968        let back: Model = serde_json::from_str(&json).unwrap();
969        assert_eq!(m, back);
970    }
971
972    #[test]
973    fn legacy_flat_view_json_loads_as_a_one_matrix_canvas() {
974        // A view exactly as written to disk BEFORE canvases existed: no
975        // `placements`, no `rect`. Literal legacy JSON on purpose -- building it
976        // with today's `View` would test the new type against itself.
977        let legacy = r#"{
978            "id": 7,
979            "name": "By Product",
980            "measure": 100,
981            "axis_order": [2, 1],
982            "n_rows": 1,
983            "n_cols": 1,
984            "page_items": [[3, 30]],
985            "filters": [{"category": 2, "items": [20]}]
986        }"#;
987        let v: View = serde_json::from_str(legacy).expect("legacy view deserializes");
988
989        // Every flat field survived verbatim.
990        assert_eq!(v.id, ViewId(7));
991        assert_eq!(v.name, Name("By Product".into()));
992        assert_eq!(v.measure, MeasureId(100));
993        assert_eq!(v.axis_order, vec![CategoryId(2), CategoryId(1)]);
994        assert_eq!((v.n_rows, v.n_cols), (1, 1));
995        assert_eq!(v.page_items, vec![(CategoryId(3), ItemId(30))]);
996        assert_eq!(
997            v.filters,
998            vec![Filter {
999                category: CategoryId(2),
1000                items: vec![ItemId(20)]
1001            }]
1002        );
1003
1004        // It is a ONE-matrix canvas, and that matrix mirrors the flat fields.
1005        assert!(v.placements.is_empty(), "no extra matrices");
1006        let ms = v.matrices();
1007        assert_eq!(ms.len(), 1);
1008        assert_eq!(ms[0].measure, MeasureId(100));
1009        assert_eq!(ms[0].axis_order, v.axis_order);
1010        assert_eq!((ms[0].n_rows, ms[0].n_cols), (1, 1));
1011        assert_eq!(ms[0].page_items, v.page_items);
1012        assert_eq!(ms[0].filters, v.filters);
1013        assert_eq!(ms[0].rect, CanvasRect::default(), "geometry defaulted");
1014
1015        // ...and it still FILTERS identically, via the view and the matrix.
1016        assert!(v.allows(CategoryId(2), ItemId(20)));
1017        assert!(!v.allows(CategoryId(2), ItemId(21)));
1018        assert!(v.allows(CategoryId(1), ItemId(10))); // unfiltered category
1019        assert!(!ms[0].allows(CategoryId(2), ItemId(21)));
1020
1021        // A legacy view with NO n_rows/n_cols at all (pre-stacking) still
1022        // defaults to 1/1, as it did before this change.
1023        let prestacking = r#"{"id": 1, "name": "V", "measure": 100, "axis_order": [1, 2]}"#;
1024        let v2: View = serde_json::from_str(prestacking).expect("pre-stacking view");
1025        assert_eq!((v2.n_rows, v2.n_cols), (1, 1));
1026        assert_eq!(v2.matrices().len(), 1);
1027    }
1028
1029    /// Stored lambdas load from JSON or text, a text one calling another
1030    /// whatever their ids; a row that never parses is an error naming it.
1031    #[test]
1032    fn load_lambdas_from_rows() {
1033        let mut m = time_product_model();
1034        let twice = NamedLambda {
1035            name: Name("Twice".into()),
1036            params: vec!["x".into()],
1037            body: Expr::BinaryOp(
1038                BinaryOp::Mul,
1039                Box::new(Expr::Ref(
1040                    functions::lambda_param(0),
1041                    DimensionSpec::default(),
1042                )),
1043                Box::new(Expr::Literal(Value::Number(2.0))),
1044            ),
1045        };
1046        m.load_lambdas(&[
1047            (1, "Big = LAMBDA(x, Norm(x, Time) > 1)".into()),
1048            (2, "Norm = LAMBDA(x, c, Twice(x) / MAX(x OVER c))".into()),
1049            (3, serde_json::to_string(&twice).unwrap()),
1050        ])
1051        .unwrap();
1052        assert_eq!(m.lambdas[&3], twice);
1053        assert_eq!(m.lambda_by_name("big").map(|(id, _)| id), Some(1));
1054        let norm = &m.lambdas[&2];
1055        assert_eq!(
1056            norm.param_kinds(),
1057            Ok(vec![LambdaParam::Reduced, LambdaParam::Category])
1058        );
1059        let Expr::BinaryOp(_, call, _) = &m.lambdas[&1].body else {
1060            panic!("{:?}", m.lambdas[&1].body)
1061        };
1062        assert!(matches!(&**call, Expr::Call(f, a)
1063            if *f == functions::named_lambda(2) && a[1] == Expr::Category(CategoryId(1))));
1064        // Big calls Norm, which calls Twice; a measure calls Big.
1065        m.add_measure(Measure {
1066            id: MeasureId(200),
1067            name: Name("Flag".into()),
1068            value_type: ValueType::Boolean,
1069            categories: vec![CategoryId(2)],
1070            kind: MeasureKind::Derived(Formula::new(Expr::Call(
1071                functions::named_lambda(1),
1072                vec![Expr::Ref(MeasureId(100), DimensionSpec::default())],
1073            ))),
1074            description: None,
1075        });
1076        assert_eq!(m.lambda_users(3), (vec![1, 2], vec![MeasureId(200)]));
1077        assert_eq!(m.lambda_users(1), (vec![], vec![MeasureId(200)]));
1078        assert_eq!(m.lambda_users(9), (vec![], vec![]));
1079        for (row, why) in [
1080            ("Bad = LAMBDA(x, Nope(x))", "unknown function: Nope"),
1081            ("Bad = Price * 2", "is not a LAMBDA"),
1082        ] {
1083            let e = m.clone().load_lambdas(&[(9, row.into())]).unwrap_err();
1084            assert!(e.starts_with("lambda 9: ") && e.contains(why), "{row}: {e}");
1085        }
1086    }
1087
1088    #[test]
1089    fn multi_placement_view_round_trips_and_filters_per_matrix() {
1090        let (time, product) = (CategoryId(1), CategoryId(2));
1091        let mut second = MatrixPlacement::new(MeasureId(101));
1092        second.axis_order = vec![time, product];
1093        second.n_rows = 2;
1094        second.n_cols = 0;
1095        second.rect = CanvasRect {
1096            x: 520.0,
1097            y: 48.5,
1098            w: 300.0,
1099            h: 200.0,
1100        };
1101        second.filters = vec![Filter {
1102            category: time,
1103            items: vec![ItemId(11)], // 2026 only, on THIS matrix
1104        }];
1105
1106        let mut primary = MatrixPlacement::new(MeasureId(100));
1107        primary.axis_order = vec![product];
1108        primary.rect = CanvasRect {
1109            x: 10.0,
1110            y: 20.0,
1111            w: 400.0,
1112            h: 150.0,
1113        };
1114        primary.filters = vec![Filter {
1115            category: product,
1116            items: vec![ItemId(20)],
1117        }];
1118
1119        let v = View::from_matrices(
1120            ViewId(3),
1121            Name("Canvas".into()),
1122            primary.clone(),
1123            vec![second.clone()],
1124        );
1125        assert_eq!(v.matrices(), vec![primary.clone(), second.clone()]);
1126
1127        // Filters are per matrix: the view's `allows` is the PRIMARY matrix's.
1128        assert!(v.allows(product, ItemId(20)));
1129        assert!(!v.allows(product, ItemId(21)));
1130        assert!(v.allows(time, ItemId(10)), "primary has no Time filter");
1131        assert!(!second.allows(time, ItemId(10)), "but matrix 2 does");
1132        assert!(second.allows(time, ItemId(11)));
1133
1134        // Serde round trip, geometry included.
1135        let json = serde_json::to_string(&v).unwrap();
1136        let back: View = serde_json::from_str(&json).unwrap();
1137        assert_eq!(back, v);
1138        assert_eq!(back.rect.x, 10.0);
1139        assert_eq!(back.placements[0].rect.y, 48.5);
1140        assert_eq!(back.placements[0].rect.w, 300.0);
1141        assert_eq!(
1142            (back.placements[0].n_rows, back.placements[0].n_cols),
1143            (2, 0)
1144        );
1145
1146        // And inside a whole Model.
1147        let mut m = time_product_model();
1148        m.add_view(v.clone());
1149        let back: Model = serde_json::from_str(&serde_json::to_string(&m).unwrap()).unwrap();
1150        assert_eq!(back, m);
1151        assert_eq!(back.views[&ViewId(3)].matrices().len(), 2);
1152    }
1153
1154    #[test]
1155    fn scenario_overlay_overrides_inputs() {
1156        let mut m = time_product_model();
1157        let product = CategoryId(2);
1158        let a = Coordinate::from_pairs([(product, ItemId(20))]);
1159        // Base: Price[WidgetA] = 10.
1160        assert_eq!(m.input(MeasureId(100), &a), Some(&Value::Number(10.0)));
1161
1162        // A what-if scenario raising WidgetA's price to 15.
1163        let mut overrides = std::collections::HashMap::new();
1164        overrides.insert((MeasureId(100), a.clone()), Value::Number(15.0));
1165        m.add_scenario(Scenario {
1166            id: ScenarioId(1),
1167            name: Name("Price hike".into()),
1168            overrides,
1169        });
1170
1171        // Base model unchanged; overlay sees the override.
1172        assert_eq!(m.input(MeasureId(100), &a), Some(&Value::Number(10.0)));
1173        let s = m.scenario_by_name("Price hike").unwrap().id;
1174        let world = m.with_scenario(s);
1175        assert_eq!(world.input(MeasureId(100), &a), Some(&Value::Number(15.0)));
1176        // The overlay is a concrete world: no scenarios to re-branch.
1177        assert!(world.scenarios.is_empty());
1178
1179        // Scenarios survive a JSON round trip.
1180        let json = serde_json::to_string(&m).unwrap();
1181        let back: Model = serde_json::from_str(&json).unwrap();
1182        assert_eq!(m, back);
1183    }
1184
1185    #[test]
1186    fn measure_dependency_closure_walks_formulas_and_external_calls() {
1187        // A -> B -> C chain via Derived formulas, plus D = CALL(f, C, E) (an
1188        // external-call measure whose "dependencies" are its arg_measures, not
1189        // an Expr). F is unrelated and must NOT appear in A's closure.
1190        let mut m = Model::new();
1191        let a = MeasureId(1);
1192        let b = MeasureId(2);
1193        let c = MeasureId(3);
1194        let d = MeasureId(4);
1195        let e = MeasureId(5);
1196        let f = MeasureId(6);
1197        let leaf = |id: MeasureId, kind: MeasureKind| Measure {
1198            id,
1199            name: Name(format!("M{}", id.0)),
1200            value_type: ValueType::Number,
1201            categories: vec![],
1202            kind,
1203            description: None,
1204        };
1205        m.add_measure(leaf(c, MeasureKind::Input));
1206        m.add_measure(leaf(e, MeasureKind::Input));
1207        m.add_measure(leaf(f, MeasureKind::Input));
1208        m.add_measure(leaf(
1209            b,
1210            MeasureKind::Derived(Formula::new(Expr::Ref(c, DimensionSpec::default()))),
1211        ));
1212        m.add_measure(leaf(
1213            a,
1214            MeasureKind::Derived(Formula::new(Expr::Ref(b, DimensionSpec::default()))),
1215        ));
1216        m.add_measure(leaf(d, MeasureKind::Input)); // external-call measures stay Input
1217        m.external_calls.insert(
1218            d,
1219            ExternalCall {
1220                func: "f".into(),
1221                arg_measures: vec![c, e],
1222                refresh_policy: Default::default(),
1223            },
1224        );
1225
1226        let closure = m.measure_dependency_closure(&[a]);
1227        assert_eq!(closure, [a, b, c].into_iter().collect());
1228
1229        let closure_d = m.measure_dependency_closure(&[d]);
1230        assert_eq!(closure_d, [d, c, e].into_iter().collect());
1231
1232        // Multiple roots union their closures; unrelated F never appears.
1233        let closure_both = m.measure_dependency_closure(&[a, d]);
1234        assert_eq!(closure_both, [a, b, c, d, e].into_iter().collect());
1235        assert!(!closure_both.contains(&f));
1236    }
1237}