Skip to main content

frazaro_core/reflect/
calc.rs

1//! `frazaro calc` (KERNEL.7, 2026-10-08): a workbook's formulas computed by
2//! the language's evaluator under Excel's library, each against the value
3//! the host saved into the file.
4//!
5//! The reader streams the file's rows into a grid, `vla-lang`'s sheet
6//! model, the inverse of what `reflect` prints: a `cell` row a constant, a
7//! `formula` row the formula with the `cell` row before it kept aside as the
8//! host's cached value, a `name` row a defined name. The evaluator
9//! (`vla_lang::calc::Calc`) then computes every formula in dependency order,
10//! and the door prints one `calc` row per formula cell, sheets in tab order
11//! and cells in row-major order:
12//!
13//! ```text
14//! (cycle "Model!A1" "Model!B1")                        every cycle first, its cells
15//! (calc "Output" "C2" "=B2+B3" 15 15 agree)            computed, cached, the verdict
16//! (calc "Output" "E2" "=VLOOKUP(1,A:B,2)" (not-computed "VLOOKUP") 3 unchecked)
17//! (calc "Checks" "A1" "=Model!B3*2" 800 none unchecked)   a file with no cached value
18//! ```
19//!
20//! The verdict is `agree` when the computed value and the cached one are
21//! the same under the one stated tolerance (`vla_lang::calc::agrees`:
22//! fifteen significant digits for a number, Excel's documented precision),
23//! `differ` when both exist and are not, `unchecked` when the file holds no
24//! cached value, the formula was not computed, or the cached value is one
25//! this version does not compare. This is the treaty's twelfth oracle: the
26//! goldens under `scripts/recalc/` are this door's own output over the
27//! fixtures, and `scripts/reflect/saved.xlsx`, saved by Excel 365, is the
28//! first whose every row must `agree`. A cycle, an unreadable reference and
29//! a function outside the subset are each named in their cell's row and
30//! refuse nothing here: a workbook is read as it is, which is what an
31//! auditor needs; the engine's `load` is where they refuse
32//! (`vla_lang::calc::Calc::first_refusal`).
33//!
34//! With `--shapes` the door computes nothing and prints the plan an
35//! engine's machine makes of the same grid, its census (KERNEL.30,
36//! [`census_of`]): a `(plan ...)` row of totals, a `(readers ...)` row a
37//! sheet and a `(shape ...)` row a shape, each read named by the path the
38//! machine takes for it, so that a model's cost is read before anything
39//! computes it.
40
41use std::collections::BTreeMap;
42
43use vla_lang::calc::{agrees, Calc, CellId, Computed, Extent, Library, Names, Reason, Shapes};
44use vla_lang::rows::{datum, quoted, Row, Sink, Value};
45use vla_lang::sheet::{cell_ref, parse_a1_range, Cell, Content, Sheet, Workbook};
46
47use super::{open, Discard};
48use crate::messages::Refusal;
49
50/// A workbook read into the grid, with the host's cached values beside it.
51#[derive(Debug, Default)]
52pub struct Collected {
53    pub model: Workbook,
54    /// The value the file holds for each formula cell, where it holds one.
55    pub cached: BTreeMap<CellId, Value>,
56}
57
58impl Collected {
59    fn place(&mut self, sheet: &str, addr: &str) -> Option<CellId> {
60        let i = self.model.ensure_sheet(sheet);
61        let r = parse_a1_range(addr)?;
62        Some((i, r.top, r.left))
63    }
64}
65
66impl Sink for Collected {
67    fn row(&mut self, row: &Row<'_>) {
68        match row {
69            Row::Sheet { name, .. } => {
70                self.model.ensure_sheet(name);
71            }
72            Row::Name { name, refers_to } => {
73                self.model
74                    .defined_names
75                    .push((name.to_string(), refers_to.to_string()));
76            }
77            Row::Table { .. } | Row::Refers { .. } => {}
78            Row::Cell { sheet, addr, value } => {
79                let Some(id) = self.place(sheet, addr) else {
80                    return;
81                };
82                let content = match value {
83                    Value::Number(text) => match text.trim().parse::<f64>() {
84                        Ok(n) if n.is_finite() => Content::Number(n),
85                        _ => Content::Text(text.clone()),
86                    },
87                    Value::Text(t) => Content::Text(t.clone()),
88                    Value::Bool(b) => Content::Bool(*b),
89                    Value::Error(e) => Content::Error(e.clone()),
90                    Value::Date(d) => Content::Text(d.clone()),
91                };
92                self.model.sheets[id.0].set(id.1, id.2, Cell { content, style: 0 });
93                self.cached.insert(id, (*value).clone());
94            }
95            Row::Formula { sheet, addr, text } => {
96                let Some(id) = self.place(sheet, addr) else {
97                    return;
98                };
99                // The cell row before this one, if any, was the host's
100                // cached value, which stays in `cached` alone.
101                let body = text.strip_prefix('=').unwrap_or(text);
102                self.model.sheets[id.0].set(
103                    id.1,
104                    id.2,
105                    Cell {
106                        content: Content::Formula(body.to_string()),
107                        style: 0,
108                    },
109                );
110            }
111        }
112    }
113}
114
115/// The counts `--counts` prints.
116#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
117pub struct Counts {
118    pub formulas: u64,
119    pub computed: u64,
120    pub not_computed: u64,
121    pub cycles: u64,
122    pub agree: u64,
123    pub differ: u64,
124    pub unchecked: u64,
125}
126
127impl Counts {
128    /// The counts as one line's words.
129    pub fn line(&self) -> String {
130        format!(
131            "formulas {} computed {} not-computed {} cycles {} agree {} differ {} unchecked {}",
132            self.formulas,
133            self.computed,
134            self.not_computed,
135            self.cycles,
136            self.agree,
137            self.differ,
138            self.unchecked
139        )
140    }
141}
142
143/// The file read into the grid: every sheet walked, the constants kept in
144/// the model and the cached values beside it; a constant-only workbook has
145/// nothing to compute and prints nothing.
146pub fn collect(bytes: &[u8], label: &str) -> Result<Collected, Refusal> {
147    let package = open(bytes, label)?;
148    let mut collected = Collected::default();
149    package.header_rows(&mut collected);
150    // Names arrive before cells, in the header; the walk adds the cells.
151    for i in 0..package.sheets().len() {
152        package.walk_sheet(i, &mut collected)?;
153    }
154    let _ = Discard;
155    Ok(collected)
156}
157
158/// A collected workbook's census (KERNEL.30, `frazaro calc --shapes`): the
159/// grid planned as an engine's machine plans one, by `Shapes::of` under the
160/// library with the grid's names and extents, every sheet read as the
161/// frame's own, a workbook holding no `.last` twin, and nothing computed. A
162/// cycle, a function the library does not hold and a construct not read
163/// are printed as facts of the plan, where a machine's load would refuse
164/// the grid. The rows end in a newline each.
165pub fn census_of(collected: &Collected, library: &Library) -> String {
166    let model = &collected.model;
167    let extents: Vec<Extent> = model.sheets.iter().map(Sheet::extent).collect();
168    let plan = Shapes::of(
169        model,
170        model.sheets.len(),
171        library,
172        &Names::of(model),
173        &extents,
174    );
175    plan.census(model, library)
176}
177
178/// The rows and the counts of one recalculation of a collected workbook.
179pub fn rows_of(collected: &Collected, library: &Library) -> (String, Counts) {
180    let calc = Calc::run(&collected.model, library);
181    let mut out = String::new();
182    let mut counts = Counts::default();
183    for cycle in calc.cycles() {
184        counts.cycles += 1;
185        let cells: Vec<String> = cycle
186            .iter()
187            .map(|id| quoted(&calc.spell_cell(*id)))
188            .collect();
189        out.push_str(&format!("(cycle {})\n", cells.join(" ")));
190    }
191    for id in calc.formula_cells() {
192        counts.formulas += 1;
193        let sheet = &collected.model.sheets[id.0].name;
194        let addr = cell_ref(id.1, id.2);
195        let formula = format!("={}", calc.formula(id).unwrap_or(""));
196        let computed = calc
197            .computed(id)
198            .cloned()
199            .unwrap_or(Computed::NotComputed(Reason::Cycle));
200        let cached = collected.cached.get(&id);
201        let verdict = match (&computed, cached) {
202            (Computed::Value(v), Some(c)) => match agrees(v, c) {
203                Some(true) => "agree",
204                Some(false) => "differ",
205                None => "unchecked",
206            },
207            _ => "unchecked",
208        };
209        match &computed {
210            Computed::Value(_) => counts.computed += 1,
211            Computed::NotComputed(_) => counts.not_computed += 1,
212        }
213        match verdict {
214            "agree" => counts.agree += 1,
215            "differ" => counts.differ += 1,
216            _ => counts.unchecked += 1,
217        }
218        out.push_str(&format!(
219            "(calc {} {} {} {} {} {})\n",
220            quoted(sheet),
221            quoted(&addr),
222            quoted(&formula),
223            computed.spell(),
224            cached.map(datum).unwrap_or_else(|| "none".to_string()),
225            verdict
226        ));
227    }
228    (out, counts)
229}
230
231/// The whole of a workbook's recalculation as text, one row a line in the
232/// fixed order: the API's surface and the goldens'.
233pub fn calc_text(bytes: &[u8], label: &str, library: &Library) -> Result<String, Refusal> {
234    let collected = collect(bytes, label)?;
235    Ok(rows_of(&collected, library).0)
236}
237
238/// The counts alone.
239pub fn calc_counts(bytes: &[u8], label: &str, library: &Library) -> Result<Counts, Refusal> {
240    let collected = collect(bytes, label)?;
241    Ok(rows_of(&collected, library).1)
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247    use crate::excel;
248
249    const SAVED: &[u8] = include_bytes!("../../../scripts/reflect/saved.xlsx");
250    const FIXTURE: &[u8] = include_bytes!("../../../scripts/reflect/fixture.xlsx");
251    const BUILT: &[u8] = include_bytes!("../../../scripts/build/fixture_golden.xlsx");
252
253    #[test]
254    fn the_file_excel_saved_agrees_on_every_formula() {
255        let lib = excel::library();
256        let text = calc_text(SAVED, "saved.xlsx", &lib).unwrap();
257        let rows: Vec<&str> = text.lines().collect();
258        assert_eq!(rows.len(), 5, "{text}");
259        for row in &rows {
260            assert!(row.ends_with(" agree)"), "{row}");
261        }
262        assert!(
263            rows.contains(&"(calc \"Output\" \"C2\" \"=B2+B3\" 15 15 agree)"),
264            "{text}"
265        );
266        assert!(
267            rows.contains(&"(calc \"Output\" \"D2\" \"=IFS(B2>3,\\\"big\\\",TRUE,\\\"small\\\")\" \"big\" \"big\" agree)"),
268            "{text}"
269        );
270        let counts = calc_counts(SAVED, "saved.xlsx", &lib).unwrap();
271        assert_eq!(
272            counts,
273            Counts {
274                formulas: 5,
275                computed: 5,
276                not_computed: 0,
277                cycles: 0,
278                agree: 5,
279                differ: 0,
280                unchecked: 0
281            }
282        );
283    }
284
285    /// KERNEL.20's fifth slice: the core's own build holds the value of each
286    /// formula it computes, so every row agrees with the value it folded.
287    #[test]
288    fn the_core_s_own_build_holds_the_values_it_computes_and_agrees() {
289        let lib = excel::library();
290        let text = calc_text(BUILT, "fixture_golden.xlsx", &lib).unwrap();
291        for row in text.lines() {
292            assert!(row.ends_with(" agree)"), "{row}");
293        }
294        assert!(
295            text.contains("(calc \"Output\" \"B3\" \"=B2*2\" 10 10 agree)"),
296            "{text}"
297        );
298    }
299
300    #[test]
301    fn the_reflect_fixture_names_what_this_slice_does_not_compute() {
302        let lib = excel::library();
303        let text = calc_text(FIXTURE, "fixture.xlsx", &lib).unwrap();
304        let has = |s: &str| assert!(text.contains(s), "missing {s} in\n{text}");
305        has("(calc \"Model\" \"C1\" \"=B1*2\" 2400 2400 agree)");
306        has("(calc \"Model\" \"D1\" \"=SUM(B:B)\"");
307        has("(not-computed \"Sales[Amount]\")");
308        has("(not-computed \"SEQUENCE\")");
309        has("(not-computed \"INDIRECT\")");
310        has("(not-computed \"OFFSET\")");
311        has("(not-computed \"[Rates.xlsx]Sheet1!A1\")");
312        has("(not-computed \"B1:B2\")");
313        has("(calc \"Model\" \"E5\" \"=1/0\" (error \"#DIV/0!\") (error \"#DIV/0!\") agree)");
314        has("(calc \"Model\" \"B4\" \"=B3*Rate\" 80 80 agree)");
315        has("(calc \"Model\" \"G2\" \"=Local\" 800 800 agree)");
316        has("(calc \"Model\" \"D4\" \"=SUM(Model:Scratch!B1)\"");
317        let counts = calc_counts(FIXTURE, "fixture.xlsx", &lib).unwrap();
318        assert_eq!(counts.differ, 0, "{text}");
319        assert_eq!(counts.cycles, 0);
320        assert!(counts.agree >= 15, "{}", counts.line());
321    }
322}