cat / 012
clone

a spreadsheet for the terminal: lotus 1-2-3 looks, google sheets keys and formulas, one go binary

Describe the bounds of support as they are, and run doclint in make lint

Dami committed 3c8fa3d
docs/limits.md reads as the current bounds: what 012 handles, where it
degrades, why, and how to measure it. Only the undone items stay in
What would raise the bounds.
commit
3c8fa3d20eeff9b127c9c7989cd5d10f0d85b3f1
parent
2a8d678f2f02b9af1dadc0f32649424cbbdb8401
author
Dami <dami.etoile@gmail.com>
3 files changed, +192 -316
Mdocs/limits.md500+++++++++++-------------------
Mdocs/testing.md2+-
Mscripts/lint.sh6+
M docs/limits.md +185 -315
@@ -7,12 +7,10 @@ real terminal emulator), and the telemetry described in
77 [observability.md](observability.md), which tracks how they move.88 99 Machine: Apple M5 Pro (18 cores), 48 GB, macOS, Go 1.27.1, arm64. Times10-are single-threaded: the engine and the UI run on one goroutine. Last full11-run: commit 4dbe37d (after the engine and UI restructure), `BENCHTIME=2s`12-on an otherwise idle machine; it matched the numbers below within noise.13-The grid step (see [Step A: the grid](#step-a-the-grid)) was measured14-before and after back to back, `-benchtime 1s`, commits bdc5259 and15-bb4ff53.10+are single-threaded: the engine and the UI run on one goroutine. Figures11+are medians of `-benchtime 1s` or `BENCHTIME=2s` runs on an otherwise12+idle machine; a range means runs differed by that much, and differences13+under about 5% are noise.1614 1715 The yardstick is a keystroke through to its frame: under 16 ms feels1816 instant (one frame at 60 Hz), under 100 ms feels responsive, past that
@@ -23,8 +21,8 @@ it lags, and past a second it stalls.
2321 | Dimension | Comfortable | Degraded | Unsupported | Dominant cost |2422 |---|---|---|---|---|2523 | Sheet size | A grid of 1,048,576 x 16,384 (A..XFD), with up to `max-cells` cells (two million by default): navigation, drawing and every command cost what the cells cost, not the grid | Loading and saving two million cells: 1 to 2 s; 600 MB of heap | More than `max-cells` cells: imports keep whole rows up to it and say what they dropped; larger pastes and fills are refused | Heap per cell (about 300 B), JSON file format |26-| Incremental recalc | A change that makes formulas read under about 500,000 cells in total (fan-out, chains and volatiles of 8192 cells: 1 to 2 ms); 1000 SUMs over a full column: 0.7 ms; 8192 running totals: 2.7 ms | | | About 20 ns per cell read: two map lookups |27-| Full recalc | Any sheet: under 40 ms for 213 k numbers; 1000 full-column SUMs 1.1 ms; 8192 running totals 2.6 ms | | | Same as above |24+| Incremental recalc | A change that makes formulas read under about 500,000 cells in total (fan-out, chains and volatiles of 8192 cells: 1 to 2 ms); 1000 SUMs over a full column: 0.75 ms; 8192 running totals: 2.8 ms | 60 criteria functions (SUMIF, COUNTIFS, AVERAGEIF) over whole columns of 8192 rows: 37 ms an edit | | About 20 ns per cell read: two map lookups |25+| Full recalc | Any sheet: under 40 ms for 213 k numbers; 1000 full-column SUMs 1 ms; 8192 running totals 2.5 ms | 60 whole-column criteria functions over 8192 rows: 59 ms | | Same as above |2826 | Rendering | Any sheet at up to 200 x 60: 1 ms a frame; 400 x 120: 4.5 ms | 20 charts at 400 x 120: 7 ms | | View building styled strings, then Bubble Tea parsing and diffing them |2927 | Selection statistics | Any selection: extending one over all 2 M cells costs 0.4 ms a key | | | Per column with data, 1024 or 64 rows at a time from an index on the blocks of filled cells, plus the rows at the selection's ends |3028 | Imports | CSV, SQLite, Parquet: 1 to 3 M cells/s (two million cells in about 1 s); XLSX numbers or text: 0.7 to 1.1 M cells/s | XLSX with formulas: 0.4 to 0.6 M cells/s | Data past `max-cells` or the grid (dropped, with a note); XLSX files past the reader's limits (refused) | Building cells one at a time; XML decoding; XLSX formula translation |
@@ -43,21 +41,19 @@ Hard limits: `sheet.MaxRows = 1,048,576`, `sheet.MaxCols = 16,384`
4341 two million cells by default): imports keep whole rows up to it, pastes4442 and fills write at most that many cells at once. WK1 files keep their own4543 8192 x 256. Past about two million cells the costs below grow with the46-cells, as they always did; only the grid is no longer a cost.44+cells; the grid itself costs nothing (see [The grid](#the-grid)).4745 4846 | Measure | 8192 x 26 (213 k cells) | 8192 x 256 (2.1 M cells) |4947 |---|---|---|5048 | Build through `Load`, then full recalc | 90 ms | 1.4 s |5149 | Live heap | 64 MB | 602 MB (about 300 B per number cell) |5250 | Save `.012` (JSON, one line per cell) | 74 ms, 4.6 MB | 1.04 s, 47 MB |53-| Open `.012` | 134 ms (116 MB allocated) | 2.08 s (1.3 GB allocated) |51+| Open `.012` | 120 to 130 ms (116 MB allocated) | 1.9 to 2 s (1.3 GB allocated) |5452 | Export CSV / TSV / XLSX / SQLite | 41 / 55 / 62 / 40 ms | 0.71 / 0.66 / 0.81 / 0.72 s |5553 | Frame at 200 x 60, whole | 1.06 ms | 1.05 ms |5654 57-XLSX exports are written by 012 with archive/zip since excelize went:58-measured back to back with the excelize writer, 8192 x 26 went from 33559-to 62 ms and 379 to 1.9 MB allocated, 8192 x 256 from 0.97 to 0.81 s60-(mostly compression) and 259 to 17 MB allocated.55+XLSX exports are written by 012 with archive/zip: 1.9 MB allocated for56+8192 x 26 and 17 MB for 8192 x 256, whose time is mostly compression.6157 6258 Heap per non-blank cell after loading (`BenchmarkMemory`,6359 `BenchmarkImport`): numbers 301 B, formulas 707 B (the parsed tree and
@@ -65,6 +61,49 @@ reference lists), 200-character text 790 B, imported CSV 275 to 300 B.
6561 A `Cell` struct is 216 B; the rest is the map entry, the input string6662 and a boxed number for the parsed literal.6763 64+## The grid65+66+Nothing costs what the grid costs. Beside the cell map, occupancy67+indexes (bitmaps of 1024 rows per column) say which cells are stored and68+which have contents; ranges are read through them, so a whole column of69+ten cells is ten visits. Formats of whole columns, rows and the sheet70+live on the lines, not on cells. `TestCommandsCostTheDataNotTheGrid`71+(`internal/ui`) runs every command with the whole sheet, a whole column72+and a whole row selected on the full grid and fails past 250 ms or 8 MB;73+each takes 0.1 to 4 ms.74+75+What each part of 012 visits when given a range or selection:76+77+| Where | Visits |78+|---|---|79+| Range reads for aggregates (`Book.Fold` and `Book.Scan`) | the stored cells, in row order |80+| SUMIF(S), COUNTIF(S), AVERAGEIF(S), SUMPRODUCT, COUNTBLANK | the cells any range holds; the blanks between counted at once |81+| MATCH, VLOOKUP, HLOOKUP, XLOOKUP | the stored entries, the blanks between as one; ROWS and INDEX keep the full size |82+| Range users (`rangeIndex`), found per changed cell | interval trees per used column, whole rows in one |83+| Formatting whole columns or rows | column, row and sheet formats |84+| Used range, Ctrl+arrow, text running into view | the index of filled cells |85+| Filter over whole columns | rows with data, one test for the blank rest, stepped over at once |86+| Sort | the rows with cells |87+| Paste, fill of blanks | the clip's cells per tile; what blanks land on is cleared |88+| Copy to the system clipboard | the cells; refused past `max-cells` |89+| Pivot sources, JEV value ranges, chart detection | the rows with data; JEV ranges past 4096 cells trimmed to their data |90+| Exports of a selection, script reads | trimmed to the data |91+| Row numbers, name box | 7 digits (the header widens past 9999), XFD |92+| Column width reset, set | one undo step; a reset touches only columns with a width |93+| XLSX column widths and styles | the data's width (at least 256), a style on A and XFD as the sheet's |94+95+A sheet of 10,000 numbers spread down a million rows under 300096+whole-column formulas (sparse-1M) edits in 2.5 ms, draws a 200 x 6097+frame in 0.68 ms, and answers arrow keys at XFD1048576 in 0.65 ms.98+Typing an entry under 1000 SUMs over a column, through to its frame at99+200 x 60, takes 0.91 ms.100+101+The index costs a few percent on edits and loads (keeping two bitmaps102+current per cell), and running aggregates cost memory for their103+checkpoints (a 1000-SUM fan-in allocates 2 MB per recalculation). A104+range is shared only from its second read in a recalculation, so ranges105+read once cost nothing extra.106+68107 ## Formula topologies69108 70109 `BenchmarkEdit` sets one cell and recalculates what depends on it;
@@ -72,17 +111,22 @@ and a boxed number for the parsed literal.
72111 73112 | Topology | Edit | Full recalc | Undo + redo |74113 |---|---|---|---|75-| Independent cell in 8192 x 26 numbers | 0.5 us | 38 ms | 0.8 us |76-| Chain: A(n) = A(n-1)+1 down 8192 rows | 1.7 ms | 1.1 ms | 3.4 ms |77-| Fan-out: one cell read by 8192 formulas | 1.3 ms | 0.9 ms | 2.7 ms |78-| Volatile: 8192 TODAY() and RAND() | 1.25 ms | 0.8 ms | 2.5 ms |114+| Independent cell in 8192 x 26 numbers | 0.66 us | 32 to 38 ms | 0.8 us |115+| Chain: A(n) = A(n-1)+1 down 8192 rows | 1.85 ms | 1.1 to 1.4 ms | 3.4 ms |116+| Fan-out: one cell read by 8192 formulas | 1.4 ms | 0.9 ms | 2.7 ms |117+| Volatile: 8192 TODAY() and RAND() | 1.3 ms | 0.8 ms | 2.5 ms |79118 | 1000 named ranges, one formula each | 48 us | 2.8 ms | 99 us |80-| Fan-in: 1000 x SUM(A1:A8192), or SUM(A:A) | 0.72 ms | 1.1 ms | 1.4 ms |81-| Running totals: 8192 x SUM($A$1:An) | 2.7 ms | 2.6 ms | 5.4 ms |82-| Sparse: 10,000 numbers spread down a million rows, 1000 each of SUM(A:A), running totals and VLOOKUP over A:B | 2.6 ms | 3.9 ms | 5.1 ms |119+| Fan-in: 1000 x SUM(A1:A8192), or SUM(A:A) | 0.75 ms | 1 ms | 1.4 ms |120+| Running totals: 8192 x SUM($A$1:An) | 2.85 ms | 2.5 ms | 5.4 ms |121+| Sparse: 10,000 numbers spread down a million rows, 1000 each of SUM(A:A), running totals and VLOOKUP over A:B | 2.45 ms | 3.6 ms | 5.1 ms |122+| Criteria: 60 SUMIF, COUNTIFS and AVERAGEIF over whole columns of 8192 rows | 37 ms | 59 ms | |123+| Lookup: 300 VLOOKUP, MATCH and XLOOKUP into 8192 keys | 25 ms | 26.5 ms | |83124 84125 Every volatile formula is recomputed on every change, as in Sheets, so85-8192 volatile cells add about 1.2 ms to every edit anywhere.126+8192 volatile cells add about 1.2 ms to every edit anywhere. Criteria127+and lookups cost what their ranges hold, not what the grid holds: the128+criteria collect positions from the occupancy index, and lookups walk129+the stored entries with a cursor.86130 87131 Through a real terminal (`make stress-e2e`: 200 x 60, 8192 x 26 imported,88132 200 SUMs over column A): arrow keys p50 8.3 ms, p95 10.2 ms; an entry
@@ -90,11 +134,23 @@ that recalculates the 200 SUMs p50 62 to 70 ms, p95 67 to 75 ms. The
90134 arrow floor is Bubble Tea's renderer, which draws at most 120 frames a91135 second (`ui.FrameRate`, its maximum; the default is 60), so a key waits92136 up to 8 ms for the next frame; 012's own share of an arrow key is 0.293-to 1 ms. At 60 frames a second the same arrows took p50 16.6 ms, p95137+to 1 ms. At 60 frames a second the same arrows take p50 16.6 ms, p9594138 18.4 ms. The renderer writes a frame only when the view changed, so an95139 idle 012 costs the same at either rate: 0.6 to 2.2 ms of CPU a second96140 on an empty sheet over 30 s, within the noise of measuring it.97141 142+## The function library143+144+Functions live in `internal/functions`, behind a `Book` interface whose145+methods pass only values (see [architecture.md](architecture.md#functions)):146+SUM-like functions have the engine add a range up (`Fold`), other range147+walks read chunks into buffers the `Reader` reuses (`Scan`), and lookups148+walk positions with a cursor, so no function makes a call or an149+allocation per cell it reads. The criteria and lookup shapes in150+[Formula topologies](#formula-topologies) exercise the paths other than151+`Fold`. Plain arithmetic, 1000 formulas and a SUM, recalculates in 20 us152+in binary floating point and 156 us in decimal.153+98154 ## Rendering99155 100156 Only visible rows and columns are drawn, so frame time depends on the
@@ -118,33 +174,35 @@ whatever its data: only the categories that fit are drawn (a pie of 8192
118174 slices, 0.44 ms, is the worst). An image for kitty graphics is redrawn119175 when its data, size or theme changes: 25 to 260 us at 24 x 10 cells and120176 1 to 1.5 ms at 120 x 40, a pie 8.6 ms (`internal/chart`,121-`BenchmarkDraw`, `BenchmarkImage`). Key presses add little: an arrow key through to its frame122-is 0.21 ms at 80 x 24 and 1.06 ms at 200 x 60; Page Down 1.26 ms.177+`BenchmarkDraw`, `BenchmarkImage`). Key presses add little: an arrow key178+through to its frame is 0.21 ms at 80 x 24 and 1.06 ms at 200 x 60;179+Page Down 1.26 ms.180+181+## Selection statistics123182 124183 With the whole of an 8192 x 256 sheet selected, the status line's Sum,125184 Avg and Count cover 2.1 M cells. On a sheet of more than 16,384 cells,126185 a selection of more than 4096 is summed from an index of each column's127-sum, count and count of numbers in blocks of 64 rows (`internal/sheet/stats.go`):128-the whole blocks it covers, plus the rows at its ends a cell at a time.129-The index is built on first use, in one pass over the cells (the 30 ms130-the whole sum used to take every time), and a stored, deleted or131-recalculated cell marks its block to be summed again when a selection186+sum, count and count of numbers (`internal/sheet/stats.go`), kept on the187+blocks of the index of filled cells: an entry per 64 rows and a total188+per 1024, made only where there is data. A selection adds up the whole189+blocks it covers, plus the rows at its ends a cell at a time. The index190+is built on first use, in one pass over the cells, and a stored, deleted191+or recalculated cell marks its block to be summed again when a selection132192 next covers it. Sums are recomputed, never adjusted, so they don't133-drift. The result is still cached until a cell changes. Each key that134-changes a selection over the whole sheet, through to its frame135-(`BenchmarkKeystroke`, 3 runs each, same machine):193+drift. The result is cached until a cell changes. Each key that changes194+a selection over the whole sheet, through to its frame195+(`BenchmarkKeystroke`):136196 137-| Key, 8192 x 256 numbers | 80 x 24 before | after | 200 x 60 before | after |138-|---|---|---|---|---|139-| Shift+Up and Shift+Down with the data selected (`extend-data`) | 31 to 44 ms | 0.53 ms | 32 to 46 ms | 2.1 ms |140-| Ctrl+Shift+Down and Ctrl+Shift+Up over the first row (`extend-edge`) | 16 to 23 ms | 0.43 ms | 18 to 24 ms | 1.7 ms |141-| Ctrl+A, the selection standing (`select-data`) | 0.30 ms | 0.30 ms | 1.8 ms | 1.8 ms |197+| Key, 8192 x 256 numbers | 80 x 24 | 200 x 60 |198+|---|---|---|199+| Shift+Up and Shift+Down with the data selected (`extend-data`) | 0.41 to 0.53 ms | 2.1 ms |200+| Ctrl+Shift+Down and Ctrl+Shift+Up over the first row (`extend-edge`) | 0.43 ms | 1.7 ms |201+| Ctrl+A, the selection standing (`select-data`) | 0.28 ms | 1.8 ms |142202 143-A CPU profile of `extend-data` before put 54% of the time in144-`RangeStats` walking the map; the rest is building the benchmark's sheet.145-The index costs 0.5 MB per sheet that has one (32,768 blocks of 16 B),146-nothing on sheets that never had a large selection, and a nil check per147-stored or recalculated cell.203+The index costs memory only on sheets that had a large selection, in204+proportion to their data, and a nil check per stored or recalculated205+cell.148206 149207 ## Imports150208
@@ -153,11 +211,11 @@ Real, openly licensed datasets fetched by `scripts/stress-data.sh`
153211 154212 | File | Shape | Import | Throughput | Heap per cell |155213 |---|---|---|---|---|156-| OWID energy (CSV) | 23,377 x 130, 345 k cells kept | 195 ms | 1.8 M cells/s, 47 MB/s | 277 B |157-| Airport codes (CSV) | 86,134 x 13, first 8192 rows kept | 83 ms | 107 MB/s | 274 B |214+| OWID energy (CSV) | 23,377 x 130, 1.04 M cells | 0.84 to 0.95 s | 1.1 to 1.2 M cells/s | 277 B |215+| Airport codes (CSV) | 86,134 x 13, 855 k cells | 0.78 to 0.80 s | 1.1 M cells/s | 274 B |158216 | NOAA daily CO2 (CSV) | 18,304 x 2 | 6 ms | 2.7 M cells/s | 294 B |159217 | Country codes (CSV, Arabic, CJK) | 249 x 56 | 5.6 ms | 2.2 M cells/s | 272 B |160-| Apache POI formula tests (XLSX) | 788 rows, 1189 formulas, 3828 cells | 9.7 ms | 395 k cells/s | 425 B |218+| Apache POI formula tests (XLSX) | 788 rows, 1189 formulas, 3828 cells | 9.5 to 9.7 ms | 395 k cells/s | 425 B |161219 | Chinook PlaylistTrack (SQLite) | 8715 x 2 | 5.5 ms | 3.0 M cells/s | 293 B |162220 | Parquet alltypes_tiny_pages | 7300 x 13 | 40 ms | 2.4 M cells/s | 276 B |163221
@@ -165,27 +223,23 @@ Past `max-cells` (or the grid), imports keep whole rows up to it and
165223 say how many rows they left out. Parquet files and SQLite tables stop166224 reading at the last row and take the count of the rest from the file;167225 CSV, TSV and SQLite queries are read to their end to count it, without168-keeping it. The table above is from before the grid grew, when files169-were cut at row 8192; now the airport codes (855 k cells, 0.80 s) and170-OWID energy (1.04 M cells, 0.95 s) come in whole, at the same rate of171-about a million cells a second. Every importer streams,172-so memory follows the sheet, not the file. XLSX used to go through173-excelize, which held each worksheet in memory while its rows were read;174-012's own reader streams it a token at a time, keeping only the shared175-strings (in one buffer, 4 bytes a string besides the text), the cell176-formats, and the first cell of each shared formula.226+keeping it. Every importer streams, so memory follows the sheet, not the227+file. 012's XLSX reader streams a worksheet a token at a time, keeping228+only the shared strings (in one buffer, 4 bytes a string besides the229+text), the cell formats, and the first cell of each shared formula.230+XLSX imports set column widths and styles for the data's columns (at231+least 256) without an undo step each.177232 178233 XLSX imports of full sheets written by 012's exporter179-(`BenchmarkImportXLSX`) and the POI file, before (excelize) and after180-(012's reader), on an Apple M5 Pro. Peak heap is sampled while234+(`BenchmarkImportXLSX`) and the POI file. Peak heap is sampled while181235 importing, per cell kept, and includes garbage not yet collected:182236 183237 | Workbook | Time | Allocated | Allocations | Heap per cell after | Peak heap per cell |184238 |---|---|---|---|---|---|185-| 8192 x 26 numbers (213 k cells) | 480 -> 199 ms | 491 -> 170 MB | 8.4 -> 3.3 M | 304 -> 290 B | 1365 -> 589 B |186-| 8192 x 26 table, text and numbers | 642 -> 296 ms | 644 -> 234 MB | 14.4 -> 7.4 M | 297 -> 297 B | 1432 -> 722 B |187-| 8192 formulas (`=A1+1` down a column) | 33 -> 13.9 ms | 38 -> 17 MB | 718 -> 321 k | 752 -> 746 B | 4790 -> 2188 B |188-| Apache POI formula tests | 21.1 -> 9.7 ms | 22 -> 9.7 MB | 373 -> 179 k | 429 -> 425 B | 2343 -> 1288 B |239+| 8192 x 26 numbers (213 k cells) | 189 to 199 ms | 170 MB | 3.3 M | 290 B | 589 B |240+| 8192 x 26 table, text and numbers | 296 to 312 ms | 234 MB | 7.4 M | 297 B | 722 B |241+| 8192 formulas (`=A1+1` down a column) | 13.8 ms | 17 MB | 321 k | 746 B | 2188 B |242+| Apache POI formula tests | 9.5 to 9.7 ms | 9.7 MB | 179 k | 425 B | 1288 B |189243 190244 The reader refuses files past `xlsxLimits` (in `internal/fileio/xlsxpkg.go`),191245 with a message naming the limit: 10,000 files in the zip; 1 GB
@@ -203,26 +257,23 @@ entities, so entity bombs fail as unknown entities.
203257 204258 Each step keeps a copy of every cell it changed. Undoing costs about205259 what the change cost, since it recalculates the same cells. Clearing206-213 k cells and undoing it takes 158 ms.260+213 k cells and undoing it takes 158 to 164 ms.207261 208262 The history is bounded by steps (`MaxUndo = 100`) and by an estimate of209263 the memory its before-images hold (`MaxUndoBytes = 256 MB`); past either,210264 the oldest steps are dropped first, and the newest step is kept however211265 large it is. Each cell is counted as it is recorded (a fixed cost per212266 cell plus its text, more for a formula, whose parsed tree stays alive),213-so the budget costs nothing to check. The estimate follows the measured214-heap within about 10% (`BenchmarkHistoryFull`, `BenchmarkHistoryWide`,215-3 runs each, same machine):216-217-| Benchmark | Before | After |218-|---|---|---|219-| 100 edits rewriting a whole column (819 k before-images) | 250 MB held, all 100 kept | 250 MB held (239 MB estimated), all 100 kept |220-| 12 edits rewriting all of 8192 x 26 (2.6 M before-images) | 705 MB held, all 12 kept | 230 MB held (248 MB estimated), the last 4 kept |221-| BigUndo: clear 8192 x 26, undo | 154 ms, 430 k allocs | 158 ms, 430 k allocs |222-| Undo + redo of one cell (dense, names) | 0.98 us, 106 us | 1.0 us, 106 us |267+so the budget costs nothing to check; counting costs about 20 ns a268+cell, 3% of clearing a sheet and undoing it. The estimate follows the269+measured heap within about 10% (`BenchmarkHistoryFull`,270+`BenchmarkHistoryWide`):223271 224-Counting cells as they're recorded costs about 20 ns a cell, which is225-BigUndo's 3%; everything else is unchanged.272+| Benchmark | Result |273+|---|---|274+| 100 edits rewriting a whole column (819 k before-images) | 250 MB held (239 MB estimated), all 100 kept |275+| 12 edits rewriting all of 8192 x 26 (2.6 M before-images) | 230 MB held (248 MB estimated), the last 4 kept |276+| Undo + redo of one cell (dense, names) | 1.0 us, 106 us |226277 227278 ## JEV228279
@@ -234,20 +285,19 @@ formulas waiting for it and what reads them
234285 first since the last recalculation schedules one a frame later235286 (`ui.FrameInterval`), which covers every answer stored meanwhile, so236287 answers arriving together recalculate once. With a fake client237-answering instantly (`BenchmarkJEV`, 3 runs each, same machine; the238-benchmark doesn't wait the frame, so answers queued together, up to 8,239-recalculate together):288+answering instantly (`BenchmarkJEV`; the benchmark doesn't wait the289+frame, so answers queued together, up to 8, recalculate together):240290 241-| JEV cells | Per answer before | after | All answers before | after |242-|---|---|---|---|---|243-| 100 | 29 us | 3.5 us | 2.9 ms | 0.35 ms |244-| 1000 | 270 us | 3.6 us | 0.27 s | 3.6 ms |245-| 4000 | 1.2 ms | 3.8 us | 4.9 s | 15 ms |291+| JEV cells | Per answer | All answers |292+|---|---|---|293+| 100 | 3.5 us | 0.35 ms |294+| 1000 | 3.6 us | 3.6 ms |295+| 4000 | 3.8 us | 14 to 15 ms |246296 247297 Against the real service the network dominates: at most 8 questions are248-in flight, so 4000 questions at 300 ms each take 2.5 minutes, and the249-recalculation is now a few microseconds per answer whatever the number250-of JEV cells. Other changes still recalculate every JEV cell, as every298+in flight, so 4000 questions at 300 ms each take 2.5 minutes, while the299+recalculation costs a few microseconds per answer whatever the number300+of JEV cells. Other changes recalculate every JEV cell, as every251301 volatile formula, looking the answers up in the cache.252302 253303 ## Formula depth
@@ -260,65 +310,44 @@ with the caret at the level past the cap, as other parse errors are.
260310 Excel allows 64 nested functions; 1024 is far beyond a formula written261311 by hand, and bounds the recursion of everything that walks a formula262312 (the printer, reference rewriting, the evaluator within one formula). A263-`.012` file holding a deeper formula (none could have been written by264-hand) fails to open with that message and the cell; imports keep such a265-formula's text or cached value, as for any formula they can't read.313+`.012` file holding a deeper formula fails to open with that message and314+the cell; imports keep such a formula's text or cached value, as for any315+formula they can't read.266316 267317 Evaluation recurses once per link of a chain of formulas, which no268-parser cap bounds: a chain through all 2.1 M cells of a sheet used to269-overflow the goroutine's 1 GB stack and kill the program. Evaluation now270-counts its depth in cells and operators; a cell reached past 65,536271-levels is put off: the evaluation in progress is abandoned (its cells go272-back to dirty), the cell is evaluated from the top of the stack, and the273-abandoned evaluation is retried and finds it done. Values are exactly as274-before, a cycle longer than the limit is still a cycle, and the stack275-stays under 32 MB (`TestFullSheetChain`, run with276-`SHEET_FULL_CHAIN=1`, checks it under a 64 MB limit: 4 s for 2.1 M277-cells, building included).278-279-Same machine, 8 runs each with before and after interleaved, other280-work running (differences under 10% are noise):281-282-| Benchmark | Before | After |283-|---|---|---|284-| Parse (`BenchmarkParse`) | 2.36 us | 2.41 us |285-| 1000 nested parentheses / IFs, parse and evaluate | 195 / 425 us | 203 / 438 us |286-| 10,000 nested parentheses / IFs | 1.8 / 4.4 ms, accepted | 1.1 / 2.0 ms to refuse |287-| RecalcAll chain of 8192 / dense 8192 x 26 | 1.33 / 50 ms | 1.28 to 1.38 / 41 to 51 ms |288-| Edit chain of 8192 / 8192 volatiles | 2.36 / 1.68 ms | 2.34 / 1.72 ms |318+parser cap bounds, so it counts its depth in cells and operators: a cell319+reached past 65,536 levels is put off. The evaluation in progress is320+abandoned (its cells go back to dirty), the cell is evaluated from the321+top of the stack, and the abandoned evaluation is retried and finds it322+done. Values are the same as with unbounded recursion, a cycle longer323+than the limit is still a cycle, and the stack stays under 32 MB324+(`TestFullSheetChain`, run with `SHEET_FULL_CHAIN=1`, checks it under a325+64 MB limit: 4 s for a chain through all 2.1 M cells, building326+included).327+328+| Benchmark | Time |329+|---|---|330+| Parse (`BenchmarkParse`) | 2.4 us |331+| 1000 nested parentheses / IFs, parse and evaluate | 203 / 438 us |332+| 10,000 nested parentheses / IFs | 1.1 / 2.0 ms to refuse |289333 290334 ## Several sheets291335 292336 A workbook recalculates all its sheets together. Formulas that name a293337 sheet (`Data!A1`) are indexed by the sheet names they use: a changed cell294-on a sheet some formula names scans those cross-sheet formulas, as range295-users were scanned before they were indexed by column, so a change there296-costs O(changed cells x cross-sheet formulas); cells on sheets no formula297-names skip the scan. Each sheet's lookup, which resolves sheet names, is298-made once per recalculation, and the recalculation state stays on each299-sheet keyed by address, so a single-sheet workbook computes as before.338+on a sheet some formula names scans those cross-sheet formulas, so a339+change there costs O(changed cells x cross-sheet formulas); cells on340+sheets no formula names skip the scan. Each sheet's lookup, which341+resolves sheet names, is made once per recalculation, and the342+recalculation state stays on each sheet keyed by address. Every cell343+read goes through one function call to resolve its sheet, 3 to 5% of a344+read-heavy recalculation.300345 301346 | Benchmark (`internal/sheet`, `go test -bench SheetEdit`) | Edit |302347 |---|---|303348 | 5000 formulas on one sheet reading `A(n)` and `SUM(A1:A10)` | 2.7 ms |304349 | The same 5000 formulas reading `Data!A(n)` and `SUM(Data!A1:A10)` | 3.5 ms |305350 306-The stress shapes before and after workbooks (5 runs each, same machine;307-differences under about 5% are noise):308-309-| Benchmark | Before | After |310-|---|---|---|311-| RecalcAll dense 8192 x 26 | 36.1 ms | 36.7 ms |312-| RecalcAll fan-in / running | 170 / 706 ms | 177 / 742 ms |313-| Edit chain / fan-in / running | 1.64 / 168 / 694 ms | 1.92 / 173 / 726 ms |314-| Undo chain | 3.4 ms | 4.0 ms |315-| BigUndo | 140 ms, 431 k allocs | 158 ms, 856 k allocs |316-| Open 8192 x 26 / 8192 x 256 | 120 ms / 1.85 s | 120 ms / 1.91 s |317-318-Cell reads now go through one more function call to resolve the sheet,319-which is most of the 3 to 5% on read-heavy shapes; undo steps key their320-before-images by sheet and address, which is BigUndo's extra allocation.321-322351 ## Serving over SSH323352 324353 `BenchmarkSessions` (`internal/serve/stress_test.go`) opens N sessions,
@@ -327,25 +356,15 @@ opening the same 1000 x 26 sheet of numbers in every one, then has every
327356 session press an arrow key at once, 200 times, timing each key from the328357 client's write to the first bytes of its frame arriving. The clients329358 run in the same process, so the heap and CPU figures include their side330-of the connections and are upper bounds for the server. Medians of 3331-runs, `-benchtime 200x`, at 60 frames a second:359+of the connections and are upper bounds for the server. Sessions ask360+for 120 frames a second, as the local app does (`-benchtime 200x`):332361 333362 | Sessions, sheet | Heap per session | Key to frame p50 / p95 / max | CPU per frame |334363 |---|---|---|---|335-| 10, new sheet | 1.30 MiB | 10.7 / 16.6 / 17.1 ms | 0.95 ms |336-| 10, 1000 x 26 numbers | 8.5 MiB | 11.2 / 16.6 / 18.0 ms | 1.15 ms |337-| 50, new sheet | 1.30 MiB | 9.2 / 16.3 / 19.2 ms | 0.53 ms |338-| 50, 1000 x 26 numbers | 8.5 MiB | 9.4 / 16.7 / 20.5 ms | 0.69 ms |339-340-Sessions now ask for 120 frames a second, as the local app does. One341-run each, back to back, before and after:342-343-| Sessions, sheet | p50 / p95 / max at 60 fps | at 120 fps | CPU per frame, 60 / 120 fps |344-|---|---|---|---|345-| 10, new sheet | 10.8 / 16.6 / 16.8 ms | 5.1 / 8.3 / 9.6 ms | 0.78 / 0.67 ms |346-| 10, 1000 x 26 numbers | 9.7 / 16.6 / 20.7 ms | 4.3 / 8.3 / 8.5 ms | 1.41 / 0.71 ms |347-| 50, new sheet | 9.3 / 16.3 / 18.5 ms | 5.0 / 8.7 / 17.8 ms | 0.58 / 0.53 ms |348-| 50, 1000 x 26 numbers | 9.5 / 16.7 / 19.7 ms | 5.1 / 8.9 / 12.8 ms | 0.71 / 0.66 ms |364+| 10, new sheet | 1.30 MiB | 5.1 / 8.3 / 9.6 ms | 0.67 ms |365+| 10, 1000 x 26 numbers | 8.5 MiB | 4.3 / 8.3 / 8.5 ms | 0.71 ms |366+| 50, new sheet | 1.30 MiB | 5.0 / 8.7 / 17.8 ms | 0.53 ms |367+| 50, 1000 x 26 numbers | 8.5 MiB | 5.1 / 8.9 / 12.8 ms | 0.66 ms |349368 350369 Latency doesn't move from 10 to 50 sessions: it's Bubble Tea's frame351370 pacing (at most one frame every 8.3 ms, so a key waits half a frame on
@@ -355,34 +374,30 @@ Bubble Tea's render buffers (about 1 MiB at 120 x 40, growing with the
355374 window), 130 KiB in its input key table and under 50 KiB in both ends356375 of the SSH connection, so the figure is close to the server's own. On357376 top come about 300 B per cell of the sheets a session has open;358-sessions opening the same file each hold a copy. CPU per frame covers both ends of the connection (the359-encryption twice, the client reading the frame); at 0.7 ms, 50 sessions360-typing continuously at 60 frames a second would keep about two cores361-busy, and at 120 about four: frames are drawn only when a session's362-screen changes, so the rate is the most keys can cause, not a cost of363-being connected.377+sessions opening the same file each hold a copy. CPU per frame covers378+both ends of the connection (the encryption twice, the client reading379+the frame); at 0.7 ms, 50 sessions typing continuously at 120 frames a380+second would keep about four cores busy: frames are drawn only when a381+session's screen changes, so the rate is the most keys can cause, not a382+cost of being connected.364383 365384 ## Pivot tables366385 367386 A pivot table is recomputed whole whenever a cell of its source range is368-recalculated: it reads every source row once, groups it, and rewrites369-only the result cells that changed, so what reads the results370-recalculates only when they move. `BenchmarkPivot` edits one cell of a371-full 8191-row table (an id, a category of 8, a number and one of 1000372-items, twice) under a pivot and times the edit, the recalculation and373-the pivot together:387+recalculated: it reads every source row once, groups it by a comparable388+key struct (no string built per row), and rewrites only the result cells389+that changed, so what reads the results recalculates only when they390+move. `BenchmarkPivot` edits one cell of a full 8191-row table (an id, a391+category of 8, a number and one of 1000 items, twice) under a pivot and392+times the edit, the recalculation and the pivot together:374393 375394 | Pivot | Result rows | Edit | Allocations |376395 |---|---|---|---|377-| Rows: 8 categories; SUM and COUNTA | 10 | 0.95 ms | 196 |396+| Rows: 8 categories; SUM and COUNTA | 10 | 0.77 to 0.95 ms | 196 |378397 | Rows: 8 x 8 categories with subtotals; columns: 8; SUM and AVERAGE | 75 | 2.7 to 2.9 ms | 2.5 k |379398 | Rows: 1000 items sorted by their sum; SUM and COUNTUNIQUE | 1001 | 3.5 to 4.2 ms | 14 k |380399 | Frequency table of the 1000 items | 1001 | 2.5 to 2.8 ms | 24 k |381400 382-Groups are found by a comparable key struct rather than a string built383-per row: that took the 8-category pivot from 8.4 k to 196 allocations384-per edit, the others from 32 to 39 k to the figures above.385-386401 ## Macros387402 388403 A macro's script runs on its own goroutine; every call it makes to the
@@ -404,147 +419,11 @@ found through the sheet's index. Every run stops after 10
404419 million Starlark steps (`macro.DefaultMaxSteps`), about a second of pure405420 computation.406421 407-## Step A: the grid408-409-The grid grew from 8192 x 256 to 1,048,576 x 16,384 once nothing cost410-what the grid costs. Beside the cell map, occupancy indexes (bitmaps of411-1024 rows per column) say which cells are stored and which have412-contents; ranges are read through them, so a whole column of ten cells is413-ten visits. Formats of whole columns, rows and the sheet live on the414-lines. `TestCommandsCostTheDataNotTheGrid` (`internal/ui`) runs every415-command with the whole sheet, a whole column and a whole row selected on416-the full grid and fails past 250 ms or 8 MB; each takes 0.1 to 4 ms.417-418-Code that cost the grid, and what it does now:419-420-| Where | Was | Now |421-|---|---|---|422-| Range reads for aggregates (then `reader.cells`; now `Book.Fold` and `Book.Scan`) | every address of the range | the stored cells, in row order |423-| SUMIF(S), COUNTIF(S), AVERAGEIF(S), SUMPRODUCT, COUNTBLANK | a mask over every cell | the cells any range holds; the blanks between counted at once |424-| MATCH, VLOOKUP, HLOOKUP, XLOOKUP | every entry of the column searched | the stored entries, the blanks between as one; ROWS and INDEX keep the full size |425-| Range users (`rangeIndex`) | a fixed array of 256 column buckets, scanned per change | interval trees per used column, whole rows in one |426-| Formatting whole columns or rows | up to 65,536 cells made, then only existing ones | column, row and sheet formats |427-| Used range, Ctrl+arrow, text running into view | every cell, or every row or column between | the index of filled cells |428-| Filter over whole columns | every row tested and hidden one by one | rows with data, one test for the blank rest, stepped over at once |429-| Sort | every row of the range | the rows with cells |430-| Paste, fill of blanks | every address of the destination | the clip's cells per tile; what blanks land on is cleared |431-| Copy to the system clipboard | every blank row between values | refused past `max-cells` cells |432-| Pivot sources, JEV value ranges, chart detection | every row of the range | the rows with data; JEV ranges past 4096 cells trimmed to their data |433-| Exports of a selection, script reads | every row of whole columns | trimmed to the data |434-| Row numbers, name box | 5 digits, IV | 7 digits (the header widens past 9999), XFD |435-| Column width reset, set | an undo step per column of the selection | one step; a reset touches only columns with a width |436-| XLSX column widths and styles | 256 columns | the data's width (at least 256), a style on A and XFD as the sheet's |437-438-Before and after, back to back (`-benchtime 1s`, medians of one run):439-440-| Benchmark | Before | After |441-|---|---|---|442-| Edit fan-in, 1000 x SUM(A1:A8192) | 176 ms | 0.72 ms |443-| Edit running totals, 8192 x SUM($A$1:An) | 0.73 s | 2.7 ms |444-| RecalcAll fan-in / running totals | 178 / 739 ms | 1.1 / 2.6 ms |445-| Undo + redo fan-in / running totals | 352 ms / 1.47 s | 1.4 / 5.4 ms |446-| Typing an entry under 1000 SUMs, through to its frame (200 x 60) | 59 ms | 0.91 ms |447-| Edit sparse-1M (10,000 numbers over a million rows, 3000 whole-column formulas) | | 2.6 ms |448-| Frame at 200 x 60 on sparse-1M; arrow keys at XFD1048576 | | 0.68 ms; 0.65 ms |449-| Edit a cell of 8192 x 26 numbers | 619 ns | 662 ns |450-| Open 8192 x 26 / 8192 x 256 | 127 ms / 1.97 s | 130 ms / 1.99 s |451-| Build 8192 x 26 through `Load` | 88 ms | 90 ms |452-| BigUndo: clear 8192 x 26, undo | 159 ms | 164 ms |453-| Sort 8191 rows | 31 ms | 32 ms |454-| Pivot over 8191 rows, 8 categories | 0.89 ms | 0.77 ms |455-| Extend a selection of 8192 x 256 by a row | 31 ms | 35 ms (measured alone; 0.41 ms once merged with main's selection statistics, below) |456-457-The index costs a few percent on edits and loads (keeping two bitmaps458-current per cell), and running aggregates cost memory for their459-checkpoints (a 1000-SUM fan-in allocates 2 MB per recalculation instead460-of 0.2 MB). A range is shared only from its second read in a461-recalculation, so ranges read once cost nothing extra.462-463-### Merged with main464-465-Step A was merged with main at 7431646 (selection statistics by block,466-JEV recalc by question, 120 fps, depth limits, the streaming XLSX467-reader). Main's dense statistics index, one entry per 64 rows of every468-column, would have been 268 M entries at the new grid; it now lives on469-the blocks of the index of filled cells, an entry per 64 rows and a470-total per 1024, made where there is data. Main and the merge back to471-back (`-benchtime 1s`):472-473-| Benchmark | Main | Merged |474-|---|---|---|475-| Extend a selection of 8192 x 256 by a row, 80 x 24 | 0.47 ms | 0.41 ms |476-| Select all of 8192 x 256, 80 x 24 | 0.28 ms | 0.28 ms |477-| JEV, 4000 cells answered | 14.5 ms | 14.3 ms |478-| Import XLSX 8192 x 26 numbers / table / 8192 formulas | 187 / 313 / 13.6 ms | 189 / 312 / 13.8 ms |479-| Import the POI formula tests (XLSX) | 9.7 ms | 9.5 ms |480-| Edit fan-in / running totals | 186 / 777 ms | 0.73 / 2.7 ms |481-| Sort 8191 rows | 29.7 ms | 32.3 ms |482-| BigUndo | 150 ms | 156 ms |483-| Open 8192 x 26 | 117 ms | 121 ms |484-485-CSV imports take longer only because they keep more: the airport codes486-(855 k cells, 0.78 s) and OWID energy (1.04 M cells, 0.84 s) now come in487-whole. XLSX imports set column widths and styles for the data's columns488-(at least 256), without an undo step each: setting 16,384 of them one489-step at a time took the POI file from 9.7 to 44 ms before the fix.490-491-## The function library492-493-The function library moved out of the engine into `internal/functions`,494-behind a `Book` interface whose methods pass only values (see495-[architecture.md](architecture.md#functions)): SUM-like functions have496-the engine add a range up (`Fold`), other range walks read chunks into497-buffers the `Reader` reuses (`Scan`), and lookups walk positions with a498-cursor. The criteria and lookup shapes (`criteria-60xSUMIF8192`: SUMIF,499-COUNTIFS and AVERAGEIF over whole columns of 8192 rows;500-`lookup-300xVLOOKUP8192`: VLOOKUP, MATCH and XLOOKUP into 8192 keys) were501-added for it. Before and after, back to back (`-benchtime 1s`, medians of502-three alternating runs; differences under about 3% are noise):503-504-| Benchmark | Before | After |505-|---|---|---|506-| Edit fan-in, 1000 x SUM(A1:A8192) / SUM(A:A) | 742 / 741 us | 749 / 750 us |507-| Edit running totals | 2.79 ms | 2.85 ms |508-| Edit sparse-1M | 2.64 ms, 9611 allocs | 2.45 ms, 3610 allocs |509-| Edit criteria / lookup | 40.7 / 26.3 ms | 37.1 / 25.3 ms |510-| Edit chain / fan-out / volatile / dense | 1.88 / 1.39 / 1.29 ms / 673 ns | 1.85 / 1.40 / 1.29 ms / 662 ns |511-| RecalcAll fan-in / running / sparse-1M | 0.97 / 2.50 / 3.82 ms | 0.97 / 2.51 / 3.62 ms |512-| RecalcAll criteria / lookup | 66.6 / 27.5 ms | 58.8 / 26.5 ms |513-| RecalcAll dense 8192 x 26 | 32.5 ms | 31.8 ms |514-| Arithmetic, binary / decimal (1000 formulas and a SUM) | 20.6 / 160 us | 19.8 / 156 us |515-516-Allocations are the same or fewer everywhere: lookups no longer make a517-closure per search, and criteria collect positions from the occupancy518-index without a call per cell.519-520-## Hotspots found and fixed521-522-Each with its benchmark before and after, in the commit that fixed it.523-524-| Hotspot | Evidence | Before | After |525-|---|---|---|---|526-| SUM, AVERAGE, COUNT allocated twice per cell read: `return &v` of the `Value` parameter moved it to the heap on every call | CPU profile of a fan-in edit: `toNum` and `aggregate.func1.1` allocating 3 GB | Edit/fanin 320 ms, 16.4 M allocs | 203 ms, 2 k allocs |527-| Every changed cell scanned every formula with a range reference | Running totals edit: 67 M `Rect.Contains` calls | Edit/running 1.83 s | 0.70 s |528-| `RecalcAll` traced dependents from every cell (it found nothing new) | RecalcAll/names spent its time in the name scan | RecalcAll/names 157 ms, fanin 395 ms | 2.8 ms, 172 ms |529-| Overflowing text cut with `ansi.Cut` per column, rescanning the text each time | CPU profile of View on long text: 90% in `ansi.Cut` | View/longtext 400 x 120: 40.5 ms | 2.1 ms |530-| `Names()` upper-cased both names in every sort comparison, every frame | 21 k allocations per View with 1000 names | View/names 80 x 24: 662 us | 197 us |531-| Status line summed a whole-sheet selection with 2 M map lookups, every frame | CPU profile: `RangeStats` 87% of a frame | 91 ms per frame | 0.3 ms (cached), 29 ms when the selection changes |532-| JEV cache keys encoded with `encoding/json` | CPU profile of answers: JSON 60% of recalc | JEV/1000 575 ms, JEV/4000 9.3 s | 263 ms, 5.1 s |533-| Aggregates read every cell of every range, 1000 identical SUMs a thousand times, running totals O(n^2) | Fan-in and running-total edits | Edit/fanin 176 ms, running 0.73 s | 0.72 ms, 2.7 ms (running aggregates shared per recalculation) |534-| Range users bucketed by a fixed array of 256 columns, scanned whole per changed cell | 16,384 columns; whole-row ranges in every bucket | | Interval trees per column, wide ranges in one tree |535-| A range read visited every address, blank or not | A whole column is a million addresses | | Occupancy index: only stored cells are visited |536-| Changing a selection summed every cell in it | CPU profile of Shift+Up over the whole sheet: `RangeStats` 54% | extend-data 80 x 24: 31 to 44 ms | 0.53 ms |537-| Every JEV answer recalculated every JEV cell | Per-answer cost grew with the number of JEV cells | JEV/4000 4.9 s | 15 ms |538-| Bubble Tea drew at most 60 frames a second | Arrow keys through a terminal waited for the next frame | p50 16.6 ms, p95 18.4 ms | 8.3 ms, 10.2 ms |539-| Lazy evaluation recursed once per link of a chain | A chain through all 2 M cells of a sheet overflowed the 1 GB stack and killed the program | crash | 4 s to build and evaluate, under 32 MB of stack |540-541422 ## What would raise the bounds542423 543-In order of value for effort. The grid is Excel's since step A; the544-first two are what raising `max-cells` toward Google Sheets' 10 M cells545-would take; without them a 10 M-cell sheet would need about 3 GB of546-heap and take 10 s to open. (Shared range results, which was the second,547-is done: see Step A.)424+In order of value for effort. The first two are what raising425+`max-cells` toward Google Sheets' 10 M cells would take; without them a426+10 M-cell sheet would need about 3 GB of heap and take 10 s to open.548427 549428 1. **Compact cell storage** (L, 2 to 3 weeks). Cells live in a550429 `map[Addr]*Cell` behind `cellStore` (`internal/sheet/store.go`), 300 B
@@ -565,20 +444,9 @@ is done: see Step A.)
565444 allocation tenfold; a columnar or gzip-compressed variant would cut566445 the size about fourfold. The format is `internal/sheet/file.go`, apart567446 from the cell store.568-3. **Incremental selection statistics.** Done: per-column block569- aggregates, marked stale as cells change (see [Rendering](#rendering));570- extending a whole-sheet selection went from 31 to 44 ms to 0.53 ms.571-4. **JEV recalc by question.** Done: an answer recalculates the cells572- waiting for it, and answers within a frame recalculate together (see573- [JEV](#jev)); answering 4000 questions went from 4.9 s of CPU to574- 15 ms.575-5. **Smaller undo steps** (S, 1 day). The history is capped by the447+3. **Smaller undo steps** (S, 1 day). The history is capped by the576448 memory its before-images hold; storing formatting-only changes as577449 diffs rather than whole cells would let it keep more of them.578-6. **Frame rate.** Done: 120 frames a second, Bubble Tea's maximum,579- locally and over SSH; arrow keys through a terminal went from p50580- 16.6 ms to 8.3 ms, with no more CPU when idle.581-7. **Recursion limits.** Done: see [Formula depth](#formula-depth).582450 583451 ## Measuring584452
@@ -589,6 +457,8 @@ make stress-report # latest against previous and baseline, with trends
589457 make stress-e2e # key press to screen through libghostty590458 ```591459 460+`make stress` fetches the datasets with `scripts/stress-data.sh`, which461+checks each against its checksum in `scripts/stress-data.tsv`.592462 `TestCommandsCostTheDataNotTheGrid` runs with `go test ./internal/ui`593463 (it skips under `-short`) and guards the grid: a command whose cost grows594464 with its selection's area fails it.
M docs/testing.md +1 -1
M scripts/lint.sh +6 -0