test+docs: derive #304's cap fixtures from MAX_CATEGORIES; record the coupling found (improvise-felo.1)

The duplicate step's cap check already reads the one constant after the
merge; its tests filled 12 categories and now fill to the cap. An
injected literal-12 check fails keeping_duplicates_separate_is_refused_at_the_category_cap.
core.md section 9 gains the inventory of what degrades as categories grow.

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-24 19:26:41 -07:00
parent bd957fd619
commit 7b9e868bd2
4 changed files with 69 additions and 8 deletions
+60
View File
@@ -575,6 +575,66 @@ the outlier, and width is not its cause: it grows 4× when the **records**
double, at either width. `RecordRow::group` finds each cell's row with a linear
scan (improvise-sk32).
#### Coupling found
Raising the constant was used to look for code that assumes few categories.
This inventory was taken at felo.1's merge of `gh/main` `4f38a35e`. Line
numbers are dated, so re-derive them before citing.
**Reads the constant (fixed in felo.1).** The wizard's precondition, and the
duplicate step's cap check that improvise-cgqt added (`separate_blocked`). A
fixture that fills to the cap either derives it or fills until
`add_category` refuses.
**Separate limits, left alone.** None of these is a category count:
- `MAX_ROW_HEADER_W = 24` (`improvise-protocol/src/render.rs`) is the widest
one header level is drawn, in cells. It equals the new cap by coincidence.
- `MAX_FEATURES = 64` (`improvise-gp/src/fit.rs`) is the encoded-width budget
of one fit.
- `CATEGORY_THRESHOLD = 20` (`import/analyzer.rs`) is the distinct-value count
at which a text field becomes a `Label`.
**Degrades as categories grow.** These are not fixed here; each is a place
where 24 is worse than 12. Most were already reachable at 12.
- **The TUI row header is the sum of its levels.** In `src/ui/grid.rs`, around
`row_header_width`/`data_area_width` (and
`ui::viewport::compute_row_header_width`), each level takes 4 to 24 cells.
Categories on Row therefore cost at least 4 cells each, and the data area
saturates to zero width with no data drawn.
- **Column headers take one line per level, but the keypress path does not
count them.** `App`'s `visible_rows` is `term_height − 8`, a fixed allowance
that ignores the column levels, the group row and the page bar. So with
several categories on Column, the cursor can scroll below the drawn grid.
- **The page bar is one unclipped line.** It is `GridWidget::render`'s
`page_coords` join. Default seating (`View::on_category_added`) sends every
regular category after the second to Page. A 24-category import therefore
puts 22 selectors on one line, clips most of them without a mark, and opens
on a single slice.
- **Side panels do not scroll.** In `src/ui/panel.rs`, an item past
`item_height` is not drawn, and the cursor can move onto it unseen. The
category panel lists every category plus its expanded items.
- **The wizard's field list does not scroll.** Its `ReviewProposals` arm in
`src/ui/import_wizard_ui.rs` breaks at the popup's bottom (the popup is at
most 30 rows high), unlike the steps that use `scrolling`. About 22 fields
fit, so the over-the-cap case cannot show every field it asks the user to
fix.
- **Loading the store is O(k log k) per cell and O(cells × k) in memory.** It
measured 3× from 12 to 24 (the table above).
- **A fit declines more columns.** `unaffordable_categories` drops the widest
category columns once the encoding passes `MAX_FEATURES`. A wide document
is fit on fewer of its categories, and the fit says so.
- **`cross_product` is exponential in the categories placed on one axis**, and
`prune_empty`'s `has_value` matrix is rows × columns. The cap never bounded
either.
- **Header repeat suppression costs O(levels) per position and level**
(`render.rs::shows`). That is O(rows × levels²) per frame, which is small at
24.
The browser adapts where the TUI does not. `#tiles` and `#categories` scroll,
and `web/shell.js` charges each column level to the extent.
### Seating takes the kind, because a name cannot carry it (improvise-cj4u)
`View::on_category_added` takes a `CategoryKind` beside the name. It used to
+1 -1
View File
@@ -3235,7 +3235,7 @@ mod prop_tests {
if let Some(c) = m.category_mut("_Measure") { c.add_item("Revenue"); }
let key = CellKey::new(vec![("_Measure".into(), "Revenue".into())]);
m.set_cell(key.clone(), CellValue::Number(val));
let _ = m.add_category(&new_cat); // may hit the 12-cat limit
let _ = m.add_category(&new_cat); // may be refused; either way the cell survives
prop_assert_eq!(m.evaluate(&key), Some(CellValue::Number(val)));
}
+5 -4
View File
@@ -2581,10 +2581,10 @@ mod tests {
);
}
/// Twelve categories and a collision: the case the user hit. Option (b)
/// needs a thirteenth, so it is shown, disabled, with the remedy.
/// A full set of categories and a collision: the case the user hit. Option
/// (b) needs one more, so it is shown, disabled, with the remedy.
fn wizard_at_the_cap() -> ImportWizard {
let mut recs = category_records(12);
let mut recs = category_records(crate::model::MAX_CATEGORIES);
for r in recs.as_array_mut().unwrap() {
r["amount"] = json!(1.0);
}
@@ -2603,7 +2603,8 @@ mod tests {
assert_eq!(options.len(), 3);
assert_eq!(options[0].disabled, None);
let reason = options[1].disabled.clone().expect("separate is disabled");
assert!(reason.contains("12"), "{reason}");
let cap = crate::model::MAX_CATEGORIES.to_string();
assert!(reason.contains(&cap), "names the cap: {reason}");
assert!(reason.contains("Skip"), "names the remedy: {reason}");
assert_eq!(options[2].disabled, None);
+3 -3
View File
@@ -921,7 +921,7 @@ mod tests {
.chain([0])
.map(|r| {
let mut m = serde_json::Map::new();
for i in 0..12 {
for i in 0..crate::model::MAX_CATEGORIES {
m.insert(format!("c{i:02}"), json!(format!("v{r}")));
}
m.insert("amount".into(), json!(1.0));
@@ -948,13 +948,13 @@ mod tests {
buf_text(&buf)
}
/// The reported import: twelve categories and a collision.
/// The reported import: a full set of categories and a collision.
fn wizard_at_the_cap() -> ImportWizard {
let recs: Vec<serde_json::Value> = (0..3)
.chain([0])
.map(|r| {
let mut m = serde_json::Map::new();
for i in 0..12 {
for i in 0..crate::model::MAX_CATEGORIES {
m.insert(format!("c{i:02}"), json!(format!("v{r}")));
}
m.insert("amount".into(), json!(1.0));