diff --git a/context/code/improvise-incremental.md b/context/code/improvise-incremental.md index 7e033151..e5a8ff1c 100644 --- a/context/code/improvise-incremental.md +++ b/context/code/improvise-incremental.md @@ -216,3 +216,67 @@ taken where clause 4 and clause 7 leave a choice; evidence is in - Eviction of shapes, and the build as a sliced job. - `Sum`, `Avg` (step 6) and formulas (step 5) are eager's, behind the same trait. - The ADR 008 fixture measurement. + +## Step 5: formulas (improvise-shbc) + +`Incremental` no longer calls `recompute_formulas`. It keeps the formula cache (`Model::formula_cell`, +`set_formula_cell`, `evaluate_formula_cell`, the three small additions to `improvise-core`) and decides +which cells of it to evaluate again; the evaluator that computes each value is core's own. The ADR +(clauses 5 and 6) is followed in outline, and the places it is not are the reason for this section. + +1. **A stem is the unit, because eager's cache is per stem.** `Stems` (`formulas.rs`) holds, per + target category, how many stored cells give each stem (a cell minus that category and the + None categories, as `recompute_formulas` derives them), and which stems at least one category + gives (`live`). Counts come from the raw store changes, one net count per stem per category per + batch, so an overwrite never makes a stem vanish and reappear. A stem that appears is + evaluated; one that goes has its cells removed. +2. **A stem's cells are evaluated from nothing.** `recompute_formulas` clears the cache and runs + fixed-point passes over every stem together. When no formula reads another stem's cache the stems + are independent, so running the same passes on one stem from an empty cache gives exactly what + the whole run gives for it, including `circular` for a cell still moving after sixteen + passes. That is why a dirty stem is cleared first (`passes-start-warm` shows what a warm start + changes). Where they are not independent (`Stems::is_wide`: two target categories, or a name in + another category that is not None) the backend falls back to `recompute_formulas` for the whole + cache, so those documents are exact and not incremental. +3. **Which stems a change reaches is a superset, read off the formulas.** For formula `f`, + `Reads` is the set of names in its expression and in the formulas those names target, and the + categories that puts in play (its target's, every name's, every `WHERE`'s and `IS`'s). A stored + cell can change what `f` evaluates to at stem `s` only if it agrees with `s` everywhere outside + those categories. `Readers` indexes each live stem by what is left of it outside them, per + category set, so a changed cell finds its stems by looking up its own projection on each set: + no scan of the stems. This is clause 5's input rule made coarser, once for every shape of + formula: it re-evaluates a stem for a cell of an item the formula never names (a change to a + `Note` cell evaluates the stems `Profit` has). It cannot miss one, which is what the aggregate + over an expression needs, since the cells it reads are chosen at run time by the records. +4. **The aggregate-over-expression pipeline (clause 5, items 1 to 3) is not built.** A dirty stem + evaluates its formulas whole, so `SUM(Revenue * Price)` is re-folded over its records when any + cell under the stem changes, not adjusted for the one record. That needs the evaluator to + accept a maintained aggregate in place of folding, which is a change to core's evaluator and + to the order its records are visited in (insertion order, not `CellKey` order). It is a bead. +5. **Dependency order is an optimisation and is checked against the plain passes.** `Order` is the + formulas' dependency graph by name. When it has no cycle and there are fewer formulas than + passes, one evaluation per cell in dependency order reaches the fixed point eager's passes + reach (a chain of fifteen links needs fifteen passes and a sixteenth to see no change). It + differs from the passes in one place: a cell holding NaN is never equal to itself, so eager's passes + never see it settle and call it `circular`, and the cells that read it keep what they computed + from the NaN. `mark_nan_circular` does the same afterwards. `Evaluation::Plain` always runs + the passes; `tests/ordering.rs` compares the two and `tests/formulas.rs` compares both with + eager. +6. **A formula edit rebuilds the stems.** Adding, removing or replacing a formula (the fingerprint + of the formula list moves) rebuilds the stems from the store by one pass, clears the cache and + evaluates every stem, because a formula has a cell at each. Clause 5 wants a formula delta; + the index is not maintained across a formula edit. `Meter::rebuilds` counts it. +7. **A new aggregation dirties every stem if a formula reads that measure.** `measure_agg` is not + a cell, so no change reaches the readers; `reads_name` asks the formulas. +8. **A write of the same bits changes nothing; a write of `-0.0` over `0.0` does.** The cell delta + treats the two as one value (clause 3), but a formula may not (`Units ^ -1` is `inf` and + `-inf`), so the formula side reads the raw change log, not the canonical delta. + +### Not built in step 5 + +- The aggregate-over-expression pipeline and the per-record order key (item 4). +- Stems that survive a formula edit (item 6), and name-level selectivity within a stem (item 3). +- Incremental cache for documents with formulas in more than one target category, or a name in + a non-None category other than the formula's own (item 2): they use the whole recompute. +- Strongly connected components as a maintained structure: `Order` is rebuilt with the stems, which + is O(formulas) on a formula edit, and cycles are found by depth-first search, not Tarjan's. diff --git a/context/test-evidence/improvise-incremental.md b/context/test-evidence/improvise-incremental.md index ed7c03bd..b2fe4414 100644 --- a/context/test-evidence/improvise-incremental.md +++ b/context/test-evidence/improvise-incremental.md @@ -448,3 +448,151 @@ The smallest of the minima, the largest of the maxima and the sum of the counts. ### group::tests::text_is_unanimous_or_mixed_across_groups One shared text is that text, two are `mixed`, a number among text is `mixed`, and so is a NaN. + +## Step 5: the formula cache (improvise-shbc) + +**How the faults were injected.** Twenty-seven faults went into `src/formulas.rs` and +`src/incremental.rs`, one at a time, each run against the whole crate and reverted before the next. +A first pass left eight alive; five were a gap in the tests and were closed by the tests below, and +the remaining two (`born-stems-not-evaluated`, `target-category-not-read`) cannot change an answer. +What each fault was and the test that names it: +- a cell that gives a stem is not enough to evaluate it / no reader is dirtied: + `a_cell_edit_evaluates_the_one_stem_that_reads_it_whatever_the_table_size`, + `every_formula_in_the_pool_is_added_edited_around_and_removed_like_eager` and six others; +- a stem that no cell gives keeps its cells: `a_new_record_adds_its_stem_and_a_cleared_one_takes_it_away`; +- reads skip the formulas a name targets: `formulas::tests::what_a_formula_reads_includes_what_the_formulas_it_names_read`; +- reads skip the filters' categories: `a_filter_on_a_category_the_stem_has_reads_the_cells_of_other_stems`; +- reads skip the formula's own target name: + `a_new_aggregation_for_the_measure_a_filtered_formula_falls_back_to_changes_it`; +- the formula set is never wide / one target category is never wide / a name in another category is + never wide: `two_target_categories_read_each_others_stems_whatever_the_order_they_are_added_in`, + `a_name_in_another_category_reads_another_stems_cell`; +- every order settles in one pass / a cycle is never seen / sixteen formulas settle in one pass: + `formulas::tests::a_self_reference_and_a_mutual_reference_are_cyclic`, + `formulas::tests::sixteen_formulas_leave_no_pass_to_spare`, + `a_formula_that_keeps_moving_for_sixteen_passes_is_circular`, + `dependency_order_and_the_plain_passes_leave_the_same_cache`; +- dependencies after dependents: `dependency_order_and_the_plain_passes_leave_the_same_cache` and five more; +- NaN is not marked circular: `a_formula_cell_holding_nan_is_circular_and_the_cells_that_read_it_keep_the_nan_they_read`; +- fifteen passes / seventeen passes: `a_chain_added_last_link_first_is_circular_exactly_where_eager_says`; +- the passes end without marking the cells still moving, or begin from the old cache: + `a_chain_added_last_link_first_is_circular_exactly_where_eager_says`, + `a_formula_that_keeps_moving_for_sixteen_passes_is_circular`; +- a new aggregation dirties nothing: `incremental_matches_eager_on_the_hand_model_under_every_aggregation`; +- a write of the same value over a signed zero is no change: + `overwriting_zero_with_negative_zero_changes_what_a_formula_reads`; +- a formula replaced by one of the same target is no change: `a_formula_replaced_in_place_is_evaluated_afresh`; +- stems keep the None categories: eleven tests, the first `a_chain_added_last_link_first_is_circular_exactly_where_eager_says`; +- a removed cell counts as an added one: `a_new_record_adds_its_stem_and_a_cleared_one_takes_it_away`; +- a wide document is evaluated by stem, a rebuild keeps the old cells, a removed last formula keeps + the cache: `every_formula_in_the_pool_is_added_edited_around_and_removed_like_eager`, + `incremental_matches_eager_on_a_pooled_total_of_a_ratio`. + +**Faults that cannot be seen, and why.** A born stem is found again by the lookup of the cell that +gave it, since the cell agrees with the stem outside the target category, so skipping the extra +"born means dirty" step changes nothing. The target category being in a formula's reads cannot +matter either: stems are derived by removing it, so no stem has it to drop. + +**What the random tests found, once each, before the rules in the reasoning file existed.** NaN as +a formula result (eager calls it `circular`; a one-pass order did not), a stem containing a formula +target's coordinate (stored data at a target, a second target category: the stems then read each +other), and, from the fixture shrinking to one product, a filter over the stem's own category. + +### a_cell_edit_evaluates_the_one_stem_that_reads_it_whatever_the_table_size + +At 12 and 48 products, an overwrite of one product's Revenue evaluates one stem and two formula +cells (`Profit`, `Margin`) and rebuilds nothing; eager evaluates every stem for every pass. Counts +are operations. Fails when no reader is dirtied. + +### a_formula_edit_evaluates_every_stem_once_and_rebuilds_the_index_once + +A new formula has a cell at every stem, so every stem is evaluated once and the stems are rebuilt once. + +### a_new_record_adds_its_stem_and_a_cleared_one_takes_it_away + +Records appear in and disappear from the store; their stems appear and go with their formula cells. + +### every_formula_in_the_pool_is_added_edited_around_and_removed_like_eager + +Each of the 18 formulas (chains, a cycle, `SUM` over an expression, a share, filters, `IS`, a +reciprocal, two that target Region) added, the cells around it edited and the formula removed, asking +everything the view draws at every step. + +### a_formula_that_keeps_moving_for_sixteen_passes_is_circular + +`Loop = Loop + 1` with a value stored at its target never settles and is `circular`; without a value +it settles on its first error, `ref:Loop`, and eager agrees with both. + +### a_chain_added_last_link_first_is_circular_exactly_where_eager_says + +Chains of 14 to 18 formulas each referring to the one before, added last first: eager's passes make +one link of progress per pass, so the longer chains are `circular`. Fails when the pass bound moves +by one in either direction and when dependency order is not limited to fewer formulas than passes. + +### a_formula_replaced_in_place_is_evaluated_afresh + +`Profit` redefined keeps its position and moves its cells; the fingerprint of the formula list +includes the text. + +### overwriting_zero_with_negative_zero_changes_what_a_formula_reads + +`Units ^ -1` over a sum of zero is `inf` and, once both cells are `-0.0`, `-inf`. The cell delta calls +the two zeros equal, so the formula side reads the raw change log. + +### a_filter_on_a_category_the_stem_has_reads_the_cells_of_other_stems + +`MAX(Revenue WHERE Product = A)` at the stem of product B reads product A's cells. Fails when a +filter's category is not among a formula's reads. + +### two_target_categories_read_each_others_stems_whatever_the_order_they_are_added_in + +A formula targeting Region and one targeting `_Measure`, with a value stored at the second's target: +the first's stems then contain the second's coordinate and read its cache. The documents fall back to +the whole recompute. + +### a_name_in_another_category_reads_another_stems_cell + +`IF(Revenue > 1000, B, Revenue)`: at the stem of a big product it reads the cache of the stem of B, +which is evaluated after it. Fails when such a formula set is not wide. + +### a_formula_cell_holding_nan_is_circular_and_the_cells_that_read_it_keep_the_nan_they_read + +NaN is not equal to itself, so eager's passes never see it settle. Fails when the ordered path leaves it. + +### a_new_aggregation_for_the_measure_a_filtered_formula_falls_back_to_changes_it + +A `WHERE` that does not match falls back to the raw group at the formula's own target, so a new +aggregation for that measure changes the formula. Fails when a formula's target is not among its reads. + +### dependency_order_and_the_plain_passes_leave_the_same_cache + +Up to four formulas drawn from the pool (cycles and Region-targeted ones among them), told to a +backend that evaluates in dependency order and one that always runs eager's passes; their models, +cache included, print alike. This is the test clause 6 asks for. + +### the_formula_fixture_matches_eager_over_random_formula_and_cell_edits + +Cells written and cleared (including NaN and `-0.0`, a region and product the fixture lacks, and +cells at formula targets), pool formulas added and removed, and new aggregations; read with Region +folded and no view. Found the NaN and multiple-target cases above. + +### what_the_formula_fixture_draws_matches_eager_over_random_edits + +The same edits, asking everything the fixture's view draws at each step. + +### formulas::tests::a_chain_is_ordered_dependencies_first_and_is_not_cyclic + +`Margin = Profit / Revenue` listed before `Profit`: dependency order puts `Profit` first. + +### formulas::tests::a_self_reference_and_a_mutual_reference_are_cyclic + +`X = X + 1` and `A = B + 1, B = A + 1` have no order. + +### formulas::tests::sixteen_formulas_leave_no_pass_to_spare + +Fifteen formulas in a chain settle in one ordered pass; sixteen do not, because eager needs the +sixteenth pass to see nothing move. + +### formulas::tests::what_a_formula_reads_includes_what_the_formulas_it_names_read + +`Margin` names `Profit`, which names `Cost`; the reads of `Margin` include `Cost`. diff --git a/crates/improvise-incremental/src/formulas.rs b/crates/improvise-incremental/src/formulas.rs index 4258ce17..78f100fd 100644 --- a/crates/improvise-incremental/src/formulas.rs +++ b/crates/improvise-incremental/src/formulas.rs @@ -37,9 +37,8 @@ fn walk(expr: &Expr, names: &mut BTreeSet, filters: &mut BTreeSet, @@ -59,11 +58,15 @@ fn reads_of(model: &Model, at: usize) -> Reads { walk(&f.expr, &mut names, &mut filters); filters.extend(f.filter.iter().map(|x| x.category.clone())); reads.cats.insert(f.target_category.clone()); + reads.names.insert(f.target.clone()); reads.cats.extend(filters); for name in names { let resolved = model.find_item_category(&name).map(String::from); reads.cats.extend(resolved); - let targets = formulas.iter().enumerate().filter(|(_, g)| g.target == name); + let targets = formulas + .iter() + .enumerate() + .filter(|(_, g)| g.target == name); todo.extend(targets.map(|(j, _)| j)); reads.names.insert(name); } @@ -213,15 +216,19 @@ impl Stems { pub fn build(model: &Model, none_cats: &[String]) -> Stems { let formulas = model.formulas(); let reads: Vec = (0..formulas.len()).map(|i| reads_of(model, i)).collect(); - let targets: BTreeSet<&str> = formulas.iter().map(|f| f.target_category.as_str()).collect(); - let wide = targets.len() > 1 || formulas.iter().any(|f| { - let (mut names, mut filters) = (BTreeSet::new(), BTreeSet::new()); - walk(&f.expr, &mut names, &mut filters); - names.iter().any(|n| { - let cat = model.find_item_category(n); - cat.is_some_and(|c| c != f.target_category && !none_cats.iter().any(|x| x == c)) - }) - }); + let targets: BTreeSet<&str> = formulas + .iter() + .map(|f| f.target_category.as_str()) + .collect(); + let wide = targets.len() > 1 + || formulas.iter().any(|f| { + let (mut names, mut filters) = (BTreeSet::new(), BTreeSet::new()); + walk(&f.expr, &mut names, &mut filters); + names.iter().any(|n| { + let cat = model.find_item_category(n); + cat.is_some_and(|c| c != f.target_category && !none_cats.iter().any(|x| x == c)) + }) + }); let mut stems = Stems { none_cats: none_cats.to_vec(), target_cats: formulas.iter().map(|f| f.target_category.clone()).collect(), @@ -313,7 +320,12 @@ impl Stems { pub fn step(&mut self, changes: &[&CellChange]) -> Moved { let presence: Vec<(&CellKey, i64)> = changes .iter() - .map(|c| (&c.key, i64::from(c.new.is_some()) - i64::from(c.old.is_some()))) + .map(|c| { + ( + &c.key, + i64::from(c.new.is_some()) - i64::from(c.old.is_some()), + ) + }) .collect(); let (born, gone) = self.count(&presence); let mut dirty: BTreeSet = born.into_iter().collect(); diff --git a/crates/improvise-incremental/src/incremental.rs b/crates/improvise-incremental/src/incremental.rs index 1ca6dd27..990eaabd 100644 --- a/crates/improvise-incremental/src/incremental.rs +++ b/crates/improvise-incremental/src/incremental.rs @@ -266,7 +266,8 @@ impl Incremental { let new = self.evaluate_cell(key); if self.model.formula_cell(key) != new.as_ref() { moving.push(key); - self.model.set_formula_cell(key.clone(), new.or_else(|| self.keep(key))); + self.model + .set_formula_cell(key.clone(), new.or_else(|| self.keep(key))); } } if moving.is_empty() { diff --git a/crates/improvise-incremental/tests/formulas.proptest-regressions b/crates/improvise-incremental/tests/formulas.proptest-regressions deleted file mode 100644 index 61fb2c20..00000000 --- a/crates/improvise-incremental/tests/formulas.proptest-regressions +++ /dev/null @@ -1,7 +0,0 @@ -# Seeds for failure cases proptest has generated in the past. It is -# automatically read and these particular cases re-run before any -# novel cases are generated. -# -# It is recommended to check this file in to source control so that -# everyone who runs the test benefits from these saved cases. -cc afa9f2dae4083512a61fc43079707ae6fef3a5893d4e31291f5a8d7a1abb0021 # shrinks to order = [10], by_view_none = false diff --git a/crates/improvise-incremental/tests/formulas.rs b/crates/improvise-incremental/tests/formulas.rs index d4c9ce68..c7706ab9 100644 --- a/crates/improvise-incremental/tests/formulas.rs +++ b/crates/improvise-incremental/tests/formulas.rs @@ -52,8 +52,14 @@ fn a_formula_that_keeps_moving_for_sixteen_passes_is_circular() { ]) }; let circular = Some(CellValue::Error("circular".into())); - assert_eq!(backend.value(&at("Loop"), Aggregation::OverHidden), circular); - assert_eq!(backend.value(&at("Ping"), Aggregation::OverHidden), circular); + assert_eq!( + backend.value(&at("Loop"), Aggregation::OverHidden), + circular + ); + assert_eq!( + backend.value(&at("Ping"), Aggregation::OverHidden), + circular + ); let unseeded = Some(CellValue::Error("ref:Loop".into())); let b = CellKey::new(vec![ ("Product".into(), "B".into()), @@ -114,7 +120,11 @@ fn a_cell_edit_evaluates_the_one_stem_that_reads_it_whatever_the_table_size() { backend.apply(&bump("East", "P3", "Revenue", 50.0)); let spent = backend.meter(); assert_eq!(spent.stems - before.stems, 1, "{products} products"); - assert_eq!(spent.formula_cells - before.formula_cells, 2, "Profit and Margin"); + assert_eq!( + spent.formula_cells - before.formula_cells, + 2, + "Profit and Margin" + ); assert_eq!(spent.rebuilds, before.rebuilds, "no stem index is rebuilt"); } } @@ -131,16 +141,112 @@ fn a_formula_edit_evaluates_every_stem_once_and_rebuilds_the_index_once() { } #[test] -fn a_formula_replaced_in_place_is_evaluated_afresh_and_a_signed_zero_is_a_change() { +fn a_formula_replaced_in_place_is_evaluated_afresh() { let wb = formula_workbook(); let none = vec!["Region".to_string()]; let swapped = parse_formula("Profit = Revenue + Cost", "_Measure").unwrap(); + for by in [Evaluation::Ordered, Evaluation::Plain] { + same_model(&wb.model, &none, &[Edit::AddFormula(swapped.clone())], by).unwrap(); + } +} + +#[test] +fn overwriting_zero_with_negative_zero_changes_what_a_formula_reads() { + let wb = formula_workbook(); + let none = vec!["Region".to_string()]; + let mut edits = vec![formula_edit(16)]; + edits.extend(["East", "West"].map(|r| bump(r, "A", "Units", 0.0))); + edits.extend(["East", "West"].map(|r| bump(r, "A", "Units", -0.0))); + for by in [Evaluation::Ordered, Evaluation::Plain] { + same_model(&wb.model, &none, &edits, by).unwrap(); + } +} + +#[test] +fn a_filter_on_a_category_the_stem_has_reads_the_cells_of_other_stems() { + let wb = formula_workbook(); + let none = vec!["Region".to_string()]; let edits = [ - Edit::AddFormula(swapped), - formula_edit(16), - bump("East", "A", "Units", 0.0), - bump("East", "A", "Units", -0.0), - bump("West", "A", "Units", -0.0), + bump("East", "B", "Revenue", 5.0), + bump("West", "B", "Cost", 2.0), + formula_edit(9), + bump("East", "A", "Revenue", 100.0), + ]; + for by in [Evaluation::Ordered, Evaluation::Plain] { + same_model(&wb.model, &none, &edits, by).unwrap(); + } +} + +#[test] +fn two_target_categories_read_each_others_stems_whatever_the_order_they_are_added_in() { + let wb = formula_workbook(); + let none = none_of(&wb); + let edits = [ + formula_edit(14), + bump("East", "A", "Loop", 0.0), + formula_edit(6), + ]; + for by in [Evaluation::Ordered, Evaluation::Plain] { + same_model(&wb.model, &none, &edits, by).unwrap(); + } +} + +#[test] +fn a_name_in_another_category_reads_another_stems_cell() { + let mut wb = formula_workbook(); + wb.model.category_mut("Product").unwrap().add_item("B"); + let none = vec!["Region".to_string()]; + let pf = parse_formula("Pf = IF(Revenue > 1000, B, Revenue)", "_Measure").unwrap(); + let edits = [ + bump("East", "A", "Revenue", 5000.0), + bump("East", "B", "Revenue", 7.0), + Edit::AddFormula(pf), + bump("East", "B", "Revenue", 9.0), + ]; + for by in [Evaluation::Ordered, Evaluation::Plain] { + same_model(&wb.model, &none, &edits, by).unwrap(); + } +} + +#[test] +fn a_formula_cell_holding_nan_is_circular_and_the_cells_that_read_it_keep_the_nan_they_read() { + let wb = formula_workbook(); + let none = vec!["Region".to_string()]; + let nan = bump("East", "A", "Revenue", f64::NAN); + let mut backend = Incremental::new(wb.model.clone(), none.clone()); + backend.apply(&nan); + let at = |m: &str| { + CellKey::new(vec![ + ("Product".into(), "A".into()), + ("_Measure".into(), m.into()), + ]) + }; + let circular = Some(CellValue::Error("circular".into())); + assert_eq!( + backend.value(&at("Profit"), Aggregation::OverHidden), + circular + ); + assert_eq!( + backend.value(&at("Margin"), Aggregation::OverHidden), + circular + ); + for by in [Evaluation::Ordered, Evaluation::Plain] { + same_model(&wb.model, &none, std::slice::from_ref(&nan), by).unwrap(); + } +} + +#[test] +fn a_new_aggregation_for_the_measure_a_filtered_formula_falls_back_to_changes_it() { + let wb = formula_workbook(); + let none = vec!["Region".to_string()]; + let edits = [ + formula_edit(4), + bump("East", "A", "Bonus", 3.0), + bump("West", "A", "Bonus", 4.0), + Edit::SetMeasureAgg { + measure: "Bonus".into(), + func: improvise_core::formula::ast::AggFunc::Count, + }, ]; for by in [Evaluation::Ordered, Evaluation::Plain] { same_model(&wb.model, &none, &edits, by).unwrap(); diff --git a/crates/improvise-incremental/tests/support/mod.rs b/crates/improvise-incremental/tests/support/mod.rs index 60c7276a..a21a31bc 100644 --- a/crates/improvise-incremental/tests/support/mod.rs +++ b/crates/improvise-incremental/tests/support/mod.rs @@ -237,7 +237,7 @@ pub fn hidden_counted_workbook() -> improvise_core::workbook::Workbook { /// Formulas the random tests add and remove, as `(text, target category)`: chains, a cycle, /// aggregates over expressions, filters, a ratio, and two that target Region. -pub const FORMULA_POOL: [(&str, &str); 17] = [ +pub const FORMULA_POOL: [(&str, &str); 18] = [ ("Profit = Revenue - Cost", "_Measure"), ("Margin = Profit / Revenue", "_Measure"), ("Weighted = SUM(Revenue * Units)", "_Measure"), @@ -255,6 +255,7 @@ pub const FORMULA_POOL: [(&str, &str); 17] = [ ("Total = East + West", "Region"), ("EastRev = Revenue", "Region"), ("Recip = Units ^ -1", "_Measure"), + ("Pa = A + Revenue", "_Measure"), ]; pub fn formula_edit(index: usize) -> Edit { @@ -280,19 +281,16 @@ pub fn formula_workbook() -> improvise_core::workbook::Workbook { for r in ["East", "West"] { wb.add_item("Region", r).unwrap(); } - for p in ["A"] { - wb.add_item("Product", p).unwrap(); - } + wb.add_item("Product", "A").unwrap(); for m in ["Revenue", "Cost", "Units"] { wb.add_item("_Measure", m).unwrap(); } let mut n = 1.0; for r in ["East", "West"] { - for p in ["A"] { - for (m, scale) in [("Revenue", 10.0), ("Cost", 4.0), ("Units", 2.0)] { - wb.model.set_cell(key(r, p, m), CellValue::Number(scale * n)); - n += 1.0; - } + for (m, scale) in [("Revenue", 10.0), ("Cost", 4.0), ("Units", 2.0)] { + wb.model + .set_cell(key(r, "A", m), CellValue::Number(scale * n)); + n += 1.0; } } let profit = parse_formula("Profit = Revenue - Cost", "_Measure").unwrap(); @@ -311,17 +309,27 @@ pub fn bump(region: &str, product: &str, measure: &str, to: f64) -> Edit { } /// Both backends told every edit; their models, formula cache included, must print alike. -pub fn same_model(model: &Model, none: &[String], edits: &[Edit], by: Evaluation) -> Result<(), String> { +pub fn same_model( + model: &Model, + none: &[String], + edits: &[Edit], + by: Evaluation, +) -> Result<(), String> { let mut eager = Eager::new(model.clone(), none.to_vec()); let mut incremental = Incremental::new(model.clone(), none.to_vec()).with_evaluation(by); for edit in edits { eager.apply(edit); incremental.apply(edit); } - let (want, got) = (format!("{:?}", eager.model()), format!("{:?}", incremental.model())); + let (want, got) = ( + format!("{:?}", eager.model()), + format!("{:?}", incremental.model()), + ); match want == got { true => Ok(()), - false => Err(format!("after {edits:?}:\n eager {want}\n incremental {got}")), + false => Err(format!( + "after {edits:?}:\n eager {want}\n incremental {got}" + )), } } @@ -339,7 +347,7 @@ fn any_value() -> impl Strategy { pub fn any_edit() -> impl Strategy { let regions = prop::sample::select(vec!["East", "West", "North"]); let products = prop::sample::select(vec!["A", "B", "C"]); - let measures = prop::sample::select(vec!["Revenue", "Cost", "Units", "Loop", "Pong"]); + let measures = prop::sample::select(vec!["Revenue", "Cost", "Units", "Loop", "Pong", "Pa"]); let at = (regions, products, measures); let funcs = vec![ AggFunc::Sum, @@ -370,10 +378,12 @@ pub fn same_orders(model: &Model, none: &[String], edits: &[Edit]) -> Result<(), ordered.apply(edit); plain.apply(edit); } - let (want, got) = (format!("{:?}", plain.model()), format!("{:?}", ordered.model())); + let (want, got) = ( + format!("{:?}", plain.model()), + format!("{:?}", ordered.model()), + ); match want == got { true => Ok(()), false => Err(format!("after {edits:?}:\n plain {want}\n ordered {got}")), } } -