diff --git a/benches/grid_projection.rs b/benches/grid_projection.rs index 1f3fd08f..946610e4 100644 --- a/benches/grid_projection.rs +++ b/benches/grid_projection.rs @@ -54,6 +54,50 @@ fn dense_app(regions: usize, products: usize, measures: usize) -> App { app } +/// Days in collapsed groups of `per_group` on Row, with Product: every row is a +/// fold over a few days, never the whole category (improvise-jq0m). +fn grouped_app(days: usize, per_group: usize, products: usize, measures: usize) -> App { + let mut wb = Workbook::new("Grouped"); + wb.add_category("Day").unwrap(); + wb.add_category("Product").unwrap(); + for d in 0..days { + let group = format!("G{}", d / per_group); + wb.add_item_in_group("Day", format!("Day{d}"), group).unwrap(); + } + for p in 0..products { + wb.add_item("Product", format!("Product{p}")).unwrap(); + } + for m in 0..measures { + wb.add_item("_Measure", format!("Measure{m}")).unwrap(); + } + { + let v = wb.active_view_mut(); + v.set_axis("Day", Axis::Row); + v.set_axis("Product", Axis::Row); + v.set_axis("_Measure", Axis::Column); + for g in 0..days.div_ceil(per_group) { + v.toggle_group_collapse("Day", &format!("G{g}")); + } + } + let mut n = 0.0f64; + for d in 0..days { + for p in 0..products { + for m in 0..measures { + n += 1.0; + let key = CellKey::new(vec![ + ("Day".to_string(), format!("Day{d}")), + ("Product".to_string(), format!("Product{p}")), + ("_Measure".to_string(), format!("Measure{m}")), + ]); + wb.model.set_cell(key, CellValue::Number(n)); + } + } + } + let mut app = App::new(wb, None); + app.rebuild_layout(); + app +} + fn whole_grid(app: &App) -> Viewport { Viewport { row_offset: 0, @@ -105,7 +149,10 @@ fn time_widths(iters: u32, app: &App) -> Duration { } fn case(label: &str, regions: usize, products: usize, measures: usize, iters: u32) { - let app = dense_app(regions, products, measures); + report(label, dense_app(regions, products, measures), iters); +} + +fn report(label: &str, app: App, iters: u32) { let rows = app.layout.row_count(); let cols = app.layout.col_count(); let cells = rows * cols; @@ -139,4 +186,8 @@ fn main() { case("large", 60, 20, 8, 50); case("18k cells (gotcha 41)", 150, 20, 6, 20); case("pathological", 300, 30, 10, 5); + 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); + report("1000 days in pairs", grouped_app(1000, 2, 10, 4), 3); } diff --git a/crates/improvise-core/src/model/cell.rs b/crates/improvise-core/src/model/cell.rs index 38121786..19254613 100644 --- a/crates/improvise-core/src/model/cell.rs +++ b/crates/improvise-core/src/model/cell.rs @@ -378,16 +378,30 @@ impl DataStore { /// Positions in `cells` of every cell matching `partial`, **in insertion /// order**. The index narrows; the primary map orders (§8). fn matching_positions(&self, partial: &[(String, String)]) -> Vec { - // Intern the partial key (lookup only, no new symbols) - let interned_partial: Vec<(Symbol, Symbol)> = partial + self.intern_partial(partial) + .map(|pairs| self.positions_of(&pairs)) + .unwrap_or_default() + } + + /// `partial` as symbols, or `None` when a name was never interned, which + /// no stored cell can then match. + fn intern_partial(&self, partial: &[(String, String)]) -> Option> { + partial .iter() - .filter_map(|(c, i)| Some((self.symbols.get(c)?, self.symbols.get(i)?))) - .collect(); + .map(|(c, i)| Some((self.symbols.get(c)?, self.symbols.get(i)?))) + .collect() + } - if interned_partial.len() < partial.len() { - return vec![]; - } + /// How many candidates narrowing by `pairs` would visit: its smallest index set. + fn narrowing_cost(&self, pairs: &[(Symbol, Symbol)]) -> usize { + pairs + .iter() + .map(|pair| self.index.get(pair).map_or(0, HashSet::len)) + .min() + .unwrap_or(self.cells.len()) + } + fn positions_of(&self, interned_partial: &[(Symbol, Symbol)]) -> Vec { let mut sets: Vec<&HashSet> = interned_partial .iter() .filter_map(|pair| self.index.get(pair)) @@ -450,7 +464,7 @@ impl DataStore { .iter() .all(|(cat, items)| ikey.0.iter().any(|(c, i)| c == cat && items.contains(i))) }; - self.matching_positions(partial) + self.spanned_positions(partial, &wanted) .into_iter() .filter_map(|n| self.cells.get_index(n)) .filter(|(ikey, _)| admits(ikey)) @@ -458,6 +472,35 @@ impl DataStore { .collect() } + /// Candidates for a span: narrowed once by `partial`, or once per covered item + /// of its smallest span, whichever visits fewer. Sorted (improvise-jq0m). + fn spanned_positions( + &self, + partial: &[(String, String)], + wanted: &[(Symbol, HashSet)], + ) -> Vec { + self.intern_partial(partial) + .map(|base| self.narrowed(&base, wanted)) + .unwrap_or_default() + } + + fn narrowed(&self, base: &[(Symbol, Symbol)], wanted: &[(Symbol, HashSet)]) -> Vec { + let with = |cat: Symbol, item: Symbol| [base, &[(cat, item)]].concat(); + let per_item: Vec> = wanted + .iter() + .min_by_key(|(_, items)| items.len()) + .map(|(cat, items)| items.iter().map(|i| with(*cat, *i)).collect()) + .unwrap_or_default(); + let item_cost: usize = per_item.iter().map(|p| self.narrowing_cost(p)).sum(); + let narrow_per_item = !per_item.is_empty() && item_cost < self.narrowing_cost(base); + let mut positions: Vec = match narrow_per_item { + true => per_item.iter().flat_map(|p| self.positions_of(p)).collect(), + false => self.positions_of(base), + }; + positions.sort_unstable(); + positions + } + /// Every cell matching `partial`, in insertion order. Allocates a /// `CellKey` per match — prefer `matching_values` when values suffice. #[allow(dead_code)] @@ -1008,6 +1051,60 @@ mod prop_tests { "returned key {result_key} does not match partial {partial:?}"); } } + + /// improvise-jq0m: whichever narrowing it picks, a span returns what a lookup + /// per member returns, in store order. context/test-evidence/core.md. + #[test] + fn a_span_finds_what_its_members_find_in_store_order( + cells in prop::collection::vec((0u8..6, 0u8..3, 0u8..2, any::()), 0..40), + covered_r in prop::collection::vec(any::(), 7), + covered_p in prop::option::of(prop::collection::vec(any::(), 3)), + base_m in 0u8..2, + exact in any::(), + ) { + let mut store = DataStore::new(); + for (n, (r, p, m, extra)) in cells.iter().enumerate() { + let mut key = CellKey::new(vec![ + ("P".into(), format!("p{p}")), + ("M".into(), format!("m{m}")), + ]); + // r = 5 leaves the record without the spanned category at all. + key = match r { 5 => key, r => key.with("R", format!("r{r}")) }; + key = match extra { true => key.with("X", "x"), false => key }; + store.set(key, CellValue::Number(n as f64)); + } + // r6 is never stored, so it is a covered item the store never interned. + let pick = |prefix: &str, flags: &[bool]| -> Vec { + flags.iter().enumerate().filter(|(_, f)| **f) + .map(|(i, _)| format!("{prefix}{i}")).collect() + }; + let rs = pick("r", &covered_r); + let ps = covered_p.as_deref().map(|f| pick("p", f)); + let base = CellKey::new(vec![("M".into(), format!("m{base_m}"))]); + let mut spans: Vec<(&str, Vec<&str>)> = vec![("R", rs.iter().map(String::as_str).collect())]; + if let Some(ps) = &ps { + spans.push(("P", ps.iter().map(String::as_str).collect())); + } + let mut members = vec![base.clone()]; + for (cat, items) in &spans { + members = members.iter() + .flat_map(|k| items.iter().map(|i| k.clone().with(*cat, *i))) + .collect(); + } + let matched: std::collections::HashSet = members.iter() + .flat_map(|k| match exact { + true => store.get(k).map(|_| k.clone()).into_iter().collect::>(), + false => store.matching_cells(&k.0).into_iter().map(|(k, _)| k).collect(), + }) + .collect(); + let expected: Vec = store.iter_cells() + .filter(|(k, _)| matched.contains(k)) + .map(|(_, v)| v.clone()) + .collect(); + let got: Vec = store.spanned_values(&base.0, &spans, exact) + .into_iter().cloned().collect(); + prop_assert_eq!(got, expected); + } } }