Merge pull request #291 from fiddlerwoaroof/fold-memo/metf-opbo
Land #258 268 to main (merged into stale bases)
This commit is contained in:
@@ -5,7 +5,7 @@ use std::time::{Duration, Instant};
|
||||
|
||||
use improvise::format::FormatPlan;
|
||||
use improvise::model::cell::{CellKey, CellValue};
|
||||
use improvise::view::Axis;
|
||||
use improvise::view::{Axis, GridLayout};
|
||||
use improvise::workbook::Workbook;
|
||||
use improvise_command::ui::app::App;
|
||||
use improvise_command::ui::project::{self, Viewport};
|
||||
@@ -228,6 +228,21 @@ keypress {keypress:>11.3?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The layout build with `prune_empty` on: every cell evaluated to find the empty
|
||||
/// rows and columns, on the same keypress path as `grid_of` (improvise-metf).
|
||||
fn report_prune(label: &str, mut app: App, iters: u32) {
|
||||
app.model_state.workbook.active_view_mut().prune_empty = true;
|
||||
let model = &app.model_state.workbook.model;
|
||||
let view = app.model_state.workbook.active_view();
|
||||
let mut best = Duration::MAX;
|
||||
for _ in 0..iters.max(3) {
|
||||
let start = Instant::now();
|
||||
std::hint::black_box(GridLayout::with_frozen_records(model, view, None));
|
||||
best = best.min(start.elapsed());
|
||||
}
|
||||
println!("{label:<22} layout build with prune_empty {best:>11.3?}");
|
||||
}
|
||||
|
||||
fn main() {
|
||||
println!("project::grid_of, one call = one TUI frame's grid cache");
|
||||
println!("a 60fps frame budget is 16.7ms; the TUI redraws on each keypress\n");
|
||||
@@ -241,6 +256,10 @@ fn main() {
|
||||
println!("\nfolded rows and a five-formula chain (improvise-h6z1)\n");
|
||||
report("folded chain ~18k", chain_app(1000, 30), 5);
|
||||
report("folded chain ~90k", chain_app(5000, 30), 3);
|
||||
println!("\nthe same, pruned (improvise-metf)\n");
|
||||
report_prune("dense 18k", dense_app(150, 20, 6), 5);
|
||||
report_prune("folded chain ~18k", chain_app(1000, 30), 5);
|
||||
report_prune("folded chain ~90k", chain_app(5000, 30), 3);
|
||||
println!("\ncollapsed small groups on the row axis (improvise-jq0m)\n");
|
||||
report("364 days in weeks", grouped_app(364, 7, 20, 6), 5);
|
||||
report("364 days in pairs", grouped_app(364, 2, 20, 6), 5);
|
||||
|
||||
+21
-3
@@ -931,9 +931,9 @@ every member. Five decisions:
|
||||
what gave the wrong ratios. The remaining ~6× is each column's cell
|
||||
re-deriving the chain below it (45 evaluations per row where 9 would do).
|
||||
**The memo is now shared across one grid build** (improvise-h6z1).
|
||||
`viewport::grid_body` makes one `FoldMemo` and passes it through
|
||||
`GridLayout::evaluate_in` and `column_total` for every cell and total, so a
|
||||
fold row's chain is evaluated 9 times, not 45. A memo is valid only for the
|
||||
`viewport::grid_body` takes one `GridLayout::evaluator` and asks it for
|
||||
every cell and total, so a fold row's chain is evaluated 9 times, not 45.
|
||||
`prune_empty` does the same (improvise-metf). A memo is valid only for the
|
||||
model state it read. That is why it is made per build and never stored on
|
||||
`GridLayout`, which outlives cell writes.
|
||||
The memo is keyed by the fold key **and** the span's categories. Within one
|
||||
@@ -941,6 +941,24 @@ every member. Five decisions:
|
||||
always names the row categories and a total's never does. It is kept because
|
||||
`evaluate_spanning_in` is public, and a caller sharing one memo across two
|
||||
spans would otherwise read the wrong fold. No test can see it today.
|
||||
**The aggregation and the covers are the memo's, not the call's**
|
||||
(improvise-pntu). They are the same argument as the span, but they were
|
||||
left as parameters. So one memo passed with covers=all and then with Feb
|
||||
hidden returned the first fold's answer (5.0 where 2.0 was right). Widening
|
||||
the key instead would have needed a closure's identity, and a closure has
|
||||
none worth hashing. So `FoldMemo::new(agg, covers)` owns both, and
|
||||
`evaluate_covering_in`/`evaluate_spanning_in` read them off the memo. A
|
||||
mismatched pair can no longer be passed.
|
||||
**A grid's evaluations cannot be handed another grid's memo either**
|
||||
(improvise-opbo). `GridLayout::evaluate_in` and `column_total` used to take
|
||||
any `&FoldMemo`. A memo built from a layout showing Feb, passed to a layout
|
||||
hiding it, gave 5.0 where 2.0 was right: a consistent pair, only for a
|
||||
different grid. `GridLayout::evaluator(model)` now returns a `GridEval` that
|
||||
owns a memo its own layout built, with `cell` and `column_total` on it, and
|
||||
the memo-taking methods are gone. `Model::evaluate_covering_in` and
|
||||
`evaluate_spanning_in` still take a `&FoldMemo`, but a memo there carries
|
||||
its own agg and covers, so the only mismatch left is a memo over a
|
||||
different *model state*. That is the per-build rule above, unchanged.
|
||||
- **A fold covers the members the grid draws** (improvise-f2oo). Hidden members
|
||||
are not in it. So a fold is exactly the rows it replaces, the total of a
|
||||
column's total does not move on `z`, and a group whose every member is hidden
|
||||
|
||||
@@ -6435,6 +6435,40 @@ reset and the client never heard. That rival also fails
|
||||
`a_geometry_command_relays_the_cursor_reset_to_the_asker_too` and the
|
||||
ws-server's `the_other_document_would_also_have_a_cursor_to_invalidate`.
|
||||
|
||||
### one_grid_build_evaluates_each_fold_formula_once_for_cells_and_totals
|
||||
|
||||
**What was unguarded (improvise-0jw5).** PR #247's whole effect is
|
||||
`viewport::grid_body` making one fold memo and passing it to every cell and
|
||||
every total. Replacing the per-cell memo with a fresh one passed the whole
|
||||
workspace. `a_fold_row_evaluates_each_formula_once_across_the_row` calls
|
||||
`GridLayout::evaluate_in` with a memo of its own. So it pins the layout-level
|
||||
API, not the build that is supposed to use it. The totals' memo had no guard
|
||||
at all.
|
||||
|
||||
**The test.** It uses a folded F0..F8 chain, as in the layout test. It counts
|
||||
fold-formula evaluations across **one `grid_body` call** and asserts **18**:
|
||||
F0..F8 once for the Q1 fold row and once for the totals, whose keys omit
|
||||
Month. A **setup** assertion first counts the same cells and totals, each on a
|
||||
fresh memo, and requires **90** (45 and 45). That makes 18 an observed saving
|
||||
and not a coincidence of the fixture.
|
||||
|
||||
**The setup assertion paid for itself on the first run.** The counter lives in
|
||||
improvise-core and was `#[cfg(test)]`, which is false when core is compiled as
|
||||
a dependency. The first run read **0** for both counts. A test comparing the two runs,
|
||||
"shared <= separate", would have passed as 0 <= 0 under every rival, forever.
|
||||
The absolute setup value is what refused it. The counter is now `pub` under `cfg(any(test, feature =
|
||||
"test-support"))`, and the increment is gated the same way. improvise-core's
|
||||
new `test-support` feature is enabled only by improvise-command's
|
||||
dev-dependency, following improvise-26dm's precedent. It is off in every
|
||||
shipped build.
|
||||
|
||||
**Rivals, each a source edit run against this test:**
|
||||
- `cell_value` handed `&layout.fold_memo()` per cell: fails.
|
||||
- The totals handed `&layout.fold_memo()` per column: fails.
|
||||
|
||||
Each rival re-derives one half's chain, so the count moves off 18. The file
|
||||
was restored after each and compared byte for byte.
|
||||
|
||||
## `ui/host.rs` — the abort arm of `Host::mutating_frame` (improvise-we1a)
|
||||
|
||||
### every_failing_model_effect_that_aborts_also_refuses
|
||||
|
||||
@@ -1578,3 +1578,106 @@ Three faults were injected, each run against the full `improvise-core` and
|
||||
|
||||
The regression itself is a cost, which no test here can see. The evidence for
|
||||
it is the `grid_projection` bench's grouped cases, added for this ticket.
|
||||
|
||||
### a_memo_answers_for_the_covers_it_was_built_with
|
||||
|
||||
**What broke (improvise-pntu).** Found reviewing PR #247, and it was also
|
||||
ChatGPT Codex's P2 there. `FoldMemo` was keyed by (fold key, span categories),
|
||||
while `evaluate_covering_in` took the aggregation and the covers as separate
|
||||
arguments. With `margin_book(["R = Revenue / Cost"])`, key
|
||||
`{Month=Q1, _Measure=R}` and `Exact`, one memo was passed first with covers=all
|
||||
and then with Feb hidden. The second call returned **5.0** (40 / 8, the first
|
||||
fold's answer) where a fresh memo gives **2.0** (10 / 5, Jan alone).
|
||||
|
||||
**Red first.** The test was first written against the old API, exactly as the
|
||||
reviewer gave the repro, and it failed `left: Some(Number(5.0)), right:
|
||||
Some(Number(2.0))`. The fix moves agg and covers into the memo
|
||||
(`FoldMemo::new(agg, covers)`), so that shape **no longer compiles**. The
|
||||
signature is the guard. The test now builds one memo per covers and asserts
|
||||
three things. Each memo gives its own fold's value. The Feb-hidden memo agrees
|
||||
with a fresh `evaluate_covering`. The covers=all memo still says 5.0 afterwards.
|
||||
|
||||
**Rival:** `FoldMemo::new` storing `|_, _| true` in place of the covers it was
|
||||
given. This test fails, because Jan alone reads 5.0.
|
||||
|
||||
### a_memo_answers_for_the_aggregation_it_was_built_with
|
||||
|
||||
pntu's other half. `{_Measure=Revenue}` names no month. So under an `Exact`
|
||||
memo it is not a stored cell (`None`), and under an `OverHidden` memo it sums
|
||||
Jan and Feb (`40.0`).
|
||||
|
||||
**Rivals:**
|
||||
- `evaluate_covering_in` passing `Aggregation::Exact` instead of the memo's
|
||||
agg: this test fails.
|
||||
- The same fault in `evaluate_spanning_in`: caught by two existing tests,
|
||||
`a_max_measures_total_does_not_move_when_a_row_group_collapses` and
|
||||
`an_average_measures_total_is_the_mean_over_its_records`. So it needed no
|
||||
new test.
|
||||
|
||||
### pruning_evaluates_each_fold_formula_once_and_keeps_the_same_rows
|
||||
|
||||
**What was slow (improvise-metf).** `GridLayout::prune_empty` evaluated every
|
||||
cell with its own fresh fold memo. That is h6z1's shape, and it sits on the same
|
||||
keypress path, since `with_frozen_records` prunes whenever the view asks. Each
|
||||
column of a folded chain re-derived the chain below it.
|
||||
|
||||
**Measured first**, on an Apple M4 Max (64 GB) running other agents' builds.
|
||||
The bench is `cargo bench --bench grid_projection`, whose new "pruned" lines time
|
||||
a `with_frozen_records` build with `prune_empty` on. There were two back-to-back
|
||||
pairs, and the second put the old line back for the "before" half:
|
||||
|
||||
| Case | Before (fresh memo per cell) | After (one memo per build) |
|
||||
|---|---|---|
|
||||
| dense 18k | 90 ms, 88 ms | 82 ms, 100 ms |
|
||||
| folded chain ~18k | 900 ms, 705 ms | 355 ms, 351 ms |
|
||||
| folded chain ~90k | 6.40 s, 6.62 s | 3.00 s, 4.96 s |
|
||||
|
||||
It pays on the folded chain: about 2.0× to 2.5× at 18k, and 1.3× to 2.1× at 90k.
|
||||
The dense grid has no folds and moves only within noise. So `prune_empty` now
|
||||
shares one memo per build, taken from `GridLayout::fold_memo` and dropped before
|
||||
the axes are pruned.
|
||||
|
||||
**The test.** It uses `margin_book`'s F0..F8 chain with Q1 collapsed. It counts
|
||||
fold-formula evaluations across one pruned `with_frozen_records` build and
|
||||
asserts **9**. It also asserts that the pruned rows are exactly the rows a fresh
|
||||
`evaluate` per cell would keep, so sharing the memo cannot change what is
|
||||
pruned. And it asserts that the Q1 fold row survives, so the count is not
|
||||
measured over an empty grid.
|
||||
|
||||
**Rival:** the old line, `self.evaluate(model, &k)` per cell. This test fails:
|
||||
`left: 45, right: 9`.
|
||||
|
||||
### a_grid_evaluates_under_its_own_covers_not_another_grids
|
||||
|
||||
**What was open (improvise-opbo, P3).** It confirms ChatGPT Codex's P2 on #258.
|
||||
`GridLayout::evaluate_in` and `column_total` took any `&FoldMemo`. With
|
||||
`margin_book(["R = Revenue / Cost"])` and Q1 collapsed, there are two layouts
|
||||
over one model: one draws Jan and Feb, the other hides Feb. The first layout's
|
||||
memo, handed to the second layout's `evaluate_in`, gave **5.0** for
|
||||
`{Month=Q1, _Measure=R}` where the second grid reads **2.0**. Nothing in
|
||||
production reached it, because each build made its own memo.
|
||||
|
||||
**Red first.** The test was first written against `evaluate_in` with a foreign
|
||||
memo. It failed `left: Some(Number(5.0)), right: Some(Number(2.0))`.
|
||||
|
||||
**Fix: the review's proposal.** `GridLayout::evaluator(model)` returns a
|
||||
`GridEval` that borrows the layout and owns a memo the layout built itself. It
|
||||
has `cell(key)` and `column_total(col)`. `evaluate_in`, the memo-taking
|
||||
`column_total`, and `fold_memo` are gone. So the shape the test first used no
|
||||
longer compiles, and the signature is the guard. The test now asserts two
|
||||
things. Each layout's evaluator answers for its own covers: 5.0 and 2.0. And
|
||||
asking one does not move the other.
|
||||
|
||||
**Rival:** the evaluator's covers ignoring the layout's hidden items. This test
|
||||
fails `left: Some(Number(5.0)), right: Some(Number(2.0))`.
|
||||
|
||||
**The earlier rivals, restated for the new API, still fail:**
|
||||
- `pruning_evaluates_each_fold_formula_once_and_keeps_the_same_rows` with a
|
||||
fresh evaluator per cell: `left: 45, right: 9`.
|
||||
- `one_grid_build_evaluates_each_fold_formula_once_for_cells_and_totals` with
|
||||
a fresh evaluator per cell: `left: 54, right: 18`.
|
||||
- The same test with a fresh evaluator per total: `left: 54, right: 18`.
|
||||
|
||||
The entries above for h6z1, 0jw5 and metf name `evaluate_in` and `fold_memo`
|
||||
because those were the API when they were written. This entry supersedes the
|
||||
names, not the arguments.
|
||||
|
||||
@@ -23,5 +23,6 @@ unicode-width = "^0.2"
|
||||
web-time = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
improvise-core = { path = "../improvise-core", features = ["test-support"] }
|
||||
improvise-protocol = { path = "../improvise-protocol", features = ["test-support"] }
|
||||
tempfile = "3"
|
||||
|
||||
@@ -9,8 +9,7 @@ use crate::format::{FormatPlan, NumberFormat};
|
||||
use crate::model::Model;
|
||||
use crate::model::category::MEASURE_CATEGORY;
|
||||
use crate::model::cell::{CellKey, CellValue};
|
||||
use crate::model::types::FoldMemo;
|
||||
use crate::view::{AxisEntry, GridLayout};
|
||||
use crate::view::{AxisEntry, GridEval, GridLayout};
|
||||
|
||||
/// Minimum column width — enough for short numbers/labels + 1 char gap.
|
||||
pub const MIN_COL_WIDTH: u16 = 5;
|
||||
@@ -47,16 +46,14 @@ pub fn grid_body(
|
||||
let n = layout.col_count();
|
||||
let records = layout.is_records_mode();
|
||||
let mut widths = header_level_seed(layout, n);
|
||||
// One memo for the whole build: a fold every cell and total reaches is
|
||||
// One evaluator for the whole build: a fold every cell and total reaches is
|
||||
// evaluated once (improvise-h6z1). The model does not change during it.
|
||||
let memo = FoldMemo::default();
|
||||
let eval = layout.evaluator(model);
|
||||
// A column mixing measures has no total to show: `context/code/command.md`.
|
||||
let measures_on_rows = layout.row_cats.iter().any(|c| c == MEASURE_CATEGORY);
|
||||
let totalled = !records && !measures_on_rows && layout.row_count() > 0 && n > 0;
|
||||
let totals: Vec<Option<CellValue>> = match totalled {
|
||||
true => (0..n)
|
||||
.map(|ci| layout.column_total(model, ci, &memo))
|
||||
.collect(),
|
||||
true => (0..n).map(|ci| eval.column_total(ci)).collect(),
|
||||
false => Vec::new(),
|
||||
};
|
||||
let mut window: Vec<Vec<Option<CellValue>>> = Vec::with_capacity(rows.len());
|
||||
@@ -73,7 +70,7 @@ pub fn grid_body(
|
||||
false => Vec::new(),
|
||||
};
|
||||
for ci in 0..n {
|
||||
let (key, value) = cell_value(model, layout, records, ri, ci, &memo);
|
||||
let (key, value) = cell_value(layout, &eval, records, ri, ci);
|
||||
let fmt = plan.at(key.as_ref());
|
||||
let text = match records {
|
||||
true => value.as_ref().map(|v| v.to_string()).unwrap_or_default(),
|
||||
@@ -106,20 +103,17 @@ pub fn grid_body(
|
||||
|
||||
/// The one evaluation. Records mode reads its wide row; pivot mode aggregates.
|
||||
fn cell_value(
|
||||
model: &Model,
|
||||
layout: &GridLayout,
|
||||
eval: &GridEval,
|
||||
records: bool,
|
||||
row: usize,
|
||||
col: usize,
|
||||
memo: &FoldMemo,
|
||||
) -> (Option<CellKey>, Option<CellValue>) {
|
||||
match records {
|
||||
true => (None, layout.records_value(row, col)),
|
||||
false => {
|
||||
let key = layout.cell_key(row, col);
|
||||
let value = key
|
||||
.as_ref()
|
||||
.and_then(|k| layout.evaluate_in(model, k, memo));
|
||||
let value = key.as_ref().and_then(|k| eval.cell(k));
|
||||
(key, value)
|
||||
}
|
||||
}
|
||||
@@ -190,3 +184,76 @@ pub fn compute_visible_cols(
|
||||
}
|
||||
count.max(1)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::model::types::FOLD_FORMULA_EVALUATIONS;
|
||||
use crate::view::Axis;
|
||||
use crate::workbook::Workbook;
|
||||
|
||||
/// Q1 = {Jan, Feb} collapsed, Q2 = {Apr}; F0 = Revenue and F(i) = F(i-1) twice.
|
||||
fn folded_chain() -> Workbook {
|
||||
let mut wb = Workbook::new("T");
|
||||
wb.add_category("Month").unwrap();
|
||||
wb.add_item_in_group("Month", "Apr", "Q2").unwrap();
|
||||
wb.add_item_in_group("Month", "Jan", "Q1").unwrap();
|
||||
wb.add_item_in_group("Month", "Feb", "Q1").unwrap();
|
||||
wb.add_item("_Measure", "Revenue").unwrap();
|
||||
for (month, revenue) in [("Jan", 10.0), ("Feb", 30.0)] {
|
||||
let key = CellKey::new(vec![
|
||||
("Month".into(), month.into()),
|
||||
("_Measure".into(), "Revenue".into()),
|
||||
]);
|
||||
wb.model.set_cell(key, CellValue::Number(revenue));
|
||||
}
|
||||
wb.model
|
||||
.add_formula(crate::formula::parse_formula("F0 = Revenue", "_Measure").unwrap());
|
||||
for i in 1..=8 {
|
||||
let text = format!("F{i} = F{p} + F{p}", p = i - 1);
|
||||
wb.model
|
||||
.add_formula(crate::formula::parse_formula(&text, "_Measure").unwrap());
|
||||
}
|
||||
wb.active_view_mut().set_axis("Month", Axis::Row);
|
||||
wb.active_view_mut().set_axis("_Measure", Axis::Column);
|
||||
wb.active_view_mut().toggle_group_collapse("Month", "Q1");
|
||||
wb.model.recompute_formulas(&wb.active_view().none_cats());
|
||||
wb
|
||||
}
|
||||
|
||||
fn evaluations(run: impl FnOnce()) -> usize {
|
||||
FOLD_FORMULA_EVALUATIONS.with(|n| n.set(0));
|
||||
run();
|
||||
FOLD_FORMULA_EVALUATIONS.with(|n| n.get())
|
||||
}
|
||||
|
||||
/// improvise-0jw5: context/test-evidence/command.md.
|
||||
#[test]
|
||||
fn one_grid_build_evaluates_each_fold_formula_once_for_cells_and_totals() {
|
||||
let wb = folded_chain();
|
||||
let view = wb.active_view();
|
||||
let layout = GridLayout::new(&wb.model, view);
|
||||
let plan = FormatPlan::of(&wb.model, view);
|
||||
let (rows, cols) = (0..layout.row_count(), 0..layout.col_count());
|
||||
let apart = evaluations(|| {
|
||||
for (r, c) in rows.clone().flat_map(|r| cols.clone().map(move |c| (r, c))) {
|
||||
let key = layout.cell_key(r, c).unwrap();
|
||||
layout.evaluate(&wb.model, &key);
|
||||
}
|
||||
for c in cols.clone() {
|
||||
layout.evaluator(&wb.model).column_total(c);
|
||||
}
|
||||
});
|
||||
let together = evaluations(|| {
|
||||
grid_body(&wb.model, &layout, &plan, rows.clone(), cols.clone());
|
||||
});
|
||||
assert_eq!(
|
||||
apart, 90,
|
||||
"setup: 45 for the Q1 row's cells, 45 for the totals"
|
||||
);
|
||||
assert_eq!(
|
||||
together, 18,
|
||||
"F0..F8 once for the Q1 row, once for the totals"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,11 @@ serde = { version = "1", features = ["derive"] }
|
||||
# wasm-clean.
|
||||
siphasher = "1"
|
||||
|
||||
[features]
|
||||
# Exposes the fold-evaluation counter to other crates' tests (improvise-0jw5).
|
||||
# Off in every shipped build; enabled only by improvise-command's dev-dependency.
|
||||
test-support = []
|
||||
|
||||
[dev-dependencies]
|
||||
proptest = "1"
|
||||
serde_json = "1"
|
||||
|
||||
@@ -138,28 +138,42 @@ fn agg_values(
|
||||
}
|
||||
|
||||
// How many formulas a fold has evaluated: what improvise-o1y0's regression counts.
|
||||
#[cfg(test)]
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
thread_local! {
|
||||
pub(crate) static FOLD_FORMULA_EVALUATIONS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
pub static FOLD_FORMULA_EVALUATIONS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// Each (fold key, span) evaluated once across one grid build (improvise-o1y0,
|
||||
/// improvise-h6z1). Valid for one model state only: `context/code/core.md` §14.
|
||||
#[derive(Default)]
|
||||
pub struct FoldMemo(std::cell::RefCell<HashMap<(CellKey, Vec<String>), MemoEntry>>);
|
||||
/// Each (fold key, span) evaluated once per grid build, under the agg and covers it
|
||||
/// owns (improvise-o1y0, h6z1, pntu). One model state only: `context/code/core.md` §14.
|
||||
pub struct FoldMemo<'a> {
|
||||
agg: Aggregation,
|
||||
covers: OwnedCovers<'a>,
|
||||
seen: std::cell::RefCell<HashMap<(CellKey, Vec<String>), MemoEntry>>,
|
||||
}
|
||||
|
||||
/// The members a memo's folds cover, owned so a caller cannot pass another set.
|
||||
type OwnedCovers<'a> = Box<dyn Fn(&str, &str) -> bool + 'a>;
|
||||
|
||||
/// `None` while in progress, which is how a cycle is seen (improvise-o1y0).
|
||||
type MemoEntry = Option<Option<CellValue>>;
|
||||
|
||||
impl FoldMemo {
|
||||
impl<'a> FoldMemo<'a> {
|
||||
pub fn new(agg: Aggregation, covers: impl Fn(&str, &str) -> bool + 'a) -> Self {
|
||||
Self {
|
||||
agg,
|
||||
covers: Box::new(covers),
|
||||
seen: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn get(&self, key: &CellKey, span: Span) -> Option<MemoEntry> {
|
||||
let at = (key.clone(), span.over.to_vec());
|
||||
self.0.borrow().get(&at).cloned()
|
||||
self.seen.borrow().get(&at).cloned()
|
||||
}
|
||||
|
||||
fn put(&self, key: &CellKey, span: Span, entry: MemoEntry) {
|
||||
let at = (key.clone(), span.over.to_vec());
|
||||
self.0.borrow_mut().insert(at, entry);
|
||||
self.seen.borrow_mut().insert(at, entry);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,7 +218,7 @@ enum Reach<'a> {
|
||||
Fold {
|
||||
agg: Aggregation,
|
||||
span: Span<'a>,
|
||||
memo: &'a FoldMemo,
|
||||
memo: &'a FoldMemo<'a>,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -697,21 +711,17 @@ impl Model {
|
||||
agg: Aggregation,
|
||||
covers: Covers,
|
||||
) -> Option<CellValue> {
|
||||
let span = Span { covers, over: &[] };
|
||||
self.evaluate_fold(key, agg, span, &FoldMemo::default())
|
||||
self.evaluate_covering_in(key, &FoldMemo::new(agg, covers))
|
||||
}
|
||||
|
||||
/// [`Self::evaluate_covering`] sharing `memo` with every other evaluation of one
|
||||
/// grid build, which must not outlive the model state it read (improvise-h6z1).
|
||||
pub fn evaluate_covering_in(
|
||||
&self,
|
||||
key: &CellKey,
|
||||
agg: Aggregation,
|
||||
covers: Covers,
|
||||
memo: &FoldMemo,
|
||||
) -> Option<CellValue> {
|
||||
let span = Span { covers, over: &[] };
|
||||
self.evaluate_fold(key, agg, span, memo)
|
||||
/// [`Self::evaluate_covering`] under `memo`'s aggregation and covers, sharing it
|
||||
/// with one grid build; it must not outlive the model state (improvise-h6z1, pntu).
|
||||
pub fn evaluate_covering_in(&self, key: &CellKey, memo: &FoldMemo) -> Option<CellValue> {
|
||||
let span = Span {
|
||||
covers: &*memo.covers,
|
||||
over: &[],
|
||||
};
|
||||
self.evaluate_fold(key, memo.agg, span, memo)
|
||||
}
|
||||
|
||||
/// The value `key` has as ONE fold over every covered item of the `over`
|
||||
@@ -723,20 +733,21 @@ impl Model {
|
||||
agg: Aggregation,
|
||||
covers: Covers,
|
||||
) -> Option<CellValue> {
|
||||
self.evaluate_spanning_in(key, over, agg, covers, &FoldMemo::default())
|
||||
self.evaluate_spanning_in(key, over, &FoldMemo::new(agg, covers))
|
||||
}
|
||||
|
||||
/// [`Self::evaluate_spanning`] sharing one grid build's `memo` (improvise-h6z1).
|
||||
/// [`Self::evaluate_spanning`] under one grid build's `memo` (improvise-h6z1, pntu).
|
||||
pub fn evaluate_spanning_in(
|
||||
&self,
|
||||
key: &CellKey,
|
||||
over: &[String],
|
||||
agg: Aggregation,
|
||||
covers: Covers,
|
||||
memo: &FoldMemo,
|
||||
) -> Option<CellValue> {
|
||||
let span = Span { covers, over };
|
||||
self.evaluate_fold(key, agg, span, memo)
|
||||
let span = Span {
|
||||
covers: &*memo.covers,
|
||||
over,
|
||||
};
|
||||
self.evaluate_fold(key, memo.agg, span, memo)
|
||||
}
|
||||
|
||||
/// A key naming a group folds; any other key reads as before. A formula is
|
||||
@@ -773,7 +784,7 @@ impl Model {
|
||||
Some(None) => Some(CellValue::Error("circular".into())),
|
||||
None => {
|
||||
memo.put(key, span, None);
|
||||
#[cfg(test)]
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
FOLD_FORMULA_EVALUATIONS.with(|n| n.set(n.get() + 1));
|
||||
let reach = Reach::Fold { agg, span, memo };
|
||||
let value = self.eval_formula_folded(formula, key, expansion, agg, reach);
|
||||
|
||||
@@ -611,14 +611,15 @@ impl GridLayout {
|
||||
return;
|
||||
}
|
||||
|
||||
// Build a row×col grid of "has content?"
|
||||
// Build a row×col grid of "has content?", one fold memo for the whole
|
||||
// walk (improvise-metf). It is dropped before the axes are pruned.
|
||||
let mut has_value = vec![vec![false; cc]; rc];
|
||||
for (ri, row) in has_value.iter_mut().enumerate() {
|
||||
for (ci, cell) in row.iter_mut().enumerate() {
|
||||
*cell = self
|
||||
.cell_key(ri, ci)
|
||||
.and_then(|k| self.evaluate(model, &k))
|
||||
.is_some();
|
||||
{
|
||||
let eval = self.evaluator(model);
|
||||
for (ri, row) in has_value.iter_mut().enumerate() {
|
||||
for (ci, cell) in row.iter_mut().enumerate() {
|
||||
*cell = self.cell_key(ri, ci).and_then(|k| eval.cell(&k)).is_some();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -658,22 +659,20 @@ impl GridLayout {
|
||||
/// The value of a pivot key as THIS grid reads it: aggregated over its None
|
||||
/// axis, and a fold over the members it draws (core.md §14). One evaluation.
|
||||
pub fn evaluate(&self, model: &Model, key: &CellKey) -> Option<CellValue> {
|
||||
self.evaluate_in(model, key, &FoldMemo::default())
|
||||
self.evaluator(model).cell(key)
|
||||
}
|
||||
|
||||
/// [`Self::evaluate`] sharing one grid build's `memo` (improvise-h6z1).
|
||||
pub fn evaluate_in(&self, model: &Model, key: &CellKey, memo: &FoldMemo) -> Option<CellValue> {
|
||||
let drawn = |cat: &str, item: &str| !self.hidden.get(cat).is_some_and(|h| h.contains(item));
|
||||
model.evaluate_covering_in(key, Aggregation::of(&self.none_cats), &drawn, memo)
|
||||
}
|
||||
|
||||
/// A column's total: its cell as if every drawn row were ONE fold, so a
|
||||
/// formula is evaluated over the pooled rows (improvise-wm20, core.md §14).
|
||||
pub fn column_total(&self, model: &Model, col: usize, memo: &FoldMemo) -> Option<CellValue> {
|
||||
let key = self.column_key(col)?;
|
||||
let drawn = |cat: &str, item: &str| !self.hidden.get(cat).is_some_and(|h| h.contains(item));
|
||||
let agg = Aggregation::of(&self.none_cats);
|
||||
model.evaluate_spanning_in(&key, &self.row_cats, agg, &drawn, memo)
|
||||
/// One grid build's evaluator, whose fold memo this layout built, so no other
|
||||
/// grid's memo can reach its cells or totals (improvise-h6z1, pntu, opbo).
|
||||
pub fn evaluator<'a>(&'a self, model: &'a Model) -> GridEval<'a> {
|
||||
let memo = FoldMemo::new(Aggregation::of(&self.none_cats), |cat, item| {
|
||||
!self.hidden.get(cat).is_some_and(|h| h.contains(item))
|
||||
});
|
||||
GridEval {
|
||||
layout: self,
|
||||
model,
|
||||
memo,
|
||||
}
|
||||
}
|
||||
|
||||
/// Display text for (row, col) in either mode.
|
||||
@@ -903,12 +902,36 @@ fn cross_product(model: &Model, view: &View, cats: &[String]) -> Vec<AxisEntry>
|
||||
result
|
||||
}
|
||||
|
||||
/// One grid build's evaluations under one fold memo its own layout built, valid
|
||||
/// for one model state (improvise-h6z1, opbo). `context/code/core.md` §14.
|
||||
pub struct GridEval<'a> {
|
||||
layout: &'a GridLayout,
|
||||
model: &'a Model,
|
||||
memo: FoldMemo<'a>,
|
||||
}
|
||||
|
||||
impl GridEval<'_> {
|
||||
/// A pivot key's value as this grid reads it: [`GridLayout::evaluate`], shared.
|
||||
pub fn cell(&self, key: &CellKey) -> Option<CellValue> {
|
||||
self.model.evaluate_covering_in(key, &self.memo)
|
||||
}
|
||||
|
||||
/// A column's total: its cell as if every drawn row were ONE fold, so a
|
||||
/// formula is evaluated over the pooled rows (improvise-wm20, core.md §14).
|
||||
pub fn column_total(&self, col: usize) -> Option<CellValue> {
|
||||
let key = self.layout.column_key(col)?;
|
||||
self.model
|
||||
.evaluate_spanning_in(&key, &self.layout.row_cats, &self.memo)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
AxisEntry, GridLayout, NEW_CATEGORY_COL, NEW_MEASURE_COL, RecordCol, data_items,
|
||||
prune_axis_items, synthetic_record_info,
|
||||
};
|
||||
use crate::model::Aggregation;
|
||||
use crate::model::cell::{CellKey, CellValue};
|
||||
use crate::model::types::{FOLD_FORMULA_EVALUATIONS, FoldMemo};
|
||||
use crate::view::Axis;
|
||||
@@ -1848,6 +1871,45 @@ mod tests {
|
||||
.and_then(|v| v.as_f64())
|
||||
}
|
||||
|
||||
/// improvise-metf: pruning evaluates each formula a fold row reaches once, and
|
||||
/// keeps what a fresh evaluation per cell keeps. context/test-evidence/core.md.
|
||||
#[test]
|
||||
fn pruning_evaluates_each_fold_formula_once_and_keeps_the_same_rows() {
|
||||
let chain: Vec<String> = std::iter::once("F0 = Revenue".to_string())
|
||||
.chain((1..=8).map(|i| format!("F{i} = F{p} + F{p}", p = i - 1)))
|
||||
.collect();
|
||||
let chain: Vec<&str> = chain.iter().map(String::as_str).collect();
|
||||
let mut wb = margin_book(&chain);
|
||||
wb.active_view_mut().toggle_group_collapse("Month", "Q1");
|
||||
wb.model.recompute_formulas(&wb.active_view().none_cats());
|
||||
let unpruned = GridLayout::new(&wb.model, wb.active_view());
|
||||
let kept_by_fresh_evaluation: Vec<String> = (0..unpruned.row_count())
|
||||
.filter(|&r| {
|
||||
(0..unpruned.col_count()).any(|c| {
|
||||
let k = unpruned.cell_key(r, c).unwrap();
|
||||
unpruned.evaluate(&wb.model, &k).is_some()
|
||||
})
|
||||
})
|
||||
.map(|r| unpruned.row_label(r))
|
||||
.collect();
|
||||
wb.active_view_mut().prune_empty = true;
|
||||
FOLD_FORMULA_EVALUATIONS.with(|n| n.set(0));
|
||||
let pruned = GridLayout::with_frozen_records(&wb.model, wb.active_view(), None);
|
||||
let evaluations = FOLD_FORMULA_EVALUATIONS.with(|n| n.get());
|
||||
let rows: Vec<String> = (0..pruned.row_count())
|
||||
.map(|r| pruned.row_label(r))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
rows, kept_by_fresh_evaluation,
|
||||
"sharing the memo changed what is pruned"
|
||||
);
|
||||
assert!(
|
||||
rows.iter().any(|r| r == "Q1"),
|
||||
"setup: the fold row survives pruning"
|
||||
);
|
||||
assert_eq!(evaluations, 9, "F0..F8 once each for the fold row, not 45");
|
||||
}
|
||||
|
||||
/// improvise-h6z1: one grid build evaluates each formula a fold row reaches
|
||||
/// once, not once per column. context/test-evidence/core.md.
|
||||
#[test]
|
||||
@@ -1867,12 +1929,12 @@ mod tests {
|
||||
.filter_map(|c| layout.cell_key(q1, c))
|
||||
.collect();
|
||||
let evaluations = |shared: bool| {
|
||||
let memo = FoldMemo::default();
|
||||
let eval = layout.evaluator(&wb.model);
|
||||
FOLD_FORMULA_EVALUATIONS.with(|n| n.set(0));
|
||||
let values: Vec<_> = row_keys
|
||||
.iter()
|
||||
.map(|k| match shared {
|
||||
true => layout.evaluate_in(&wb.model, k, &memo),
|
||||
true => eval.cell(k),
|
||||
false => layout.evaluate(&wb.model, k),
|
||||
})
|
||||
.collect();
|
||||
@@ -1888,6 +1950,82 @@ mod tests {
|
||||
assert_eq!(together, 9, "F0..F8 once each across the whole row");
|
||||
}
|
||||
|
||||
/// improvise-pntu: context/test-evidence/core.md.
|
||||
#[test]
|
||||
fn a_memo_answers_for_the_covers_it_was_built_with() {
|
||||
let wb = margin_book(&["R = Revenue / Cost"]);
|
||||
let key = CellKey::new(vec![
|
||||
("Month".into(), "Q1".into()),
|
||||
("_Measure".into(), "R".into()),
|
||||
]);
|
||||
let all = FoldMemo::new(Aggregation::Exact, |_, _| true);
|
||||
let no_feb = FoldMemo::new(Aggregation::Exact, |_, item| item != "Feb");
|
||||
let pooled = wb.model.evaluate_covering_in(&key, &all);
|
||||
let jan_only = wb.model.evaluate_covering_in(&key, &no_feb);
|
||||
assert_eq!(
|
||||
pooled,
|
||||
Some(CellValue::Number(5.0)),
|
||||
"40 / 8 over Jan and Feb"
|
||||
);
|
||||
assert_eq!(
|
||||
jan_only,
|
||||
Some(CellValue::Number(2.0)),
|
||||
"10 / 5 over Jan alone"
|
||||
);
|
||||
let fresh = wb
|
||||
.model
|
||||
.evaluate_covering(&key, Aggregation::Exact, &|_, item| item != "Feb");
|
||||
assert_eq!(jan_only, fresh, "a memo agrees with a fresh evaluation");
|
||||
assert_eq!(wb.model.evaluate_covering_in(&key, &all), pooled);
|
||||
}
|
||||
|
||||
/// improvise-pntu's other half: the aggregation is the memo's too.
|
||||
#[test]
|
||||
fn a_memo_answers_for_the_aggregation_it_was_built_with() {
|
||||
let wb = margin_book(&[]);
|
||||
let key = CellKey::new(vec![("_Measure".into(), "Revenue".into())]);
|
||||
let exact = FoldMemo::new(Aggregation::Exact, |_, _| true);
|
||||
let over = FoldMemo::new(Aggregation::OverHidden, |_, _| true);
|
||||
assert_eq!(wb.model.evaluate_covering_in(&key, &exact), None);
|
||||
assert_eq!(
|
||||
wb.model.evaluate_covering_in(&key, &over),
|
||||
Some(CellValue::Number(40.0)),
|
||||
"Jan and Feb summed"
|
||||
);
|
||||
}
|
||||
|
||||
/// improvise-opbo: a grid's evaluation cannot be handed another grid's memo.
|
||||
#[test]
|
||||
fn a_grid_evaluates_under_its_own_covers_not_another_grids() {
|
||||
let mut wb = margin_book(&["R = Revenue / Cost"]);
|
||||
wb.active_view_mut().toggle_group_collapse("Month", "Q1");
|
||||
let all = GridLayout::new(&wb.model, wb.active_view());
|
||||
let mut no_feb_view = wb.active_view().clone();
|
||||
no_feb_view.hide_item("Month", "Feb");
|
||||
let no_feb = GridLayout::new(&wb.model, &no_feb_view);
|
||||
let key = CellKey::new(vec![
|
||||
("Month".into(), "Q1".into()),
|
||||
("_Measure".into(), "R".into()),
|
||||
]);
|
||||
let on_all = all.evaluator(&wb.model);
|
||||
let on_no_feb = no_feb.evaluator(&wb.model);
|
||||
assert_eq!(
|
||||
on_all.cell(&key),
|
||||
Some(CellValue::Number(5.0)),
|
||||
"Jan and Feb"
|
||||
);
|
||||
assert_eq!(
|
||||
on_no_feb.cell(&key),
|
||||
Some(CellValue::Number(2.0)),
|
||||
"the grid hiding Feb reads Jan alone"
|
||||
);
|
||||
assert_eq!(
|
||||
on_all.cell(&key),
|
||||
Some(CellValue::Number(5.0)),
|
||||
"and neither moved the other"
|
||||
);
|
||||
}
|
||||
|
||||
/// improvise-eccl — see context/test-evidence/core.md.
|
||||
#[test]
|
||||
fn a_folds_ratio_formula_is_the_ratio_of_its_folded_inputs() {
|
||||
|
||||
@@ -4,8 +4,8 @@ pub mod types;
|
||||
|
||||
pub use axis::{Axis, AxisColor};
|
||||
pub use layout::{
|
||||
AxisEntry, GridLayout, NEW_CATEGORY_COL, NEW_MEASURE_COL, RecordCol, RecordRow, ViewStep,
|
||||
drill_constraint, drill_snapshot, drill_steps, records_spine, records_steps,
|
||||
AxisEntry, GridEval, GridLayout, NEW_CATEGORY_COL, NEW_MEASURE_COL, RecordCol, RecordRow,
|
||||
ViewStep, drill_constraint, drill_snapshot, drill_steps, records_spine, records_steps,
|
||||
synthetic_record_info,
|
||||
};
|
||||
pub use types::{
|
||||
|
||||
Reference in New Issue
Block a user