feat(core): CellKey is Ord and DataStore can log its cell changes (improvise-5r8c)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
This commit is contained in:
Claude (adr009-step3)
2026-10-10 11:21:52 -07:00
committed by Edward Langley
parent 56b7770282
commit 5c7a12fd3d
5 changed files with 418 additions and 6 deletions
+107 -4
View File
@@ -7,7 +7,7 @@ use super::symbol::{Symbol, SymbolTable};
/// A cell key: (category, item) pairs, always sorted by category name. The /// A cell key: (category, item) pairs, always sorted by category name. The
/// constructors are the only way to build one — context/code/core.md §1. /// constructors are the only way to build one — context/code/core.md §1.
#[derive(Debug, Clone, PartialEq, Eq, Hash)] #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct CellKey(pub Vec<(String, String)>); pub struct CellKey(pub Vec<(String, String)>);
impl CellKey { impl CellKey {
@@ -194,6 +194,15 @@ impl RecordEntry {
} }
} }
/// One cell's move in the store: `old` is `None` for a new cell, `new` is
/// `None` for a removal. ADR 009 clause 3; `context/code/improvise-incremental.md`.
#[derive(Debug, Clone, PartialEq)]
pub struct CellChange {
pub key: CellKey,
pub old: Option<CellValue>,
pub new: Option<CellValue>,
}
#[cfg(test)] #[cfg(test)]
thread_local! { thread_local! {
/// How many lookups fell through to set narrowing, for the exact-path test. /// How many lookups fell through to set narrowing, for the exact-path test.
@@ -215,6 +224,9 @@ pub struct DataStore {
/// How many stored cells have each number of coordinates; its largest key /// How many stored cells have each number of coordinates; its largest key
/// says when a partial can match only one exact cell. /// says when a partial can match only one exact cell.
arities: std::collections::BTreeMap<usize, usize>, arities: std::collections::BTreeMap<usize, usize>,
/// The changes since `record_changes`, or `None` while nobody listens, so an
/// unwatched store keeps no log.
changes: Option<Vec<CellChange>>,
} }
/// Resolved through the symbol table and sorted wherever the storage is a /// Resolved through the symbol table and sorted wherever the storage is a
@@ -227,6 +239,7 @@ impl std::fmt::Debug for DataStore {
index, index,
records, records,
arities: _, arities: _,
changes: _,
} = self; } = self;
let cells: Vec<_> = cells let cells: Vec<_> = cells
.iter() .iter()
@@ -302,7 +315,19 @@ impl DataStore {
self.index.entry(*pair).or_default().insert(ikey.clone()); self.index.entry(*pair).or_default().insert(ikey.clone());
} }
let width = ikey.0.len(); let width = ikey.0.len();
let is_new_cell = self.cells.insert(ikey, value).is_none(); let previous = match &mut self.changes {
Some(log) => {
let previous = self.cells.insert(ikey, value.clone());
log.push(CellChange {
key,
old: previous.clone(),
new: Some(value),
});
previous
}
None => self.cells.insert(ikey, value),
};
let is_new_cell = previous.is_none();
if is_new_cell { if is_new_cell {
*self.arities.entry(width).or_default() += 1; *self.arities.entry(width).or_default() += 1;
} }
@@ -316,6 +341,24 @@ impl DataStore {
entry.cells += usize::from(is_new_cell); entry.cells += usize::from(is_new_cell);
} }
/// Start logging every `set` and `remove` as a [`CellChange`], from empty.
pub fn record_changes(&mut self) {
self.changes = Some(Vec::new());
}
/// Stop logging and drop what was logged.
pub fn stop_recording_changes(&mut self) {
self.changes = None;
}
/// The changes since the last drain, oldest first. Logging goes on.
pub fn drain_changes(&mut self) -> Vec<CellChange> {
self.changes
.as_mut()
.map(std::mem::take)
.unwrap_or_default()
}
pub fn get(&self, key: &CellKey) -> Option<&CellValue> { pub fn get(&self, key: &CellKey) -> Option<&CellValue> {
let ikey = self.lookup_key(key)?; let ikey = self.lookup_key(key)?;
self.cells.get(&ikey) self.cells.get(&ikey)
@@ -340,7 +383,14 @@ impl DataStore {
let Some(ikey) = self.lookup_key(key) else { let Some(ikey) = self.lookup_key(key) else {
return; return;
}; };
if self.cells.shift_remove(&ikey).is_some() { if let Some(old) = self.cells.shift_remove(&ikey) {
if let Some(log) = &mut self.changes {
log.push(CellChange {
key: key.clone(),
old: Some(old),
new: None,
});
}
if let Some(n) = self.arities.get_mut(&ikey.0.len()) { if let Some(n) = self.arities.get_mut(&ikey.0.len()) {
*n -= 1; *n -= 1;
if *n == 0 { if *n == 0 {
@@ -714,6 +764,17 @@ mod cell_key {
assert!(!k.matches_partial(&partial)); assert!(!k.matches_partial(&partial));
} }
#[test]
fn keys_order_by_their_sorted_coordinates() {
let a = key(&[("A", "z")]);
let b = key(&[("A", "z"), ("B", "a")]);
let c = key(&[("A", "z"), ("B", "b")]);
let d = key(&[("C", "a")]);
let mut keys = vec![d.clone(), c.clone(), a.clone(), b.clone()];
keys.sort();
assert_eq!(keys, vec![a, b, c, d]);
}
#[test] #[test]
fn display_format() { fn display_format() {
let k = key(&[("Region", "East")]); let k = key(&[("Region", "East")]);
@@ -723,7 +784,7 @@ mod cell_key {
#[cfg(test)] #[cfg(test)]
mod data_store { mod data_store {
use super::{CellKey, CellValue, DataStore}; use super::{CellChange, CellKey, CellValue, DataStore};
fn key(pairs: &[(&str, &str)]) -> CellKey { fn key(pairs: &[(&str, &str)]) -> CellKey {
CellKey::new( CellKey::new(
@@ -757,6 +818,48 @@ mod data_store {
assert_eq!(store.get(&k), Some(&CellValue::Number(99.0))); assert_eq!(store.get(&k), Some(&CellValue::Number(99.0)));
} }
#[test]
fn a_store_reports_no_changes_until_asked_to_record_them() {
let mut store = DataStore::new();
store.set(key(&[("Region", "East")]), CellValue::Number(1.0));
assert!(store.drain_changes().is_empty());
}
#[test]
fn a_recording_store_reports_a_new_cell_an_overwrite_and_a_removal() {
let mut store = DataStore::new();
let k = key(&[("Region", "East")]);
store.record_changes();
store.set(k.clone(), CellValue::Number(1.0));
store.set(k.clone(), CellValue::Number(2.0));
store.remove(&k);
store.remove(&k);
let change = |old: Option<f64>, new: Option<f64>| CellChange {
key: k.clone(),
old: old.map(CellValue::Number),
new: new.map(CellValue::Number),
};
assert_eq!(
store.drain_changes(),
vec![
change(None, Some(1.0)),
change(Some(1.0), Some(2.0)),
change(Some(2.0), None)
],
"removing an absent cell reports nothing"
);
assert!(store.drain_changes().is_empty(), "a drain empties the log");
}
#[test]
fn a_store_that_stopped_recording_reports_nothing_more() {
let mut store = DataStore::new();
store.record_changes();
store.stop_recording_changes();
store.set(key(&[("Region", "East")]), CellValue::Number(1.0));
assert!(store.drain_changes().is_empty());
}
#[test] #[test]
fn remove_evicts_key() { fn remove_evicts_key() {
let mut store = DataStore::new(); let mut store = DataStore::new();
+2 -1
View File
@@ -6,9 +6,10 @@ license = "Apache-2.0"
description = "The seam between improvise-core and the incremental circuit: the Derived trait, the Eager oracle and the differential harness (ADR 009 clause 2)." description = "The seam between improvise-core and the incremental circuit: the Derived trait, the Eager oracle and the differential harness (ADR 009 clause 2)."
# ADR 009 clause 2: depends on improvise-core, never the other way round. The # ADR 009 clause 2: depends on improvise-core, never the other way round. The
# `zset` edge arrives with the first backend that uses it (step 3), not before. # `zset` edge is the cell delta of step 3 (improvise-5r8c).
[dependencies] [dependencies]
improvise-core = { path = "../improvise-core" } improvise-core = { path = "../improvise-core" }
zset = { path = "../zset" }
[dev-dependencies] [dev-dependencies]
improvise-io = { path = "../improvise-io" } improvise-io = { path = "../improvise-io" }
+261
View File
@@ -0,0 +1,261 @@
//! The cell delta: what a write changed, as a Z-set (ADR 009 clause 3).
//! Reasoning: `context/code/improvise-incremental.md`.
use std::cmp::Ordering;
use improvise_core::model::cell::{CellChange, CellKey, CellValue};
use improvise_core::model::record::RecordHash;
use zset::ZSet;
/// A cell value that compares by the float rules, with every NaN one value: `-0.0`
/// equals `+0.0`. Built only by [`DeltaValue::new`], which makes both true of the
/// inner value, so the derived-looking order below is a total order.
#[derive(Debug, Clone)]
pub struct DeltaValue(CellValue);
impl DeltaValue {
pub fn new(value: &CellValue) -> Self {
DeltaValue(match value {
CellValue::Number(n) if n.is_nan() => CellValue::Number(f64::NAN),
CellValue::Number(n) if *n == 0.0 => CellValue::Number(0.0),
other => other.clone(),
})
}
pub fn value(&self) -> &CellValue {
&self.0
}
}
impl Ord for DeltaValue {
fn cmp(&self, other: &Self) -> Ordering {
match (&self.0, &other.0) {
(CellValue::Number(a), CellValue::Number(b)) => a.total_cmp(b),
(CellValue::Text(a), CellValue::Text(b)) => a.cmp(b),
(CellValue::Error(a), CellValue::Error(b)) => a.cmp(b),
(a, b) => rank(a).cmp(&rank(b)),
}
}
}
fn rank(value: &CellValue) -> u8 {
match value {
CellValue::Number(_) => 0,
CellValue::Text(_) => 1,
CellValue::Error(_) => 2,
}
}
impl PartialOrd for DeltaValue {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl PartialEq for DeltaValue {
fn eq(&self, other: &Self) -> bool {
self.cmp(other) == Ordering::Equal
}
}
impl Eq for DeltaValue {}
/// What the store's cells changed by: `+(k, v)` new, `-(k, v0) + (k, v1)` overwritten,
/// `-(k, v0)` removed, and nothing for an overwrite that changes no value.
pub type CellDelta = ZSet<(CellKey, DeltaValue)>;
/// The delta of `changes`, in order. Changes that cancel leave nothing.
pub fn cell_delta<'a>(changes: impl IntoIterator<Item = &'a CellChange>) -> CellDelta {
let mut delta = CellDelta::new();
for change in changes {
if let Some(old) = &change.old {
delta.insert((change.key.clone(), DeltaValue::new(old)), -1);
}
if let Some(new) = &change.new {
delta.insert((change.key.clone(), DeltaValue::new(new)), 1);
}
}
delta
}
/// The records `delta` touches, ascending: the record tier of ADR 001's basis.
pub fn moved_records(delta: &CellDelta) -> Vec<RecordHash> {
let mut records: Vec<RecordHash> = delta
.iter()
.map(|((key, _), _)| RecordHash::of(key))
.collect();
records.sort_unstable();
records.dedup();
records
}
#[cfg(test)]
mod tests {
use super::*;
fn key(region: &str, measure: &str) -> CellKey {
CellKey::new(vec![
("Region".into(), region.into()),
("_Measure".into(), measure.into()),
])
}
fn change(k: &CellKey, old: Option<CellValue>, new: Option<CellValue>) -> CellChange {
CellChange {
key: k.clone(),
old,
new,
}
}
fn num(n: f64) -> CellValue {
CellValue::Number(n)
}
fn delta_of(old: Option<CellValue>, new: Option<CellValue>) -> CellDelta {
cell_delta(&[change(&key("East", "Revenue"), old, new)])
}
#[test]
fn a_new_cell_an_overwrite_and_a_removal_have_the_clause_3_shapes() {
let k = key("East", "Revenue");
let at = |v: f64| (k.clone(), DeltaValue::new(&num(v)));
assert_eq!(delta_of(None, Some(num(1.0))), ZSet::singleton(at(1.0), 1));
assert_eq!(
delta_of(Some(num(1.0)), Some(num(2.0))),
ZSet::from_iter([(at(1.0), -1), (at(2.0), 1)])
);
assert_eq!(delta_of(Some(num(1.0)), None), ZSet::singleton(at(1.0), -1));
}
#[test]
fn overwriting_a_value_with_one_that_compares_equal_is_the_zero_delta() {
let nans = [
f64::NAN,
-f64::NAN,
f64::from_bits(0x7ff0_0000_0000_0001),
f64::from_bits(0xfff8_dead_beef_0001),
];
for a in nans {
for b in nans {
assert!(
delta_of(Some(num(a)), Some(num(b))).is_zero(),
"NaN {:#x} over NaN {:#x}",
a.to_bits(),
b.to_bits()
);
}
}
assert!(delta_of(Some(num(0.0)), Some(num(-0.0))).is_zero());
assert!(delta_of(Some(num(-0.0)), Some(num(0.0))).is_zero());
let text = || Some(CellValue::Text("x".into()));
assert!(delta_of(text(), text()).is_zero());
}
#[test]
fn values_that_differ_by_the_float_rules_stay_different() {
let pairs = [
(num(1.0), num(1.0000000000000002)),
(num(f64::INFINITY), num(f64::NEG_INFINITY)),
(num(f64::NAN), num(0.0)),
(num(0.0), CellValue::Text("0".into())),
(CellValue::Text("a".into()), CellValue::Error("a".into())),
];
for (a, b) in pairs {
assert!(
!delta_of(Some(a.clone()), Some(b.clone())).is_zero(),
"{a:?} over {b:?}"
);
}
}
#[test]
fn the_order_is_total_and_agrees_with_equality() {
let values = [
num(f64::NEG_INFINITY),
num(-1.0),
num(-0.0),
num(0.0),
num(1.0),
num(f64::INFINITY),
num(f64::NAN),
num(-f64::NAN),
CellValue::Text(String::new()),
CellValue::Text("a".into()),
CellValue::Error("a".into()),
];
let wrapped: Vec<DeltaValue> = values.iter().map(DeltaValue::new).collect();
for a in &wrapped {
for b in &wrapped {
assert_eq!(a.cmp(b), b.cmp(a).reverse());
assert_eq!(a == b, a.cmp(b) == Ordering::Equal);
for c in &wrapped {
if a <= b && b <= c {
assert!(a <= c, "{a:?} {b:?} {c:?}");
}
}
}
}
assert_eq!(wrapped[2], wrapped[3], "-0.0 is +0.0");
assert_eq!(wrapped[6], wrapped[7], "every NaN is one value");
}
#[test]
fn numbers_order_by_total_cmp_with_the_canonical_nan_last() {
let ascending = [f64::NEG_INFINITY, -1.5, -0.0, 2.0, f64::INFINITY, -f64::NAN];
let mut shuffled: Vec<DeltaValue> =
[2.0, -f64::NAN, -1.5, f64::INFINITY, -0.0, f64::NEG_INFINITY]
.iter()
.map(|n| DeltaValue::new(&num(*n)))
.collect();
shuffled.sort();
let want: Vec<DeltaValue> = ascending
.iter()
.map(|n| DeltaValue::new(&num(*n)))
.collect();
assert_eq!(shuffled, want);
assert_eq!(
shuffled[2].value().as_f64().map(f64::to_bits),
Some(0),
"and the zero among them reads as +0.0"
);
}
#[test]
fn a_wrapped_value_reads_back_with_the_canonical_nan_and_zero() {
let nan = DeltaValue::new(&num(-f64::NAN));
assert_eq!(
nan.value().as_f64().map(f64::to_bits),
Some(f64::NAN.to_bits())
);
let zero = DeltaValue::new(&num(-0.0));
assert_eq!(zero.value().as_f64().map(f64::to_bits), Some(0));
}
#[test]
fn a_change_that_undoes_an_earlier_one_cancels_it() {
let k = key("East", "Revenue");
let delta = cell_delta(&[
change(&k, None, Some(num(1.0))),
change(&k, Some(num(1.0)), Some(num(2.0))),
change(&k, Some(num(2.0)), None),
]);
assert!(delta.is_zero());
}
#[test]
fn moved_records_names_each_record_once_whatever_the_measure() {
let delta = cell_delta(&[
change(&key("East", "Revenue"), None, Some(num(1.0))),
change(&key("East", "Cost"), None, Some(num(2.0))),
change(&key("West", "Cost"), Some(num(3.0)), Some(num(4.0))),
change(&key("South", "Cost"), Some(num(5.0)), Some(num(5.0))),
]);
let mut want = vec![
RecordHash::of(&key("East", "Revenue")),
RecordHash::of(&key("West", "Revenue")),
];
want.sort_unstable();
assert_eq!(moved_records(&delta), want, "South wrote nothing new");
}
}
+2
View File
@@ -1,10 +1,12 @@
//! The plug: what the evaluator reads, and the oracle that answers it today. //! The plug: what the evaluator reads, and the oracle that answers it today.
//! Reasoning: `context/code/improvise-incremental.md`. //! Reasoning: `context/code/improvise-incremental.md`.
mod delta;
mod eager; mod eager;
mod edit; mod edit;
mod harness; mod harness;
pub use delta::{CellDelta, DeltaValue, cell_delta, moved_records};
pub use eager::Eager; pub use eager::Eager;
pub use edit::Edit; pub use edit::Edit;
pub use harness::{ pub use harness::{
+46 -1
View File
@@ -7,7 +7,8 @@ use improvise_core::model::cell::{CellKey, CellValue};
use improvise_core::model::{Aggregation, Model}; use improvise_core::model::{Aggregation, Model};
use improvise_core::view::Axis; use improvise_core::view::Axis;
use improvise_incremental::{ use improvise_incremental::{
Derived, Divergence, Eager, Edit, Maintained, Probe, differential, probes, same_value, CellDelta, DeltaValue, Derived, Divergence, Eager, Edit, Maintained, Probe, cell_delta,
differential, probes, same_value,
}; };
use proptest::prelude::*; use proptest::prelude::*;
use std::path::Path; use std::path::Path;
@@ -594,6 +595,50 @@ fn any_value() -> impl Strategy<Value = CellValue> {
] ]
} }
/// The store as a Z-set, every cell at weight one.
fn snapshot(model: &Model) -> CellDelta {
model
.data
.iter_cells()
.map(|(k, v)| ((k, DeltaValue::new(v)), 1))
.collect()
}
/// Values the delta must treat as the float rules do: both zeros, several NaNs.
fn delta_value() -> impl Strategy<Value = CellValue> {
prop_oneof![
(-2i32..3).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::from_bits(0x7ff0_0000_0000_0001))),
prop::sample::select(vec!["n", "x"]).prop_map(|t| CellValue::Text(t.into())),
]
}
fn cell_edit() -> impl Strategy<Value = Edit> {
prop_oneof![
(any_key(), delta_value()).prop_map(|(k, v)| Edit::SetCell(k, v)),
any_key().prop_map(Edit::ClearCell),
]
}
proptest! {
#[test]
fn the_cell_delta_of_an_edit_is_the_store_after_minus_the_store_before(
edits in prop::collection::vec(cell_edit(), 0..12)
) {
let mut model = hand_model();
model.data.record_changes();
for edit in &edits {
let before = snapshot(&model);
edit.apply(&mut model);
let delta = cell_delta(&model.data.drain_changes());
prop_assert_eq!(delta, &snapshot(&model) - &before, "after {:?}", edit);
}
}
}
fn any_edit() -> impl Strategy<Value = Edit> { fn any_edit() -> impl Strategy<Value = Edit> {
prop_oneof![ prop_oneof![
(any_key(), any_value()).prop_map(|(k, v)| Edit::SetCell(k, v)), (any_key(), any_value()).prop_map(|(k, v)| Edit::SetCell(k, v)),