feat(core): raise MAX_CATEGORIES from 12 to 24, with one home (improvise-felo.1)
The wizard's private copy of the limit is gone; it reads the model's now-public constant. Tests that fill to the cap derive it. Why 12 was chosen and what 24 costs: context/code/core.md section 9. 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:
+52
-1
@@ -518,12 +518,63 @@ explicitly would be redundant in the file format and confusing in the UI.
|
||||
|
||||
`Label` is a high-cardinality per-row field (description, id, note): stored
|
||||
alongside the data so it shows up in record/drill views, but defaulting to
|
||||
`Axis::None` and excluded from the `MAX_CATEGORIES = 12` pivot limit and from
|
||||
`Axis::None` and excluded from the `MAX_CATEGORIES` pivot limit and from
|
||||
auto Row/Column assignment.
|
||||
|
||||
`Category::add_item` deduplicates by name and assigns IDs from a private
|
||||
counter, so external code cannot fabricate an `ItemId`.
|
||||
|
||||
### The limit is 24, and nothing is exponential in it (improvise-felo.1)
|
||||
|
||||
`MAX_CATEGORIES` counts **regular** categories only. It is `pub` in
|
||||
`model/types.rs` and re-exported as `improvise_core::model::MAX_CATEGORIES`,
|
||||
and it has one home: the import wizard's precondition reads it rather than
|
||||
keeping a copy. It had a copy until felo.1, a private `const` of its own, and
|
||||
raising the model's number alone would have left the wizard refusing a 13th
|
||||
category the model accepts. Tests that fill to the cap derive it from the
|
||||
constant; `add_category_max_limit` is the one place the number 24 is spelled.
|
||||
|
||||
**Why 12.** Nothing recorded says. The limit arrived in the initial commit
|
||||
with no comment, ADR or ticket behind it, and nothing in `context/` or `kb.pl`
|
||||
argues for it. The likeliest source is Lotus Improv, which this tool follows
|
||||
by name (`context/plan.md`) and whose worksheets are remembered as capped at
|
||||
12 categories. That is not verified here. It was never a cost bound. The user raised it on 2026-09-25: "12 categories is
|
||||
feeling small, we should increase to 24". An E*Trade import hit exactly 12,
|
||||
leaving no room for improvise-cgqt's disambiguating category.
|
||||
|
||||
**What scales with the number of categories**, read off the code:
|
||||
|
||||
- `CellKey` is a sorted `Vec` of pairs: building one is O(k log k), and
|
||||
comparing or hashing one is O(k).
|
||||
- The `DataStore` secondary index holds one `(category, item)` entry per
|
||||
coordinate per cell. That is O(k) per insert and O(cells × k) in memory.
|
||||
- Records mode has one column per regular category, so it is linear in k.
|
||||
- `cross_product` is the product of the item counts of the categories **on
|
||||
an axis**. That is exponential in what the user places on the axis, and the
|
||||
cap never bounded it: 12 categories of 4 items on rows is already 16M rows.
|
||||
`prune_empty` is what keeps it drawable.
|
||||
- `improvise-gp` bounds its encoded width separately (`MAX_FEATURES = 64`),
|
||||
so a wide fit is refused by name rather than slowed.
|
||||
|
||||
**Measured**, `cargo run --release -p improvise-core --example
|
||||
category_width_bench`, on an Apple M4 Max. Each category has 4 items and each
|
||||
record carries 2 measures. The pivot is one category on rows, `_Measure` on
|
||||
columns and the rest aggregated away. Figures are the best of 5 (the store
|
||||
build is the best of 3), and they held across two runs.
|
||||
|
||||
| categories | records | build store | pivot, every cell | records build | records draw |
|
||||
|---|---|---|---|---|---|
|
||||
| 12 | 2,500 | 26–30 ms | 1.6 ms | 79 ms | 1.6 ms |
|
||||
| 24 | 2,500 | 80–83 ms | 3.0–3.7 ms | 82 ms | 3.7 ms |
|
||||
| 12 | 5,000 | 54–57 ms | 3.2–3.9 ms | 302 ms | 3.1 ms |
|
||||
| 24 | 5,000 | 163–172 ms | 6.3–8.0 ms | 321 ms | 7.0 ms |
|
||||
|
||||
Doubling the width doubles the per-cell work and triples loading the store,
|
||||
which also builds and hashes CellKeys twice as long. The records-mode build is
|
||||
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).
|
||||
|
||||
### 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
|
||||
|
||||
Reference in New Issue
Block a user