feat(incremental): order-free aggregates and occupancy from maintained groups (improvise-u2le)

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 14:30:41 -07:00
committed by Edward Langley
parent 29f1f85fe1
commit a18a092186
11 changed files with 1199 additions and 217 deletions
+65
View File
@@ -0,0 +1,65 @@
//! What a fold reaches: eager's `group_expansion`, read off the model (ADR 009 clause 7).
//! Reasoning: `context/code/improvise-incremental.md`, step 4.
use improvise_core::model::Model;
use improvise_core::model::cell::CellKey;
/// A fold's reach as data: `base` is the key with each expanded category removed, and
/// `spans` the covered items of each, in expansion order.
pub struct Expansion {
pub base: CellKey,
pub spans: Vec<(String, Vec<String>)>,
}
impl Expansion {
/// Each category `key` folds, with its covered members: a GROUP it names, or a
/// category in `over` it omits. No spans when neither.
pub fn of(
model: &Model,
key: &CellKey,
covers: &dyn Fn(&str, &str) -> bool,
over: &[String],
) -> Self {
let grouped = key.0.iter().map(|(cat, name)| {
let members: Vec<&str> = model
.category(cat)
.filter(|c| !c.items.contains_key(name.as_str()))
.map(|c| c.items_in_group(name))
.unwrap_or_default();
(cat.as_str(), members)
});
let spanned = over.iter().filter(|cat| key.get(cat).is_none()).map(|cat| {
let items = model.category(cat).map(|c| c.ordered_item_names());
(cat.as_str(), items.unwrap_or_default())
});
let spans: Vec<(String, Vec<String>)> = grouped
.chain(spanned)
.map(|(cat, members)| {
let covered = members.into_iter().filter(|m| covers(cat, m));
(
cat.to_string(),
covered.map(String::from).collect::<Vec<_>>(),
)
})
.filter(|(_, covered)| !covered.is_empty())
.collect();
let base = spans.iter().fold(key.clone(), |k, (cat, _)| k.without(cat));
Expansion { base, spans }
}
/// One key per member: the cross product in expansion order.
pub fn members(&self) -> Vec<CellKey> {
let mut expanded = vec![self.base.clone()];
for (cat, covered) in &self.spans {
expanded = expanded
.iter()
.flat_map(|k| {
covered
.iter()
.map(|m| k.clone().with(cat.as_str(), m.as_str()))
})
.collect();
}
expanded
}
}
+194
View File
@@ -0,0 +1,194 @@
//! One group's maintained state and how it is read (ADR 009 clause 4).
//! Reasoning: `context/code/improvise-incremental.md`, step 4.
use crate::DeltaValue;
use improvise_core::formula::ast::AggFunc;
use improvise_core::model::cell::CellValue;
use std::collections::BTreeMap;
/// A group's cells: how many, how many are numbers, and a multiset of their values.
/// An update touches one map entry, so it costs O(log d) in the distinct values.
#[derive(Debug, Clone, Default)]
pub struct Group {
cells: i64,
numbers: i64,
values: BTreeMap<DeltaValue, i64>,
}
impl Group {
/// Adds `weight` copies of `value`; a negative weight retracts.
pub fn apply(&mut self, value: &DeltaValue, weight: i64) {
self.cells += weight;
if matches!(value.value(), CellValue::Number(_)) {
self.numbers += weight;
}
let held = self.values.get(value).copied().unwrap_or(0) + weight;
match held {
0 => self.values.remove(value),
n => self.values.insert(value.clone(), n),
};
}
pub fn cells(&self) -> i64 {
self.cells
}
pub fn is_empty(&self) -> bool {
self.cells == 0
}
/// A group of exactly these values, for a fold over cells read one by one.
pub fn of<'a>(values: impl IntoIterator<Item = &'a CellValue>) -> Self {
let mut group = Group::default();
for value in values {
group.apply(&DeltaValue::new(value), 1);
}
group
}
/// The smallest number that is not NaN, if the group has one.
fn smallest(&self) -> Option<f64> {
self.values.keys().find_map(non_nan)
}
/// The largest number that is not NaN, if the group has one.
fn largest(&self) -> Option<f64> {
self.values.keys().rev().find_map(non_nan)
}
}
fn non_nan(value: &DeltaValue) -> Option<f64> {
match value.value() {
CellValue::Number(n) if !n.is_nan() => Some(*n),
_ => None,
}
}
/// The aggregates a group answers without looking at order: step 4's.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OrderFree {
Count,
Min,
Max,
}
impl OrderFree {
/// `Sum` and `Avg` depend on the order eager adds in, so they are not here.
pub fn of(func: &AggFunc) -> Option<Self> {
match func {
AggFunc::Count => Some(OrderFree::Count),
AggFunc::Min => Some(OrderFree::Min),
AggFunc::Max => Some(OrderFree::Max),
AggFunc::Sum | AggFunc::Avg => None,
}
}
}
/// What eager's `aggregate_cell_values` returns for the union of `groups`: numbers reduce,
/// anything else is unanimous or `Error("mixed")`, and no cells is absent.
pub fn fold_groups(groups: &[&Group], func: OrderFree) -> Option<CellValue> {
let cells: i64 = groups.iter().map(|g| g.cells).sum();
let numbers: i64 = groups.iter().map(|g| g.numbers).sum();
match (cells, numbers == cells) {
(0, _) => None,
(_, true) => Some(CellValue::Number(reduce(groups, func, cells))),
(_, false) => Some(unanimous(groups)),
}
}
/// `Min` and `Max` skip NaN, and are infinite when nothing else is left, as eager's folds are.
fn reduce(groups: &[&Group], func: OrderFree, cells: i64) -> f64 {
match func {
OrderFree::Count => cells as f64,
OrderFree::Min => {
let smallest = groups.iter().filter_map(|g| g.smallest());
smallest.fold(f64::INFINITY, f64::min)
}
OrderFree::Max => {
let largest = groups.iter().filter_map(|g| g.largest());
largest.fold(f64::NEG_INFINITY, f64::max)
}
}
}
fn unanimous(groups: &[&Group]) -> CellValue {
let mut distinct = groups.iter().flat_map(|g| g.values.keys());
let first = distinct.next();
match first.filter(|first| distinct.all(|v| v == *first)) {
Some(only) => only.value().clone(),
None => CellValue::Error("mixed".into()),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn group(values: &[CellValue]) -> Group {
Group::of(values)
}
fn num(n: f64) -> CellValue {
CellValue::Number(n)
}
#[test]
fn a_retraction_removes_the_value_it_added() {
let mut g = group(&[num(1.0), num(2.0)]);
g.apply(&DeltaValue::new(&num(1.0)), -1);
assert_eq!(g.cells(), 1);
assert_eq!(
fold_groups(&[&g], OrderFree::Min),
Some(num(2.0)),
"the retracted minimum is gone"
);
}
#[test]
fn an_emptied_group_is_absent_and_keeps_no_entries() {
let mut g = group(&[num(1.0)]);
g.apply(&DeltaValue::new(&num(1.0)), -1);
assert!(g.is_empty());
assert!(g.values.is_empty(), "no zero-weight entry is left behind");
assert_eq!(fold_groups(&[&g], OrderFree::Count), None);
}
#[test]
fn min_and_max_skip_nan_and_are_infinite_over_nothing_else() {
let some = group(&[num(f64::NAN), num(3.0), num(-1.0)]);
assert_eq!(fold_groups(&[&some], OrderFree::Min), Some(num(-1.0)));
assert_eq!(fold_groups(&[&some], OrderFree::Max), Some(num(3.0)));
let only_nan = group(&[num(f64::NAN), num(-f64::NAN)]);
assert_eq!(
fold_groups(&[&only_nan], OrderFree::Min),
Some(num(f64::INFINITY))
);
assert_eq!(
fold_groups(&[&only_nan], OrderFree::Max),
Some(num(f64::NEG_INFINITY))
);
assert_eq!(fold_groups(&[&only_nan], OrderFree::Count), Some(num(2.0)));
}
#[test]
fn a_fold_over_several_groups_is_the_fold_of_their_union() {
let (a, b) = (group(&[num(5.0), num(9.0)]), group(&[num(2.0)]));
assert_eq!(fold_groups(&[&a, &b], OrderFree::Min), Some(num(2.0)));
assert_eq!(fold_groups(&[&a, &b], OrderFree::Max), Some(num(9.0)));
assert_eq!(fold_groups(&[&a, &b], OrderFree::Count), Some(num(3.0)));
}
#[test]
fn text_is_unanimous_or_mixed_across_groups() {
let text = |s: &str| CellValue::Text(s.into());
let (a, b) = (group(&[text("x"), text("x")]), group(&[text("x")]));
assert_eq!(fold_groups(&[&a, &b], OrderFree::Min), Some(text("x")));
let c = group(&[text("y")]);
let mixed = Some(CellValue::Error("mixed".into()));
assert_eq!(fold_groups(&[&a, &c], OrderFree::Min), mixed);
let with_number = group(&[text("x"), num(1.0)]);
assert_eq!(fold_groups(&[&with_number], OrderFree::Count), mixed);
let nan_among_text = group(&[num(f64::NAN), text("x")]);
assert_eq!(fold_groups(&[&nan_among_text], OrderFree::Count), mixed);
}
}
@@ -0,0 +1,186 @@
//! The incremental backend (ADR 009 clauses 3, 4 and 7).
//! Reasoning: `context/code/improvise-incremental.md`, step 4.
use crate::fold::Expansion;
use crate::group::{Group, OrderFree, fold_groups};
use crate::shapes::{Meter, Shapes};
use crate::{Derived, Edit, Maintained, Reading, cell_delta};
use improvise_core::formula::ast::AggFunc;
use improvise_core::model::category::MEASURE_CATEGORY;
use improvise_core::model::cell::{CellKey, CellValue};
use improvise_core::model::types::Covers;
use improvise_core::model::{Aggregation, Model};
use std::collections::BTreeMap;
/// A model whose aggregates are maintained from the cell delta, not re-folded on read.
pub struct Incremental {
model: Model,
none_cats: Vec<String>,
shapes: Shapes,
}
static SUM: AggFunc = AggFunc::Sum;
impl Incremental {
pub fn new(mut model: Model, none_cats: Vec<String>) -> Self {
model.data.record_changes();
model.recompute_formulas(&none_cats);
Incremental {
model,
none_cats,
shapes: Shapes::default(),
}
}
pub fn model(&self) -> &Model {
&self.model
}
/// The work done so far, in operations.
pub fn meter(&self) -> Meter {
self.shapes.meter()
}
/// How many category sets have a maintained group map.
pub fn shape_count(&self) -> usize {
self.shapes.shape_count()
}
fn func_for(&self, key: &CellKey) -> &AggFunc {
key.get(MEASURE_CATEGORY)
.and_then(|m| self.model.measure_agg.get(m))
.unwrap_or(&SUM)
}
fn read(
&self,
key: &CellKey,
agg: Aggregation,
covers: Covers,
over: &[String],
) -> Option<CellValue> {
let owned = self.model.formula_for(key).is_some();
let func = OrderFree::of(self.func_for(key)).filter(|_| !owned);
let Some(func) = func else {
return match over.is_empty() {
true => self.model.evaluate_covering(key, agg, covers),
false => self.model.evaluate_spanning(key, over, agg, covers),
};
};
let expansion = Expansion::of(&self.model, key, covers, over);
match (expansion.spans.is_empty(), agg) {
(true, Aggregation::Exact) => self.model.data.get(key).cloned(),
(true, Aggregation::OverHidden) => self.fold(std::slice::from_ref(key), func),
(false, Aggregation::Exact) => {
let members = expansion.members();
let cells = members.iter().filter_map(|m| self.model.data.get(m));
fold_groups(&[&Group::of(cells)], func)
}
(false, Aggregation::OverHidden) => self.fold(&expansion.members(), func),
}
}
fn fold(&self, keys: &[CellKey], func: OrderFree) -> Option<CellValue> {
self.shapes
.read(&self.model.data, keys, |groups| fold_groups(groups, func))
}
/// The items a stored cell may hold in `cat` to be named by `item`: that item, or a member
/// of the group it names.
fn holders(&self, cat: &str, item: &str) -> Vec<String> {
let members = self
.model
.category(cat)
.filter(|c| !c.items.contains_key(item))
.map(|c| c.items_in_group(item))
.unwrap_or_default();
std::iter::once(item)
.chain(members)
.map(String::from)
.collect()
}
fn cells_at(&self, at: &CellKey) -> i64 {
let keys = std::slice::from_ref(at);
let cells = |groups: &[&Group]| groups.first().map_or(0, |g| g.cells());
self.shapes.read(&self.model.data, keys, cells)
}
/// Cells at or under `at` that draw no hidden coordinate: all of them, less those with a
/// hidden item in a category `at` leaves free (inclusion-exclusion over those categories).
fn drawn_cells(&self, at: &CellKey, reading: &Reading) -> i64 {
if at.0.iter().any(|(cat, item)| !reading.covers(cat, item)) {
return 0;
}
let mut free: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
for (cat, item) in reading.hidden().filter(|(c, _)| at.get(c).is_none()) {
free.entry(cat).or_default().push(item);
}
let free: Vec<(&str, Vec<&str>)> = free.into_iter().collect();
self.excluding(at, &free)
}
/// `cells_at(at)` less the cells with an item from the first free category's hidden items,
/// each term of the sum being one choice of hidden item per category it keeps.
fn excluding(&self, at: &CellKey, free: &[(&str, Vec<&str>)]) -> i64 {
let Some(((cat, hidden), rest)) = free.split_first() else {
return self.cells_at(at);
};
let kept = self.excluding(at, rest);
let dropped: i64 = hidden
.iter()
.map(|item| self.excluding(&at.clone().with(*cat, *item), rest))
.sum();
kept - dropped
}
}
impl Derived for Incremental {
fn value(&self, key: &CellKey, agg: Aggregation) -> Option<CellValue> {
self.read(key, agg, &|_, _| true, &[])
}
fn raw_fold(&self, partial: &CellKey) -> Option<CellValue> {
match self.model.formula_for(partial) {
Some(_) => None,
None => self.value(partial, Aggregation::OverHidden),
}
}
fn covered(&self, key: &CellKey, reading: &Reading) -> Option<CellValue> {
self.read(key, reading.agg, &|c, i| reading.covers(c, i), &[])
}
fn total(&self, key: &CellKey, over: &[String], reading: &Reading) -> Option<CellValue> {
self.read(key, reading.agg, &|c, i| reading.covers(c, i), over)
}
fn occupancy(&self, partial: &CellKey, reading: &Reading) -> usize {
let mut alternatives = vec![CellKey::new(vec![])];
for (cat, item) in &partial.0 {
let holders = self.holders(cat, item);
alternatives = alternatives
.iter()
.flat_map(|k| {
holders
.iter()
.map(|h| k.clone().with(cat.as_str(), h.as_str()))
})
.collect();
}
let total: i64 = alternatives
.iter()
.map(|at| self.drawn_cells(at, reading))
.sum();
total as usize
}
}
impl Maintained for Incremental {
fn apply(&mut self, edit: &Edit) {
edit.apply(&mut self.model);
let delta = cell_delta(&self.model.data.drain_changes());
self.shapes.apply(&delta);
self.model.recompute_formulas(&self.none_cats);
}
}
+6
View File
@@ -4,8 +4,12 @@
mod delta;
mod eager;
mod edit;
mod fold;
mod group;
mod harness;
mod incremental;
mod reading;
mod shapes;
pub use delta::{CellDelta, DeltaValue, cell_delta, moved_records};
pub use eager::Eager;
@@ -14,7 +18,9 @@ pub use harness::{
Divergence, MAX_DROPPED, Maintained, Probe, Report, differential, differential_in,
drawn_probes, edit_probes, probes, same_value,
};
pub use incremental::Incremental;
pub use reading::Reading;
pub use shapes::Meter;
use improvise_core::model::Aggregation;
use improvise_core::model::cell::{CellKey, CellValue};
@@ -29,6 +29,11 @@ impl Reading {
Self::new(Aggregation::of(none_cats), hidden)
}
/// The hidden `(category, item)` pairs, ascending.
pub fn hidden(&self) -> impl Iterator<Item = &(String, String)> {
self.hidden.iter()
}
/// Whether the view draws `item` of `cat`.
pub fn covers(&self, cat: &str, item: &str) -> bool {
!self.hidden.contains(&(cat.to_string(), item.to_string()))
+122
View File
@@ -0,0 +1,122 @@
//! The maintained groups, one map per set of named categories (ADR 009 clause 7).
//! Reasoning: `context/code/improvise-incremental.md`, step 4.
use crate::group::Group;
use crate::{CellDelta, DeltaValue};
use improvise_core::model::cell::{CellKey, DataStore};
use std::cell::{Cell, RefCell};
use std::collections::BTreeMap;
/// How much work was done: operations, never time.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct Meter {
/// Group updates applied from a delta.
pub updates: u64,
/// Groups looked up to answer a read.
pub reads: u64,
/// Shapes built from the store.
pub builds: u64,
/// Cells visited by those builds.
pub built_cells: u64,
}
/// The groups of one category set: a stored cell lands in the group of its coordinates
/// on those categories, if it has them all.
struct Shape {
groups: BTreeMap<CellKey, Group>,
}
fn project(key: &CellKey, cats: &[String]) -> Option<CellKey> {
let coords: Option<Vec<(String, String)>> = cats
.iter()
.map(|c| key.get(c).map(|i| (c.clone(), i.to_string())))
.collect();
coords.map(CellKey::new)
}
impl Shape {
fn apply(&mut self, cats: &[String], delta: &CellDelta) -> u64 {
let mut updates = 0;
for ((key, value), weight) in delta.iter() {
let Some(at) = project(key, cats) else {
continue;
};
let group = self.groups.entry(at.clone()).or_default();
group.apply(value, weight);
updates += 1;
if group.is_empty() {
self.groups.remove(&at);
}
}
updates
}
}
/// Every shape a read has asked for. A shape is built from the store on its first read (a
/// full pass) and fed every delta after.
#[derive(Default)]
pub struct Shapes {
built: RefCell<BTreeMap<Vec<String>, Shape>>,
meter: Cell<Meter>,
}
impl Shapes {
pub fn meter(&self) -> Meter {
self.meter.get()
}
fn bump(&self, f: impl FnOnce(&mut Meter)) {
let mut m = self.meter.get();
f(&mut m);
self.meter.set(m);
}
pub fn shape_count(&self) -> usize {
self.built.borrow().len()
}
/// Feeds `delta` to every shape built so far.
pub fn apply(&mut self, delta: &CellDelta) {
let updates: u64 = self
.built
.get_mut()
.iter_mut()
.map(|(cats, shape)| shape.apply(cats, delta))
.sum();
self.bump(|m| m.updates += updates);
}
/// Runs `read` on the groups at `keys` in the shape of their categories, building the
/// shape first if no read has asked for it. A key with no group is left out.
pub fn read<R>(
&self,
store: &DataStore,
keys: &[CellKey],
read: impl FnOnce(&[&Group]) -> R,
) -> R {
let Some(first) = keys.first() else {
return read(&[]);
};
let cats: Vec<String> = first.0.iter().map(|(c, _)| c.clone()).collect();
let mut built = self.built.borrow_mut();
let shape = built.entry(cats.clone()).or_insert_with(|| {
let mut shape = Shape {
groups: BTreeMap::new(),
};
let cells = store.iter_cells();
let all: CellDelta = cells
.map(|(k, v)| ((k, DeltaValue::new(v)), 1))
.collect::<CellDelta>();
let visited = all.len() as u64;
shape.apply(&cats, &all);
self.bump(|m| {
m.builds += 1;
m.built_cells += visited;
});
shape
});
let groups: Vec<&Group> = keys.iter().filter_map(|k| shape.groups.get(k)).collect();
self.bump(|m| m.reads += groups.len() as u64);
read(&groups)
}
}
@@ -0,0 +1,109 @@
//! `Incremental` against `Eager` over the corpus and over random edit sequences.
//! Evidence: `context/test-evidence/improvise-incremental.md`.
mod support;
use improvise_core::formula::ast::AggFunc;
use improvise_core::model::Model;
use improvise_core::model::cell::CellValue;
use improvise_core::view::View;
use improvise_incremental::{Edit, Incremental, differential_in};
use support::{
corpus, edits_for, grouped_model, hand_model, hidden_workbook, none_of, ratio_workbook,
wide_workbook,
};
fn run(model: &Model, views: &[View], none: &[String], edits: &[Edit]) -> Result<(), String> {
let candidate = Incremental::new(model.clone(), none.to_vec());
differential_in(model, views, none, candidate, edits)
.map(|_| ())
.map_err(|d| d.to_string())
}
fn agg_edits(measure: &str) -> Vec<Edit> {
[
AggFunc::Count,
AggFunc::Min,
AggFunc::Max,
AggFunc::Avg,
AggFunc::Sum,
]
.into_iter()
.map(|func| Edit::SetMeasureAgg {
measure: measure.into(),
func,
})
.collect()
}
#[test]
fn incremental_matches_eager_on_the_hand_model_under_every_aggregation() {
let model = hand_model();
let none = vec!["Region".to_string()];
let mut edits = agg_edits("Revenue");
edits.extend(agg_edits("Note"));
edits.push(Edit::SetCell(
support::key("East", "A", "Revenue"),
CellValue::Text("oops".into()),
));
edits.push(Edit::ClearCell(support::key("West", "A", "Revenue")));
run(&model, &[], &none, &edits).unwrap();
}
#[test]
fn incremental_matches_eager_on_the_shipped_documents() {
let mut files_done: Vec<String> = Vec::new();
for (name, model, none, views) in corpus() {
let file = name.split(':').next().unwrap().to_string();
if files_done.contains(&file) {
continue;
}
files_done.push(file);
let mut edits = edits_for(&model);
if model.data.iter_cells().count() > 40 {
edits.truncate(4);
}
run(&model, &views, &none, &edits).unwrap_or_else(|d| panic!("{name}: {d}"));
}
}
#[test]
fn incremental_matches_eager_on_hidden_items_and_collapsed_groups() {
let wb = hidden_workbook();
let views: Vec<View> = wb.views.values().cloned().collect();
let none = none_of(&wb);
let mut edits = edits_for(&wb.model);
edits.extend(agg_edits("Revenue"));
run(&wb.model, &views, &none, &edits).unwrap();
}
#[test]
fn incremental_matches_eager_on_a_group_read_without_a_view() {
let model = grouped_model();
let mut edits = agg_edits("Revenue");
edits.push(Edit::SetCell(
support::key("East", "A", "Revenue"),
CellValue::Number(f64::NAN),
));
edits.push(Edit::ClearCell(support::key("West", "A", "Revenue")));
run(&model, &[], &[], &edits).unwrap();
run(&model, &[], &["Product".to_string()], &edits).unwrap();
}
#[test]
fn incremental_matches_eager_on_a_cell_a_view_draws_that_drops_four_coordinates() {
let wb = wide_workbook();
let views: Vec<View> = wb.views.values().cloned().collect();
let none = none_of(&wb);
let mut edits = edits_for(&wb.model);
edits.extend(agg_edits("Revenue"));
run(&wb.model, &views, &none, &edits).unwrap();
}
#[test]
fn incremental_matches_eager_on_a_pooled_total_of_a_ratio() {
let wb = ratio_workbook();
let views: Vec<View> = wb.views.values().cloned().collect();
let none = none_of(&wb);
run(&wb.model, &views, &none, &edits_for(&wb.model)).unwrap();
}
@@ -0,0 +1,183 @@
//! What `Incremental` costs in operations, at two sizes of one deterministic table.
//! Evidence: `context/test-evidence/improvise-incremental.md`. Counts, never time.
use improvise_core::formula::ast::AggFunc;
use improvise_core::model::cell::{CellKey, CellValue};
use improvise_core::model::{Aggregation, Model};
use improvise_incremental::{Derived, Edit, Incremental, Maintained, Meter, Reading};
fn cell(r: usize, p: usize, m: usize) -> CellKey {
CellKey::new(vec![
("Region".into(), format!("r{r}")),
("Product".into(), format!("p{p}")),
("Month".into(), format!("m{m}")),
("_Measure".into(), "Revenue".into()),
])
}
/// `n` regions by `n` products by 6 months, one measure, values from a fixed sequence.
fn table(n: usize, func: AggFunc) -> Model {
let mut model = Model::new("table");
for (cat, prefix, count) in [("Region", "r", n), ("Product", "p", n), ("Month", "m", 6)] {
model.add_category(cat).unwrap();
for i in 0..count {
model
.category_mut(cat)
.unwrap()
.add_item(format!("{prefix}{i}"));
}
}
model.category_mut("_Measure").unwrap().add_item("Revenue");
model.measure_agg.insert("Revenue".into(), func);
let mut seed = 1u64;
for r in 0..n {
for p in 0..n {
for m in 0..6 {
seed = seed
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
let v = ((seed >> 33) % 1000) as f64;
model.set_cell(cell(r, p, m), CellValue::Number(v));
}
}
}
model
}
fn region_partial() -> CellKey {
CellKey::new(vec![
("Region".into(), "r1".into()),
("_Measure".into(), "Revenue".into()),
])
}
fn product_partial() -> CellKey {
CellKey::new(vec![
("Product".into(), "p2".into()),
("_Measure".into(), "Revenue".into()),
])
}
fn since(before: Meter, after: Meter) -> Meter {
Meter {
updates: after.updates - before.updates,
reads: after.reads - before.reads,
builds: after.builds - before.builds,
built_cells: after.built_cells - before.built_cells,
}
}
#[test]
fn a_group_read_comes_from_a_maintained_group() {
let model = table(6, AggFunc::Min);
let mut backend = Incremental::new(model.clone(), vec![]);
let want = backend.raw_fold(&region_partial());
assert!(matches!(want, Some(CellValue::Number(_))));
assert_eq!(backend.meter().builds, 1, "the first read builds its shape");
assert_eq!(backend.meter().reads, 1);
backend.apply(&Edit::SetCell(cell(1, 0, 0), CellValue::Number(-5.0)));
assert_eq!(
backend.raw_fold(&region_partial()),
Some(CellValue::Number(-5.0)),
"the new minimum comes from the delta"
);
assert_eq!(
backend.meter().builds,
1,
"and no second pass over the store"
);
}
/// The operations one overwrite costs once `shapes` category sets are maintained.
fn overwrite_cost(n: usize, shapes: usize) -> (Meter, usize) {
let mut backend = Incremental::new(table(n, AggFunc::Max), vec![]);
let partials = [region_partial(), product_partial()];
for partial in partials.iter().take(shapes) {
backend.raw_fold(partial);
}
let before = backend.meter();
backend.apply(&Edit::SetCell(cell(1, 2, 3), CellValue::Number(2000.0)));
let cost = since(before, backend.meter());
let seen = backend.raw_fold(&region_partial());
assert_eq!(seen, Some(CellValue::Number(2000.0)));
(cost, backend.shape_count())
}
#[test]
fn an_overwrite_costs_two_updates_per_shape_whatever_the_table_size() {
for shapes in [1, 2] {
let (small, built) = overwrite_cost(6, shapes);
let (large, _) = overwrite_cost(12, shapes);
assert!(built >= shapes);
assert_eq!(
small.updates,
2 * shapes as u64,
"retract and insert, per shape"
);
assert_eq!(small, large, "216 cells and 864 cells cost the same");
assert_eq!(large.builds, 0);
}
}
#[test]
fn a_read_visits_one_group_where_eager_visits_every_cell_it_covers() {
for n in [6, 12] {
let model = table(n, AggFunc::Min);
let backend = Incremental::new(model.clone(), vec![]);
backend.raw_fold(&region_partial());
let before = backend.meter();
backend.raw_fold(&region_partial());
assert_eq!(since(before, backend.meter()).reads, 1, "n = {n}");
let eager_visits = model.data.matching_values(&region_partial().0).len();
assert_eq!(
eager_visits,
n * 6,
"eager folds a region's {} cells",
n * 6
);
}
}
#[test]
fn a_collapsed_group_reads_one_group_per_member_it_covers() {
let mut model = table(6, AggFunc::Count);
let region = model.category_mut("Region").unwrap();
region.add_group(improvise_core::model::category::Group::new("Coast"));
for item in ["r0", "r1", "r2"] {
region.items.get_mut(item).unwrap().group = Some("Coast".into());
}
let backend = Incremental::new(model, vec![]);
let coast = CellKey::new(vec![
("Region".into(), "Coast".into()),
("_Measure".into(), "Revenue".into()),
]);
backend.raw_fold(&coast);
let before = backend.meter();
assert_eq!(
backend.raw_fold(&coast),
Some(CellValue::Number(3.0 * 6.0 * 6.0))
);
assert_eq!(since(before, backend.meter()).reads, 3, "r0, r1 and r2");
}
#[test]
fn occupancy_with_a_hidden_item_reads_the_groups_it_subtracts() {
let backend = Incremental::new(table(6, AggFunc::Count), vec![]);
let reading = Reading::new(
Aggregation::OverHidden,
[("Product".to_string(), "p3".to_string())],
);
let line = CellKey::new(vec![("Region".into(), "r0".into())]);
assert_eq!(
backend.occupancy(&line, &reading),
5 * 6,
"p3's six cells are left out"
);
let before = backend.meter();
backend.occupancy(&line, &reading);
assert_eq!(
since(before, backend.meter()).reads,
2,
"the line, less p3 under it"
);
}
+6 -217
View File
@@ -5,21 +5,17 @@ use improvise_core::formula::ast::AggFunc;
use improvise_core::formula::parse_formula;
use improvise_core::model::cell::{CellKey, CellValue};
use improvise_core::model::{Aggregation, Model};
use improvise_core::view::{Axis, GridLayout, View};
use improvise_core::view::{Axis, GridLayout};
use improvise_incremental::{
CellDelta, DeltaValue, Derived, Divergence, Eager, Edit, Maintained, Probe, Reading,
cell_delta, differential, differential_in, drawn_probes, probes, same_value,
};
use proptest::prelude::*;
use std::path::Path;
fn key(region: &str, product: &str, measure: &str) -> CellKey {
CellKey::new(vec![
("Region".into(), region.into()),
("Product".into(), product.into()),
("_Measure".into(), measure.into()),
])
}
mod support;
use support::{
coastal, corpus, edits_for, grouped_model, hand_model, hidden_workbook, key, none_of,
ratio_workbook, wide_workbook,
};
fn partial(product: &str, measure: &str) -> CellKey {
CellKey::new(vec![
@@ -40,34 +36,6 @@ fn mixed() -> Option<CellValue> {
Some(CellValue::Error("mixed".into()))
}
/// Region is the None category. Revenue and Cost are numbers; Note is text,
/// unanimous under A and conflicting under B.
fn hand_model() -> Model {
let mut m = Model::new("hand");
for (cat, items) in [("Region", ["East", "West"]), ("Product", ["A", "B"])] {
m.add_category(cat).unwrap();
for item in items {
m.category_mut(cat).unwrap().add_item(item);
}
}
for measure in ["Revenue", "Cost", "Note"] {
m.category_mut("_Measure").unwrap().add_item(measure);
}
let rows = [
("East", "A", 10.0, 4.0, "n", "n"),
("West", "A", 5.0, 2.0, "n", "n"),
("East", "B", 7.0, 3.0, "x", "x"),
("West", "B", 1.0, 1.0, "y", "y"),
];
for (r, p, rev, cost, note, _) in rows {
m.set_cell(key(r, p, "Revenue"), CellValue::Number(rev));
m.set_cell(key(r, p, "Cost"), CellValue::Number(cost));
m.set_cell(key(r, p, "Note"), CellValue::Text(note.into()));
}
m.add_formula(parse_formula("Profit = Revenue - Cost", "_Measure").unwrap());
m
}
fn hand_eager() -> Eager {
Eager::new(hand_model(), vec!["Region".into()])
}
@@ -149,65 +117,6 @@ fn eager_numbers_among_text_are_mixed() {
assert_eq!(eager.raw_fold(&partial("B", "Revenue")), mixed());
}
type Doc = (String, Model, Vec<String>, Vec<View>);
fn corpus() -> Vec<Doc> {
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
let mut out = Vec::new();
for file in ["examples/demo.improv", "web/demo.improv"] {
let wb = improvise_io::persistence::load(&root.join(file)).unwrap();
let mut seen: Vec<Vec<String>> = Vec::new();
for (name, view) in &wb.views {
let none: Vec<String> = view
.categories_on(Axis::None)
.into_iter()
.map(String::from)
.collect();
if !seen.contains(&none) {
seen.push(none.clone());
let views = wb.views.values().cloned().collect();
out.push((format!("{file}:{name}"), wb.model.clone(), none, views));
}
}
}
out.push(("hand".into(), hand_model(), vec!["Region".into()], vec![]));
let hidden = hidden_workbook();
let views = hidden.views.values().cloned().collect();
let none = none_of(&hidden);
out.push(("hidden".into(), hidden.model, none, views));
out
}
/// Edits built from the model's own first cell: overwrite, text, clear, a new
/// record, a new aggregation, and the first formula removed then restored.
fn edits_for(model: &Model) -> Vec<Edit> {
let Some((first, _)) = model.data.iter_cells().next() else {
return vec![];
};
let (last_cat, _) = first.0.last().unwrap().clone();
let measure = first.get("_Measure").map(String::from);
let fresh = first.clone().with(last_cat, "Zz-new");
let mut edits = vec![
Edit::SetCell(first.clone(), CellValue::Number(7.5)),
Edit::SetCell(first.clone(), CellValue::Text("t".into())),
Edit::SetCell(fresh.clone(), CellValue::Number(3.0)),
Edit::ClearCell(first),
];
edits.extend(measure.map(|m| Edit::SetMeasureAgg {
measure: m,
func: AggFunc::Max,
}));
if let Some(f) = model.formulas().first() {
edits.push(Edit::RemoveFormula {
target: f.target.clone(),
target_category: f.target_category.clone(),
});
edits.push(Edit::AddFormula(f.clone()));
}
edits.push(Edit::ClearCell(fresh));
edits
}
#[test]
fn eager_matches_the_evaluator_on_the_shipped_documents() {
for (name, model, none, views) in corpus() {
@@ -273,29 +182,6 @@ fn eager_agrees_with_itself_over_every_corpus_edit_sequence() {
}
}
fn grouped_model() -> Model {
let mut m = Model::new("grouped");
m.add_category("Region").unwrap();
m.add_category("Product").unwrap();
m.category_mut("Region")
.unwrap()
.add_group(improvise_core::model::category::Group::new("Coastal"));
let region = m.category_mut("Region").unwrap();
region.add_item_in_group("East", "Coastal");
region.add_item_in_group("West", "Coastal");
region.add_item("Inland");
m.category_mut("Product").unwrap().add_item("A");
m.category_mut("_Measure").unwrap().add_item("Revenue");
for (r, v) in [("East", 10.0), ("West", 5.0), ("Inland", 2.0)] {
m.set_cell(key(r, "A", "Revenue"), CellValue::Number(v));
}
m
}
fn coastal() -> CellKey {
key("Coastal", "A", "Revenue")
}
/// A wrong backend: an `Eager` told every edit, answering one thing wrongly.
#[derive(Clone, Copy)]
enum Fault {
@@ -732,43 +618,6 @@ proptest! {
}
}
/// Six categories, four of them None in the one view: the cell it draws
/// drops four coordinates of every stored key.
fn wide_workbook() -> improvise_core::workbook::Workbook {
let mut wb = improvise_core::workbook::Workbook::new("wide");
for c in ["C0", "C1", "C2", "C3", "C4"] {
wb.add_category(c).unwrap();
wb.add_item(c, "a").unwrap();
}
wb.add_item("C0", "b").unwrap();
wb.add_item("_Measure", "Revenue").unwrap();
for (c0, v) in [("a", 1.0), ("b", 2.0)] {
let k = CellKey::new(vec![
("C0".into(), c0.into()),
("C1".into(), "a".into()),
("C2".into(), "a".into()),
("C3".into(), "a".into()),
("C4".into(), "a".into()),
("_Measure".into(), "Revenue".into()),
]);
wb.model.set_cell(k, CellValue::Number(v));
}
let view = wb.active_view_mut();
view.set_axis("C0", Axis::Row);
view.set_axis("_Measure", Axis::Column);
for c in ["C1", "C2", "C3", "C4"] {
view.set_axis(c, Axis::None);
}
wb
}
/// The None categories of the active view, virtual ones included: the set an
/// `Eager` standing for that view is built with.
fn none_of(wb: &improvise_core::workbook::Workbook) -> Vec<String> {
let none = wb.active_view().categories_on(Axis::None);
none.into_iter().map(String::from).collect()
}
fn wide_cell() -> CellKey {
CellKey::new(vec![
("C0".into(), "a".into()),
@@ -820,38 +669,6 @@ fn the_harness_checks_a_cell_a_view_draws_that_drops_four_coordinates() {
assert_eq!(*d.probe, Probe::Value(wide_cell(), Aggregation::Exact));
}
/// East is hidden and Coastal is collapsed (a group is, by default), so the Coastal row folds West only;
/// Inland has a cell under A only, so B's column holds just Coastal's.
fn hidden_workbook() -> improvise_core::workbook::Workbook {
let mut wb = improvise_core::workbook::Workbook::new("hidden");
for c in ["Region", "Product"] {
wb.add_category(c).unwrap();
}
wb.add_item_in_group("Region", "East", "Coastal").unwrap();
wb.add_item_in_group("Region", "West", "Coastal").unwrap();
wb.add_item("Region", "Inland").unwrap();
wb.add_item("Product", "A").unwrap();
wb.add_item("Product", "B").unwrap();
wb.add_item("_Measure", "Revenue").unwrap();
let cells = [
("East", "A", 10.0),
("West", "A", 5.0),
("East", "B", 20.0),
("West", "B", 1.0),
("Inland", "A", 2.0),
];
for (r, p, v) in cells {
wb.model
.set_cell(key(r, p, "Revenue"), CellValue::Number(v));
}
let view = wb.active_view_mut();
view.set_axis("Region", Axis::Row);
view.set_axis("Product", Axis::Column);
view.set_axis("_Measure", Axis::None);
view.hide_item("Region", "East");
wb
}
/// A backend that reads the view wrongly in exactly one way.
#[derive(Clone, Copy)]
enum Blindness {
@@ -917,34 +734,6 @@ fn run_blind_on(
}
}
/// Nothing is hidden and Margin is a ratio: the pooled total of Margin is
/// Revenue over Cost summed across the rows, which no single row's fold gives.
fn ratio_workbook() -> improvise_core::workbook::Workbook {
let mut wb = improvise_core::workbook::Workbook::new("ratio");
for c in ["Region", "Product"] {
wb.add_category(c).unwrap();
}
wb.add_item("Region", "East").unwrap();
wb.add_item("Region", "West").unwrap();
wb.add_item("Product", "A").unwrap();
for m in ["Revenue", "Cost"] {
wb.add_item("_Measure", m).unwrap();
}
for (r, rev, cost) in [("East", 10.0, 4.0), ("West", 5.0, 2.0)] {
wb.model
.set_cell(key(r, "A", "Revenue"), CellValue::Number(rev));
wb.model
.set_cell(key(r, "A", "Cost"), CellValue::Number(cost));
}
wb.model
.add_formula(parse_formula("Margin = Revenue / Cost", "_Measure").unwrap());
let view = wb.active_view_mut();
view.set_axis("Region", Axis::Row);
view.set_axis("_Measure", Axis::Column);
view.set_axis("Product", Axis::None);
wb
}
#[test]
fn the_harness_passes_a_view_reader_that_is_right() {
run_blind(None).unwrap();
@@ -0,0 +1,98 @@
//! `Incremental` against `Eager` over random edit sequences.
//! Evidence: `context/test-evidence/improvise-incremental.md`.
mod support;
use improvise_core::formula::ast::AggFunc;
use improvise_core::model::Model;
use improvise_core::model::cell::{CellKey, CellValue};
use improvise_core::view::View;
use improvise_incremental::{Edit, Incremental, differential_in};
use proptest::prelude::*;
use support::{grouped_model, hand_model, hidden_workbook, none_of, ratio_workbook, wide_workbook};
fn pool() -> impl Strategy<Value = CellValue> {
prop_oneof![
(-3i32..4).prop_map(|n| CellValue::Number(f64::from(n))),
Just(CellValue::Number(-0.0)),
Just(CellValue::Number(f64::NAN)),
Just(CellValue::Number(-f64::NAN)),
Just(CellValue::Number(f64::INFINITY)),
prop::sample::select(vec!["n", "x"]).prop_map(|t| CellValue::Text(t.into())),
Just(CellValue::Error("e".into())),
]
}
/// Edits built from a document's own cells: overwrite, a new record in a made-up item,
/// clear, and a new aggregation for one of its measures.
fn edits_over(model: &Model) -> BoxedStrategy<Edit> {
let keys: Vec<CellKey> = model.data.iter_cells().map(|(k, _)| k).take(24).collect();
let measures: Vec<String> = model.measure_item_names();
let funcs = vec![
AggFunc::Sum,
AggFunc::Avg,
AggFunc::Min,
AggFunc::Max,
AggFunc::Count,
];
let existing = prop::sample::select(keys.clone());
let fresh = (prop::sample::select(keys), 0usize..3).prop_map(|(k, which)| {
let (cat, _) = k.0[which % k.0.len()].clone();
k.with(cat, "Zz-new")
});
prop_oneof![
(existing.clone(), pool()).prop_map(|(k, v)| Edit::SetCell(k, v)),
(fresh, pool()).prop_map(|(k, v)| Edit::SetCell(k, v)),
existing.prop_map(Edit::ClearCell),
(prop::sample::select(measures), prop::sample::select(funcs))
.prop_map(|(m, func)| Edit::SetMeasureAgg { measure: m, func }),
]
.boxed()
}
fn small_documents() -> Vec<(String, Model, Vec<String>, Vec<View>)> {
let hidden = hidden_workbook();
let ratio = ratio_workbook();
let wide = wide_workbook();
let views = |wb: &improvise_core::workbook::Workbook| wb.views.values().cloned().collect();
vec![
("hand".into(), hand_model(), vec!["Region".into()], vec![]),
("grouped".into(), grouped_model(), vec![], vec![]),
(
"hidden".into(),
hidden.model.clone(),
none_of(&hidden),
views(&hidden),
),
(
"ratio".into(),
ratio.model.clone(),
none_of(&ratio),
views(&ratio),
),
(
"wide".into(),
wide.model.clone(),
none_of(&wide),
views(&wide),
),
]
}
fn any_document_edits() -> impl Strategy<Value = (usize, Vec<Edit>)> {
let docs = small_documents();
(0..docs.len()).prop_flat_map(move |i| {
let edits = prop::collection::vec(edits_over(&docs[i].1), 0..6);
(Just(i), edits)
})
}
proptest! {
#[test]
fn incremental_equals_eager_over_random_edit_sequences((i, edits) in any_document_edits()) {
let (name, model, none, views) = small_documents().swap_remove(i);
let candidate = Incremental::new(model.clone(), none.clone());
let ran = differential_in(&model, &views, &none, candidate, &edits);
prop_assert!(ran.is_ok(), "{name}: {}", ran.unwrap_err());
}
}
@@ -0,0 +1,225 @@
//! Fixtures the integration tests share: the shipped corpus and the hand-built models.
#![allow(dead_code)]
use improvise_core::formula::ast::AggFunc;
use improvise_core::formula::parse_formula;
use improvise_core::model::Model;
use improvise_core::model::cell::{CellKey, CellValue};
use improvise_core::view::{Axis, View};
use improvise_incremental::Edit;
use std::path::Path;
pub type Doc = (String, Model, Vec<String>, Vec<View>);
pub fn key(region: &str, product: &str, measure: &str) -> CellKey {
CellKey::new(vec![
("Region".into(), region.into()),
("Product".into(), product.into()),
("_Measure".into(), measure.into()),
])
}
/// Region is the None category. Revenue and Cost are numbers; Note is text,
/// unanimous under A and conflicting under B.
pub fn hand_model() -> Model {
let mut m = Model::new("hand");
for (cat, items) in [("Region", ["East", "West"]), ("Product", ["A", "B"])] {
m.add_category(cat).unwrap();
for item in items {
m.category_mut(cat).unwrap().add_item(item);
}
}
for measure in ["Revenue", "Cost", "Note"] {
m.category_mut("_Measure").unwrap().add_item(measure);
}
let rows = [
("East", "A", 10.0, 4.0, "n", "n"),
("West", "A", 5.0, 2.0, "n", "n"),
("East", "B", 7.0, 3.0, "x", "x"),
("West", "B", 1.0, 1.0, "y", "y"),
];
for (r, p, rev, cost, note, _) in rows {
m.set_cell(key(r, p, "Revenue"), CellValue::Number(rev));
m.set_cell(key(r, p, "Cost"), CellValue::Number(cost));
m.set_cell(key(r, p, "Note"), CellValue::Text(note.into()));
}
m.add_formula(parse_formula("Profit = Revenue - Cost", "_Measure").unwrap());
m
}
pub fn corpus() -> Vec<Doc> {
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
let mut out = Vec::new();
for file in ["examples/demo.improv", "web/demo.improv"] {
let wb = improvise_io::persistence::load(&root.join(file)).unwrap();
let mut seen: Vec<Vec<String>> = Vec::new();
for (name, view) in &wb.views {
let none: Vec<String> = view
.categories_on(Axis::None)
.into_iter()
.map(String::from)
.collect();
if !seen.contains(&none) {
seen.push(none.clone());
let views = wb.views.values().cloned().collect();
out.push((format!("{file}:{name}"), wb.model.clone(), none, views));
}
}
}
out.push(("hand".into(), hand_model(), vec!["Region".into()], vec![]));
let hidden = hidden_workbook();
let views = hidden.views.values().cloned().collect();
let none = none_of(&hidden);
out.push(("hidden".into(), hidden.model, none, views));
out
}
/// Edits built from the model's own first cell: overwrite, text, clear, a new
/// record, a new aggregation, and the first formula removed then restored.
pub fn edits_for(model: &Model) -> Vec<Edit> {
let Some((first, _)) = model.data.iter_cells().next() else {
return vec![];
};
let (last_cat, _) = first.0.last().unwrap().clone();
let measure = first.get("_Measure").map(String::from);
let fresh = first.clone().with(last_cat, "Zz-new");
let mut edits = vec![
Edit::SetCell(first.clone(), CellValue::Number(7.5)),
Edit::SetCell(first.clone(), CellValue::Text("t".into())),
Edit::SetCell(fresh.clone(), CellValue::Number(3.0)),
Edit::ClearCell(first),
];
edits.extend(measure.map(|m| Edit::SetMeasureAgg {
measure: m,
func: AggFunc::Max,
}));
if let Some(f) = model.formulas().first() {
edits.push(Edit::RemoveFormula {
target: f.target.clone(),
target_category: f.target_category.clone(),
});
edits.push(Edit::AddFormula(f.clone()));
}
edits.push(Edit::ClearCell(fresh));
edits
}
pub fn grouped_model() -> Model {
let mut m = Model::new("grouped");
m.add_category("Region").unwrap();
m.add_category("Product").unwrap();
m.category_mut("Region")
.unwrap()
.add_group(improvise_core::model::category::Group::new("Coastal"));
let region = m.category_mut("Region").unwrap();
region.add_item_in_group("East", "Coastal");
region.add_item_in_group("West", "Coastal");
region.add_item("Inland");
m.category_mut("Product").unwrap().add_item("A");
m.category_mut("_Measure").unwrap().add_item("Revenue");
for (r, v) in [("East", 10.0), ("West", 5.0), ("Inland", 2.0)] {
m.set_cell(key(r, "A", "Revenue"), CellValue::Number(v));
}
m
}
pub fn coastal() -> CellKey {
key("Coastal", "A", "Revenue")
}
/// Six categories, four of them None in the one view: the cell it draws
/// drops four coordinates of every stored key.
pub fn wide_workbook() -> improvise_core::workbook::Workbook {
let mut wb = improvise_core::workbook::Workbook::new("wide");
for c in ["C0", "C1", "C2", "C3", "C4"] {
wb.add_category(c).unwrap();
wb.add_item(c, "a").unwrap();
}
wb.add_item("C0", "b").unwrap();
wb.add_item("_Measure", "Revenue").unwrap();
for (c0, v) in [("a", 1.0), ("b", 2.0)] {
let k = CellKey::new(vec![
("C0".into(), c0.into()),
("C1".into(), "a".into()),
("C2".into(), "a".into()),
("C3".into(), "a".into()),
("C4".into(), "a".into()),
("_Measure".into(), "Revenue".into()),
]);
wb.model.set_cell(k, CellValue::Number(v));
}
let view = wb.active_view_mut();
view.set_axis("C0", Axis::Row);
view.set_axis("_Measure", Axis::Column);
for c in ["C1", "C2", "C3", "C4"] {
view.set_axis(c, Axis::None);
}
wb
}
/// The None categories of the active view, virtual ones included: the set an
/// `Eager` standing for that view is built with.
pub fn none_of(wb: &improvise_core::workbook::Workbook) -> Vec<String> {
let none = wb.active_view().categories_on(Axis::None);
none.into_iter().map(String::from).collect()
}
/// East is hidden and Coastal is collapsed (a group is, by default), so the Coastal row folds West only;
/// Inland has a cell under A only, so B's column holds just Coastal's.
pub fn hidden_workbook() -> improvise_core::workbook::Workbook {
let mut wb = improvise_core::workbook::Workbook::new("hidden");
for c in ["Region", "Product"] {
wb.add_category(c).unwrap();
}
wb.add_item_in_group("Region", "East", "Coastal").unwrap();
wb.add_item_in_group("Region", "West", "Coastal").unwrap();
wb.add_item("Region", "Inland").unwrap();
wb.add_item("Product", "A").unwrap();
wb.add_item("Product", "B").unwrap();
wb.add_item("_Measure", "Revenue").unwrap();
let cells = [
("East", "A", 10.0),
("West", "A", 5.0),
("East", "B", 20.0),
("West", "B", 1.0),
("Inland", "A", 2.0),
];
for (r, p, v) in cells {
wb.model
.set_cell(key(r, p, "Revenue"), CellValue::Number(v));
}
let view = wb.active_view_mut();
view.set_axis("Region", Axis::Row);
view.set_axis("Product", Axis::Column);
view.set_axis("_Measure", Axis::None);
view.hide_item("Region", "East");
wb
}
/// Nothing is hidden and Margin is a ratio: the pooled total of Margin is
/// Revenue over Cost summed across the rows, which no single row's fold gives.
pub fn ratio_workbook() -> improvise_core::workbook::Workbook {
let mut wb = improvise_core::workbook::Workbook::new("ratio");
for c in ["Region", "Product"] {
wb.add_category(c).unwrap();
}
wb.add_item("Region", "East").unwrap();
wb.add_item("Region", "West").unwrap();
wb.add_item("Product", "A").unwrap();
for m in ["Revenue", "Cost"] {
wb.add_item("_Measure", m).unwrap();
}
for (r, rev, cost) in [("East", 10.0, 4.0), ("West", 5.0, 2.0)] {
wb.model
.set_cell(key(r, "A", "Revenue"), CellValue::Number(rev));
wb.model
.set_cell(key(r, "A", "Cost"), CellValue::Number(cost));
}
wb.model
.add_formula(parse_formula("Margin = Revenue / Cost", "_Measure").unwrap());
let view = wb.active_view_mut();
view.set_axis("Region", Axis::Row);
view.set_axis("_Measure", Axis::Column);
view.set_axis("Product", Axis::None);
wb
}