Skip to main content

pmcp_workbook_runtime/sheet_ir/
executor.rs

1//! The interpreted typed-IR executor (CMP-05, D-01): the SERVE-time topo-ordered
2//! driver that walks the per-cell [`Dag`] in dependency order, fills a
3//! [`CellEnv`], and evaluates each [`CellExpr::Formula`] through a RECURSIVE
4//! `eval_expr`.
5//!
6//! Phase 11 Plan 05 boundary: this is the RUNTIME `run()` path — it re-runs an
7//! ALREADY-built+expanded `Dag` (the server deserializes a pre-built IR + DAG).
8//! Leaf arithmetic routes through the PURE-RUST [`super::scalar_eval`] via
9//! [`eval_leaf`] (no SWC/JS kernel); function dispatch routes through
10//! [`semantics::apply`]; range member-keys + 2-D shape come from the public
11//! [`resolve::expand_range`]. This crate exports a silent-drop [`build_dag`]
12//! (reconstructs edges from an ALREADY-built IR at load time; a failed range
13//! expansion contributes no edge). The finding-pushing DAG-BUILD pipeline
14//! (`rebase`/loop unroll, `run_with_loop`) lives in the compiler crate
15//! (Phase 93), which calls THIS `run()` — extend that pipeline there rather
16//! than shadowing this `build_dag`.
17//!
18//! Order comes from [`toposort`] ONLY — NEVER calcChain (RESEARCH Pitfall 3). A
19//! `toposort` cycle surfaces as ONE located `dag/cycle` [`LintFinding`].
20
21use std::collections::HashMap;
22
23use serde::Serialize;
24
25use crate::dag::{toposort, Dag};
26use crate::excel_error::ExcelError;
27use crate::finding::{LintFinding, Severity};
28use crate::formula::{BinOp, Expr, UnOp};
29use crate::resolve;
30
31use super::eval_bridge::{eval_leaf, from_json, percent, powf, to_json, CellEnv};
32use super::eval_value::EvalValue;
33use super::semantics;
34use super::value::CellValue;
35use super::{Cell, CellExpr};
36
37/// A per-cell evidence record the executor emits as it computes — the classifier
38/// consumes it as the deciding evidence for a mismatch. Owned and
39/// serde/schemars-clean.
40#[derive(Debug, Clone, Default, Serialize, schemars::JsonSchema)]
41pub struct EvalTrace {
42    /// The `cell_key` (`sheet!addr`) this trace describes.
43    pub cell: String,
44    /// The serialized [`Expr`] formula (a debug render); `None` for a literal.
45    pub formula: Option<String>,
46    /// Each resolved ref/range-member key + the [`CellValue`] it carried in env.
47    pub resolved_refs: Vec<(String, CellValue)>,
48    /// The [`semantics::apply`] function name dispatched (when the cell is a Call).
49    pub dispatched_fn: Option<String>,
50    /// The materialized scalar/range-flattened operands fed to the function/leaf.
51    pub operand_values: Vec<CellValue>,
52    /// Human-readable coercion notes.
53    pub coercions: Vec<String>,
54    /// The preflight error this cell short-circuited on, if any (D-04).
55    pub short_circuited: Option<ExcelError>,
56}
57
58impl EvalTrace {
59    /// A fresh trace located at `cell`.
60    fn new(cell: &str) -> Self {
61        EvalTrace {
62            cell: cell.to_string(),
63            ..Default::default()
64        }
65    }
66}
67
68/// The result of an executor [`run`]: the computed cell map + the per-cell
69/// evidence traces. Owned, serde/schemars-clean.
70#[derive(Debug, Clone, Default, Serialize, schemars::JsonSchema)]
71pub struct RunResult {
72    /// `cell_key -> computed CellValue` for every cell the walk evaluated.
73    pub computed: HashMap<String, CellValue>,
74    /// `cell_key -> EvalTrace` evidence record for every formula cell.
75    pub traces: HashMap<String, EvalTrace>,
76}
77
78/// The owning sheet of a fully-qualified `sheet!addr` cell key — the default an
79/// unqualified Range falls back to (WR-04). Splitter sibling of the key builder
80/// [`crate::range_ref::cell_key`]; empty when the key carries no `!`.
81fn owning_sheet(key: &str) -> &str {
82    key.split_once('!').map_or("", |(s, _)| s)
83}
84
85/// Walk the per-cell [`Dag`] in toposort order, filling `env` from `seed`, and
86/// compute every cell in `ir`. Returns the computed `{cell_key -> CellValue}` map
87/// together with per-cell [`EvalTrace`] evidence, or ONE located `dag/cycle`
88/// [`LintFinding`] when the DAG is cyclic.
89///
90/// `seed` carries pre-loaded `Role::Input` cells + per-quote plot details (cells
91/// ABSENT from `ir`) so the leaf inputs resolve before the walk (D-01/D-06).
92pub fn run(
93    ir: &HashMap<String, Cell>,
94    dag: &Dag,
95    seed: &CellEnv,
96) -> Result<RunResult, Box<LintFinding>> {
97    let order = toposort(dag).map_err(|residual| {
98        let (sheet, cell) = residual
99            .first()
100            .and_then(|k| k.split_once('!'))
101            .map(|(s, a)| (s.to_string(), Some(a.to_string())))
102            .unwrap_or_default();
103        Box::new(LintFinding::new(
104            Severity::Error,
105            "dag/cycle",
106            sheet,
107            cell,
108            format!("dependency cycle through cells: {}", residual.join(" → ")),
109            "break the cycle by removing one of the circular references",
110        ))
111    })?;
112
113    let mut env = seed.clone();
114    let mut errs: HashMap<String, ExcelError> = HashMap::new();
115    let mut computed: HashMap<String, CellValue> = HashMap::new();
116    let mut traces: HashMap<String, EvalTrace> = HashMap::new();
117
118    for key in order {
119        match ir.get(&key) {
120            Some(Cell {
121                expr: CellExpr::Literal(v),
122                ..
123            }) => {
124                // Seed-preserving: a caller-seeded value (pre-loaded into `seed`
125                // by `validate_input`) WINS over an IR literal of the same key. The
126                // executor's seed contract is that `Role::Input` cells are ABSENT
127                // from `ir`; this guard is defense-in-depth so a future
128                // compiler-emitted bundle that repeats the input-literal shape can
129                // no longer silently clobber a validated caller seed (CR-01).
130                if env.get(&key).is_none() {
131                    env = env.seed_cell(&key, v);
132                }
133                if let CellValue::Error(err) = v {
134                    errs.insert(key.clone(), *err);
135                }
136                computed.insert(key.clone(), v.clone());
137            },
138            Some(Cell {
139                expr: CellExpr::Formula(e),
140                ..
141            }) => {
142                let mut trace = EvalTrace::new(&key);
143                // WR-04: without the owning sheet, an empty `range.sheet`
144                // expanded to phantom `"!B2"` keys that could never match env.
145                let current_sheet = owning_sheet(&key);
146                let result = eval_expr(e, &env, &errs, current_sheet, &mut trace);
147                if let CellValue::Error(err) = &result {
148                    errs.insert(key.clone(), *err);
149                    trace.short_circuited.get_or_insert(*err);
150                }
151                if let Some(j) = to_json(&result) {
152                    env = env.with_value(&key, j);
153                }
154                computed.insert(key.clone(), result);
155                traces.insert(key.clone(), trace);
156            },
157            // A cell in the DAG but absent from `ir` is a pre-seeded input.
158            None => {},
159        }
160    }
161
162    Ok(RunResult { computed, traces })
163}
164
165/// Recursively evaluate the WHOLE [`Expr`] tree to a scalar [`CellValue`].
166/// `current_sheet` is the OWNING cell's sheet (WR-04) — the default an
167/// unqualified [`Expr::Range`] member falls back to in `expand_range`.
168fn eval_expr(
169    e: &Expr,
170    env: &CellEnv,
171    errs: &HashMap<String, ExcelError>,
172    current_sheet: &str,
173    trace: &mut EvalTrace,
174) -> CellValue {
175    trace.formula.get_or_insert_with(|| format!("{e:?}"));
176    let mut ctx = Ctx {
177        env,
178        errs,
179        current_sheet,
180        trace,
181    };
182    match e {
183        Expr::Call { name, args } => {
184            eval_call(name, args, ctx.env, ctx.errs, ctx.current_sheet, ctx.trace)
185        },
186        Expr::BinaryOp { left, op, right } => eval_binary_op(e, left, *op, right, &mut ctx),
187        Expr::UnaryOp { op, operand } => eval_unary_op(e, *op, operand, &mut ctx),
188        other => {
189            record_refs(other, ctx.env, ctx.current_sheet, ctx.trace);
190            eval_leaf(other, ctx.env, ctx.errs)
191        },
192    }
193}
194
195/// Evaluate an [`Expr::Call`]: materialize every argument, record the dispatched
196/// function name + flattened operand evidence into `trace`, then route through
197/// [`semantics::apply`]. Behavior-identical to the original inline `Call` arm.
198fn eval_call(
199    name: &str,
200    args: &[Expr],
201    env: &CellEnv,
202    errs: &HashMap<String, ExcelError>,
203    current_sheet: &str,
204    trace: &mut EvalTrace,
205) -> CellValue {
206    let vals: Vec<EvalValue> = args
207        .iter()
208        .map(|a| materialize_arg(a, env, errs, current_sheet, trace))
209        .collect();
210    trace.dispatched_fn = Some(name.to_string());
211    for v in &vals {
212        record_operand_values(v, trace);
213    }
214    semantics::apply(name, &vals)
215}
216
217/// Push the scalar/range-flattened operand values of one [`EvalValue`] into the
218/// trace evidence. A `Range` contributes every member in row-major order.
219fn record_operand_values(v: &EvalValue, trace: &mut EvalTrace) {
220    match v {
221        EvalValue::Scalar(cv) => trace.operand_values.push(cv.clone()),
222        EvalValue::Range(rows) => {
223            for row in rows {
224                for cv in row {
225                    trace.operand_values.push(cv.clone());
226                }
227            }
228        },
229    }
230}
231
232/// The borrowed evaluation context threaded through the per-variant `eval_*`
233/// helpers: the value env, the per-cell error map, the owning sheet (WR-04), and
234/// the mutable trace evidence record. Bundles four otherwise-redundant params so
235/// the helpers stay under clippy's argument-count bar without changing behavior.
236struct Ctx<'a> {
237    env: &'a CellEnv,
238    errs: &'a HashMap<String, ExcelError>,
239    current_sheet: &'a str,
240    trace: &'a mut EvalTrace,
241}
242
243impl Ctx<'_> {
244    /// Recursively evaluate `e` under this context (re-borrows the threaded refs).
245    fn eval(&mut self, e: &Expr) -> CellValue {
246        eval_expr(e, self.env, self.errs, self.current_sheet, self.trace)
247    }
248}
249
250/// Evaluate an [`Expr::BinaryOp`]. `^` routes through the off-evaluator `powf`
251/// helper; a fully leaf-lowerable pair lowers WHOLE through the scalar evaluator;
252/// otherwise each operand is recursively evaluated, re-lowered to a leaf, and the
253/// binary op is replayed through `eval_leaf`. `e` is the original node (passed so
254/// the leaf paths re-lower without rebuilding it). Behavior-identical.
255fn eval_binary_op(e: &Expr, left: &Expr, op: BinOp, right: &Expr, ctx: &mut Ctx) -> CellValue {
256    if matches!(op, BinOp::Pow) {
257        let lv = ctx.eval(left);
258        let rv = ctx.eval(right);
259        return match (semantics::to_number(&lv), semantics::to_number(&rv)) {
260            (Ok(b), Ok(x)) => finite_or_num(powf(b, x)),
261            (Err(e), _) | (_, Err(e)) => CellValue::Error(e),
262        };
263    }
264    if is_leaf_lowerable(left) && is_leaf_lowerable(right) {
265        record_refs(e, ctx.env, ctx.current_sheet, ctx.trace);
266        return eval_leaf(e, ctx.env, ctx.errs);
267    }
268    let l = ctx.eval(left);
269    let r = ctx.eval(right);
270    let lowered = Expr::BinaryOp {
271        left: Box::new(scalar_to_leaf(&l)),
272        op,
273        right: Box::new(scalar_to_leaf(&r)),
274    };
275    eval_leaf(&lowered, ctx.env, ctx.errs)
276}
277
278/// Evaluate an [`Expr::UnaryOp`]. `%` routes through the off-evaluator `percent`
279/// helper; a leaf-lowerable operand lowers WHOLE through the scalar evaluator;
280/// otherwise the operand is recursively evaluated, re-lowered to a leaf, and the
281/// unary op is replayed through `eval_leaf`. `e` is the original node.
282/// Behavior-identical.
283fn eval_unary_op(e: &Expr, op: UnOp, operand: &Expr, ctx: &mut Ctx) -> CellValue {
284    if matches!(op, UnOp::Percent) {
285        let v = ctx.eval(operand);
286        return match semantics::to_number(&v) {
287            Ok(x) => finite_or_num(percent(x)),
288            Err(e) => CellValue::Error(e),
289        };
290    }
291    if is_leaf_lowerable(operand) {
292        record_refs(e, ctx.env, ctx.current_sheet, ctx.trace);
293        return eval_leaf(e, ctx.env, ctx.errs);
294    }
295    let v = ctx.eval(operand);
296    let lowered = Expr::UnaryOp {
297        op,
298        operand: Box::new(scalar_to_leaf(&v)),
299    };
300    eval_leaf(&lowered, ctx.env, ctx.errs)
301}
302
303/// Normalize an `f64` result of an off-evaluator helper (`^`/`%`) to a typed
304/// Excel error when it is non-finite (CR-02). `NaN` → `#DIV/0!`; `±inf` →
305/// `#NUM!`; a finite value is wrapped unchanged.
306fn finite_or_num(n: f64) -> CellValue {
307    if n.is_nan() {
308        CellValue::Error(ExcelError::DivZero)
309    } else if !n.is_finite() {
310        CellValue::Error(ExcelError::Num)
311    } else {
312        CellValue::Number(n)
313    }
314}
315
316/// True iff `e` is a node the scalar evaluator can lower WHOLE — it contains NO
317/// `Call`/`Range`/`Name` (and no `^`/`%`).
318fn is_leaf_lowerable(e: &Expr) -> bool {
319    match e {
320        Expr::Ref(_) | Expr::Number(_) | Expr::Str(_) | Expr::Bool(_) | Expr::ErrorLit(_) => true,
321        Expr::Range(_) | Expr::Name(_) | Expr::Call { .. } => false,
322        Expr::BinaryOp { left, op, right } => {
323            !matches!(op, BinOp::Pow) && is_leaf_lowerable(left) && is_leaf_lowerable(right)
324        },
325        Expr::UnaryOp { op, operand } => !matches!(op, UnOp::Percent) && is_leaf_lowerable(operand),
326    }
327}
328
329/// Substitute an already-evaluated scalar [`CellValue`] back into an [`Expr`] leaf.
330fn scalar_to_leaf(cv: &CellValue) -> Expr {
331    match cv {
332        CellValue::Number(n) => Expr::Number(*n),
333        CellValue::Text(s) => Expr::Str(s.clone()),
334        CellValue::Bool(b) => Expr::Bool(*b),
335        CellValue::Empty => Expr::Number(0.0), // empty-cell-as-0
336        CellValue::Error(e) => Expr::ErrorLit(*e),
337    }
338}
339
340/// Materialize a function argument into an [`EvalValue`]. `current_sheet` is the
341/// OWNING cell's sheet — `expand_range` defaults an unqualified range
342/// (`range.sheet.is_empty()`) onto it (WR-04: the old
343/// `if range.sheet.is_empty() { "" }` conditional was a no-op that expanded
344/// unqualified ranges to phantom `"!B2"` keys).
345fn materialize_arg(
346    a: &Expr,
347    env: &CellEnv,
348    errs: &HashMap<String, ExcelError>,
349    current_sheet: &str,
350    trace: &mut EvalTrace,
351) -> EvalValue {
352    match a {
353        Expr::Range(range) => match resolve::expand_range(range, current_sheet) {
354            Ok((keys, shape)) => build_range(&keys, shape, env, errs, trace),
355            Err(_) => {
356                trace.short_circuited.get_or_insert(ExcelError::Ref);
357                EvalValue::Scalar(CellValue::Error(ExcelError::Ref))
358            },
359        },
360        Expr::Name(_) => EvalValue::Scalar(CellValue::Error(ExcelError::Name)),
361        scalar => EvalValue::Scalar(eval_expr(scalar, env, errs, current_sheet, trace)),
362    }
363}
364
365/// Rebuild a shape-correct 2-D `Vec<Vec<CellValue>>` from column-major member
366/// `keys` + the [`resolve::RangeShape`]. An ABSENT member is the Pitfall-5 HARD
367/// error. A member that COMPUTED an Excel error is looked up in `errs` (errored
368/// cells never enter `env` per D-04 — `to_json` returns `None`) and propagates
369/// its ACTUAL error (e.g. `#DIV/0!`), never a misleading `#REF!` (WR-03).
370fn build_range(
371    keys: &[String],
372    shape: resolve::RangeShape,
373    env: &CellEnv,
374    errs: &HashMap<String, ExcelError>,
375    trace: &mut EvalTrace,
376) -> EvalValue {
377    let rows = shape.rows as usize;
378    let cols = shape.cols as usize;
379    let mut out: Vec<Vec<CellValue>> = Vec::with_capacity(rows);
380    for r in 0..rows {
381        let mut row_cells: Vec<CellValue> = Vec::with_capacity(cols);
382        for c in 0..cols {
383            let key = &keys[c * rows + r];
384            let cv = match env_lookup(env, key) {
385                Some(cv) => cv,
386                // The member evaluated to an error: propagate ITS error.
387                None => match errs.get(key) {
388                    Some(e) => CellValue::Error(*e),
389                    // Genuinely absent member: the Pitfall-5 hard #REF!.
390                    None => {
391                        trace.short_circuited.get_or_insert(ExcelError::Ref);
392                        CellValue::Error(ExcelError::Ref)
393                    },
394                },
395            };
396            trace.resolved_refs.push((key.clone(), cv.clone()));
397            row_cells.push(cv);
398        }
399        out.push(row_cells);
400    }
401    EvalValue::Range(out)
402}
403
404/// Walk an [`Expr`] tree and collect every cell key it DEPENDS ON, in
405/// left-to-right encounter order. An [`Expr::Ref`] contributes its single key; an
406/// [`Expr::Range`] contributes EVERY expanded member key (via the shared
407/// [`resolve::expand_range`]) — a range edge is NOT dropped. A range that fails to
408/// expand contributes nothing (it surfaces as a `#REF!` at eval time).
409///
410/// This is the SINGLE ref-walk shared by [`build_dag`] (which needs the dependency
411/// keys to build edges) and [`record_refs`] (which additionally reads each key's
412/// current env value into the trace) — so the two cannot disagree on what a cell
413/// depends on.
414fn collect_ref_keys(e: &Expr, current_sheet: &str, out: &mut Vec<String>) {
415    match e {
416        Expr::Ref(addr) => out.push(addr.clone()),
417        Expr::Range(range) => {
418            // WR-04: an unqualified range defaults onto the OWNING cell's sheet
419            // inside `expand_range` — never onto an empty-sheet `"!B2"` key.
420            if let Ok((keys, _shape)) = resolve::expand_range(range, current_sheet) {
421                out.extend(keys);
422            }
423        },
424        Expr::BinaryOp { left, right, .. } => {
425            collect_ref_keys(left, current_sheet, out);
426            collect_ref_keys(right, current_sheet, out);
427        },
428        Expr::UnaryOp { operand, .. } => collect_ref_keys(operand, current_sheet, out),
429        Expr::Call { args, .. } => {
430            for a in args {
431                collect_ref_keys(a, current_sheet, out);
432            }
433        },
434        Expr::Number(_) | Expr::Str(_) | Expr::Bool(_) | Expr::Name(_) | Expr::ErrorLit(_) => {},
435    }
436}
437
438/// Record every ref the leaf-lowered [`Expr`] `e` depends on + its current env
439/// value into the trace, sharing the single [`collect_ref_keys`] walk.
440fn record_refs(e: &Expr, env: &CellEnv, current_sheet: &str, trace: &mut EvalTrace) {
441    let mut keys = Vec::new();
442    collect_ref_keys(e, current_sheet, &mut keys);
443    for key in keys {
444        let cv = env_lookup(env, &key).unwrap_or(CellValue::Empty);
445        trace.resolved_refs.push((key, cv));
446    }
447}
448
449/// Build the per-cell dependency [`Dag`] from a pre-built IR (the served binary
450/// deserializes a pre-built IR and reconstructs the DAG ONCE at load).
451///
452/// For each cell it adds a node, and one `add_edge(cell, dep)` per dependency key
453/// the cell's formula references — collected via the SAME [`collect_ref_keys`]
454/// walk the executor's trace uses (so the DAG edges and the eval-time ref-walk
455/// agree, and `Range` edges are correctly included via `expand_range`). A literal
456/// cell is a zero-dependency node. Absent dependency endpoints are registered as
457/// nodes by [`Dag::add_edge`].
458pub fn build_dag(ir: &HashMap<String, Cell>) -> Dag {
459    let mut dag = Dag::new();
460    for (key, cell) in ir {
461        dag.add_node(key);
462        if let CellExpr::Formula(e) = &cell.expr {
463            // WR-04: same owning-sheet default as `run()` — so DAG edges and
464            // the eval-time walk agree on the SAME qualified member keys.
465            let current_sheet = owning_sheet(key);
466            let mut deps = Vec::new();
467            collect_ref_keys(e, current_sheet, &mut deps);
468            for dep in deps {
469                dag.add_edge(key, &dep);
470            }
471        }
472    }
473    dag
474}
475
476/// Look a `cell_key` up in `env`, mapping the stored `JsonValue` back to a
477/// [`CellValue`] via [`from_json`]. `None` iff the key is ABSENT.
478fn env_lookup(env: &CellEnv, key: &str) -> Option<CellValue> {
479    env.get(key).map(from_json)
480}
481
482#[cfg(test)]
483mod tests {
484    use super::*;
485    use crate::formula::BinOp;
486    use crate::range_ref::RangeRef;
487
488    fn lit(key: &str, n: f64) -> (String, Cell) {
489        (
490            key.to_string(),
491            Cell {
492                key: key.to_string(),
493                expr: CellExpr::Literal(CellValue::Number(n)),
494            },
495        )
496    }
497
498    fn formula(key: &str, e: Expr) -> (String, Cell) {
499        (
500            key.to_string(),
501            Cell {
502                key: key.to_string(),
503                expr: CellExpr::Formula(e),
504            },
505        )
506    }
507
508    fn dag_of(edges: &[(&str, &[&str])]) -> Dag {
509        let mut dag = Dag::new();
510        for (cell, deps) in edges {
511            dag.add_node(cell);
512            for d in *deps {
513                dag.add_edge(cell, d);
514            }
515        }
516        dag
517    }
518
519    fn range(sheet: &str, start: &str, end: &str) -> Expr {
520        Expr::Range(RangeRef {
521            sheet: sheet.to_string(),
522            start: start.to_string(),
523            end: end.to_string(),
524        })
525    }
526
527    fn call(name: &str, args: Vec<Expr>) -> Expr {
528        Expr::Call {
529            name: name.to_string(),
530            args,
531        }
532    }
533
534    #[test]
535    fn literal_is_seeded_and_readable_downstream() {
536        let ir: HashMap<String, Cell> = [
537            lit("S!A1", 3.0),
538            formula("S!B1", Expr::Ref("S!A1".to_string())),
539        ]
540        .into_iter()
541        .collect();
542        let dag = dag_of(&[("S!A1", &[]), ("S!B1", &["S!A1"])]);
543        let out = run(&ir, &dag, &CellEnv::new()).expect("no cycle");
544        assert_eq!(out.computed.get("S!B1"), Some(&CellValue::Number(3.0)));
545    }
546
547    #[test]
548    fn leaf_arithmetic_computes_via_pure_rust() {
549        let ir: HashMap<String, Cell> = [
550            lit("S!A1", 10.0),
551            lit("S!A2", 5.0),
552            formula(
553                "S!C1",
554                Expr::BinaryOp {
555                    left: Box::new(Expr::Ref("S!A1".to_string())),
556                    op: BinOp::Add,
557                    right: Box::new(Expr::Ref("S!A2".to_string())),
558                },
559            ),
560        ]
561        .into_iter()
562        .collect();
563        let dag = dag_of(&[("S!A1", &[]), ("S!A2", &[]), ("S!C1", &["S!A1", "S!A2"])]);
564        let out = run(&ir, &dag, &CellEnv::new()).expect("no cycle");
565        assert_eq!(out.computed.get("S!C1"), Some(&CellValue::Number(15.0)));
566    }
567
568    #[test]
569    fn cycle_is_one_located_finding_not_a_panic() {
570        let ir: HashMap<String, Cell> = [
571            formula("S!A1", Expr::Ref("S!B1".to_string())),
572            formula("S!B1", Expr::Ref("S!A1".to_string())),
573        ]
574        .into_iter()
575        .collect();
576        let mut dag = Dag::new();
577        dag.add_edge("S!A1", "S!B1");
578        dag.add_edge("S!B1", "S!A1");
579        let err = run(&ir, &dag, &CellEnv::new()).expect_err("a cycle must be Err");
580        assert_eq!(err.rule, "dag/cycle");
581        assert_eq!(err.severity, Severity::Error);
582    }
583
584    #[test]
585    fn eval_trace_records_resolved_refs() {
586        let ir: HashMap<String, Cell> = [
587            lit("S!A1", 10.0),
588            lit("S!A2", 5.0),
589            formula(
590                "S!C1",
591                Expr::BinaryOp {
592                    left: Box::new(Expr::Ref("S!A1".to_string())),
593                    op: BinOp::Add,
594                    right: Box::new(Expr::Ref("S!A2".to_string())),
595                },
596            ),
597        ]
598        .into_iter()
599        .collect();
600        let dag = dag_of(&[("S!A1", &[]), ("S!A2", &[]), ("S!C1", &["S!A1", "S!A2"])]);
601        let out = run(&ir, &dag, &CellEnv::new()).expect("no cycle");
602        let trace = out.traces.get("S!C1").expect("a trace for C1");
603        assert_eq!(
604            trace.resolved_refs,
605            vec![
606                ("S!A1".to_string(), CellValue::Number(10.0)),
607                ("S!A2".to_string(), CellValue::Number(5.0)),
608            ]
609        );
610    }
611
612    #[test]
613    fn error_cell_short_circuits_downstream() {
614        let ir: HashMap<String, Cell> = [
615            (
616                "S!A1".to_string(),
617                Cell {
618                    key: "S!A1".to_string(),
619                    expr: CellExpr::Literal(CellValue::Error(ExcelError::Ref)),
620                },
621            ),
622            formula(
623                "S!B1",
624                Expr::BinaryOp {
625                    left: Box::new(Expr::Ref("S!A1".to_string())),
626                    op: BinOp::Add,
627                    right: Box::new(Expr::Number(1.0)),
628                },
629            ),
630        ]
631        .into_iter()
632        .collect();
633        let dag = dag_of(&[("S!A1", &[]), ("S!B1", &["S!A1"])]);
634        let out = run(&ir, &dag, &CellEnv::new()).expect("no cycle");
635        assert_eq!(
636            out.computed.get("S!B1"),
637            Some(&CellValue::Error(ExcelError::Ref))
638        );
639    }
640
641    #[test]
642    fn sum_range_1d_column_major() {
643        let ir: HashMap<String, Cell> = [
644            lit("S!B2", 10.0),
645            lit("S!B3", 20.0),
646            lit("S!B4", 30.0),
647            formula("S!C1", call("SUM", vec![range("S", "B2", "B4")])),
648        ]
649        .into_iter()
650        .collect();
651        let dag = dag_of(&[
652            ("S!B2", &[]),
653            ("S!B3", &[]),
654            ("S!B4", &[]),
655            ("S!C1", &["S!B2", "S!B3", "S!B4"]),
656        ]);
657        let out = run(&ir, &dag, &CellEnv::new()).expect("no cycle");
658        assert_eq!(out.computed.get("S!C1"), Some(&CellValue::Number(60.0)));
659    }
660
661    #[test]
662    fn sum_over_range_member_with_computed_error_propagates_that_error() {
663        // WR-03 regression: B3 COMPUTES #DIV/0! (a formula cell whose result is
664        // an error never enters env — D-04, `to_json` returns None — it lives
665        // only in `errs`), so SUM(B2:B4) must propagate the member's ACTUAL
666        // #DIV/0! — not mis-report the member as an absent-cell #REF!.
667        // (NOTE: a literal `1/0` is NOT usable here — the kernel-parity scalar
668        // evaluator deliberately clamps x/0 to 0.0, see scalar_eval.rs WR-02.)
669        let ir: HashMap<String, Cell> = [
670            lit("S!B2", 10.0),
671            formula("S!B3", Expr::ErrorLit(ExcelError::DivZero)),
672            lit("S!B4", 30.0),
673            formula("S!C1", call("SUM", vec![range("S", "B2", "B4")])),
674        ]
675        .into_iter()
676        .collect();
677        let dag = dag_of(&[
678            ("S!B2", &[]),
679            ("S!B3", &[]),
680            ("S!B4", &[]),
681            ("S!C1", &["S!B2", "S!B3", "S!B4"]),
682        ]);
683        let out = run(&ir, &dag, &CellEnv::new()).expect("no cycle");
684        assert_eq!(
685            out.computed.get("S!C1"),
686            Some(&CellValue::Error(ExcelError::DivZero)),
687            "the member's actual error propagates, not #REF!"
688        );
689        // The trace evidence records the member with ITS error, not #REF!.
690        let trace = out.traces.get("S!C1").expect("a trace for C1");
691        assert!(
692            trace
693                .resolved_refs
694                .iter()
695                .any(|(k, v)| k == "S!B3" && *v == CellValue::Error(ExcelError::DivZero)),
696            "resolved_refs records S!B3 as #DIV/0!, got {:?}",
697            trace.resolved_refs
698        );
699    }
700
701    #[test]
702    fn nested_call_in_binary_op() {
703        let ir: HashMap<String, Cell> = [
704            lit("S!A1", 1.0),
705            lit("S!A2", 2.0),
706            lit("S!A3", 3.0),
707            lit("S!B1", 4.567),
708            formula(
709                "S!C1",
710                Expr::BinaryOp {
711                    left: Box::new(call("SUM", vec![range("S", "A1", "A3")])),
712                    op: BinOp::Add,
713                    right: Box::new(call(
714                        "ROUND",
715                        vec![Expr::Ref("S!B1".to_string()), Expr::Number(2.0)],
716                    )),
717                },
718            ),
719        ]
720        .into_iter()
721        .collect();
722        let dag = dag_of(&[
723            ("S!A1", &[]),
724            ("S!A2", &[]),
725            ("S!A3", &[]),
726            ("S!B1", &[]),
727            ("S!C1", &["S!A1", "S!A2", "S!A3", "S!B1"]),
728        ]);
729        let out = run(&ir, &dag, &CellEnv::new()).expect("no cycle");
730        match out.computed.get("S!C1") {
731            Some(CellValue::Number(n)) => assert!((n - 10.57).abs() < 1e-9, "got {n}"),
732            other => panic!("expected Number(10.57), got {other:?}"),
733        }
734    }
735
736    #[test]
737    fn pow_and_percent_are_not_in_the_evaluator() {
738        let ir: HashMap<String, Cell> = [
739            formula(
740                "S!C1",
741                Expr::BinaryOp {
742                    left: Box::new(Expr::Number(2.0)),
743                    op: BinOp::Pow,
744                    right: Box::new(Expr::Number(3.0)),
745                },
746            ),
747            formula(
748                "S!D1",
749                Expr::UnaryOp {
750                    op: UnOp::Percent,
751                    operand: Box::new(Expr::Number(50.0)),
752                },
753            ),
754        ]
755        .into_iter()
756        .collect();
757        let dag = dag_of(&[("S!C1", &[]), ("S!D1", &[])]);
758        let out = run(&ir, &dag, &CellEnv::new()).expect("no cycle");
759        assert_eq!(out.computed.get("S!C1"), Some(&CellValue::Number(8.0)));
760        assert_eq!(out.computed.get("S!D1"), Some(&CellValue::Number(0.5)));
761    }
762
763    #[test]
764    fn build_dag_includes_range_member_edges() {
765        // SUM over a range must produce one DAG edge PER expanded member (the
766        // server-side hand-rolled walk used to DROP range edges).
767        let ir: HashMap<String, Cell> = [
768            lit("S!B2", 10.0),
769            lit("S!B3", 20.0),
770            lit("S!B4", 30.0),
771            formula("S!C1", call("SUM", vec![range("S", "B2", "B4")])),
772        ]
773        .into_iter()
774        .collect();
775        let dag = build_dag(&ir);
776        let mut deps = dag.dependencies_of("S!C1").to_vec();
777        deps.sort();
778        assert_eq!(
779            deps,
780            vec!["S!B2".to_string(), "S!B3".to_string(), "S!B4".to_string()],
781            "every range member is a DAG edge (not dropped)"
782        );
783        // The built DAG drives a correct topo run.
784        let out = run(&ir, &dag, &CellEnv::new()).expect("no cycle");
785        assert_eq!(out.computed.get("S!C1"), Some(&CellValue::Number(60.0)));
786    }
787
788    #[test]
789    fn unqualified_range_defaults_to_the_owning_cells_sheet() {
790        // WR-04 regression: a RangeRef with an EMPTY sheet (legal per the type;
791        // expand_range supports defaulting) must default onto the OWNING cell's
792        // sheet — the old no-op conditional expanded it to phantom `"!B2"` keys
793        // that never matched env, turning every member into a silent #REF!.
794        let ir: HashMap<String, Cell> = [
795            lit("S!B2", 10.0),
796            lit("S!B3", 20.0),
797            lit("S!B4", 30.0),
798            formula("S!C1", call("SUM", vec![range("", "B2", "B4")])),
799        ]
800        .into_iter()
801        .collect();
802        // build_dag must create edges to the QUALIFIED member keys (not "!B2").
803        let dag = build_dag(&ir);
804        let mut deps = dag.dependencies_of("S!C1").to_vec();
805        deps.sort();
806        assert_eq!(
807            deps,
808            vec!["S!B2".to_string(), "S!B3".to_string(), "S!B4".to_string()],
809            "unqualified range edges are sheet-qualified, not phantom \"!B2\" nodes"
810        );
811        // And the run resolves the members (not a silent #REF!).
812        let out = run(&ir, &dag, &CellEnv::new()).expect("no cycle");
813        assert_eq!(out.computed.get("S!C1"), Some(&CellValue::Number(60.0)));
814    }
815
816    #[test]
817    fn build_dag_ref_edges_drive_a_correct_run() {
818        let ir: HashMap<String, Cell> = [
819            lit("S!A1", 3.0),
820            formula(
821                "S!B1",
822                Expr::BinaryOp {
823                    left: Box::new(Expr::Ref("S!A1".to_string())),
824                    op: BinOp::Add,
825                    right: Box::new(Expr::Number(1.0)),
826                },
827            ),
828        ]
829        .into_iter()
830        .collect();
831        let dag = build_dag(&ir);
832        assert_eq!(dag.dependencies_of("S!B1"), &["S!A1".to_string()]);
833        let out = run(&ir, &dag, &CellEnv::new()).expect("no cycle");
834        assert_eq!(out.computed.get("S!B1"), Some(&CellValue::Number(4.0)));
835    }
836
837    #[test]
838    fn coil_band_ceiling_reconciles_700() {
839        // CEILING(C6 * C17, C18): C6=666, C17=1.05, C18=50 → 700.
840        let ir: HashMap<String, Cell> = [
841            formula(
842                "5_Quantities!C8",
843                call(
844                    "CEILING",
845                    vec![
846                        Expr::BinaryOp {
847                            left: Box::new(Expr::Ref("5_Quantities!C6".to_string())),
848                            op: BinOp::Mul,
849                            right: Box::new(Expr::Ref("2_Constants!C17".to_string())),
850                        },
851                        Expr::Ref("2_Constants!C18".to_string()),
852                    ],
853                ),
854            ),
855            lit("2_Constants!C17", 1.05),
856            lit("2_Constants!C18", 50.0),
857        ]
858        .into_iter()
859        .collect();
860        let mut dag = Dag::new();
861        dag.add_node("5_Quantities!C6");
862        dag.add_node("2_Constants!C17");
863        dag.add_node("2_Constants!C18");
864        dag.add_edge("5_Quantities!C8", "5_Quantities!C6");
865        dag.add_edge("5_Quantities!C8", "2_Constants!C17");
866        dag.add_edge("5_Quantities!C8", "2_Constants!C18");
867        let seed = CellEnv::new().seed_cell("5_Quantities!C6", &CellValue::Number(666.0));
868        let out = run(&ir, &dag, &seed).expect("no cycle");
869        assert_eq!(
870            out.computed.get("5_Quantities!C8"),
871            Some(&CellValue::Number(700.0))
872        );
873    }
874}