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:
committed by
Edward Langley
parent
7e3c6b0c30
commit
933c9254a0
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
@@ -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}")),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user