cat / 012
clone

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

Merge performance quick wins: block selection stats, JEV recalc by question, 120 fps, depth limits

Dami committed 7431646
commit
74316463bdf4f40586ecb4c4c24f2d7a2de0021f
parent
9ab612ff9b817a70ff286fb420a6eb8177ddaaa1
parent
bfe10f8f1c68a4de4ccb17d3d8580b597106f8ac
author
Dami <dami.etoile@gmail.com>
27 files changed, +1100 -276
Mcmd/012/main.go2+-
Mdocs/architecture.md7+-
Mdocs/limits.md170+++++++++++++++++++++--------
Minternal/formula/formula_test.go33++++++
Minternal/formula/parse.go24++++
Minternal/jev/key.go96+----------------
Minternal/serve/serve.go2+-
Minternal/sheet/eval.go9+-
Ainternal/sheet/evaluate.go178++++++++++++++++++++++++++++++
Ainternal/sheet/evaluate_test.go103+++++++++++++++++
Minternal/sheet/functions_jev.go1+
Minternal/sheet/functions_jev_test.go42+++++++
Minternal/sheet/nav.go40-------
Minternal/sheet/recalc.go85+++------------
Minternal/sheet/remote.go42+++++++-
Ainternal/sheet/remotekey.go95++++++++++++++++
Minternal/sheet/sheet_test.go10++
Ainternal/sheet/stats.go163+++++++++++++++++++++++++++
Ainternal/sheet/stats_test.go84++++++++++++++
Minternal/sheet/store.go16++-
Minternal/sheet/stress_test.go4+-
Minternal/sheet/workbook.go16++-
Minternal/ui/edit_test.go13++
Minternal/ui/jev.go48+++++++--
Minternal/ui/jev_test.go69++++++++++++
Minternal/ui/model.go12++
Minternal/ui/stress_test.go12++-
M cmd/012/main.go +1 -1
M docs/architecture.md +5 -2
M docs/limits.md +124 -46
@@ -23,14 +23,14 @@ it lags, and past a second it stalls.
2323 | 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 (1000 x 8192 reads): 170 ms per edit | 8192 running totals (`=SUM($A$1:An)`, 33 M reads): 0.8 s per edit | About 20 ns per cell read: two map lookups |2424 | Full recalc | Any sheet without heavy range fan-in: under 40 ms for 213 k numbers | 1000 full-column SUMs: 170 ms | 8192 running totals: 0.7 s | Same as above |2525 | 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 |26-| Selection statistics | Any selection, once computed (cached) | Extending a selection over 2 M cells: 30 ms per key | | Summing 2 M map entries per change |26+| Selection statistics | Any selection: extending one over all 2 M cells costs 0.5 ms a key | | | Per column, a block of 64 rows at a time, plus the rows at the selection's ends |2727 | Imports | CSV, SQLite, Parquet: 1 to 3 M cells/s (a full 2.1 M-cell sheet 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 the limits (dropped, with a note); XLSX files past the reader's limits (refused) | Building cells one at a time; XML decoding; XLSX formula translation |2828 | Undo | One step of any size: undo costs what the edit cost | History capped at 100 steps and 256 MB of before-images: 100 whole-column steps hold 250 MB | | Whole-cell before-images, about 300 B per cell per step |29-| JEV | Up to about 1000 JEV cells: 0.3 ms of CPU per answer | 4000 JEV cells: 1.3 ms per answer, 5 s of CPU to answer them all | | Every answer recalculates every JEV cell (they're volatile) |30-| Formula depth | 10,000 nested parentheses or IFs: under 5 ms | | No explicit limit; recursion grows the stack | Recursive parser and evaluator |29+| JEV | 4000 JEV cells: 4 us of CPU per answer, 15 ms to answer them all | | | An answer recalculates the cells that asked it; answers within a frame recalculate together |30+| Formula depth | 1000 nested parentheses or IFs: under 0.5 ms; chains of formulas through every cell of a sheet | | More than 1024 levels of nesting in one formula: a parse error | Recursive parser; evaluation puts off cells past 65,536 levels |3131 | Macros | Replaying 1000 recorded actions: 2.2 ms, one undo step; a script's call to the sheet: about 1.4 us | | Scripts past 10 M Starlark steps: stopped, with the line | One message per call to the sheet, served in batches on the UI goroutine |3232 | Find, filter, sort, fill | Filter or sort 8191 rows: 1 to 32 ms; find over 213 k cells: 38 ms; fill 8192 rows: 4 ms | Replace all over 213 k cells: 280 ms | | Per-cell string conversion and regexp |33-| SSH sessions (`012 serve`) | 50 sessions typing at once: frames within one frame interval (p95 16.6 ms), 1.3 MiB per session plus its sheets (upper bound) | | More than `--max-sessions` (8 by default): turned away | Bubble Tea's 60 fps pacing; per session, the terminal's cell buffers |33+| SSH sessions (`012 serve`) | 50 sessions typing at once: frames within one frame interval (p95 8.7 ms), 1.3 MiB per session plus its sheets (upper bound) | | More than `--max-sessions` (8 by default): turned away | Bubble Tea's 120 fps pacing; per session, the terminal's cell buffers |3434 | Pivot tables | A pivot over 8191 rows recomputes after an edit to its source in 1 to 4 ms | | Results past 8192 x 256 (the pivot shows #REF!) | Reading each source cell of its fields: a map lookup each |3535 3636 ## Sheet size
@@ -77,11 +77,15 @@ Every volatile formula is recomputed on every change, as in Sheets, so
7777 8192 volatile cells add about 1.2 ms to every edit anywhere.7878 7979 Through a real terminal (`make stress-e2e`: 200 x 60, 8192 x 26 imported,80-200 SUMs over column A): arrow keys p50 16.7 ms, p95 17.5 ms; an entry81-that recalculates the 200 SUMs p50 57 ms, p95 72 ms. The arrow floor is82-Bubble Tea's renderer, which draws at most 60 frames a second, so a key83-waits up to 16 ms for the next frame; 012's own share of an arrow key is84-0.2 to 1 ms.80+200 SUMs over column A): arrow keys p50 8.3 ms, p95 10.2 ms; an entry81+that recalculates the 200 SUMs p50 62 to 70 ms, p95 67 to 75 ms. The82+arrow floor is Bubble Tea's renderer, which draws at most 120 frames a83+second (`ui.FrameRate`, its maximum; the default is 60), so a key waits84+up to 8 ms for the next frame; 012's own share of an arrow key is 0.285+to 1 ms. At 60 frames a second the same arrows took p50 16.6 ms, p9586+18.4 ms. The renderer writes a frame only when the view changed, so an87+idle 012 costs the same at either rate: 0.6 to 2.2 ms of CPU a second88+on an empty sheet over 30 s, within the noise of measuring it.8589 8690 ## Rendering8791
@@ -110,9 +114,29 @@ when its data, size or theme changes: 25 to 260 us at 24 x 10 cells and
110114 is 0.21 ms at 80 x 24 and 1.06 ms at 200 x 60; Page Down 1.26 ms.111115 112116 With the whole of an 8192 x 256 sheet selected, the status line's Sum,113-Avg and Count cover 2.1 M cells: 29 ms to compute, then cached until a114-cell changes, so frames with a standing selection cost 0.3 ms; each key115-that changes the selection pays the 29 ms again.117+Avg and Count cover 2.1 M cells. On a sheet of more than 16,384 cells,118+a selection of more than 4096 is summed from an index of each column's119+sum, count and count of numbers in blocks of 64 rows (`internal/sheet/stats.go`):120+the whole blocks it covers, plus the rows at its ends a cell at a time.121+The index is built on first use, in one pass over the cells (the 30 ms122+the whole sum used to take every time), and a stored, deleted or123+recalculated cell marks its block to be summed again when a selection124+next covers it. Sums are recomputed, never adjusted, so they don't125+drift. The result is still cached until a cell changes. Each key that126+changes a selection over the whole sheet, through to its frame127+(`BenchmarkKeystroke`, 3 runs each, same machine):128+129+| Key, 8192 x 256 numbers | 80 x 24 before | after | 200 x 60 before | after |130+|---|---|---|---|---|131+| 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 |132+| 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 |133+| Ctrl+A, the selection standing (`select-data`) | 0.30 ms | 0.30 ms | 1.8 ms | 1.8 ms |134+135+A CPU profile of `extend-data` before put 54% of the time in136+`RangeStats` walking the map; the rest is building the benchmark's sheet.137+The index costs 0.5 MB per sheet that has one (32,768 blocks of 16 B),138+nothing on sheets that never had a large selection, and a nil check per139+stored or recalculated cell.116140 117141 ## Imports118142
@@ -192,20 +216,65 @@ BigUndo's 3%; everything else is unchanged.
192216 ## JEV193217 194218 Each JEV formula is volatile: every recalculation looks its question up195-in the cache, and every answer that arrives recalculates all JEV196-cells. With a fake client answering instantly (`BenchmarkJEV`), the CPU197-per answer grows with the number of JEV cells:198-199-| JEV cells | Per answer | All answers |200-|---|---|---|201-| 100 | 26 us | 2.7 ms |202-| 1000 | 261 us | 0.26 s |203-| 4000 | 1.3 ms | 5.1 s |219+in the cache. A formula shown Loading… is noted against the question it220+waits for (by `RemoteCall.Key`), and an answer recalculates only the221+formulas waiting for it and what reads them222+(`Workbook.RecalcAnswered`). Answers are stored as they arrive; the223+first since the last recalculation schedules one a frame later224+(`ui.FrameInterval`), which covers every answer stored meanwhile, so225+answers arriving together recalculate once. With a fake client226+answering instantly (`BenchmarkJEV`, 3 runs each, same machine; the227+benchmark doesn't wait the frame, so answers queued together, up to 8,228+recalculate together):229+230+| JEV cells | Per answer before | after | All answers before | after |231+|---|---|---|---|---|232+| 100 | 29 us | 3.5 us | 2.9 ms | 0.35 ms |233+| 1000 | 270 us | 3.6 us | 0.27 s | 3.6 ms |234+| 4000 | 1.2 ms | 3.8 us | 4.9 s | 15 ms |204235 205236 Against the real service the network dominates: at most 8 questions are206-in flight, so 4000 questions at 300 ms each take 2.5 minutes, of which207-5 s is recalculation spread over that time. Each answer blocks the UI208-for its recalculation (1.3 ms at 4000 cells).237+in flight, so 4000 questions at 300 ms each take 2.5 minutes, and the238+recalculation is now a few microseconds per answer whatever the number239+of JEV cells. Other changes still recalculate every JEV cell, as every240+volatile formula, looking the answers up in the cache.241+242+## Formula depth243+244+The parser (`internal/formula`) counts nesting as it recurses:245+parentheses, function calls and prefix operators each open a level. Past246+`formula.MaxDepth`, 1024 levels, a formula fails to parse with "Formula247+is nested too deeply (more than 1024 levels)", shown on the context line248+with the caret at the level past the cap, as other parse errors are.249+Excel allows 64 nested functions; 1024 is far beyond a formula written250+by hand, and bounds the recursion of everything that walks a formula251+(the printer, reference rewriting, the evaluator within one formula). A252+`.012` file holding a deeper formula (none could have been written by253+hand) fails to open with that message and the cell; imports keep such a254+formula's text or cached value, as for any formula they can't read.255+256+Evaluation recurses once per link of a chain of formulas, which no257+parser cap bounds: a chain through all 2.1 M cells of a sheet used to258+overflow the goroutine's 1 GB stack and kill the program. Evaluation now259+counts its depth in cells and operators; a cell reached past 65,536260+levels is put off: the evaluation in progress is abandoned (its cells go261+back to dirty), the cell is evaluated from the top of the stack, and the262+abandoned evaluation is retried and finds it done. Values are exactly as263+before, a cycle longer than the limit is still a cycle, and the stack264+stays under 32 MB (`TestFullSheetChain`, run with265+`SHEET_FULL_CHAIN=1`, checks it under a 64 MB limit: 4 s for 2.1 M266+cells, building included).267+268+Same machine, 8 runs each with before and after interleaved, other269+work running (differences under 10% are noise):270+271+| Benchmark | Before | After |272+|---|---|---|273+| Parse (`BenchmarkParse`) | 2.36 us | 2.41 us |274+| 1000 nested parentheses / IFs, parse and evaluate | 195 / 425 us | 203 / 438 us |275+| 10,000 nested parentheses / IFs | 1.8 / 4.4 ms, accepted | 1.1 / 2.0 ms to refuse |276+| RecalcAll chain of 8192 / dense 8192 x 26 | 1.33 / 50 ms | 1.28 to 1.38 / 41 to 51 ms |277+| Edit chain of 8192 / 8192 volatiles | 2.36 / 1.68 ms | 2.34 / 1.72 ms |209278 210279 ## Several sheets211280
@@ -248,7 +317,7 @@ session press an arrow key at once, 200 times, timing each key from the
248317 client's write to the first bytes of its frame arriving. The clients249318 run in the same process, so the heap and CPU figures include their side250319 of the connections and are upper bounds for the server. Medians of 3251-runs, `-benchtime 200x`:320+runs, `-benchtime 200x`, at 60 frames a second:252321 253322 | Sessions, sheet | Heap per session | Key to frame p50 / p95 / max | CPU per frame |254323 |---|---|---|---|
@@ -257,8 +326,18 @@ runs, `-benchtime 200x`:
257326 | 50, new sheet | 1.30 MiB | 9.2 / 16.3 / 19.2 ms | 0.53 ms |258327 | 50, 1000 x 26 numbers | 8.5 MiB | 9.4 / 16.7 / 20.5 ms | 0.69 ms |259328 329+Sessions now ask for 120 frames a second, as the local app does. One330+run each, back to back, before and after:331+332+| Sessions, sheet | p50 / p95 / max at 60 fps | at 120 fps | CPU per frame, 60 / 120 fps |333+|---|---|---|---|334+| 10, new sheet | 10.8 / 16.6 / 16.8 ms | 5.1 / 8.3 / 9.6 ms | 0.78 / 0.67 ms |335+| 10, 1000 x 26 numbers | 9.7 / 16.6 / 20.7 ms | 4.3 / 8.3 / 8.5 ms | 1.41 / 0.71 ms |336+| 50, new sheet | 9.3 / 16.3 / 18.5 ms | 5.0 / 8.7 / 17.8 ms | 0.58 / 0.53 ms |337+| 50, 1000 x 26 numbers | 9.5 / 16.7 / 19.7 ms | 5.1 / 8.9 / 12.8 ms | 0.71 / 0.66 ms |338+260339 Latency doesn't move from 10 to 50 sessions: it's Bubble Tea's frame261-pacing (at most one frame every 16.7 ms, so a key waits half a frame on340+pacing (at most one frame every 8.3 ms, so a key waits half a frame on262341 average), not load. A heap profile with 50 sessions open263342 (`SERVE_HEAP_PROFILE=file`) puts three quarters of a session's heap in264343 Bubble Tea's render buffers (about 1 MiB at 120 x 40, growing with the
@@ -268,7 +347,9 @@ top come about 300 B per cell of the sheets a session has open;
268347 sessions opening the same file each hold a copy. CPU per frame covers both ends of the connection (the269348 encryption twice, the client reading the frame); at 0.7 ms, 50 sessions270349 typing continuously at 60 frames a second would keep about two cores271-busy.350+busy, and at 120 about four: frames are drawn only when a session's351+screen changes, so the rate is the most keys can cause, not a cost of352+being connected.272353 273354 ## Pivot tables274355
@@ -325,14 +406,17 @@ Each with its benchmark before and after, in the commit that fixed it.
325406 | `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 |326407 | 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 |327408 | 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 |409+| 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 |410+| Every JEV answer recalculated every JEV cell | Per-answer cost grew with the number of JEV cells | JEV/4000 4.9 s | 15 ms |411+| 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 |412+| 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 |328413 329414 ## What would raise the bounds330415 331416 In order of value for effort. The first three are what raising the332417 limits toward Google Sheets' 10 M cells (or Excel's 1,048,576 x 16,384)333418 would take; without them a 10 M-cell sheet would need about 3 GB of334-heap, take 10 s to open and 140 ms per key to extend a selection over335-it.419+heap and take 10 s to open.336420 337421 1. **Compact cell storage** (L, 2 to 3 weeks). Cells live in a338422 `map[Addr]*Cell` behind `cellStore` (`internal/sheet/store.go`), 300 B
@@ -362,26 +446,20 @@ it.
362446 allocation tenfold; a columnar or gzip-compressed variant would cut363447 the size about fourfold. The format is `internal/sheet/file.go`, apart364448 from the cell store.365-4. **Incremental selection statistics** (S, 2 days). Keep per-column366- sums and counts, updated in `place` and after recalc, and compute a367- selection's Sum and Count from column totals minus the rows outside368- it; extending a whole-sheet selection would drop from 29 ms to well369- under 1 ms.370-5. **JEV recalc by question** (S to M, 3 days). Index JEV cells by371- question key and recalculate only the cells whose question was372- answered (and their dependents), and coalesce answers that arrive in373- the same frame: answering 4000 questions would cost 4000 small374- recalcs instead of 4000 full ones.449+4. **Incremental selection statistics.** Done: per-column block450+ aggregates, marked stale as cells change (see [Rendering](#rendering));451+ extending a whole-sheet selection went from 31 to 44 ms to 0.53 ms.452+5. **JEV recalc by question.** Done: an answer recalculates the cells453+ waiting for it, and answers within a frame recalculate together (see454+ [JEV](#jev)); answering 4000 questions went from 4.9 s of CPU to455+ 15 ms.375456 6. **Smaller undo steps** (S, 1 day). The history is capped by the376457 memory its before-images hold; storing formatting-only changes as377458 diffs rather than whole cells would let it keep more of them.378-7. **Frame rate** (S, hours). Bubble Tea draws at most 60 frames a379- second; asking for 120 would halve the key-to-screen floor from about380- 16 ms to 8 ms at the cost of more redraws.381-8. **Recursion limits** (S, hours). The parser (`internal/formula`) and382- the evaluator recurse without a limit; a depth cap (Excel allows 64383- nested functions) would turn a pathological file into an error rather384- than a deep stack.459+7. **Frame rate.** Done: 120 frames a second, Bubble Tea's maximum,460+ locally and over SSH; arrow keys through a terminal went from p50461+ 16.6 ms to 8.3 ms, with no more CPU when idle.462+8. **Recursion limits.** Done: see [Formula depth](#formula-depth).385463 386464 ## Measuring387465
M internal/formula/formula_test.go +33 -0
@@ -2,6 +2,7 @@ package formula
22 33 import (44 "reflect"5+ "strings"56 "testing"67 )78
@@ -249,6 +250,10 @@ func FuzzPrint(f *testing.F) {
249250 for _, s := range []string{250251 "=A1*2", "=$A$1+A$2-$B3", "=SUM($A1:B$3)", "=-2^-2%", "=1-(2-3)", "=#NOT#A1=1#OR#B1",251252 "=2^(3^2)", `=IF(A1>2,"x""y",#REF!)`, "=1.5E-3^2", "=@SUM(A1..B3)", "=(1+2)%*-A1",253+ // At and past the nesting cap.254+ "=" + strings.Repeat("(", MaxDepth-1) + "1" + strings.Repeat(")", MaxDepth-1),255+ "=" + strings.Repeat("-(", MaxDepth/2) + "1" + strings.Repeat(")", MaxDepth/2),256+ "=" + strings.Repeat("(", MaxDepth+1) + "1" + strings.Repeat(")", MaxDepth+1),252257 } {253258 f.Add(s)254259 }
@@ -268,6 +273,34 @@ func FuzzPrint(f *testing.F) {
268273 })269274 }270275 276+// Nesting is capped at MaxDepth levels, with the error at the level277+// past it; anything within the cap parses.278+func TestParseDepth(t *testing.T) {279+ nested := func(n int) string {280+ return "=" + strings.Repeat("(", n) + "1" + strings.Repeat(")", n)281+ }282+ if _, err := Parse(nested(MaxDepth-1), testFuncs); err != nil {283+ t.Errorf("%d parentheses: %v", MaxDepth-1, err)284+ }285+ for _, in := range []string{286+ nested(MaxDepth),287+ nested(100_000),288+ "=" + strings.Repeat("-", 5000) + "1",289+ "=" + strings.Repeat("SUM(", 2000) + "1" + strings.Repeat(")", 2000),290+ "=" + strings.Repeat("(", 5000),291+ } {292+ _, err := Parse(in, testFuncs)293+ pe, ok := err.(*ParseError)294+ if !ok || !strings.HasPrefix(pe.Msg, "Formula is nested too deeply") {295+ t.Errorf("Parse(%.20q...) error = %v", in, err)296+ continue297+ }298+ if pe.Pos < MaxDepth || pe.Pos > len(in) {299+ t.Errorf("Parse(%.20q...) error at %d", in, pe.Pos)300+ }301+ }302+}303+271304 func TestParseErrors(t *testing.T) {272305 tests := []struct {273306 in string
M internal/formula/parse.go +24 -0
@@ -23,11 +23,21 @@ const (
2323 percentPower = 802424 )2525 26+// MaxDepth is how deeply a formula may nest: parentheses, function calls27+// and prefix operators each open a level (an operator of higher28+// precedence may add one or two). Excel allows 64 nested functions; this29+// is far more than a formula written by hand needs, and it bounds the30+// recursion of everything that walks a formula (the parser, printer,31+// evaluator and reference rewriting), so a pathological file fails to32+// parse rather than exhausting the stack.33+const MaxDepth = 102434+2635 type parser struct {2736 src string // the formula without its "=", for names' original spelling2837 toks []token2938 pos int3039 funcs Funcs40+ depth int // of expr calls, see MaxDepth3141 }3242 3343 // Parse parses a formula, finding the functions it calls with funcs. src
@@ -73,7 +83,21 @@ func (p *parser) isOp(op string) bool {
7383 return t.kind == tokOp && t.text == op7484 }7585 86+// expr parses an expression whose operators bind tighter than minPower.87+// Every level of nesting passes through here, so here it's counted.7688 func (p *parser) expr(minPower int) (Node, error) {89+ if p.depth >= MaxDepth {90+ return nil, &ParseError{p.peek().pos, fmt.Sprintf("Formula is nested too deeply (more than %d levels)", MaxDepth)}91+ }92+ p.depth++93+ n, err := p.climb(minPower)94+ p.depth--95+ return n, err96+}97+98+// climb parses by precedence climbing: a prefix, then infix and postfix99+// operators binding tighter than minPower.100+func (p *parser) climb(minPower int) (Node, error) {77101 left, err := p.prefix()78102 if err != nil {79103 return nil, err
M internal/jev/key.go +3 -93
@@ -1,96 +1,6 @@
11 package jev22 3-import (4- "encoding/json"5- "fmt"6- "maps"7- "slices"8- "strconv"9- "strings"3+import "github.com/FelineStateMachine/012/internal/sheet"104 11- "github.com/FelineStateMachine/012/internal/sheet"12-)13-14-// Key identifies a question by its content. JEV cells are volatile, so15-// every recalculation looks each one up: with thousands of them,16-// encoding the key with encoding/json took most of the time an answer17-// spent in recalc. The shapes a RemoteCall holds are encoded by hand,18-// each value tagged and each string length-prefixed so different calls19-// never share a key; anything else falls back to JSON.20-func Key(c sheet.RemoteCall) string {21- var b strings.Builder22- b.Grow(64 + len(c.Instructions))23- str(&b, c.Kind)24- str(&b, c.Instructions)25- if appendValue(&b, c.State) && appendValue(&b, c.Criteria) {26- return b.String()27- }28- j, err := json.Marshal(c)29- if err != nil {30- return fmt.Sprintf("%#v", c)31- }32- return "j" + string(j)33-}34-35-func str(b *strings.Builder, s string) {36- b.WriteString(strconv.Itoa(len(s)))37- b.WriteByte(':')38- b.WriteString(s)39-}40-41-// appendValue encodes v and reports whether it is a shape it knows.42-func appendValue(b *strings.Builder, v any) bool {43- switch v := v.(type) {44- case nil:45- b.WriteByte('n')46- case string:47- b.WriteByte('s')48- str(b, v)49- case float64:50- b.WriteByte('f')51- b.WriteString(strconv.FormatFloat(v, 'g', -1, 64))52- b.WriteByte(';')53- case bool:54- if v {55- b.WriteByte('T')56- } else {57- b.WriteByte('F')58- }59- case [2]string:60- b.WriteByte('p')61- str(b, v[0])62- str(b, v[1])63- case []string:64- b.WriteByte('l')65- b.WriteString(strconv.Itoa(len(v)))66- for _, s := range v {67- str(b, s)68- }69- case map[string]string:70- b.WriteByte('m')71- b.WriteString(strconv.Itoa(len(v)))72- for _, k := range slices.Sorted(maps.Keys(v)) {73- str(b, k)74- str(b, v[k])75- }76- case []any:77- b.WriteByte('a')78- b.WriteString(strconv.Itoa(len(v)))79- for _, e := range v {80- if !appendValue(b, e) {81- return false82- }83- }84- case [][]any:85- b.WriteByte('r')86- b.WriteString(strconv.Itoa(len(v)))87- for _, row := range v {88- if !appendValue(b, row) {89- return false90- }91- }92- default:93- return false94- }95- return true96-}5+// Key identifies a question by its content; see sheet.RemoteCall.Key.6+func Key(c sheet.RemoteCall) string { return c.Key() }
M internal/serve/serve.go +1 -1
M internal/sheet/eval.go +8 -1
A internal/sheet/evaluate.go +178 -0
@@ -0,0 +1,178 @@
1+package sheet2+3+import (4+ "strings"5+6+ "github.com/FelineStateMachine/012/internal/formula"7+)8+9+// Evaluation is lazy and recursive: a formula reading a dirty cell10+// evaluates it first, so a chain of formulas (each row adding to the row11+// above) recurses once per link. The parser caps how deeply one formula12+// nests (formula.MaxDepth), but a chain can run through every cell of a13+// sheet, two million links, which would take more than the goroutine's14+// stack. So evaluation counts how deep it is (w.depth: cells and15+// operators, nested), and a cell it reaches past maxEvalDepth is put off:16+// the evaluation in progress is abandoned (its cells go back to dirty),17+// the cell is evaluated on its own, from the top of the stack, and then18+// what was abandoned is tried again, finding it done. Values come out as19+// if evaluated in one go; a long chain costs about twice the work.20+//21+// Cells put off are "deferred" until they're evaluated: each depends on22+// the one put off after it, so reaching one again is a cycle, as reaching23+// a cell being evaluated is.24+25+// maxEvalDepth bounds evaluation's nesting, in cells and operators: about26+// 30 MB of stack. A var so tests can lower it.27+var maxEvalDepth = 1 << 1628+29+// tooDeep abandons an evaluation that went past maxEvalDepth.30+type tooDeep struct{}31+32+// evaluator is the state of one evaluate.33+type evaluator struct {34+ w *Workbook35+ pending []loc // cells put off, each needed by the one before36+}37+38+// evaluate computes the cells marked dirty in each sheet's calc. Cells39+// are evaluated lazily in dependency order: reading a dirty cell40+// evaluates it first. A cell that is reached again while it is still41+// being evaluated is part of a cycle and becomes ERR. The state lives on42+// the sheets, keyed by address as on one sheet, and each sheet's lookup43+// is made once, so evaluating a formula allocates nothing for sheets.44+func (w *Workbook) evaluate() {45+ w.Circular = false46+ e := &evaluator{w: w}47+ for _, s := range w.sheets {48+ s.version++49+ s.hidden.valid = false // values may have changed what the filter hides50+ s.calcGet = w.lookupOn(s, e.compute)51+ s.calcFmt = w.formatFrom(s)52+ }53+ for _, s := range w.sheets {54+ for !e.sweep(s) {55+ e.settle()56+ }57+ }58+ for _, s := range w.sheets {59+ s.calc, s.calcGet, s.calcFmt = nil, nil, nil60+ }61+}62+63+// compute returns the value of the cell at a, evaluating it first if it's64+// dirty. Every cell a formula reads comes through here, so it looks each65+// map up once: a SUM over 8192 cells makes 8192 calls.66+func (e *evaluator) compute(s *Sheet, a Addr) Value {67+ w := e.w68+ c := s.cells.get(a)69+ switch st := s.calc[a]; {70+ case c == nil:71+ return Value{}72+ case st == visiting, st == deferred:73+ w.Circular = true74+ return ErrRef75+ case st != dirty:76+ return c.Value77+ case c.derived: // a pivot's result, set when the pivot was computed78+ s.calc[a] = done79+ return c.Value80+ case c.expr != nil && w.depth >= maxEvalDepth:81+ s.calc[a] = deferred82+ e.pending = append(e.pending, loc{s, a})83+ panic(tooDeep{})84+ }85+ c.auto = Format{}86+ switch {87+ case c.Input == "":88+ c.Value = Value{}89+ case c.expr == nil && c.Format.Kind == FmtText:90+ c.Value = Value{Kind: Text, Str: c.Input}91+ case c.expr == nil:92+ c.Value = Value{Kind: Text, Str: strings.TrimPrefix(c.Input, "'")}93+ default:94+ if n, lit := c.expr.(formula.Num); lit { // a number typed in95+ c.Value = num(n.V)96+ } else {97+ e.formula(s, a, c)98+ }99+ }100+ s.calc[a] = done101+ s.cells.changed(a)102+ return c.Value103+}104+105+// formula evaluates the formula in c, at a, which may read other cells:106+// it's visiting meanwhile.107+func (e *evaluator) formula(s *Sheet, a Addr, c *Cell) {108+ w := e.w109+ s.calc[a] = visiting110+ w.depth++111+ expr := s.bound(c)112+ outer := w.evaluating113+ w.evaluating = loc{s, a}114+ c.Value = eval(w.arith(expr), s.calcGet)115+ w.evaluating = outer116+ c.auto = inferFormat(expr, s.calcFmt)117+ w.depth--118+}119+120+// sweep evaluates s's dirty cells, reporting false if it went too deep121+// and put off a cell.122+func (e *evaluator) sweep(s *Sheet) (finished bool) {123+ defer e.abandon(&finished)124+ for a, st := range s.calc {125+ if st == dirty {126+ e.compute(s, a)127+ }128+ }129+ return true130+}131+132+// settle evaluates the cells put off, deepest first, putting off more on133+// the way if need be.134+func (e *evaluator) settle() {135+ for len(e.pending) > 0 {136+ top := e.pending[len(e.pending)-1]137+ if top.s.calc[top.a] == deferred {138+ top.s.calc[top.a] = dirty139+ }140+ if e.try(top) {141+ e.pending = e.pending[:len(e.pending)-1]142+ }143+ }144+}145+146+// try evaluates the cell at l, reporting false if it went too deep and147+// put off a cell, having undone what it had started.148+func (e *evaluator) try(l loc) (finished bool) {149+ defer e.abandon(&finished)150+ e.compute(l.s, l.a)151+ return true152+}153+154+// abandon, deferred, recovers from an evaluation that went too deep:155+// the cells it was evaluating go back to dirty, those put off stay156+// deferred, and finished is set false.157+func (e *evaluator) abandon(finished *bool) {158+ r := recover()159+ if r == nil {160+ return161+ }162+ if _, deep := r.(tooDeep); !deep {163+ panic(r)164+ }165+ for _, s := range e.w.sheets {166+ for a, st := range s.calc {167+ if st == visiting {168+ s.calc[a] = dirty169+ }170+ }171+ }172+ for _, p := range e.pending {173+ p.s.calc[p.a] = deferred174+ }175+ e.w.depth = 0176+ e.w.evaluating = loc{}177+ *finished = false178+}
A internal/sheet/evaluate_test.go +103 -0
@@ -0,0 +1,103 @@
1+package sheet2+3+import (4+ "fmt"5+ "os"6+ "runtime/debug"7+ "testing"8+)9+10+// withEvalDepth lowers maxEvalDepth for a test.11+func withEvalDepth(t *testing.T, n int) {12+ t.Helper()13+ old := maxEvalDepth14+ maxEvalDepth = n15+ t.Cleanup(func() { maxEvalDepth = old })16+}17+18+// chain fills column col, rows 1 to n, with =A(row-1)+1 below a 1, so19+// the last is n.20+func chain(s *Sheet, col, n int) {21+ for row := range n {22+ in := "1"23+ if row > 0 {24+ in = "=" + Addr{Col: col, Row: row - 1}.String() + "+1"25+ }26+ s.Load(Addr{Col: col, Row: row}, in, Format{}, Style{})27+ }28+}29+30+// Evaluation past maxEvalDepth puts cells off and comes back to them:31+// values come out as if evaluated in one go.32+func TestDeepChains(t *testing.T) {33+ withEvalDepth(t, 10)34+ s := New()35+ chain(s, 0, 1000)36+ // Deep formulas in a chain: each cell nests 8 levels.37+ for row := range 300 {38+ in := "=1"39+ if row > 0 {40+ in = "=((((((((" + Addr{Col: 1, Row: row - 1}.String() + "+1))))))))"41+ }42+ s.Load(Addr{Col: 1, Row: row}, in, Format{}, Style{})43+ }44+ s.RecalcAll()45+ if got := s.Value(Addr{Row: 999}); got != num(1000) {46+ t.Errorf("A1000 = %+v", got)47+ }48+ if got := s.Value(Addr{Col: 1, Row: 299}); got != num(300) {49+ t.Errorf("B300 = %+v", got)50+ }51+ if s.Book().Circular {52+ t.Error("a chain was taken for a cycle")53+ }54+ // An edit at the top recalculates the chain the same way.55+ s.Set(at("A1"), "5")56+ if got := s.Value(Addr{Row: 999}); got != num(1004) {57+ t.Errorf("after an edit, A1000 = %+v", got)58+ }59+}60+61+// A cycle longer than maxEvalDepth is still a cycle.62+func TestDeepCycle(t *testing.T) {63+ withEvalDepth(t, 10)64+ s := New()65+ chain(s, 0, 500)66+ s.Set(at("A1"), "=A500+1")67+ if !s.Book().Circular {68+ t.Error("a cycle of 500 cells wasn't found")69+ }70+ if got := s.Value(at("A250")); got.Kind != Error {71+ t.Errorf("A250, in the cycle, = %+v", got)72+ }73+ s.Set(at("A1"), "1")74+ if s.Book().Circular || s.Value(at("A500")) != num(500) {75+ t.Errorf("after breaking the cycle: circular %v, A500 = %+v", s.Book().Circular, s.Value(at("A500")))76+ }77+}78+79+// A chain through every cell of a full sheet fits in a bounded stack.80+// Slow (2 M cells, 1 GB): set SHEET_FULL_CHAIN=1 to run it.81+func TestFullSheetChain(t *testing.T) {82+ if os.Getenv("SHEET_FULL_CHAIN") == "" {83+ t.Skip("set SHEET_FULL_CHAIN=1 to evaluate a chain through all 2 M cells")84+ }85+ defer debug.SetMaxStack(debug.SetMaxStack(64 << 20))86+ s := New()87+ for col := range MaxCols {88+ for row := range MaxRows {89+ in := "1"90+ switch {91+ case row > 0:92+ in = "=" + Addr{Col: col, Row: row - 1}.String() + "+1"93+ case col > 0:94+ in = "=" + Addr{Col: col - 1, Row: MaxRows - 1}.String() + "+1"95+ }96+ s.Load(Addr{Col: col, Row: row}, in, Format{}, Style{})97+ }98+ }99+ s.RecalcAll()100+ if got, want := s.Value(Addr{Col: MaxCols - 1, Row: MaxRows - 1}), num(MaxCols*MaxRows); got != want {101+ t.Errorf("IV8192 = %+v, want %v", got, fmt.Sprint(want.Num))102+ }103+}
M internal/sheet/functions_jev.go +1 -0
M internal/sheet/functions_jev_test.go +42 -0
@@ -109,3 +109,45 @@ func TestJEVWithoutKey(t *testing.T) {
109109 t.Errorf("no key: %+v", s.Value(at("A1")))110110 }111111 }112+113+// An answer recalculates the formulas that asked it and what reads them,114+// not every JEV formula.115+func TestRecalcAnswered(t *testing.T) {116+ f := &fakeRemote{answers: map[string]RemoteAnswer{}}117+ s := New()118+ s.SetRemote(f)119+ s.Set(at("A1"), `=JEV.TEST("x", "Q1")`)120+ s.Set(at("A2"), `=JEV.TEST("x", "Q2")`)121+ s.Set(at("A3"), `=IF(JEV.TEST("x", "Q1"), "yes", "no")`)122+ s.Set(at("B1"), `=A1`)123+ if !IsPending(s.Value(at("A1"))) || !IsPending(s.Value(at("A3"))) {124+ t.Fatalf("A1 %+v, A3 %+v", s.Value(at("A1")), s.Value(at("A3")))125+ }126+ f.answers["Q1"] = RemoteAnswer{Noul: 0.9}127+ f.asked = nil128+ q1 := RemoteCall{Kind: "noul", State: "x", Instructions: "Q1", Criteria: [2]string{}}129+ s.Book().RecalcAnswered([]RemoteCall{q1})130+ for _, c := range f.asked {131+ if c.Instructions != "Q1" {132+ t.Errorf("an answer to Q1 asked %q again", c.Instructions)133+ }134+ }135+ if len(f.asked) != 2 {136+ t.Errorf("%d questions looked up, want the 2 cells asking Q1", len(f.asked))137+ }138+ if got := s.Value(at("B1")); got != boolean(true) {139+ t.Errorf("B1, reading A1, = %+v", got)140+ }141+ if got := s.Value(at("A3")); got.Str != "yes" {142+ t.Errorf("A3 = %+v", got)143+ }144+ if !IsPending(s.Value(at("A2"))) {145+ t.Errorf("A2 = %+v", s.Value(at("A2")))146+ }147+ // Answered questions are forgotten; an unknown one does nothing.148+ f.asked = nil149+ s.Book().RecalcAnswered([]RemoteCall{q1, {Kind: "noul", Instructions: "Q9"}})150+ if len(f.asked) != 0 {151+ t.Errorf("answering again looked up %d questions", len(f.asked))152+ }153+}
M internal/sheet/nav.go +0 -40
M internal/sheet/recalc.go +15 -70
@@ -1,10 +1,6 @@
11 package sheet22 3-import (4- "strings"5-6- "github.com/FelineStateMachine/012/internal/formula"7-)3+import "github.com/FelineStateMachine/012/internal/formula"84 95 // Recalculation: a change marks the changed cells and everything that106 // transitively reads them dirty, on any sheet, then evaluates the dirty
@@ -46,13 +42,19 @@ const (
4642 dirty = iota + 14743 visiting4844 done45+ deferred // put off: evaluation went too deep, see evaluate.go4946 )5047 5148 // recalc recomputes the changed cells, volatile formulas, and everything5249 // that transitively depends on them, on any sheet.53-func (w *Workbook) recalc(changed []loc) {50+func (w *Workbook) recalc(changed []loc) { w.recalcFrom(changed, true) }51+52+// recalcFrom recomputes the changed cells and everything that53+// transitively depends on them, and the volatile formulas with theirs54+// when volatiles is set.55+func (w *Workbook) recalcFrom(changed []loc, volatiles bool) {5456 start := recalcStart()55- n := w.affected(changed)57+ n := w.affected(changed, volatiles)5658 stale := w.stalePivots()5759 w.evaluate()5860 w.observe(false, start, n)
@@ -60,13 +62,16 @@ func (w *Workbook) recalc(changed []loc) {
6062 }6163 6264 // affected marks dirty, in each sheet's calc, the changed cells, volatile63-// formulas, and every formula that transitively reads them, on any sheet,64-// and returns how many it marked.65-func (w *Workbook) affected(changed []loc) int {65+// formulas if volatiles is set, and every formula that transitively reads66+// them, on any sheet, and returns how many it marked.67+func (w *Workbook) affected(changed []loc, volatiles bool) int {6668 m := marking{w: w, queue: append([]loc(nil), changed...), named: w.namedInUse(), byName: map[*Sheet]bool{}}6769 for _, s := range w.sheets {6870 s.calc = make(map[Addr]int)6971 m.byName[s] = w.crossKeys[formula.SheetKey(s.name)] > 072+ if !volatiles {73+ continue74+ }7075 for a := range s.volatile {7176 m.queue = append(m.queue, loc{s, a})7277 }
@@ -160,66 +165,6 @@ func (c *Cell) rangeHas(a Addr) bool {
160165 return false161166 }162167 163-// evaluate computes the cells marked dirty in each sheet's calc. Cells164-// are evaluated lazily in dependency order: reading a dirty cell165-// evaluates it first. A cell that is reached again while it is still166-// being evaluated is part of a cycle and becomes ERR. The state lives on167-// the sheets, keyed by address as on one sheet, and each sheet's lookup168-// is made once, so evaluating a formula allocates nothing for sheets.169-func (w *Workbook) evaluate() {170- w.Circular = false171- var compute func(s *Sheet, a Addr) Value172- compute = func(s *Sheet, a Addr) Value {173- // Every cell a formula reads comes through here, so it looks174- // each map up once: a SUM over 8192 cells makes 8192 calls.175- c := s.cells.get(a)176- switch st := s.calc[a]; {177- case c == nil:178- return Value{}179- case st == visiting:180- w.Circular = true181- return ErrRef182- case st != dirty:183- return c.Value184- case c.derived: // a pivot's result, set when the pivot was computed185- s.calc[a] = done186- return c.Value187- }188- s.calc[a] = visiting189- c.auto = Format{}190- switch {191- case c.Input == "":192- c.Value = Value{}193- case c.expr == nil && c.Format.Kind == FmtText:194- c.Value = Value{Kind: Text, Str: c.Input}195- case c.expr == nil:196- c.Value = Value{Kind: Text, Str: strings.TrimPrefix(c.Input, "'")}197- default:198- expr := s.bound(c)199- c.Value = eval(w.arith(expr), s.calcGet)200- if _, lit := expr.(formula.Num); !lit {201- c.auto = inferFormat(expr, s.calcFmt)202- }203- }204- s.calc[a] = done205- return c.Value206- }207- for _, s := range w.sheets {208- s.version++209- s.hidden.valid = false // values may have changed what the filter hides210- s.calcGet = w.lookupOn(s, compute)211- s.calcFmt = w.formatFrom(s)212- }213- for _, s := range w.sheets {214- for a := range s.calc {215- compute(s, a)216- }217- }218- for _, s := range w.sheets {219- s.calc, s.calcGet, s.calcFmt = nil, nil, nil220- }221-}222-223168 // crossReads reports whether the formula at u reads the cell l through a224169 // reference that names l's sheet.225170 func (w *Workbook) crossReads(u, l loc) bool {
M internal/sheet/remote.go +39 -3
@@ -6,11 +6,11 @@ import "github.com/FelineStateMachine/012/internal/formula"
66 // talks to the network: it describes each question as a RemoteCall and77 // asks the workbook's RemoteSource for the answer. Until the answer arrives the cell shows88 // Loading…, and the source queues the call; when answers arrive the UI9-// calls RecalcVolatile, and the functions, being volatile, look again.9+// calls RecalcAnswered, which recomputes the formulas waiting for them.10+// The functions are volatile, so any other change looks again too.1011 1112 // RemoteCall is one question for the model, built from a function's12-// arguments. It is comparable through its JSON form, which callers use as13-// a cache key.13+// arguments. It isn't comparable: Key identifies it by its content.1414 type RemoteCall struct {1515 Kind string `json:"kind"` // "noul", "choice" or "score"1616 State any `json:"state"`
@@ -93,3 +93,39 @@ func (s *Sheet) RemoteCalls(a Addr) []RemoteCall {
9393 walk(s.bound(c))9494 return calls9595 }96+97+// wait notes that the formula being evaluated is waiting for the answer98+// to c.99+func (w *Workbook) wait(c RemoteCall) {100+ if w.evaluating.s == nil {101+ return102+ }103+ k := c.Key()104+ if w.waiting == nil {105+ w.waiting = map[string]map[loc]struct{}{}106+ }107+ if w.waiting[k] == nil {108+ w.waiting[k] = map[loc]struct{}{}109+ }110+ w.waiting[k][w.evaluating] = struct{}{}111+}112+113+// RecalcAnswered recomputes the formulas that were waiting for the114+// answers to calls, and what depends on them, on every sheet: with115+// thousands of JEV cells, an answer costs the cells that asked it, not116+// all of them. It doesn't touch the undo history.117+func (w *Workbook) RecalcAnswered(calls []RemoteCall) {118+ var changed []loc119+ for _, c := range calls {120+ k := c.Key()121+ for l := range w.waiting[k] {122+ if l.s.live && l.s.cells.get(l.a) != nil {123+ changed = append(changed, l)124+ }125+ }126+ delete(w.waiting, k)127+ }128+ if len(changed) > 0 {129+ w.recalcFrom(changed, false)130+ }131+}
A internal/sheet/remotekey.go +95 -0
@@ -0,0 +1,95 @@
1+package sheet2+3+import (4+ "encoding/json"5+ "fmt"6+ "maps"7+ "slices"8+ "strconv"9+ "strings"10+)11+12+// Key identifies a question by its content: the answer cache and the13+// engine's list of cells waiting for answers are keyed by it. JEV cells14+// are volatile, so every recalculation looks each one up: with thousands15+// of them, encoding the key with encoding/json took most of the time an16+// answer spent in recalc. The shapes a RemoteCall holds are encoded by17+// hand, each value tagged and each string length-prefixed so different18+// calls never share a key; anything else falls back to JSON.19+func (c RemoteCall) Key() string {20+ var b strings.Builder21+ b.Grow(64 + len(c.Instructions))22+ keyStr(&b, c.Kind)23+ keyStr(&b, c.Instructions)24+ if keyValue(&b, c.State) && keyValue(&b, c.Criteria) {25+ return b.String()26+ }27+ j, err := json.Marshal(c)28+ if err != nil {29+ return fmt.Sprintf("%#v", c)30+ }31+ return "j" + string(j)32+}33+34+func keyStr(b *strings.Builder, s string) {35+ b.WriteString(strconv.Itoa(len(s)))36+ b.WriteByte(':')37+ b.WriteString(s)38+}39+40+// keyValue encodes v and reports whether it is a shape it knows.41+func keyValue(b *strings.Builder, v any) bool {42+ switch v := v.(type) {43+ case nil:44+ b.WriteByte('n')45+ case string:46+ b.WriteByte('s')47+ keyStr(b, v)48+ case float64:49+ b.WriteByte('f')50+ b.WriteString(strconv.FormatFloat(v, 'g', -1, 64))51+ b.WriteByte(';')52+ case bool:53+ if v {54+ b.WriteByte('T')55+ } else {56+ b.WriteByte('F')57+ }58+ case [2]string:59+ b.WriteByte('p')60+ keyStr(b, v[0])61+ keyStr(b, v[1])62+ case []string:63+ b.WriteByte('l')64+ b.WriteString(strconv.Itoa(len(v)))65+ for _, s := range v {66+ keyStr(b, s)67+ }68+ case map[string]string:69+ b.WriteByte('m')70+ b.WriteString(strconv.Itoa(len(v)))71+ for _, k := range slices.Sorted(maps.Keys(v)) {72+ keyStr(b, k)73+ keyStr(b, v[k])74+ }75+ case []any:76+ b.WriteByte('a')77+ b.WriteString(strconv.Itoa(len(v)))78+ for _, e := range v {79+ if !keyValue(b, e) {80+ return false81+ }82+ }83+ case [][]any:84+ b.WriteByte('r')85+ b.WriteString(strconv.Itoa(len(v)))86+ for _, row := range v {87+ if !keyValue(b, row) {88+ return false89+ }90+ }91+ default:92+ return false93+ }94+ return true95+}
M internal/sheet/sheet_test.go +10 -0
A internal/sheet/stats.go +163 -0
@@ -0,0 +1,163 @@
1+package sheet2+3+// Stats summarizes the values in r, as shown in the status line for a4+// selection. Count is non-blank cells, Nums is numeric cells.5+type Stats struct {6+ Sum float647+ Count, Nums int8+}9+10+func (st *Stats) add(c *Cell) {11+ if c.Blank() {12+ return13+ }14+ st.Count++15+ if c.Value.Kind == Number {16+ st.Nums++17+ st.Sum += c.Value.Num18+ }19+}20+21+// statsCache is the last RangeStats result: the status line asks for the22+// selection's statistics on every frame.23+type statsCache struct {24+ r Rect25+ version uint6426+ st Stats27+ ok bool28+}29+30+// RangeStats computes Stats over r. The result is kept until a cell or31+// value changes. A selection over a large, well-filled sheet is summed32+// from the cells' statistics index (see statsIndex), so changing it costs33+// a block per column plus the rows at its ends, not a read of every cell.34+func (s *Sheet) RangeStats(r Rect) Stats {35+ if c := s.stats; c.ok && c.r == r && c.version == s.version {36+ return c.st37+ }38+ var st Stats39+ area := (r.To.Col - r.From.Col + 1) * (r.To.Row - r.From.Row + 1)40+ if area <= statsDirect || s.cells.len() <= statsSparse {41+ for _, c := range s.cells.inRange(r) {42+ st.add(c)43+ }44+ } else {45+ st = s.cells.rangeStats(r)46+ }47+ s.stats = statsCache{r: r, version: s.version, st: st, ok: true}48+ return st49+}50+51+const (52+ // statsBlock is how many rows of a column one entry of the index53+ // sums: at most twice that many cells per column are read at a54+ // selection's ends.55+ statsBlock = 6456+ // statsBlocks is the number of blocks down a column.57+ statsBlocks = (MaxRows + statsBlock - 1) / statsBlock58+ // statsDirect: selections of at most this many cells are summed cell59+ // by cell.60+ statsDirect = 409661+ // statsSparse: sheets of at most this many cells are summed by62+ // visiting their cells, which is cheaper than the index.63+ statsSparse = 1638464+)65+66+// blockStats is Stats over one block of a column.67+type blockStats struct {68+ sum float6469+ count, nums int3270+}71+72+// statsIndex keeps Stats for every block of statsBlock rows of every73+// column, so a large selection is summed a block at a time. It's built on74+// first use, from one pass over the cells. After that, storing or75+// deleting a cell, or recalculating its value, marks its block stale, and76+// stale blocks are summed again (a lookup per row) when a selection next77+// covers them. Blocks are recomputed rather than adjusted, so sums don't78+// drift with rounding.79+type statsIndex struct {80+ blocks []blockStats // by col*statsBlocks + row/statsBlock81+ stale []uint64 // a bit per block82+}83+84+func blockOf(a Addr) int { return a.Col*statsBlocks + a.Row/statsBlock }85+86+// touch marks the block holding a stale. A nil index has nothing to mark.87+func (x *statsIndex) touch(a Addr) {88+ if x != nil {89+ i := blockOf(a)90+ x.stale[i>>6] |= 1 << (i & 63)91+ }92+}93+94+// buildStats makes the index from one pass over every cell.95+func (st *cellStore) buildStats() *statsIndex {96+ n := MaxCols * statsBlocks97+ x := &statsIndex{blocks: make([]blockStats, n), stale: make([]uint64, (n+63)/64)}98+ for a, c := range st.m {99+ if c.Blank() {100+ continue101+ }102+ b := &x.blocks[blockOf(a)]103+ b.count++104+ if c.Value.Kind == Number {105+ b.nums++106+ b.sum += c.Value.Num107+ }108+ }109+ st.stats = x110+ return x111+}112+113+// block returns the statistics of block b of column col, summing it114+// again if it's stale.115+func (st *cellStore) block(col, b int) blockStats {116+ x := st.stats117+ i := col*statsBlocks + b118+ if x.stale[i>>6]&(1<<(i&63)) != 0 {119+ var s Stats120+ st.scan(col, b*statsBlock, min((b+1)*statsBlock, MaxRows)-1, &s)121+ x.blocks[i] = blockStats{sum: s.Sum, count: int32(s.Count), nums: int32(s.Nums)}122+ x.stale[i>>6] &^= 1 << (i & 63)123+ }124+ return x.blocks[i]125+}126+127+// scan adds the cells of column col from row from to row to into s.128+func (st *cellStore) scan(col, from, to int, s *Stats) {129+ for row := from; row <= to; row++ {130+ if c := st.m[Addr{Col: col, Row: row}]; c != nil {131+ s.add(c)132+ }133+ }134+}135+136+// rangeStats sums r from the index: in each column, the whole blocks r137+// covers, and the rows at its ends outside them cell by cell.138+func (st *cellStore) rangeStats(r Rect) Stats {139+ if st.stats == nil {140+ st.buildStats()141+ }142+ var s Stats143+ first := (r.From.Row + statsBlock - 1) / statsBlock // first whole block144+ last := first // one past the last whole block145+ for last < statsBlocks && min((last+1)*statsBlock, MaxRows)-1 <= r.To.Row {146+ last++147+ }148+ for col := r.From.Col; col <= r.To.Col; col++ {149+ if first >= last {150+ st.scan(col, r.From.Row, r.To.Row, &s)151+ continue152+ }153+ st.scan(col, r.From.Row, first*statsBlock-1, &s)154+ for b := first; b < last; b++ {155+ bs := st.block(col, b)156+ s.Sum += bs.sum157+ s.Count += int(bs.count)158+ s.Nums += int(bs.nums)159+ }160+ st.scan(col, last*statsBlock, r.To.Row, &s)161+ }162+ return s163+}
A internal/sheet/stats_test.go +84 -0
@@ -0,0 +1,84 @@
1+package sheet2+3+import (4+ "fmt"5+ "math"6+ "testing"7+)8+9+// bruteStats sums r cell by cell, as RangeStats did before the index.10+func bruteStats(s *Sheet, r Rect) Stats {11+ var st Stats12+ for row := r.From.Row; row <= r.To.Row; row++ {13+ for col := r.From.Col; col <= r.To.Col; col++ {14+ if c := s.Cell(Addr{Col: col, Row: row}); c != nil {15+ st.add(c)16+ }17+ }18+ }19+ return st20+}21+22+func sameStats(a, b Stats) bool {23+ return a.Count == b.Count && a.Nums == b.Nums && math.Abs(a.Sum-b.Sum) <= 1e-9*math.Max(1, math.Abs(b.Sum))24+}25+26+// TestRangeStatsIndex checks the block index against summing cell by27+// cell, over selections that start and end inside blocks and on their28+// edges, as cells are set, recalculated, deleted and undone.29+func TestRangeStatsIndex(t *testing.T) {30+ s := New()31+ for row := range 2000 {32+ for col := range 10 {33+ s.Load(Addr{Col: col, Row: row}, fmt.Sprint(row*10+col), Format{}, Style{})34+ }35+ }36+ s.Load(at("L1"), "=SUM(A1:A2000)", Format{}, Style{})37+ s.Load(at("K5"), "text", Format{}, Style{})38+ s.RecalcAll()39+ rects := []Rect{40+ NewRect(at("A1"), at("L8192")),41+ NewRect(at("A1"), at("IV8192")),42+ NewRect(at("B2"), at("K1999")),43+ NewRect(at("A64"), at("C129")), // rows 63..128: one whole block44+ NewRect(at("A65"), at("C128")), // rows 64..127: exactly one block45+ NewRect(at("C10"), at("F100")), // no whole block46+ NewRect(at("A1"), at("A8192")), // one column47+ NewRect(at("A100"), at("J120")), // small: cell by cell48+ }49+ check := func(when string) {50+ t.Helper()51+ for _, r := range rects {52+ if got, want := s.RangeStats(r), bruteStats(s, r); !sameStats(got, want) {53+ t.Errorf("%s: RangeStats(%s) = %+v, want %+v", when, r, got, want)54+ }55+ }56+ }57+ check("built")58+ if s.cells.stats == nil {59+ t.Fatal("the statistics index wasn't built")60+ }61+ s.Set(at("A1"), "1000000") // recalculates L1 as well62+ check("after an edit")63+ s.Set(at("C70"), "")64+ check("after clearing a cell")65+ s.Set(at("Z8000"), "7")66+ check("after a cell outside the data")67+ s.Undo()68+ s.Undo()69+ check("after undo")70+ s.Redo()71+ check("after redo")72+}73+74+func TestRangeStatsSparse(t *testing.T) {75+ s := New()76+ s.Set(at("A1"), "2")77+ s.Set(at("IV8192"), "3")78+ if got := s.RangeStats(NewRect(at("A1"), at("IV8192"))); got != (Stats{Sum: 5, Count: 2, Nums: 2}) {79+ t.Errorf("whole sparse sheet = %+v", got)80+ }81+ if s.cells.stats != nil {82+ t.Error("a sparse sheet built the statistics index")83+ }84+}
M internal/sheet/store.go +13 -3
M internal/sheet/stress_test.go +3 -1
M internal/sheet/workbook.go +12 -4
M internal/ui/edit_test.go +13 -0
M internal/ui/jev.go +39 -9
@@ -16,7 +16,8 @@ import (
1616 1717 // JEV functions are answered in the background: the engine queues1818 // questions in the cache, Update sends them as commands (a few at a time),19-// and each answer triggers a recalculation of the JEV cells.19+// and answers are stored as they arrive and, a frame later, the cells20+// that asked them are recalculated together.2021 2122 const (2223 jevParallel = 8
@@ -26,6 +27,11 @@ const (
2627 type jevRunner struct {2728 client jev.Client2829 cache *jev.Cache30+31+ // answered are the questions answered since the last recalculation,32+ // which runs gather after the first of them: at most one per frame.33+ answered []sheet.RemoteCall34+ gather time.Duration2935 }3036 3137 type jevAnswerMsg struct {
@@ -33,10 +39,13 @@ type jevAnswerMsg struct {
3339 answer sheet.RemoteAnswer3440 }3541 42+// jevRecalcMsg recalculates the cells whose questions were answered.43+type jevRecalcMsg struct{}44+3645 // EnableJEV turns on JEV functions: the cache answers them, for this3746 // workbook and those opened later, and the client asks what it lacks.3847 func (m *Model) EnableJEV(client jev.Client, cache *jev.Cache) {39- m.jev = &jevRunner{client: client, cache: cache}48+ m.jev = &jevRunner{client: client, cache: cache, gather: FrameInterval}4049 m.sheet.Book().SetRemote(cache)4150 }4251
@@ -99,16 +108,37 @@ func (j *jevRunner) send() tea.Cmd {
99108 return tea.Batch(cmds...)100109 }101110 102-// answerJEV stores an answer and recalculates the JEV cells, telling103-// the user when the last question is answered.111+// answerJEV stores an answer. The first answer since the last112+// recalculation schedules the next one, a frame later, so answers that113+// arrive together recalculate once.104114 func (m *Model) answerJEV(msg jevAnswerMsg) tea.Cmd {105- if m.jev == nil {115+ j := m.jev116+ if j == nil {117+ return nil118+ }119+ j.cache.Store(msg.call, msg.answer)120+ j.answered = append(j.answered, msg.call)121+ if len(j.answered) > 1 {122+ return nil // already scheduled123+ }124+ if j.gather <= 0 {125+ return func() tea.Msg { return jevRecalcMsg{} }126+ }127+ return tea.Tick(j.gather, func(time.Time) tea.Msg { return jevRecalcMsg{} })128+}129+130+// recalcAnswered recalculates the cells waiting for the answers stored131+// since the last time, telling the user when the last question is132+// answered.133+func (m *Model) recalcAnswered() tea.Cmd {134+ j := m.jev135+ if j == nil || len(j.answered) == 0 {106136 return nil107137 }108- busy := m.jev.busy() != ""109- m.jev.cache.Store(msg.call, msg.answer)110- m.sheet.RecalcVolatile()111- if busy && m.jev.busy() == "" {138+ calls := j.answered139+ j.answered = nil140+ m.sheet.Book().RecalcAnswered(calls)141+ if j.busy() == "" {112142 return m.term.notify("JEV finished answering in " + m.displayName())113143 }114144 return nil
M internal/ui/jev_test.go +69 -0
@@ -106,6 +106,75 @@ func TestJEVWithoutKeyExplains(t *testing.T) {
106106 }107107 }108108 109+// Answers that arrive within a frame are recalculated together, once.110+func TestJEVAnswersRecalculateOncePerFrame(t *testing.T) {111+ m, _ := jevModel(t)112+ var cmds []tea.Cmd113+ for _, q := range []string{"Q1", "Q2", "Q3"} {114+ for _, r := range `=JEV.TEST("x", "` + q + `")` {115+ m.Update(tea.KeyPressMsg{Code: r, Text: string(r)})116+ }117+ _, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})118+ cmds = append(cmds, cmd)119+ }120+ if in, _ := m.jev.cache.Busy(); in != 3 {121+ t.Fatalf("%d questions in flight, want 3", in)122+ }123+ recalcs := 0124+ sheet.OnRecalc = func(sheet.RecalcInfo) { recalcs++ }125+ defer func() { sheet.OnRecalc = nil }()126+ var answers []jevAnswerMsg127+ for _, cmd := range cmds {128+ for _, msg := range collect(cmd) {129+ if a, ok := msg.(jevAnswerMsg); ok {130+ answers = append(answers, a)131+ }132+ }133+ }134+ if len(answers) != 3 {135+ t.Fatalf("%d answers", len(answers))136+ }137+ var tick tea.Cmd138+ for i, a := range answers {139+ _, cmd := m.Update(a)140+ if i == 0 {141+ tick = cmd142+ }143+ }144+ if recalcs != 0 {145+ t.Errorf("%d recalculations before the frame", recalcs)146+ }147+ run(m, tick)148+ if recalcs != 1 {149+ t.Errorf("%d recalculations for 3 answers, want 1", recalcs)150+ }151+ for _, a := range []string{"A1", "A2", "A3"} {152+ if got := m.sheet.Value(addr(a)); got.Kind != sheet.Bool {153+ t.Errorf("%s = %+v", a, got)154+ }155+ }156+}157+158+// collect runs cmd and returns the messages it and its batches produce,159+// without feeding them to a model.160+func collect(cmd tea.Cmd) []tea.Msg {161+ if cmd == nil {162+ return nil163+ }164+ switch msg := cmd().(type) {165+ case nil:166+ return nil167+ case tea.BatchMsg:168+ var out []tea.Msg169+ for _, c := range msg {170+ out = append(out, collect(c)...)171+ }172+ return out173+ default:174+ return []tea.Msg{msg}175+ }176+}177+109178 // Starting a new sheet or opening a file keeps JEV on.110179 func TestJEVSurvivesNewSheet(t *testing.T) {111180 m, fake := jevModel(t)
M internal/ui/model.go +12 -0
M internal/ui/stress_test.go +10 -2
@@ -124,6 +124,12 @@ func key(k string) tea.KeyPressMsg {
124124 return tea.KeyPressMsg{Code: tea.KeyDown, Mod: tea.ModShift}125125 case "shift+up":126126 return tea.KeyPressMsg{Code: tea.KeyUp, Mod: tea.ModShift}127+ case "ctrl+shift+down":128+ return tea.KeyPressMsg{Code: tea.KeyDown, Mod: tea.ModCtrl | tea.ModShift}129+ case "ctrl+shift+up":130+ return tea.KeyPressMsg{Code: tea.KeyUp, Mod: tea.ModCtrl | tea.ModShift}131+ case "ctrl+shift+right":132+ return tea.KeyPressMsg{Code: tea.KeyRight, Mod: tea.ModCtrl | tea.ModShift}127133 case "ctrl+a":128134 return tea.KeyPressMsg{Code: 'a', Mod: tea.ModCtrl}129135 case "enter":
@@ -155,6 +161,7 @@ func BenchmarkKeystroke(b *testing.B) {
155161 {"select-data/dense-8192x256", uiShapes()[2], []string{"ctrl+a"}, nil},156162 {"select-sheet/dense-8192x256", uiShapes()[2], []string{"ctrl+a", "ctrl+a"}, nil},157163 {"extend-data/dense-8192x256", uiShapes()[2], []string{"ctrl+a"}, []string{"shift+up", "shift+down"}},164+ {"extend-edge/dense-8192x256", uiShapes()[2], []string{"ctrl+shift+right"}, []string{"ctrl+shift+down", "ctrl+shift+up"}},158165 {"extend/dense-8192x26", uiShapes()[1], []string{"shift+down"}, []string{"shift+down", "shift+up"}},159166 {"type/fanin-1000", uiShape{"fanin", func() *sheet.Sheet { return stress.FanIn(sheet.MaxRows, 1000) }},160167 nil, []string{"7", "enter", "up"}},
@@ -246,8 +253,8 @@ func (stressJEV) SystemOne(context.Context, typesafe.SystemOneRequest) (*typesaf
246253 }247254 248255 // BenchmarkJEV loads n JEV.TEST formulas with distinct questions and249-// drains every answer through Update, as the running program would,250-// counting frames drawn along the way at 60 per second.256+// drains every answer through Update, as the running program would.257+// Answers that are queued together (up to jevParallel) recalculate once.251258 func BenchmarkJEV(b *testing.B) {252259 for _, n := range []int{100, 1000, 4000} {253260 b.Run(fmt.Sprint(n), func(b *testing.B) {
@@ -261,6 +268,7 @@ func BenchmarkJEV(b *testing.B) {
261268 s.RecalcAll()262269 m := sized(s, 200, 60)263270 m.EnableJEV(stressJEV{}, cache)271+ m.jev.gather = 0 // answers already queued still gather; don't wait a frame264272 start := time.Now()265273 drain(m, m.jev.send())266274 if in, q := cache.Busy(); in+q > 0 {