improvise-eccl/f2oo: correct §14's premise and state the fold's coverage; evidence

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
This commit is contained in:
Edward Langley
2026-09-22 22:24:21 -07:00
parent e3aa2ccb4b
commit 79175bbb58
5 changed files with 126 additions and 29 deletions
+43 -13
View File
@@ -869,11 +869,14 @@ an involution without anything being taught about involution, and neither
### The cell
`Model::evaluate_aggregated` expands, at its head, any coordinate that names a
group rather than an item into one key per member (a cross product where several
coordinates do). Doing it in the **model** is what makes `display_text`, prune,
column totals, `grid_of`, search, yank and CSV export all follow with no change.
Three decisions:
`Model::evaluate_covering` expands, at its head, any coordinate that names a group
rather than an item into one key per covered member (a cross product where
several coordinates do). The grid reads every pivot key through
`GridLayout::evaluate`, which passes the members it draws. Since
improvise-eccl/f2oo, display text, pruning, column totals, `grid_of`, search,
yank and CSV export all go through that one evaluation, rather than each calling
`Model::evaluate_aggregated`. That remains the view-less answer, and it covers
every member. Five decisions:
- **Membership is read off each item's own `group` field, never off
`Category::groups`.** `add_item_in_group` registers no `Group`; that list is
@@ -882,14 +885,41 @@ Three decisions:
- **An item with the same name as the group wins.** `items.contains_key`
short-circuits the expansion, so an existing document with an item `Q1` reads
exactly as before.
- **Stored values fold once over every record the group covers; a derived value
folds its members' results.** Folding the members' *aggregates* is right for
`Sum` and wrong for `Avg`/`Count` under `Axis::None` — `avg(avg(2,4), 9) = 6`
where the group's average is `5`. A formula target has no stored records to
pool, so its members' evaluated results are folded with the measure's
aggregation; for a ratio formula that is the usual pivot-table limitation, not
a new one. Hidden items are included in the fold, as on `Axis::None`: hiding
decides what is drawn, not what an aggregate covers.
- **Stored values fold once over every record the group covers.** Folding the
members' *aggregates* is right for `Sum` and wrong for `Avg`/`Count` under
`Axis::None`: `avg(avg(2,4), 9) = 6` where the group's average is `5`.
- **A formula is evaluated over the fold, never folded itself** (improvise-eccl).
This bullet used to say a formula target "has no stored records to pool", so
its members' results were folded with the measure's aggregation, and it called
that "the usual pivot-table limitation". **The premise was wrong.** A formula's
*inputs* are stored and can be pooled, and the model already evaluated a
formula over pooled inputs whenever a category sat on `Axis::None`:
`recompute_formulas` strips the None categories from the stems. Folding the
members' results summed ratios instead, so `Margin = (Revenue - Cost) / Revenue`
at 0.5 and 0.9 showed **1.4** on the Q1 row where Month-on-None showed **0.8**.
Spreadsheet calculated fields do not have that limitation either. The formula
is now evaluated at the group key, with each reference read through the fold
(`Reach::Fold` in `model/types.rs`):
- a raw measure reads the stored fold;
- another formula target is evaluated over the fold in turn;
- an aggregate inside the formula (`SUM`, `MAX`, …) pools every covered
member's records. `MAX(Revenue)` had been *summing* the members' maxima.
The collapsed row now agrees with the None-axis answer. **Test with a ratio**:
a sum agrees under both rules, which is how this was missed. One residual
difference is stated rather than hidden. `IS` and a `WHERE` on the grouped
category itself see the group's name, so `Month IS Q1` is true on the fold
and false on a None axis.
- **A fold covers the members the grid draws** (improvise-f2oo). Hidden members
are not in it. So a fold is exactly the rows it replaces, the totals strip does
not move on `z`, and a group whose every member is hidden has no row. That last
point was already true, and it contradicted the old rule, under which a hidden
value appeared only while a sibling was visible. This deliberately **differs
from `Axis::None`**, where hidden items still count, because the two replace
different things. A fold replaces *rows the user sees*. A None axis replaces a
*dimension*, and hiding an item on it hides nothing that was drawn. The view's
hidden set reaches the model as the `covers` predicate `GridLayout::evaluate`
passes, since the model knows no view.
### Settability and drill
+38 -2
View File
@@ -1341,8 +1341,8 @@ this fixture and the value is absent — this test, the next, and
Under `Axis::None` (`Store`), `Avg` and `Count` over `Jan/A=2, Jan/B=4, Feb/A=9`.
The group's average is `5` and its count `3`; folding the members' aggregates gives
`avg(3, 9) = 6` and `count(2, 1) = 2`. Rival: always fold members' results (the
formula-target branch taken for stored values) — this test fails. `Sum` cannot see
`avg(3, 9) = 6` and `count(2, 1) = 2`. Rival: always fold members' results (what the
formula-target branch did before improvise-eccl) — this test fails. `Sum` cannot see
the difference, which is why the fixture sets `measure_agg`.
### model::types::model_tests::an_item_named_like_a_group_is_read_as_the_item
@@ -1361,3 +1361,39 @@ fold is the row `z` names the group from. Note the narrowing this implies: an
**empty** collapsed group now has an empty fold row, which `prune_empty` drops, and
its header goes with it. lhi4 kept that header because it was the only way back to
the rows; the fold is now that way back.
### a_folds_ratio_formula_is_the_ratio_of_its_folded_inputs
improvise-eccl. `Margin = (Revenue - Cost) / Revenue` with Jan `10/5` and Feb `30/3`
under `Q1`, and Apr in `Q2` with no data, so the Q1 fold is row 1. The member ratios
are 0.5 and 0.9, so the three candidate rules give three different answers: summed
1.4, averaged 0.7, pooled `(40 - 8) / 40` 0.8. The expected value is the **pooled**
one, and it is also asserted equal to the same document with Month on `Axis::None`,
which is the ticket's criterion. The None-axis document works because Apr carries
no data, so the None axis pools exactly Q1's months. At `a164726f` the fold read
**1.4**. The ticket's own fixture (0.5 and 0.5) would have separated sum from
pool, but not mean from pool. The second formula set chains
(`Profit = Revenue - Cost`, `Margin = Profit / Revenue`), so a reference to
another formula target is read over the fold as well. A rule that folds a formula
ref's members while pooling raw refs fails there.
### a_folds_aggregate_formula_pools_its_members_records
The guard on the other side of improvise-eccl. Once a formula at a fold is
evaluated at the group key, an aggregate inside it (`SUM`, `MAX`) must pool every
covered member's records, because `Month=Q1` matches no record. `MAX(Revenue)` is
the discriminating case. It **failed before the change too**: the old rule folded
the members' `MAX`es with the measure's `Sum`, giving 40 where the answer is 30.
`SUM` alone agrees under the old rule and the new one. Rival: `contexts_of` stops
pooling at a fold (`Reach::Fold { .. } => Vec::new()`). This test alone fails.
### collapsing_a_group_with_a_hidden_member_leaves_the_total_where_it_was
improvise-f2oo. `Q1 = {Jan 3, Feb 4}`, `Q2 = {Apr 100}`, Feb hidden. Expanded, the
drawn rows total 103. At `a164726f` collapsing gave a Q1 fold of 7 and a total of
**107**: `z` moved the total. The ruling: a fold covers the members the grid
draws. The test asserts 103 both ways and a fold of 3. It then hides Jan too and
asserts that there is no Q1 row and a total of 100. That half already held, and it
is the inconsistency the ticket named: before, a hidden value appeared only while
a sibling was visible. Rival: `GridLayout::evaluate` covering every member
(`&|_, _| true`). This test alone fails, with 107.
+2 -4
View File
@@ -6,8 +6,8 @@ use unicode_width::UnicodeWidthStr;
use improvise_protocol::header_level_widths;
use crate::format::{FormatPlan, NumberFormat};
use crate::model::cell::{CellKey, CellValue};
use crate::model::Model;
use crate::model::cell::{CellKey, CellValue};
use crate::view::{AxisEntry, GridLayout};
/// Minimum column width — enough for short numbers/labels + 1 char gap.
@@ -110,9 +110,7 @@ fn cell_value(
true => (None, layout.records_value(row, col)),
false => {
let key = layout.cell_key(row, col);
let value = key
.as_ref()
.and_then(|k| layout.evaluate(model, k));
let value = key.as_ref().and_then(|k| layout.evaluate(model, k));
(key, value)
}
}
+29 -4
View File
@@ -138,7 +138,10 @@ pub type Covers<'a> = &'a dyn Fn(&str, &str) -> bool;
#[derive(Clone, Copy)]
enum Reach<'a> {
Record,
Fold { agg: Aggregation, covers: Covers<'a> },
Fold {
agg: Aggregation,
covers: Covers<'a>,
},
}
/// The **record-level** evaluator: no cache, because a member key is not a stem
@@ -644,7 +647,12 @@ impl Model {
}
/// A group's stored values fold ONCE over every record the group covers.
fn fold_stored(&self, key: &CellKey, members: &[CellKey], agg: Aggregation) -> Option<CellValue> {
fn fold_stored(
&self,
key: &CellKey,
members: &[CellKey],
agg: Aggregation,
) -> Option<CellValue> {
let values: Vec<&CellValue> = members
.iter()
.flat_map(|k| match agg {
@@ -674,7 +682,14 @@ impl Model {
return self.fold_stored(key, members, agg);
}
let reach = Reach::Fold { agg, covers };
match eval_expr(&formula.expr, key, self, &formula.target_category, depth, reach) {
match eval_expr(
&formula.expr,
key,
self,
&formula.target_category,
depth,
reach,
) {
Ok(n) => Some(CellValue::Number(n)),
Err(e) => Some(CellValue::Error(e)),
}
@@ -986,7 +1001,17 @@ mod agg_helper_tests {
let empty = Expr::Ref("Revenue".to_string());
let ctx = CellKey::new(vec![]);
let never = |_: &Expr, _: &CellKey| unreachable!("a bare Ref never folds per record");
let run = |func| eval_agg(&model, &[ctx.clone()], "_Measure", &func, &empty, None, &never);
let run = |func| {
eval_agg(
&model,
&[ctx.clone()],
"_Measure",
&func,
&empty,
None,
&never,
)
};
assert_eq!(run(AggFunc::Sum), Ok(0.0));
assert_eq!(run(AggFunc::Count), Ok(0.0));
+14 -6
View File
@@ -661,7 +661,6 @@ impl GridLayout {
axis_label(&self.col_items, col)
}
/// Display text for (row, col) in either mode.
/// The value of a pivot key as THIS grid reads it: aggregated over its None
/// axis, and a fold over the members it draws (core.md §14). One evaluation.
pub fn evaluate(&self, model: &Model, key: &CellKey) -> Option<CellValue> {
@@ -669,6 +668,7 @@ impl GridLayout {
model.evaluate_covering(key, Aggregation::of(&self.none_cats), &drawn)
}
/// Display text for (row, col) in either mode.
pub fn display_text(
&self,
model: &Model,
@@ -681,8 +681,7 @@ impl GridLayout {
} else {
self.cell_key(row, col)
.and_then(|key| {
let value =
self.evaluate(model, &key)?;
let value = self.evaluate(model, &key)?;
Some(plan.at(Some(&key)).value(Some(&value)))
})
.unwrap_or_default()
@@ -1803,7 +1802,9 @@ mod tests {
&["Profit = Revenue - Cost", "Margin = Profit / Revenue"][..],
] {
let mut folded = margin_book(formulas);
folded.active_view_mut().toggle_group_collapse("Month", "Q1");
folded
.active_view_mut()
.toggle_group_collapse("Month", "Q1");
let fold = grid_value(&mut folded, "Q1", "Margin").expect("a Q1 fold");
let mut none = margin_book(formulas);
@@ -1811,7 +1812,10 @@ mod tests {
let over_none = grid_value(&mut none, "", "Margin").expect("a None-axis cell");
assert!((fold - 0.8).abs() < 1e-9, "{formulas:?}: fold {fold}");
assert!((fold - over_none).abs() < 1e-9, "{formulas:?}: {fold} vs {over_none}");
assert!(
(fold - over_none).abs() < 1e-9,
"{formulas:?}: {fold} vs {over_none}"
);
}
}
@@ -1858,7 +1862,11 @@ mod tests {
wb.active_view_mut().toggle_group_collapse("Month", "Q1");
assert_eq!(column_total(&wb), 103.0, "z moved the total");
assert_eq!(grid_value(&mut wb, "Q1", "Food"), Some(3.0), "Feb is hidden");
assert_eq!(
grid_value(&mut wb, "Q1", "Food"),
Some(3.0),
"Feb is hidden"
);
// Every member hidden: no fold is drawn, and the total still agrees.
wb.active_view_mut().hide_item("Month", "Jan");