test(incremental): formula cache reads, wide sets, NaN and the evidence for step 5 (improvise-shbc)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
This commit is contained in:
Claude (dbsp-backend)
2026-10-10 15:08:53 -07:00
committed by Edward Langley
parent 7e3c6b0c30
commit 933c9254a0
7 changed files with 380 additions and 46 deletions
+64
View File
@@ -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.
@@ -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`.
+26 -14
View File
@@ -37,9 +37,8 @@ fn walk(expr: &Expr, names: &mut BTreeSet<String>, filters: &mut BTreeSet<String
}
}
/// What one formula reads: the names in its expression and in the formulas those names
/// target, and the categories that puts in play. A cell the formula can read agrees with
/// the stem everywhere but on `cats`.
/// What one formula reads: names, through the formulas they target, and the categories
/// that puts in play. Reasoning: `context/code/improvise-incremental.md`, step 5.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Reads {
pub names: BTreeSet<String>,
@@ -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<Reads> = (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<CellKey> = born.into_iter().collect();
@@ -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() {
@@ -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
+115 -9
View File
@@ -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();
@@ -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<Value = CellValue> {
pub fn any_edit() -> impl Strategy<Value = Edit> {
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}")),
}
}