test(incremental): hidden members under every aggregation, evidence and the step 4 reasoning (improvise-u2le)

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-backend)
2026-10-10 14:42:43 -07:00
committed by Edward Langley
parent a18a092186
commit c6fbce0ca1
5 changed files with 264 additions and 2 deletions
+71
View File
@@ -145,3 +145,74 @@ about hidden items, totals or occupancy. Both are closed. These are the readings
`Eager`-against-evaluator comparison as well.
6. **`Probe` is `Hash + Eq`**, so `Asked` deduplicates on the probe itself (`Aggregation` gained
`Hash` in core for it).
## Step 4: the incremental backend (improvise-u2le)
`Incremental` (`incremental.rs`) is the first `Derived` that keeps state. It owns a `Model`
(the store) and `Shapes` (`shapes.rs`, the groups). `Maintained::apply` applies the edit to the
model, drains the store's change log into a `CellDelta` and feeds the delta to every shape. A
read asks a group, never the store, for what step 4 owns. The rest of this section is the readings
taken where clause 4 and clause 7 leave a choice; evidence is in
`context/test-evidence/improvise-incremental.md`.
1. **A shape is a set of category names, not a None list.** Clause 4 says "a group is the cell key
minus the None categories". The trait is asked about keys of any category set (the harness
drops up to three coordinates of a stored key), so the unit is the set a key names: a `Shape`
maps the projection of a stored cell onto those categories to a `Group`, and a cell lands in
it only if it has all of them. A view's None list is one of these sets. Clause 7's "one
circuit serves every shape key with the same None list" holds trivially, since the sets are
shared by every reader.
2. **A shape is built on its first read, by one pass over the store, and fed every delta after.**
Clause 7 makes a new key's first build a sliced job under ADR 008's job runner. That runner
does not exist, so the pass is synchronous and counted (`Meter::builds`, `built_cells`).
Nothing evicts a shape. `Shapes` holds its map in a `RefCell` because `Derived` reads take
`&self`; the cell is private to `shapes.rs` and no reference to it leaves a method.
3. **A group is a count, a count of numbers and a multiset** (`group.rs`). One update touches one
map entry (O(log d)); the count and the multiset serve every function, so
`SetMeasureAgg` rebuilds nothing. A read of several groups (a collapsed group, a total) folds
their union without merging them: `fold_groups` sums the counts and takes the smallest of the
groups' minima, the largest of their maxima, or the one value they share.
4. **Eager's rules are reproduced, not improved.** An all-numbers group reduces; any other group
is unanimous or `Error("mixed")`, so `Count` of a group holding text is that text. `Min` and
`Max` skip NaN and are `+inf`/`-inf` over only NaN, as `fold(INFINITY, f64::min)` is. The
multiset orders by the delta's total order, with NaN last, so the skip is a step past one
key. A group holding a NaN and a non-number is mixed, because NaN is not equal to itself in
`CellValue`'s `==` and is a distinct key here.
5. **`Sum` and `Avg` stay with the store until step 6.** Their eager result depends on the
order the cells are added in (insertion order in the store, not `CellKey` order, whatever
clause 3 says), so a group cannot reproduce it without keeping that order. The backend routes
a read whose measure aggregation is `Sum` or `Avg` to `Model::evaluate_covering` and
`evaluate_spanning`, which is eager's own path. Step 6 replaces both sides with `ExactSum`.
Because `Sum` is the default, the corpus reaches the groups only once a `SetMeasureAgg`
edit has changed a measure; the differential tests apply those on purpose.
6. **Formulas stay with the model until step 5.** A key a formula owns is read through the model,
and `apply` calls `Model::recompute_formulas`, as `Eager` does. Step 5 replaces the call.
7. **A fold is the union of its members' groups.** `fold.rs` is `Model::group_expansion` read off
the public model: the cross product of the covered members of every group the key names and
every covered item of every category in `over` that it omits. Under `OverHidden` the members
are groups of one shape (the base's categories plus the spans'). Under `Exact` the cells
admitted are exactly the member keys, so the read is a lookup per member and a throwaway
`Group`. A total therefore reads one group per covered row, not one per stored cell, but still
one per row: clause 7's maintained totals with a subtracted hidden overlay are not built (see
"Not built").
8. **Occupancy counts by inclusion and exclusion.** `occupancy(partial)` is the cells under
`partial`, where a coordinate naming a group matches each of its members' items, with no
hidden coordinate. For each way of choosing a member per group coordinate the count is the
group's cell count, less the cells holding a hidden item of a category the key leaves free,
by inclusion and exclusion over those categories: `N(P) - sum N(P + h) + ...`. The cost is
the product of the hidden items per free category, a group read each. A key naming a hidden
item itself is zero.
9. **The meter counts operations.** `Meter` is `updates` (group updates a delta caused), `reads`
(groups a read looked at), `builds` and `built_cells`. `tests/measure.rs` fixes one table at
two sizes and asserts the counts: an overwrite costs two updates per shape whatever the table
holds, and a read of a group of 36 or 72 cells costs one group. Eager's cost for the same read
is `matching_values().len()`, which the test prints beside it. This is the local fixture the
brief asked for; ADR 009 step 4 says "measured at ADR 008's fixture", which needs ADR 008
(unapproved) and is deferred.
### Not built in step 4
- Maintained totals (clause 7): a total reads one group per covered row.
- Eviction of shapes, and the build as a sliced job.
- `Sum`, `Avg` (step 6) and formulas (step 5) are eager's, behind the same trait.
- The ADR 008 fixture measurement.
@@ -303,3 +303,148 @@ its stored keys are asked by the subset probes. Fault: not skipping it fails thi
The oracle recomputes its formulas under one None set; a view under another is not asked, since
the oracle would answer it from the wrong cache. Fault: dropping the filter fails this test.
## Step 4: the incremental backend (improvise-u2le)
**How the faults were injected.** Twenty-two faults were put into `src/group.rs`, `src/shapes.rs`,
`src/fold.rs` and `src/incremental.rs` one at a time, each run against the whole crate
(`cargo test -p improvise-incremental --no-fail-fast`) and reverted before the next. Two survive
because they change nothing observable (below); every other is named by at least one test:
- a group ignores a retraction: `group::tests::a_retraction_removes_the_value_it_added`,
`group::tests::an_emptied_group_is_absent_and_keeps_no_entries`,
`incremental_matches_eager_on_the_shipped_documents`, `incremental_equals_eager_over_random_edit_sequences`;
- a group never counts its numbers: `a_group_read_comes_from_a_maintained_group`,
`group::tests::min_and_max_skip_nan_and_are_infinite_over_nothing_else` and eight differentials;
- `Max` takes the first key: `group::tests::min_and_max_skip_nan_and_are_infinite_over_nothing_else`,
`an_overwrite_costs_two_updates_per_shape_whatever_the_table_size` and five differentials;
- `Count` counts one: `a_collapsed_group_reads_one_group_per_member_it_covers`, `group::tests::*`;
- unanimity ignores disagreement: `group::tests::text_is_unanimous_or_mixed_across_groups`,
`incremental_matches_eager_on_the_hand_model_under_every_aggregation`;
- an `Exact` fold reads groups: `incremental_matches_eager_on_a_group_read_without_a_view`,
`incremental_matches_eager_on_hidden_items_and_collapsed_groups`;
- a formula-owned key is served from groups: `incremental_matches_eager_on_a_formula_read_under_every_aggregation`;
- `Sum` read as `Count`: seven differentials, so the delegation is observed;
- occupancy counts a hidden coordinate / ignores a free hidden category / adds the hidden cells
/ ignores a named group: the hidden-item differentials and
`occupancy_with_a_hidden_item_reads_the_groups_it_subtracts` (the last two of those);
- shapes are not fed after they are built: `a_group_read_comes_from_a_maintained_group`,
`an_overwrite_costs_two_updates_per_shape_whatever_the_table_size`;
- a shape keeps an emptied group: `a_group_whose_cells_are_all_cleared_is_no_longer_read`;
- the expansion ignores `covers`:
`incremental_matches_eager_on_a_collapsed_group_with_a_hidden_member_under_every_aggregation`;
- the members ignore the spans: `a_collapsed_group_reads_one_group_per_member_it_covers`;
- `apply` skips the model or leaves the formulas stale: the differentials, with and without formulas;
- the measure's aggregation is ignored: `a_collapsed_group_reads_one_group_per_member_it_covers`,
`a_group_read_comes_from_a_maintained_group`;
- `raw_fold` answers for a formula target: `incremental_matches_eager_on_a_pooled_total_of_a_ratio`,
`incremental_matches_eager_on_the_hand_model_under_every_aggregation`.
**Faults that cannot be seen, and why.** `Group::smallest` reading the first key even when it is NaN
returns NaN, and `f64::min(INFINITY, NaN)` is `INFINITY`, which is the answer for a group of only
NaN. A projection that keeps the cells lacking a shape's category files them under a key that
names fewer categories, and a read of that shape never asks for such a key.
**The fixture, and a first version that was blind.** `hidden_workbook` draws the measure on
no axis, so its cells carry no `_Measure` coordinate, the measure's aggregation never applies and the
expansion fault above survived. `hidden_counted_workbook` draws the measure on the columns.
### incremental_matches_eager_on_the_hand_model_under_every_aggregation
Applies `Count`, `Min`, `Max`, `Avg`, `Sum` to a numeric measure and to a text measure, then a text
over a number and a clear. Fails on a group that ignores retractions, one that never counts numbers,
`Max` reading the first key and unanimity that ignores disagreement.
### incremental_matches_eager_on_the_shipped_documents
Both shipped documents and the hand and hidden fixtures, over `edits_for`. It reaches the group path
only after the edit that sets a measure's aggregation, so it fails on the faults that touch the
fold, not on one in the `Sum` path.
### incremental_matches_eager_on_hidden_items_and_collapsed_groups
East is hidden and Coastal collapsed. Fails on occupancy that counts a hidden coordinate and on an
`Exact` fold that reads groups. The measure is not drawn here, so it does not see `covers`.
### incremental_matches_eager_on_a_collapsed_group_with_a_hidden_member_under_every_aggregation
The same workbook with the measure on the columns, so the Coastal row folds West only and the
measure's aggregation applies to it. Fails on an expansion that ignores `covers`.
### incremental_matches_eager_on_a_formula_read_under_every_aggregation
`Profit` is a formula target, so its key must be read by the model. Fails when the backend serves a
formula-owned key from groups.
### incremental_matches_eager_on_a_group_read_without_a_view
`grouped_model` with a NaN, a clear and every aggregation, once with no None category and once with
Product. Fails on an `Exact` fold that reads groups.
### incremental_matches_eager_on_a_cell_a_view_draws_that_drops_four_coordinates
The wide fixture under the new backend: a drawn cell that drops four coordinates of every stored
key. Fails on a group that never counts numbers and on shapes not fed after the build.
### incremental_matches_eager_on_a_pooled_total_of_a_ratio
The ratio fixture's formula `Margin` pooled over the rows. Fails when `apply` leaves the formulas
stale and when a formula key is read as a raw fold.
### incremental_equals_eager_over_random_edit_sequences
Up to six edits drawn from each fixture's own cells (an overwrite, a record in a made-up item, a
clear, a new aggregation) with values from a pool that has both zeros, two NaNs, an infinity, text
and an error, 256 sequences over five fixtures, probing everything each view draws. Fails on every
fault listed above that a deterministic test did not already see.
### a_group_read_comes_from_a_maintained_group
A `Min` read builds one shape and reads one group; after an overwrite the new minimum comes from the
delta and the store is not scanned again. Fails on a group that never counts numbers, on shapes that
are not fed after the build and on a backend that ignores the measure's aggregation.
### an_overwrite_costs_two_updates_per_shape_whatever_the_table_size
At 216 and 864 cells, with one and with two shapes built, one overwrite costs a retraction and an
insertion per shape and builds nothing; the two sizes cost the same. Fails on shapes that are not
fed, which cost nothing, and on `Max` reading the first key, since the read after the write is wrong.
### a_read_visits_one_group_where_eager_visits_every_cell_it_covers
A group read at two table sizes costs one group each; eager's `matching_values` visits the 36 and 72
cells the region covers. The counts are deterministic operations; no wall clock is read.
### a_collapsed_group_reads_one_group_per_member_it_covers
Coast holds three regions, so a `Count` of it reads three groups and answers 108. Fails on members
that ignore the spans and on a `Count` that counts once.
### occupancy_with_a_hidden_item_reads_the_groups_it_subtracts
A line with one hidden product is its cells less the hidden product's, and reads two groups (the
line and the line under that product). Fails on occupancy that ignores or adds the hidden cells.
### a_group_whose_cells_are_all_cleared_is_no_longer_read
Clearing the only cell of a group removes the group, and a later read looks at none. Fails when a
shape keeps the emptied group.
### group::tests::a_retraction_removes_the_value_it_added
A multiset that ignores a negative weight keeps the minimum it should have lost.
### group::tests::an_emptied_group_is_absent_and_keeps_no_entries
After the last retraction the group reads as absent and its map holds no zero-weight entry.
### group::tests::min_and_max_skip_nan_and_are_infinite_over_nothing_else
NaN is skipped by `Min` and `Max`, a group of only NaN gives `+inf` and `-inf` and still counts.
### group::tests::a_fold_over_several_groups_is_the_fold_of_their_union
The smallest of the minima, the largest of the maxima and the sum of the counts.
### group::tests::text_is_unanimous_or_mixed_across_groups
One shared text is that text, two are `mixed`, a number among text is `mixed`, and so is a NaN.
@@ -9,8 +9,8 @@ use improvise_core::model::cell::CellValue;
use improvise_core::view::View;
use improvise_incremental::{Edit, Incremental, differential_in};
use support::{
corpus, edits_for, grouped_model, hand_model, hidden_workbook, none_of, ratio_workbook,
wide_workbook,
corpus, edits_for, grouped_model, hand_model, hidden_counted_workbook, hidden_workbook,
none_of, ratio_workbook, wide_workbook,
};
fn run(model: &Model, views: &[View], none: &[String], edits: &[Edit]) -> Result<(), String> {
@@ -77,6 +77,27 @@ fn incremental_matches_eager_on_hidden_items_and_collapsed_groups() {
run(&wb.model, &views, &none, &edits).unwrap();
}
#[test]
fn incremental_matches_eager_on_a_collapsed_group_with_a_hidden_member_under_every_aggregation() {
let wb = hidden_counted_workbook();
let views: Vec<View> = wb.views.values().cloned().collect();
let none = none_of(&wb);
let mut edits = agg_edits("Revenue");
edits.push(Edit::SetCell(
support::key("Inland", "A", "Revenue"),
CellValue::Number(f64::NAN),
));
edits.extend(agg_edits("Revenue"));
run(&wb.model, &views, &none, &edits).unwrap();
}
#[test]
fn incremental_matches_eager_on_a_formula_read_under_every_aggregation() {
let model = hand_model();
let none = vec!["Region".to_string()];
run(&model, &[], &none, &agg_edits("Profit")).unwrap();
}
#[test]
fn incremental_matches_eager_on_a_group_read_without_a_view() {
let model = grouped_model();
@@ -181,3 +181,18 @@ fn occupancy_with_a_hidden_item_reads_the_groups_it_subtracts() {
"the line, less p3 under it"
);
}
#[test]
fn a_group_whose_cells_are_all_cleared_is_no_longer_read() {
let mut backend = Incremental::new(table(2, AggFunc::Count), vec![]);
let one = cell(1, 1, 1);
assert_eq!(backend.raw_fold(&one), Some(CellValue::Number(1.0)));
backend.apply(&Edit::ClearCell(cell(1, 1, 1)));
let before = backend.meter();
assert_eq!(backend.raw_fold(&one), None, "nothing is left under it");
assert_eq!(
since(before, backend.meter()).reads,
0,
"and no empty group stands in for it"
);
}
@@ -223,3 +223,13 @@ pub fn ratio_workbook() -> improvise_core::workbook::Workbook {
view.set_axis("Product", Axis::None);
wb
}
/// [`hidden_workbook`] drawn with the measure on the columns and Product folded: its cells
/// carry a measure, so the measure's aggregation reaches every row, total and collapsed group.
pub fn hidden_counted_workbook() -> improvise_core::workbook::Workbook {
let mut wb = hidden_workbook();
let view = wb.active_view_mut();
view.set_axis("Product", Axis::None);
view.set_axis("_Measure", Axis::Column);
wb
}