fix(incremental): over takes the exported feed alias, drops the model's cells and refuses cell edits in every build (improvise-rnak)

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-sources)
2026-10-10 19:00:51 -07:00
committed by Edward Langley
parent 88b644ff2d
commit 82fbe16ea4
6 changed files with 122 additions and 18 deletions
+2 -2
View File
@@ -192,8 +192,8 @@ impl Summary {
/// its readers (a database) does so through interior mutability of its own.
pub type SharedSource = std::sync::Arc<dyn CellSource + Send + Sync>;
/// A source that reports its changes. Its cells are the state after the changes it has drained,
/// so a snapshot read now plus the changes drained later counts no change twice or not at all.
/// A source that reports its changes; its cells are the state as of its last drain.
/// A drain is destructive: a feed has one drainer (`context/code/source.md`).
pub trait ChangeSource: CellSource {
/// The changes since the last drain, oldest first; reads see them from now on.
fn drain_changes(&self) -> Vec<CellChange>;
@@ -9,11 +9,10 @@ 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::{CellChange, CellKey, CellValue};
use improvise_core::model::source::{ChangeSource, SharedFeed};
use improvise_core::model::source::{SharedFeed, SharedSource};
use improvise_core::model::types::{Covers, settled};
use improvise_core::model::{Aggregation, Model};
use std::collections::BTreeMap;
use std::sync::Arc;
/// How a stem's formulas are evaluated: in dependency order where one pass is exact, or by
/// eager's passes always. The two must agree; `Plain` is the reference.
@@ -47,12 +46,9 @@ impl Incremental {
/// A backend over `feed`, which holds the cells and reports their changes; `model`'s own
/// store is not read. Reasoning: `context/code/improvise-incremental.md`, step 7.
pub fn over<S: ChangeSource + Send + Sync + 'static>(
model: Model,
feed: Arc<S>,
none_cats: Vec<String>,
) -> Self {
let model = model.over(feed.clone());
pub fn over(model: Model, feed: SharedFeed, none_cats: Vec<String>) -> Self {
let source: SharedSource = feed.clone();
let model = model.over(source);
Self::start(model, none_cats, Some(feed))
}
@@ -390,6 +386,11 @@ impl Derived for Incremental {
impl Maintained for Incremental {
fn apply(&mut self, edit: &Edit) {
let refused = self.feed.is_some() && edit.key().is_some();
assert!(
!refused,
"a fed backend is read-only: cell edits go through the source"
);
edit.apply(&mut self.model);
let changes = self.drain();
self.absorb(Some(edit), &changes);
@@ -5,12 +5,14 @@ mod support;
use improvise_core::formula::ast::AggFunc;
use improvise_core::model::cell::{CellKey, CellValue};
use improvise_core::model::source::ChangeSource;
use improvise_core::model::source::{ChangeSource, SharedFeed};
use improvise_core::model::{Aggregation, Model};
use improvise_incremental::{
Derived, Eager, Edit, Maintained, Meter, differential_in, edit_probes, probes, same_value,
Derived, Eager, Edit, Incremental, Maintained, Meter, differential_in, edit_probes, probes,
same_value,
};
use support::live::Fed;
use std::sync::Arc;
use support::live::{Fed, LiveTable};
use support::{corpus, edits_for, hand_model, key};
#[test]
@@ -108,7 +110,7 @@ fn row(r: usize, p: usize, m: usize) -> CellKey {
])
}
/// `n` regions by `n` products by 6 months of one measure, as a model with a profit-style formula.
/// `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)] {
@@ -211,3 +213,72 @@ fn an_overwrite_by_the_same_value_moves_nothing() {
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"
);
}