fix(protocol): a formula-owned cell projects CellId::Derived, not Record

improvise-ir7l. project::cell_id decided Record vs Aggregate purely on whether
a regular category sat on Axis::None; it never asked whether the coordinate
named a formula target. So a formula cell was a Record, render.rs::editable
said true, and a write to it landed, was stored, and was then shadowed forever
by formula_cache — not a revert, a permanent silent shadow, on the demo's
front page.

Rather than a second "but not really" flag beside CellId (the two-answers
shape ADR 002 exists to forbid), a formula-owned cell now projects a different
kind of identity. Derived carries the formula's target and no surrogate, so
the write is unconstructible rather than refused.

The predicate is Model::formula_for(&CellKey), one home, which already existed
three times open-coded inside model/types.rs. The TUI lands in the same change:
EditOrDrill calls the same predicate and refuses, or the two front ends
diverge.

Two orderings are load-bearing and tested: Derived is asked before the record
branch (the coordinate is stored), and after the aggregate branch (gotcha 10 —
an aggregating formula cell is still drillable).

Two tests were passing while their names were false for the Profit column and
are renamed and two-sided now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Edward Langley
2026-09-20 10:59:59 -07:00
parent 52507a6678
commit 0f14b2a39e
14 changed files with 287 additions and 52 deletions
+29
View File
@@ -258,6 +258,35 @@ client's own token is the lookup key for its own version, with no `CellKey`
rebuilt on the hot path. `measure` is absent for a record carrying no
`_Measure` coordinate at all — the shape `add-record-row` seeds.
### `CellId::Derived` — a formula owns the cell (improvise-ir7l)
A formula target is a real stored coordinate, so the record branch claimed it
and every client offered an editor over it. The write *landed* and was then
shadowed forever, because `evaluate_aggregated` reads `formula_cache` before
raw data — not a revert, a permanent silent shadow.
The fix keeps ADR 002's discipline rather than adding a second answer beside
it: a formula-owned cell projects a **different kind of identity**, so the
write is unconstructible instead of flagged. `Derived` carries the formula's
target and **no surrogate**, so there is nothing a client could spend on a
`SetCell`; that is the same reason `Aggregate` carries an `AggregateHash` and
not a `RecordHash`.
Two orderings matter in `project::cell_id`. Derived is asked **before** the
record branch, since the coordinate is stored and the record branch would
otherwise win. It is asked **after** the aggregate branch, because a formula
cell that also aggregates is still drillable (gotcha 10) — `Aggregate` and
`Derived` are not alternatives for the same cell, and reversing the two turns
a drillable cell into a dead one.
The predicate itself is `Model::formula_for(&CellKey)` in `improvise-core`,
the one home of "is this cell derived". It already existed three times inside
`model/types.rs` as an open-coded loop; the TUI's `EditOrDrill` and the
producer now call the same function rather than each spelling the rule. The
TUI had to change in the same breath: it re-derives from the `Model` it holds,
which is legitimate (it is the authority), but it must give the *same* answer
or the two front ends diverge — design-principles §5.
### `CellDisplay::id` — `None` is not "aggregate"
That is `Some(CellId::Aggregate(..))`, and the distinction is the whole point
+15
View File
@@ -665,3 +665,18 @@ persistence criterion.
- **`a_delete_is_visible_at_once_and_lands_on_commit`** — both halves, because
either one alone would pass against a backend that got the timing exactly
backwards.
### the_shipped_demo_seeds_a_worker_whose_raw_cells_are_settable
improvise-ir7l renamed this from `..._whose_every_cell_is_settable` and
narrowed what it claims. The old loop called `MainThread::cell` — which panics
on anything but a stored cell — over *every* column including `Profit`, which
`web/demo.improv` defines as `Revenue - Cost`. It passed only because a formula
target projected `CellId::Record`, which is the bug the ticket names: a test
whose name was false for a third of the grid it walked.
It now walks the two raw columns and asserts separately that the `Profit`
column is `CellId::Derived`, so it fails if either half moves. That second
assertion is what makes the narrowing honest rather than a retreat — dropping
the column would have left the page free to offer it again with nothing
noticing.
+30
View File
@@ -2166,3 +2166,33 @@ step machine; every other printable key edits the name (gotcha 22).
On the confirmation step Enter installs the built workbook and Esc steps back — it
does not throw the import away, and no key closes the wizard outright
(improvise-907, from improvise-c6j).
### a_formula_owned_cell_is_projected_as_derived_and_its_siblings_are_not
improvise-ir7l. `project::cell_id` decided `Record` vs `Aggregate` purely on
whether a regular category sat on `Axis::None`; it never asked whether the
`_Measure` coordinate named a formula target. So a formula cell was a `Record`,
`render.rs::editable` said true, and a write to it was accepted, stored and
then shadowed forever by `formula_cache`.
The fault injected: replacing the `derived` lookup with `None`, which is the
pre-fix code exactly. The test then reports `Record(RecordCell { measure:
Some("Profit") })` where it wants `Derived { target: "Profit" }`. The siblings
are asserted in the same test so a producer that answered `Derived` for *every*
cell could not pass.
### an_aggregating_formula_cell_is_still_projected_as_an_aggregate
The negative arm of the same change, and the one a careless fix breaks: gotcha
10 says drilling into a formula cell is supported, so `Aggregate` must still
win where the cell aggregates. Proved discriminating by returning `derived`
before the aggregate branch — the cell then reads `Derived` and becomes
undrillable, with nothing else in the suite noticing.
### edit_or_drill_refuses_a_formula_owned_cell
The TUI half. `EditOrDrill` never asked the question either, so the terminal
offered the same editor over the same shadowed cell. Landing only the
projection half would have made the two front ends disagree, which is the
defect design-principles §5 is about. Fault injected by deleting the guard: the
command returns `[SetBuffer{edit}, ChangeMode(Editing)]` again.
+12 -1
View File
@@ -329,7 +329,7 @@ close.
## `tests/web_demo_model.rs`
### every_visible_cell_in_the_web_demo_is_settable
### every_web_demo_cell_is_settable_except_the_column_a_formula_owns
improvise-bck. The demo document the standalone page opens must be one a visitor
can *type into*, and the bug is not hypothetical: `examples/demo.improv` leaves
@@ -348,6 +348,17 @@ and could agree with itself while disagreeing with the client.
It was proved discriminating by pointing it at `examples/demo.improv`, where it
names the aggregate at (0,0).
**improvise-ir7l renamed it, because the old name was false.** The demo's
third column is `Profit = Revenue - Cost`, and a formula target is a stored
coordinate, so it projected `Record` and the page offered an editor over it —
the test passed while its name was wrong for a third of the landing-page grid,
which is exactly the shape this repo keeps finding. The assertion is now
two-sided: the two raw columns must be `Record` and the `Profit` column must
be `CellId::Derived`, so it fails if either half moves. Proved discriminating
in both directions by deleting the `derived` branch of `project::cell_id`
(Profit reads back as a `Record`) and, separately, by pointing it at
`examples/demo.improv` again (the aggregate at (0,0) still fails it).
### the_web_demo_shows_a_formula_beside_its_inputs
The payoff a visitor should see without pressing a key: a formula column beside
@@ -188,6 +188,31 @@ mod tests {
);
}
/// improvise-ir7l: the cell under the cursor is a formula target, so there is
/// nothing to type into. `context/test-evidence/command.md`.
#[test]
fn edit_or_drill_refuses_a_formula_owned_cell() {
let mut m = two_cat_model();
m.active_view_mut().set_axis("Type", crate::view::Axis::Row);
m.active_view_mut()
.set_axis("_Measure", crate::view::Axis::Column);
m.model
.add_formula(crate::formula::parse_formula("Profit = 1", "_Measure").expect("parses"));
let layout = make_layout(&m);
let reg = make_registry();
let ctx = make_ctx(&m, &layout, &reg);
let dbg = effects_debug(
&EditOrDrill {
edit_mode: AppMode::editing(),
}
.execute(&ctx),
);
assert!(
dbg.contains("computed by a formula") && !dbg.contains("Editing"),
"a formula cell must refuse rather than open an editor, got: {dbg}"
);
}
#[test]
fn enter_search_mode_sets_flag_and_clears_query() {
let m = two_cat_model();
@@ -313,6 +338,18 @@ impl Cmd for EditOrDrill {
};
return DrillIntoCell { key }.execute(ctx);
}
// The same question `cell_id` asks, off the same predicate: a formula owns
// the cell, so there is nothing to type into (improvise-ir7l).
if let Some(formula) = ctx
.cell_key()
.as_ref()
.and_then(|k| ctx.model.formula_for(k))
{
return vec![effect::set_status(format!(
"{} is computed by a formula — edit the formula, not the cell",
formula.target
))];
}
vec![
Box::new(effect::SetBuffer {
name: "edit".to_string(),
+73 -10
View File
@@ -308,11 +308,18 @@ fn cell_id(app: &App, row: usize, col: usize) -> Option<CellId> {
.category(cat)
.is_some_and(|category| category.kind.is_regular())
});
// A derived cell is asked about before a stored one: a formula target is a
// real coordinate, so the record branch would otherwise claim it (ir7l).
let derived = model.formula_for(&key).map(|f| CellId::Derived {
target: f.target.clone(),
});
Some(match aggregated {
true => CellId::Aggregate(AggregateHash::of(&key)),
false => CellId::Record(RecordCell {
measure: key.get(MEASURE_CATEGORY).map(String::from),
record: RecordHash::of(&key),
false => derived.unwrap_or_else(|| {
CellId::Record(RecordCell {
measure: key.get(MEASURE_CATEGORY).map(String::from),
record: RecordHash::of(&key),
})
}),
})
}
@@ -673,7 +680,7 @@ impl ClientSession {
.flatten()
.filter_map(|cell| match cell.id.as_ref()? {
CellId::Record(rc) => Some((rc.record, app.record_version_of(rc.record))),
CellId::Aggregate(_) => None,
CellId::Aggregate(_) | CellId::Derived { .. } => None,
})
.collect();
let desired = basis::published(app.document_generation(), app.layout_version(), records);
@@ -964,7 +971,7 @@ mod tests {
.flatten()
.filter_map(|cell| match cell.id.as_ref()? {
CellId::Record(rc) => Some((rc.record, app.record_version_of(rc.record))),
CellId::Aggregate(_) => None,
CellId::Aggregate(_) | CellId::Derived { .. } => None,
})
.collect(),
));
@@ -2201,6 +2208,12 @@ mod cell_identity {
/// Region on Row, `_Measure` on Column, nothing regular on None. Three rows and
/// two measures, so "the record" is not "the row" or "the cell".
fn raw_app() -> App {
let mut app = App::new(raw_workbook(), None);
app.rebuild_layout();
app
}
fn raw_workbook() -> Workbook {
let mut wb = Workbook::new("Raw");
wb.add_category("Region").unwrap();
for item in ["North", "South", "East"] {
@@ -2221,9 +2234,7 @@ mod cell_identity {
);
}
}
let mut app = App::new(wb, None);
app.rebuild_layout();
app
wb
}
/// The same with a second *regular* category left on None, so every visible cell
@@ -2259,6 +2270,33 @@ mod cell_identity {
app
}
/// `raw_app` plus `Profit = Revenue - Cost`, so column 2 is formula-owned and
/// columns 0 and 1 are the raw cells that feed it.
fn formula_app() -> App {
let mut wb = raw_workbook();
wb.model.add_formula(
improvise_formula::parse_formula("Profit = Revenue - Cost", "_Measure").unwrap(),
);
let mut app = App::new(wb, None);
app.rebuild_layout();
app
}
/// The same document with a second regular category left on None, so the
/// formula column aggregates as well as being derived.
fn aggregating_formula_app() -> App {
let mut wb = raw_workbook();
wb.add_category("Product").unwrap();
wb.add_item("Product", "Shirts").unwrap();
wb.active_view_mut().set_axis("Product", Axis::None);
wb.model.add_formula(
improvise_formula::parse_formula("Profit = Revenue - Cost", "_Measure").unwrap(),
);
let mut app = App::new(wb, None);
app.rebuild_layout();
app
}
fn id_at(app: &App, row: usize, col: usize) -> CellId {
grid_of(app, WHOLE)
.cell(row, col)
@@ -2271,7 +2309,7 @@ mod cell_identity {
fn record_of_id(id: CellId) -> RecordCell {
match id {
CellId::Record(cell) => cell,
CellId::Aggregate(hash) => panic!("expected a stored cell, got an aggregate {hash}"),
other => panic!("expected a stored cell, got {other:?}"),
}
}
@@ -2282,6 +2320,31 @@ mod cell_identity {
assert!(matches!(id_at(&raw_app(), 0, 0), CellId::Record(_)));
}
/// A formula target is a real coordinate, so the record branch claimed it and
/// the browser offered an editor. `context/test-evidence/command.md`.
#[test]
fn a_formula_owned_cell_is_projected_as_derived_and_its_siblings_are_not() {
let app = formula_app();
assert_eq!(
id_at(&app, 0, 2),
CellId::Derived {
target: "Profit".to_string()
}
);
assert!(matches!(id_at(&app, 0, 0), CellId::Record(_)));
assert!(matches!(id_at(&app, 0, 1), CellId::Record(_)));
}
/// Gotcha 10: a formula cell that *aggregates* is still drillable, so the
/// derived answer must not displace the aggregate one.
#[test]
fn an_aggregating_formula_cell_is_still_projected_as_an_aggregate() {
assert!(matches!(
id_at(&aggregating_formula_app(), 0, 2),
CellId::Aggregate(_)
));
}
#[test]
fn a_cell_that_sums_over_a_none_axis_category_is_projected_as_an_aggregate() {
assert!(matches!(
@@ -2642,7 +2705,7 @@ mod basis_check {
.expect("a stored cell is projected with an identity")
{
CellId::Record(cell) => cell,
CellId::Aggregate(h) => panic!("the fixture must project stored cells, got {h}"),
other => panic!("the fixture must project stored cells, got {other:?}"),
}
}
+13 -15
View File
@@ -247,6 +247,14 @@ impl Model {
&self.formulas
}
/// The formula that owns `key`, if one does. **The one home** of "is this
/// cell derived" — every other answer to it is a second derivation.
pub fn formula_for(&self, key: &CellKey) -> Option<&Formula> {
self.formulas
.iter()
.find(|f| key.get(&f.target_category) == Some(f.target.as_str()))
}
/// Names of all categories, including the virtual ones.
pub fn category_names(&self) -> Vec<&str> {
self.categories.keys().map(|s| s.as_str()).collect()
@@ -312,14 +320,10 @@ impl Model {
if depth == 0 {
return Some(CellValue::Error("circular".into()));
}
for formula in &self.formulas {
if let Some(item_val) = key.get(&formula.target_category)
&& item_val == formula.target
{
return self.eval_formula_depth(formula, key, depth - 1);
}
match self.formula_for(key) {
Some(formula) => self.eval_formula_depth(formula, key, depth - 1),
None => self.data.get(key).cloned(),
}
self.data.get(key).cloned()
}
/// The aggregation to apply to a cell's raw values, from the measure
@@ -426,14 +430,8 @@ impl Model {
/// One formula cell for the fixed-point pass: raw aggregation for a
/// non-formula ref, the cache for a formula ref.
fn evaluate_formula_cell(&self, key: &CellKey, none_cats: &[String]) -> Option<CellValue> {
for formula in &self.formulas {
if let Some(item_val) = key.get(&formula.target_category)
&& item_val == formula.target
{
return self.eval_formula_with_cache(formula, key, none_cats);
}
}
None
self.formula_for(key)
.and_then(|formula| self.eval_formula_with_cache(formula, key, none_cats))
}
/// The cached evaluator. Its twin is `eval_formula_depth`; a rule stated
+3
View File
@@ -47,6 +47,9 @@ pub enum CellId {
Record(RecordCell),
/// An aggregate over stored cells. `Drill` takes this and nothing else.
Aggregate(AggregateHash),
/// A cell a formula owns. It carries no surrogate, so a write for it is
/// unconstructible rather than refused — see `context/code/protocol.md` §3.
Derived { target: String },
}
/// One cell as the client draws it. Deliberately not a `CellValue` — the
+21 -1
View File
@@ -462,6 +462,9 @@ fn edit_or_drill(state: &ClientState, mode: AppMode) -> Option<Vec<Command>> {
CellId::Aggregate(_) => Some(vec![Command::SetStatus(
"drilling into an aggregate needs the host — not in the browser yet".to_string(),
)]),
CellId::Derived { target } => Some(vec![Command::SetStatus(format!(
"{target} is computed by a formula — edit the formula, not the cell"
))]),
}
}
@@ -526,7 +529,7 @@ fn commit(state: &ClientState, edit_mode: AppMode, advance: Advance) -> Option<V
fn record_at_cursor(state: &ClientState) -> Option<improvise_protocol::RecordHash> {
match cell_at_cursor(state)?.id.as_ref()? {
CellId::Record(cell) => Some(cell.record),
CellId::Aggregate(_) => None,
CellId::Aggregate(_) | CellId::Derived { .. } => None,
}
}
@@ -1149,6 +1152,23 @@ mod tests {
);
}
/// `i` on a formula-owned cell names the formula, so the user learns where
/// the value comes from rather than typing into a shadowed cell (ir7l).
#[test]
fn i_on_a_derived_cell_names_the_formula() {
let client = client_on(CellId::Derived {
target: "Profit".into(),
});
let KeyOutcome::Local(commands) = resolve(client.state(), ev(KeyCode::Char('i'))) else {
panic!("a derived cell must not be a silent no-op");
};
assert!(
matches!(&commands[..], [Command::SetStatus(msg)] if msg.contains("Profit")
&& msg.contains("formula")),
"expected a status naming the formula target, got {commands:?}"
);
}
/// Enter in `Editing` is `[commit-cell-edit editing, clear-buffer edit]`
/// and every part shows up here — including the odd-looking trailing clear.
#[test]
+2 -2
View File
@@ -127,11 +127,11 @@ impl Client {
}
/// The address of the stored cell at a viewport position, or `None` on the
/// three shapes that are not one. The last is not a check (ADR 002).
/// four shapes that are not one. The last is not a check (ADR 002).
pub fn record_cell_at(&self, row: usize, col: usize) -> Option<RecordCell> {
match self.state.grid.cell(row, col)?.id.clone()? {
CellId::Record(cell) => Some(cell),
CellId::Aggregate(_) => None,
CellId::Aggregate(_) | CellId::Derived { .. } => None,
}
}
+17 -5
View File
@@ -305,8 +305,8 @@ mod tests {
})
}
/// A 2x2 grid whose four cells cover the three id shapes plus a padded
/// slot, so one fixture answers every editability question.
/// A 2x3 grid whose cells cover the four id shapes plus a padded slot, so
/// one fixture answers every editability question.
fn group(cat: &str, name: &str, collapsed: bool) -> FrameEntry {
FrameEntry::Group(improvise_protocol::FrameGroup {
cat: cat.into(),
@@ -351,13 +351,21 @@ mod tests {
view,
seed: vec![
Projection::Grid(GridUpdate::RowFrame(AxisFrame::flat(["North", "South"]))),
Projection::Grid(GridUpdate::ColFrame(AxisFrame::flat(["Revenue", "Total"]))),
Projection::Grid(GridUpdate::ColFrame(AxisFrame::flat([
"Revenue", "Total", "Profit",
]))),
Projection::Grid(GridUpdate::Cells(vec![
((0, 0), CellDisplay::number("1200").with_id(record_id())),
(
(0, 1),
CellDisplay::number("1900").with_id(CellId::Aggregate(aggregate())),
),
(
(0, 2),
CellDisplay::number("700").with_id(CellId::Derived {
target: "Profit".into(),
}),
),
((1, 0), CellDisplay::text("pending")),
])),
],
@@ -365,7 +373,7 @@ mod tests {
client
}
/// ADR 002's permission, as the page sees it: three different facts, and
/// ADR 002's permission, as the page sees it: four different facts, and
/// only the first is a cell a user may type into.
#[test]
fn only_a_record_cell_renders_as_editable() {
@@ -378,6 +386,10 @@ mod tests {
!model.rows[0].cells()[1].editable,
"an aggregate is a drill target, and Drill is not on the wire yet"
);
assert!(
!model.rows[0].cells()[2].editable,
"a formula owns the cell, so a write to it would be shadowed on recompute"
);
assert!(
!model.rows[1].cells()[0].editable,
"id: None is not 'aggregate' — it is 'no projected cell', and not writable either"
@@ -396,7 +408,7 @@ mod tests {
assert!(model.rows[0].cells()[0].numeric);
assert!(!model.rows[1].cells()[0].numeric);
assert_eq!(model.rows[0].cells()[0].text, "1200");
assert_eq!(model.col_levels[0], vec!["Revenue", "Total"]);
assert_eq!(model.col_levels[0], vec!["Revenue", "Total", "Profit"]);
assert_eq!(model.rows[1].labels(), ["South"]);
}
+1 -1
View File
@@ -189,7 +189,7 @@ mod tests {
fn addressed(grid: &GridCache, row: usize, col: usize) -> RecordCell {
match grid.cells[row][col].id.as_ref().expect("a projected cell") {
CellId::Record(rc) => rc.clone(),
CellId::Aggregate(_) => panic!("row {row} col {col} is an aggregate, not writable"),
other => panic!("row {row} col {col} is not writable: {other:?}"),
}
}
@@ -120,7 +120,7 @@ impl MainThread {
fn cell(&self, row: usize, col: usize) -> RecordCell {
match self.grid.cells[row][col].id.as_ref().expect("a cell") {
CellId::Record(rc) => rc.clone(),
CellId::Aggregate(_) => panic!("({row},{col}) is an aggregate"),
other => panic!("({row},{col}) is not a stored cell: {other:?}"),
}
}
@@ -300,7 +300,7 @@ fn the_vfs_offers_every_document_the_origin_holds() {
/// The document the static site actually ships, driven the way the page drives
/// it. See `context/test-evidence/clients.md`.
#[test]
fn the_shipped_demo_seeds_a_worker_whose_every_cell_is_settable() {
fn the_shipped_demo_seeds_a_worker_whose_raw_cells_are_settable() {
let seed = std::fs::read(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../web/demo.improv"))
.expect("web/demo.improv is where the site build expects it");
@@ -318,13 +318,22 @@ fn the_shipped_demo_seeds_a_worker_whose_every_cell_is_settable() {
);
assert_eq!(main.text_at(0, 2), "12,200", "Profit is live on arrival");
// `cell` panics on an aggregate, which is exactly the failure a visitor
// meets as a cell that does not answer a click (ADR 002).
// `cell` panics on anything but a stored cell, which is exactly the failure a
// visitor meets as a cell that does not answer a click (ADR 002).
for row in 0..main.grid.row_frame.len() {
for col in 0..main.grid.col_frame.len() {
for col in 0..2 {
main.cell(row, col);
}
}
// The third column is `Profit`, which a formula owns: the page must not
// offer it, so it carries no surrogate to offer (improvise-ir7l).
for row in 0..main.grid.row_frame.len() {
assert!(
matches!(main.grid.cells[row][2].id, Some(CellId::Derived { .. })),
"row {row}'s Profit cell must be Derived, got {:?}",
main.grid.cells[row][2].id
);
}
// And the payoff the page exists to show: an edit to an input moves the
// formula beside it, through the projection stream.
+20 -12
View File
@@ -11,10 +11,10 @@ fn demo_app() -> App {
App::new(workbook, Some(path))
}
/// Every cell the page paints names a stored record, so every cell is one the
/// visitor may click and change.
/// Every cell the page paints is either one the visitor may change or one a
/// formula owns — never an aggregate. `context/test-evidence/tui.md`.
#[test]
fn every_visible_cell_in_the_web_demo_is_settable() {
fn every_web_demo_cell_is_settable_except_the_column_a_formula_owns() {
let app = demo_app();
let (rows, cols) = (app.layout.row_count(), app.layout.col_count());
assert!(rows > 0 && cols > 0, "the demo grid must not be empty");
@@ -32,15 +32,23 @@ fn every_visible_cell_in_the_web_demo_is_settable() {
for r in 0..rows {
for c in 0..cols {
let cell = grid.cell(r, c).expect("a projected cell at every position");
match &cell.id {
Some(CellId::Record(_)) => {}
other => panic!(
"cell ({r},{c}) — row {:?}, column {:?} — is {other:?}, not a Record, so the \
page will show it as un-editable",
grid.row_frame.item(r),
grid.col_frame.item(c),
),
}
let expected_derived = grid
.col_frame
.item(c)
.is_some_and(|t| t.as_slice() == ["Profit"]);
let ok = match (&cell.id, expected_derived) {
(Some(CellId::Record(_)), false) => true,
(Some(CellId::Derived { target }), true) => target == "Profit",
_ => false,
};
assert!(
ok,
"cell ({r},{c}) — row {:?}, column {:?} — is {:?}; the two raw columns must be \
Records the page offers, and Profit must be Derived so it does not",
grid.row_frame.item(r),
grid.col_frame.item(c),
cell.id,
);
}
}
}