From 6096c03caacad7594ec6189a58c7dbe43a7c3f8b Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 08:45:14 -0700 Subject: [PATCH] Make Model's Debug exhaustive and the Debug fingerprint canonical across instances (improvise-djcn) Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- crates/improvise-command/src/ui/reduce.rs | 40 +++++++++++++++++++++-- crates/improvise-core/src/model/cell.rs | 33 ++++++++++++++++++- crates/improvise-core/src/model/symbol.rs | 12 ++++++- crates/improvise-core/src/model/types.rs | 25 +++++++++----- crates/improvise-core/src/view/types.rs | 34 ++++++++++++++++++- 5 files changed, 130 insertions(+), 14 deletions(-) diff --git a/crates/improvise-command/src/ui/reduce.rs b/crates/improvise-command/src/ui/reduce.rs index dcc8f070..85d215c1 100644 --- a/crates/improvise-command/src/ui/reduce.rs +++ b/crates/improvise-command/src/ui/reduce.rs @@ -920,13 +920,47 @@ pub(crate) mod tests { fn a_model_command_lands_where_its_keypress_lands() { /// Two categories on different axes: a one-category fixture cannot tell /// a transpose from a cycle (`doctrine(check_the_checker)`). + /// Cells and multi-entry view maps too: two `App`s are compared, so every + /// `HashMap` the fingerprint walks must hold 2+ entries (improvise-djcn). fn pivoted() -> App { + use crate::model::cell::{CellKey, CellValue}; let mut app = app(); - app.model_state.workbook.add_category("Month").unwrap(); - app.model_state.workbook.add_item("Month", "Jan").unwrap(); - let view = app.model_state.workbook.active_view_mut(); + let wb = &mut app.model_state.workbook; + let items = [ + ("Region", ["South", "East", "West"]), + ("Month", ["Jan", "Feb", "Mar"]), + ("Channel", ["Web", "Store", "Phone"]), + ("Year", ["2024", "2025", "2026"]), + ]; + for (category, names) in items { + wb.add_category(category).ok(); + names.iter().for_each(|n| drop(wb.add_item(category, *n))); + } + for (n, (region, month)) in [("North", "Jan"), ("South", "Feb"), ("East", "Jan")] + .into_iter() + .enumerate() + { + let key = CellKey::new(vec![ + ("Region".into(), region.into()), + ("Month".into(), month.into()), + ("Channel".into(), "Web".into()), + ("Year".into(), "2024".into()), + ]); + wb.model.set_cell(key, CellValue::Number(n as f64 + 1.0)); + } + let view = wb.active_view_mut(); view.set_axis("Region", Axis::Row); view.set_axis("Month", Axis::Column); + view.set_axis("Channel", Axis::Page); + view.set_axis("Year", Axis::Page); + view.set_page_selection("Channel", "Web"); + view.set_page_selection("Year", "2024"); + view.hide_item("Region", "West"); + view.hide_item("Region", "South"); + view.hide_item("Month", "Mar"); + view.toggle_group_collapse("Region", "Coastal"); + view.toggle_group_collapse("Region", "Inland"); + view.toggle_group_collapse("Month", "Q1"); app.rebuild_layout(); app } diff --git a/crates/improvise-core/src/model/cell.rs b/crates/improvise-core/src/model/cell.rs index 7a10edf1..9b55a6c2 100644 --- a/crates/improvise-core/src/model/cell.rs +++ b/crates/improvise-core/src/model/cell.rs @@ -194,7 +194,7 @@ impl RecordEntry { } } -#[derive(Debug, Clone, Default)] +#[derive(Clone, Default)] pub struct DataStore { /// Primary storage: interned keys, insertion-ordered so records mode can /// show rows in entry order. That order is correctness — core.md §8. @@ -208,6 +208,37 @@ pub struct DataStore { records: HashMap, } +/// Resolved through the symbol table and sorted wherever the storage is a +/// `HashMap`, so two equal stores print alike (improvise-djcn). +impl std::fmt::Debug for DataStore { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let DataStore { + cells, + symbols, + index, + records, + } = self; + let cells: Vec<_> = cells + .iter() + .map(|(key, value)| (self.to_cell_key(key), value)) + .collect(); + let index: std::collections::BTreeMap<_, std::collections::BTreeSet<_>> = index + .iter() + .map(|((cat, item), keys)| { + let pair = (symbols.resolve(*cat), symbols.resolve(*item)); + (pair, keys.iter().map(|k| self.to_cell_key(k).0).collect()) + }) + .collect(); + let records: std::collections::BTreeMap<_, _> = records.iter().collect(); + f.debug_struct("DataStore") + .field("cells", &cells) + .field("symbols", symbols) + .field("index", &index) + .field("records", &records) + .finish() + } +} + impl DataStore { pub fn new() -> Self { Self::default() diff --git a/crates/improvise-core/src/model/symbol.rs b/crates/improvise-core/src/model/symbol.rs index 65d53857..a60ec9c9 100644 --- a/crates/improvise-core/src/model/symbol.rs +++ b/crates/improvise-core/src/model/symbol.rs @@ -5,12 +5,22 @@ use std::collections::HashMap; pub struct Symbol(u64); /// Bidirectional string ↔ Symbol mapping. -#[derive(Debug, Clone, Default)] +#[derive(Clone, Default)] pub struct SymbolTable { to_id: HashMap, to_str: Vec, } +/// The interned strings, sorted: which `Symbol` each got is interning order, +/// not content, so two equal stores print alike (improvise-djcn). +impl std::fmt::Debug for SymbolTable { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let SymbolTable { to_id, to_str: _ } = self; + let interned: std::collections::BTreeSet<&String> = to_id.keys().collect(); + f.debug_set().entries(interned).finish() + } +} + impl SymbolTable { #[allow(dead_code)] pub fn new() -> Self { diff --git a/crates/improvise-core/src/model/types.rs b/crates/improvise-core/src/model/types.rs index 8ae92d91..96c7057b 100644 --- a/crates/improvise-core/src/model/types.rs +++ b/crates/improvise-core/src/model/types.rs @@ -317,16 +317,25 @@ pub struct Model { /// cache in hash order, which is not a change (improvise-pc3b, gotcha 18). impl std::fmt::Debug for Model { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let mut aggs: Vec<_> = self.measure_agg.iter().collect(); - aggs.sort_by(|a, b| a.0.cmp(b.0)); - let mut cache: Vec<_> = self.formula_cache.iter().collect(); + // Destructured, so a new field is a compile error here (improvise-djcn). + let Model { + name, + categories, + data, + formulas, + next_category_id, + measure_agg, + formula_cache, + } = self; + let aggs: std::collections::BTreeMap<_, _> = measure_agg.iter().collect(); + let mut cache: Vec<_> = formula_cache.iter().collect(); cache.sort_by(|a, b| a.0.0.cmp(&b.0.0)); f.debug_struct("Model") - .field("name", &self.name) - .field("categories", &self.categories) - .field("data", &self.data) - .field("formulas", &self.formulas) - .field("next_category_id", &self.next_category_id) + .field("name", name) + .field("categories", categories) + .field("data", data) + .field("formulas", formulas) + .field("next_category_id", next_category_id) .field("measure_agg", &aggs) .field("formula_cache", &cache) .finish() diff --git a/crates/improvise-core/src/view/types.rs b/crates/improvise-core/src/view/types.rs index 1967bfb8..e9b91961 100644 --- a/crates/improvise-core/src/view/types.rs +++ b/crates/improvise-core/src/view/types.rs @@ -27,7 +27,7 @@ pub fn unused_view_name<'a>(existing: impl IntoIterator) -> Stri .expect("a name is free below usize::MAX, there being fewer views") } -#[derive(Debug, Clone)] +#[derive(Clone)] pub struct View { pub name: String, /// Axis assignment for each category @@ -44,6 +44,38 @@ pub struct View { pub prune_empty: bool, } +/// Sorted wherever the storage is a `HashMap`, so two equal views print alike; +/// destructured, so a new field is a compile error here (improvise-djcn). +impl std::fmt::Debug for View { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + use std::collections::{BTreeMap, BTreeSet}; + let View { + name, + category_axes, + page_selections, + hidden_items, + collapsed_groups, + number_format, + prune_empty, + } = self; + let sorted = |m: &HashMap>| -> BTreeMap> { + m.iter() + .map(|(k, v)| (k.clone(), v.iter().cloned().collect())) + .collect() + }; + let pages: BTreeMap<_, _> = page_selections.iter().collect(); + f.debug_struct("View") + .field("name", name) + .field("category_axes", category_axes) + .field("page_selections", &pages) + .field("hidden_items", &sorted(hidden_items)) + .field("collapsed_groups", &sorted(collapsed_groups)) + .field("number_format", number_format) + .field("prune_empty", prune_empty) + .finish() + } +} + impl View { /// True for the on-demand scratch views, rebuilt rather than persisted. pub fn is_virtual(&self) -> bool {