82fbe16ea4
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
285 lines
10 KiB
Rust
285 lines
10 KiB
Rust
//! `Incremental` over a source that reports its own changes (ADR 009 step 7, improvise-rnak).
|
|
//! Evidence: `context/test-evidence/improvise-incremental.md`. Counts, never time.
|
|
|
|
mod support;
|
|
|
|
use improvise_core::formula::ast::AggFunc;
|
|
use improvise_core::model::cell::{CellKey, CellValue};
|
|
use improvise_core::model::source::{ChangeSource, SharedFeed};
|
|
use improvise_core::model::{Aggregation, Model};
|
|
use improvise_incremental::{
|
|
Derived, Eager, Edit, Incremental, Maintained, Meter, differential_in, edit_probes, probes,
|
|
same_value,
|
|
};
|
|
use std::sync::Arc;
|
|
use support::live::{Fed, LiveTable};
|
|
use support::{corpus, edits_for, hand_model, key};
|
|
|
|
#[test]
|
|
fn a_live_source_matches_eager_over_the_corpus_and_its_edits() {
|
|
let (mut compared, mut files_done) = (0, Vec::<String>::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 {
|
|
let picked = [0, 2, 4].iter().filter_map(|&i| edits.get(i).cloned());
|
|
edits = picked.collect();
|
|
}
|
|
let candidate = Fed::new(&model, none.clone());
|
|
let ran = differential_in(&model, &views, &none, candidate, &edits);
|
|
let report = ran.unwrap_or_else(|d| panic!("{name}: {d}"));
|
|
assert_eq!(report.steps, edits.len() + 1, "{name}");
|
|
compared += report.probes;
|
|
}
|
|
assert!(compared > 10_000, "only {compared} answers were compared");
|
|
}
|
|
|
|
#[test]
|
|
fn changes_named_by_the_caller_are_maintained_as_the_drain_would() {
|
|
let (model, none) = (hand_model(), vec!["Region".to_string()]);
|
|
let mut eager = Eager::new(model.clone(), none.clone());
|
|
let mut fed = Fed::new(&model, none);
|
|
let edits = [
|
|
Edit::SetCell(key("East", "A", "Revenue"), CellValue::Number(99.0)),
|
|
Edit::SetCell(key("North", "A", "Revenue"), CellValue::Number(1.0)),
|
|
Edit::ClearCell(key("West", "B", "Cost")),
|
|
Edit::SetCell(key("East", "A", "Note"), CellValue::Text("z".into())),
|
|
];
|
|
let mut questions = probes(eager.model());
|
|
let mut asked = 0;
|
|
for edit in &edits {
|
|
eager.apply(edit);
|
|
let (cell, value) = match edit {
|
|
Edit::SetCell(cell, value) => (cell, Some(value.clone())),
|
|
Edit::ClearCell(cell) => (cell, None),
|
|
_ => unreachable!("cell edits only"),
|
|
};
|
|
fed.live.write(cell.clone(), value);
|
|
let changes = fed.live.drain_changes();
|
|
assert_eq!(changes.len(), 1);
|
|
fed.inc.apply_changes(&changes);
|
|
questions.extend(probes(eager.model()));
|
|
questions.extend(edit_probes(edit));
|
|
for probe in &questions {
|
|
let (want, got) = (probe.ask(&eager), probe.ask(&fed));
|
|
assert!(
|
|
same_value(&want, &got),
|
|
"{probe:?}: eager {want:?} != fed {got:?}"
|
|
);
|
|
asked += 1;
|
|
}
|
|
}
|
|
assert!(asked > 500, "only {asked} answers were compared");
|
|
}
|
|
|
|
#[test]
|
|
fn a_write_is_invisible_until_it_is_drained() {
|
|
let model = hand_model();
|
|
let mut fed = Fed::new(&model, vec![]);
|
|
let cell = key("East", "A", "Revenue");
|
|
let before = fed.inc.value(&cell, Aggregation::Exact);
|
|
assert_eq!(before, Some(CellValue::Number(10.0)));
|
|
fed.live.write(cell.clone(), Some(CellValue::Number(50.0)));
|
|
assert_eq!(
|
|
fed.inc.value(&cell, Aggregation::Exact),
|
|
before,
|
|
"not yet drained"
|
|
);
|
|
fed.inc.sync();
|
|
assert_eq!(
|
|
fed.inc.value(&cell, Aggregation::Exact),
|
|
Some(CellValue::Number(50.0))
|
|
);
|
|
fed.inc.sync();
|
|
assert_eq!(
|
|
fed.inc.value(&cell, Aggregation::Exact),
|
|
Some(CellValue::Number(50.0))
|
|
);
|
|
}
|
|
|
|
fn row(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 of one measure, with no formula.
|
|
fn table(n: usize) -> 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(), AggFunc::Max);
|
|
let mut seed = 1u64;
|
|
for (r, p, m) in (0..n).flat_map(|r| (0..n).flat_map(move |p| (0..6).map(move |m| (r, p, m)))) {
|
|
seed = seed
|
|
.wrapping_mul(6364136223846793005)
|
|
.wrapping_add(1442695040888963407);
|
|
model.set_cell(
|
|
row(r, p, m),
|
|
CellValue::Number(((seed >> 33) % 1000) as f64),
|
|
);
|
|
}
|
|
model
|
|
}
|
|
|
|
fn since(before: Meter, after: Meter) -> Meter {
|
|
Meter {
|
|
updates: after.updates - before.updates,
|
|
sum_updates: after.sum_updates - before.sum_updates,
|
|
reads: after.reads - before.reads,
|
|
builds: after.builds - before.builds,
|
|
built_cells: after.built_cells - before.built_cells,
|
|
stems: after.stems - before.stems,
|
|
formula_cells: after.formula_cells - before.formula_cells,
|
|
rebuilds: after.rebuilds - before.rebuilds,
|
|
recomputes: after.recomputes - before.recomputes,
|
|
}
|
|
}
|
|
|
|
fn region_partial() -> CellKey {
|
|
CellKey::new(vec![
|
|
("Region".into(), "r1".into()),
|
|
("_Measure".into(), "Revenue".into()),
|
|
])
|
|
}
|
|
|
|
/// What one reported change costs once a shape is built, and what the source served for it.
|
|
fn change_cost(n: usize) -> (Meter, u64) {
|
|
let mut fed = Fed::new(&table(n), vec![]);
|
|
fed.inc.raw_fold(®ion_partial());
|
|
let (before, served) = (fed.inc.meter(), fed.live.snapshot_rows());
|
|
assert_eq!(
|
|
served,
|
|
(n * n * 6) as u64,
|
|
"the build is one pass over the table"
|
|
);
|
|
fed.live
|
|
.write(row(1, 2, 3), Some(CellValue::Number(2000.0)));
|
|
fed.inc.sync();
|
|
let cost = since(before, fed.inc.meter());
|
|
assert_eq!(
|
|
fed.inc.raw_fold(®ion_partial()),
|
|
Some(CellValue::Number(2000.0)),
|
|
"the new maximum comes from the change"
|
|
);
|
|
(cost, fed.live.snapshot_rows() - served)
|
|
}
|
|
|
|
#[test]
|
|
fn the_cost_of_a_reported_change_does_not_depend_on_the_size_of_the_table() {
|
|
let (small, small_rows) = change_cost(6);
|
|
let (large, large_rows) = change_cost(12);
|
|
assert_eq!(
|
|
(small.updates, small.sum_updates),
|
|
(2, 2),
|
|
"retract and insert"
|
|
);
|
|
assert_eq!(small, large, "216 cells and 864 cells cost the same");
|
|
assert_eq!(
|
|
(small.builds, small.built_cells),
|
|
(0, 0),
|
|
"no shape is rebuilt"
|
|
);
|
|
assert_eq!(
|
|
(small_rows, large_rows),
|
|
(0, 0),
|
|
"the source serves no snapshot for a change"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn an_overwrite_by_the_same_value_moves_nothing() {
|
|
let mut fed = Fed::new(&hand_model(), vec![]);
|
|
let cell = key("East", "A", "Revenue");
|
|
let revenue = CellKey::new(vec![("_Measure".into(), "Revenue".into())]);
|
|
let before = fed.inc.raw_fold(&revenue);
|
|
fed.live.write(cell, Some(CellValue::Number(10.0)));
|
|
fed.inc.sync();
|
|
assert_eq!(fed.inc.raw_fold(&revenue), before);
|
|
assert_eq!(before, Some(CellValue::Number(23.0)));
|
|
}
|
|
|
|
#[test]
|
|
fn a_backend_can_be_built_over_the_exported_feed_alias() {
|
|
let model = hand_model();
|
|
let rows = model.data.iter_cells().map(|(k, v)| (k, v.clone()));
|
|
let feed: SharedFeed = Arc::new(LiveTable::of(rows));
|
|
let mut inc = Incremental::over(model.clone(), feed, vec![]);
|
|
let revenue = CellKey::new(vec![("_Measure".into(), "Revenue".into())]);
|
|
assert_eq!(inc.raw_fold(&revenue), Some(CellValue::Number(23.0)));
|
|
inc.sync();
|
|
}
|
|
|
|
#[test]
|
|
fn a_populated_model_put_over_a_feed_keeps_no_cells_of_its_own() {
|
|
let (model, none) = (hand_model(), vec!["Region".to_string()]);
|
|
assert!(
|
|
model.data.iter_cells().count() > 0,
|
|
"the input is populated"
|
|
);
|
|
let rows = model.data.iter_cells().map(|(k, v)| (k, v.clone()));
|
|
let live = Arc::new(LiveTable::of(rows));
|
|
let inc = Incremental::over(model.clone(), live, none.clone());
|
|
assert_eq!(inc.model().data.iter_cells().count(), 0, "no stale copy");
|
|
assert_eq!(inc.model().get_cell(&key("East", "A", "Revenue")), None);
|
|
let eager = Eager::new(model, none);
|
|
for probe in probes(eager.model()) {
|
|
let (want, got) = (probe.ask(&eager), probe.ask(&inc));
|
|
assert!(
|
|
same_value(&want, &got),
|
|
"{probe:?}: eager {want:?} != fed {got:?}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
#[should_panic(expected = "cell edits go through the source")]
|
|
fn a_cell_edit_on_a_fed_backend_is_refused() {
|
|
let mut fed = Fed::new(&hand_model(), vec![]);
|
|
let edit = Edit::SetCell(key("East", "A", "Revenue"), CellValue::Number(1.0));
|
|
fed.inc.apply(&edit);
|
|
}
|
|
|
|
#[test]
|
|
#[should_panic(expected = "cell edits go through the source")]
|
|
fn a_cell_clear_on_a_fed_backend_is_refused() {
|
|
let mut fed = Fed::new(&hand_model(), vec![]);
|
|
fed.inc.apply(&Edit::ClearCell(key("East", "A", "Revenue")));
|
|
}
|
|
|
|
#[test]
|
|
fn a_feed_has_one_drainer() {
|
|
let model = hand_model();
|
|
let rows = model.data.iter_cells().map(|(k, v)| (k, v.clone()));
|
|
let live = Arc::new(LiveTable::of(rows));
|
|
let mut a = Incremental::over(model.clone(), live.clone(), vec![]);
|
|
let mut b = Incremental::over(model, live.clone(), vec![]);
|
|
let revenue = CellKey::new(vec![("_Measure".into(), "Revenue".into())]);
|
|
let (before_a, before_b) = (a.raw_fold(&revenue), b.raw_fold(&revenue));
|
|
assert_eq!(before_a, before_b);
|
|
live.write(key("East", "A", "Revenue"), Some(CellValue::Number(100.0)));
|
|
a.sync();
|
|
b.sync();
|
|
let (after_a, after_b) = (a.raw_fold(&revenue), b.raw_fold(&revenue));
|
|
assert_ne!(after_a, before_a, "the first drainer saw the write");
|
|
assert_eq!(
|
|
after_b, before_b,
|
|
"the second saw nothing: a drain is destructive"
|
|
);
|
|
}
|