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:
committed by
Edward Langley
parent
29f1f85fe1
commit
a18a092186
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()))
|
||||
|
||||
@@ -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(®ion_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(®ion_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(®ion_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(®ion_partial());
|
||||
let before = backend.meter();
|
||||
backend.raw_fold(®ion_partial());
|
||||
assert_eq!(since(before, backend.meter()).reads, 1, "n = {n}");
|
||||
let eager_visits = model.data.matching_values(®ion_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"
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user