Merge pull request #333 from fiddlerwoaroof/fix/felo13-layout-quadratic
perf(layout): O(1) axis index, layout and widths cached across keypresses (improvise-felo.13)
This commit is contained in:
@@ -31,6 +31,11 @@ name = "reshape_observation"
|
||||
harness = false
|
||||
test = false
|
||||
|
||||
[[bench]]
|
||||
name = "scale_bisect"
|
||||
harness = false
|
||||
test = false
|
||||
|
||||
[dependencies]
|
||||
gp-tree = { path = "crates/gp-tree" }
|
||||
improvise-core = { path = "crates/improvise-core" }
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import sys; sys.path.insert(0,'bench')
|
||||
from scale_bisect import run
|
||||
cfgs=[dict(k=k,prune=0) for k in (16,17,18,26,27)]+[dict(k=k,prune=1) for k in (7,11,12,13,14,21,22)]+[dict(k=1000,n=n,mode='records') for n in (1000,1200,1500,1800,2000,4600,14500)]
|
||||
for c in cfgs:
|
||||
c.setdefault('n',100000); c['ops']='open,key,window'
|
||||
v=[run('/tmp/sb',c,timeout=200) for _ in range(3)]
|
||||
f=lambda k:'/'.join(f"{r[k][1]:.1f}" if k in r else '-' for r in v)
|
||||
print({k:c[k] for k in c if k!='ops'},'rows',v[0].get('rows'),'open',f('open'),'key',f('keypress_down'),'win',f('browser_grid_of_top'),'tuiframe',f('tui_frame_grid_of_whole'),flush=True)
|
||||
@@ -0,0 +1,10 @@
|
||||
import sys, json; sys.path.insert(0,'bench')
|
||||
from scale_bisect import run
|
||||
cfgs=[dict(k=9,prune=0),dict(k=10,prune=0),dict(k=17,prune=0,groups=1),dict(k=26,prune=0,groups=1),
|
||||
dict(k=8,prune=1),dict(k=13,prune=1,groups=1),
|
||||
dict(k=1000,n=1300,mode='records'),dict(k=1000,n=4400,mode='records'),dict(k=1000,n=14000,mode='records')]
|
||||
for c in cfgs:
|
||||
c.setdefault('n',100000)
|
||||
for rep in range(3):
|
||||
r=run('/tmp/sb',c,timeout=300)
|
||||
print(json.dumps({'cfg':c,'rep':rep,**r}),flush=True)
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Drive benches/scale_bisect one configuration per process; parse its lines.
|
||||
usage: scale_bisect.py BIN k=.. n=.. [cats=..] [prune=..] [mode=..] [ops=..] [--timeout S]
|
||||
"""
|
||||
import subprocess, sys, re, json
|
||||
|
||||
def run(binary, cfg, timeout=90):
|
||||
args = [binary] + [f"{k}={v}" for k, v in cfg.items()]
|
||||
try:
|
||||
p = subprocess.run(args, capture_output=True, text=True, timeout=timeout)
|
||||
out, status = p.stdout, "ok"
|
||||
except subprocess.TimeoutExpired as e:
|
||||
out, status = (e.stdout or b"").decode() if isinstance(e.stdout, bytes) else (e.stdout or ""), "timeout"
|
||||
res = {"status": status}
|
||||
for line in out.splitlines():
|
||||
if line.startswith("REFUSED"):
|
||||
res["status"] = "refused"
|
||||
m = re.match(r"(\w+) min=([\d.]+) med=([\d.]+) max=([\d.]+)", line)
|
||||
if m:
|
||||
res[m[1]] = (float(m[2]), float(m[3]), float(m[4]))
|
||||
for kv in re.findall(r"(\w+)=([\d.]+)", line):
|
||||
if kv[0] in ("rows", "cols", "maxrss_mb", "improv_mb", "snapshot_kb", "rss_after_open_mb", "cells"):
|
||||
res[kv[0]] = float(kv[1])
|
||||
m = re.match(r"json_encode_setcell_updates ms=(.*)", line)
|
||||
if m:
|
||||
res["json_setcell"] = m[1]
|
||||
return res
|
||||
|
||||
if __name__ == "__main__":
|
||||
binary = sys.argv[1]
|
||||
cfg, timeout = {}, 90
|
||||
rest = sys.argv[2:]
|
||||
for a in rest:
|
||||
if a.startswith("--timeout"):
|
||||
timeout = int(a.split("=")[1])
|
||||
else:
|
||||
k, v = a.split("=", 1)
|
||||
cfg[k] = v
|
||||
print(json.dumps(run(binary, cfg, timeout)))
|
||||
@@ -0,0 +1,10 @@
|
||||
import sys; sys.path.insert(0,'bench')
|
||||
from scale_bisect import run
|
||||
import itertools
|
||||
OPS=sys.argv[1];
|
||||
cfgs=eval(sys.argv[2])
|
||||
cols=sys.argv[3].split(',')
|
||||
for c in cfgs:
|
||||
c=dict(c); c['ops']=OPS
|
||||
r=run('/tmp/sb',c,timeout=int(sys.argv[4]) if len(sys.argv)>4 else 90)
|
||||
print(c, r['status'], 'rows',r.get('rows'), *[f"{k}={r[k][1]:.1f}" if k in r else f"{k}=-" for k in cols], f"rss={r.get('maxrss_mb')}", flush=True)
|
||||
@@ -0,0 +1,347 @@
|
||||
//! One configuration per process: build a sparse document, time the real paths.
|
||||
//! `scale_bisect k=1000 n=100000 cats=3 prune=1 mode=grid`; see context/test-evidence/scale-bisect.md.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::io::Write;
|
||||
use std::time::Instant;
|
||||
|
||||
use improvise::model::cell::{CellKey, CellValue};
|
||||
use improvise::view::Axis;
|
||||
use improvise::workbook::Workbook;
|
||||
use improvise_command::ui::app::App;
|
||||
use improvise_command::ui::effect::{self, ViewTarget};
|
||||
use improvise_command::ui::host::Host;
|
||||
use improvise_command::ui::project::{self, Viewport};
|
||||
use improvise_command::ui::reduce::caught_up;
|
||||
use improvise_io::persistence::{format_md, parse_md, save_to};
|
||||
use improvise_io::storage::MemoryFs;
|
||||
use improvise_protocol::{
|
||||
ClientFrame, KeyCode, KeyEvent, KeyModifiers, ModelCommand, RecordHash, render::RecordCell,
|
||||
};
|
||||
|
||||
fn lcg(s: &mut u64, n: usize) -> usize {
|
||||
*s = s
|
||||
.wrapping_mul(6_364_136_223_846_793_005)
|
||||
.wrapping_add(1_442_695_040_888_963_407);
|
||||
(*s >> 33) as usize % n
|
||||
}
|
||||
|
||||
struct Cfg {
|
||||
k: usize,
|
||||
n: usize,
|
||||
cats: usize,
|
||||
prune: bool,
|
||||
mode: String,
|
||||
groups: bool,
|
||||
ops: String,
|
||||
}
|
||||
|
||||
fn arg(args: &HashMap<String, String>, k: &str, d: &str) -> String {
|
||||
args.get(k).cloned().unwrap_or_else(|| d.to_string())
|
||||
}
|
||||
|
||||
fn document(c: &Cfg) -> (Workbook, Vec<CellKey>) {
|
||||
let mut wb = Workbook::new("bench");
|
||||
for ci in 0..c.cats {
|
||||
let cat = format!("C{ci}");
|
||||
wb.add_category(cat.clone()).expect("under the limit");
|
||||
for i in 0..c.k {
|
||||
if c.groups && ci == 0 {
|
||||
wb.add_item_in_group(&cat, format!("i{i}"), format!("G{}", i / 10))
|
||||
.expect("added");
|
||||
} else {
|
||||
wb.add_item(&cat, format!("i{i}")).expect("added");
|
||||
}
|
||||
}
|
||||
}
|
||||
for m in ["Cost", "Rev"] {
|
||||
wb.add_item("_Measure", m).expect("virtual");
|
||||
}
|
||||
let space = (c.k as f64).powi(c.cats as i32);
|
||||
let wanted = c.n.min((space / 2.0).max(1.0) as usize);
|
||||
let (mut s, mut seen, mut recs) = (0x9E37_79B9_7F4A_7C15u64, HashSet::new(), Vec::new());
|
||||
while seen.len() < wanted {
|
||||
let d: Vec<usize> = (0..c.cats).map(|_| lcg(&mut s, c.k)).collect();
|
||||
if !seen.insert(d.clone()) {
|
||||
continue;
|
||||
}
|
||||
let coords: Vec<(String, String)> = d
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(ci, x)| (format!("C{ci}"), format!("i{x}")))
|
||||
.collect();
|
||||
recs.push(CellKey::new(coords.clone()));
|
||||
for (m, v) in [("Cost", 3.0), ("Rev", 5.0)] {
|
||||
let mut key = coords.clone();
|
||||
key.push(("_Measure".to_string(), m.to_string()));
|
||||
wb.model.set_cell(CellKey::new(key), CellValue::Number(v));
|
||||
}
|
||||
}
|
||||
let view = wb.active_view_mut();
|
||||
for ci in 0..c.cats {
|
||||
let axis = if c.mode == "records" || c.mode == "none" {
|
||||
Axis::None
|
||||
} else {
|
||||
Axis::Row
|
||||
};
|
||||
view.set_axis(&format!("C{ci}"), axis);
|
||||
}
|
||||
if c.mode == "records" {
|
||||
view.set_axis("_Index", Axis::Row);
|
||||
view.set_axis("_Dim", Axis::Column);
|
||||
} else {
|
||||
view.set_axis("_Measure", Axis::Column);
|
||||
}
|
||||
view.prune_empty = c.prune;
|
||||
(wb, recs)
|
||||
}
|
||||
|
||||
fn maxrss_mb() -> f64 {
|
||||
let mut u: libc::rusage = unsafe { std::mem::zeroed() };
|
||||
unsafe { libc::getrusage(libc::RUSAGE_SELF, &mut u) };
|
||||
u.ru_maxrss as f64 / 1048576.0
|
||||
}
|
||||
|
||||
fn rss_mb() -> f64 {
|
||||
let out = std::process::Command::new("ps")
|
||||
.args(["-o", "rss=", "-p", &std::process::id().to_string()])
|
||||
.output()
|
||||
.expect("ps");
|
||||
String::from_utf8_lossy(&out.stdout)
|
||||
.trim()
|
||||
.parse::<f64>()
|
||||
.unwrap_or(0.0)
|
||||
/ 1024.0
|
||||
}
|
||||
|
||||
/// Sample `f` up to `reps` times, stopping once `budget_ms` is spent (one sample minimum).
|
||||
fn sample(name: &str, reps: usize, budget_ms: f64, mut f: impl FnMut(usize)) {
|
||||
let mut v = Vec::new();
|
||||
let start = Instant::now();
|
||||
for r in 0..reps {
|
||||
let t = Instant::now();
|
||||
f(r);
|
||||
v.push(t.elapsed().as_secs_f64() * 1000.0);
|
||||
if start.elapsed().as_secs_f64() * 1000.0 > budget_ms {
|
||||
break;
|
||||
}
|
||||
}
|
||||
v.sort_by(|a, b| a.partial_cmp(b).expect("finite"));
|
||||
println!(
|
||||
"{name} min={:.3} med={:.3} max={:.3} reps={}",
|
||||
v[0],
|
||||
v[v.len() / 2],
|
||||
v[v.len() - 1],
|
||||
v.len()
|
||||
);
|
||||
std::io::stdout().flush().ok();
|
||||
}
|
||||
|
||||
fn once<T>(name: &str, f: impl FnOnce() -> T) -> T {
|
||||
let t = Instant::now();
|
||||
let v = f();
|
||||
println!(
|
||||
"{name} min={0:.3} med={0:.3} max={0:.3} reps=1",
|
||||
t.elapsed().as_secs_f64() * 1000.0
|
||||
);
|
||||
std::io::stdout().flush().ok();
|
||||
v
|
||||
}
|
||||
|
||||
fn key(code: KeyCode) -> KeyEvent {
|
||||
KeyEvent::new(code, KeyModifiers::NONE)
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let args: HashMap<String, String> = std::env::args()
|
||||
.skip(1)
|
||||
.filter_map(|a| {
|
||||
a.split_once('=')
|
||||
.map(|(k, v)| (k.to_string(), v.to_string()))
|
||||
})
|
||||
.collect();
|
||||
let c = Cfg {
|
||||
k: arg(&args, "k", "1000").parse().expect("k"),
|
||||
n: arg(&args, "n", "100000").parse().expect("n"),
|
||||
cats: arg(&args, "cats", "3").parse().expect("cats"),
|
||||
prune: arg(&args, "prune", "1") == "1",
|
||||
mode: arg(&args, "mode", "grid"),
|
||||
groups: arg(&args, "groups", "0") == "1",
|
||||
ops: arg(
|
||||
&args,
|
||||
"ops",
|
||||
"open,key,frame,window,setcell,host,reshape,io",
|
||||
),
|
||||
};
|
||||
let rows_cap: f64 = arg(&args, "rows_cap", "30000000").parse().expect("cap");
|
||||
let product = (c.k as f64).powi(c.cats as i32);
|
||||
if c.mode == "grid" && product > rows_cap {
|
||||
println!(
|
||||
"REFUSED product_rows={product:.3e} (> {rows_cap:.0}: layout would materialize it)"
|
||||
);
|
||||
return;
|
||||
}
|
||||
let want = |o: &str| c.ops.split(',').any(|x| x == o);
|
||||
let (wb, recs) = document(&c);
|
||||
println!(
|
||||
"cells={} records={} base_rss_mb={:.0}",
|
||||
wb.model.data.iter_cells().count(),
|
||||
recs.len(),
|
||||
rss_mb()
|
||||
);
|
||||
let mut app = once("open", || App::new(wb, None));
|
||||
println!(
|
||||
"rows={} cols={} rss_after_open_mb={:.0}",
|
||||
app.layout.row_count(),
|
||||
app.layout.col_count(),
|
||||
rss_mb()
|
||||
);
|
||||
app.term_width = 200;
|
||||
app.term_height = 50;
|
||||
let name = app.view_name().to_string();
|
||||
|
||||
if want("key") {
|
||||
sample("keypress_down", 8, 6000.0, |_| {
|
||||
app.dispatch_key(key(KeyCode::Down));
|
||||
});
|
||||
}
|
||||
if want("frame") {
|
||||
let whole = |app: &App| Viewport {
|
||||
row_offset: 0,
|
||||
col_offset: 0,
|
||||
rows: app.layout.row_count(),
|
||||
cols: app.layout.col_count(),
|
||||
};
|
||||
sample("tui_frame_grid_of_whole", 3, 8000.0, |_| {
|
||||
std::hint::black_box(project::grid_of(&app, whole(&app)));
|
||||
});
|
||||
let cache = project::grid_of(&app, whole(&app));
|
||||
let area = ratatui::layout::Rect::new(0, 0, 200, 50);
|
||||
sample("tui_render_gridwidget_buffer", 5, 4000.0, |_| {
|
||||
let mut buf = ratatui::buffer::Buffer::empty(area);
|
||||
let w = improvise::ui::grid::GridWidget::new(
|
||||
&cache,
|
||||
app.cursor(),
|
||||
&app.view_state.active_view,
|
||||
&app.layout,
|
||||
&app.view_state.mode,
|
||||
&app.view_state.search_query,
|
||||
&app.view_state.buffers,
|
||||
app.drill_state.as_ref(),
|
||||
);
|
||||
ratatui::widgets::Widget::render(w, area, &mut buf);
|
||||
std::hint::black_box(buf);
|
||||
});
|
||||
}
|
||||
if want("window") {
|
||||
let mid = app.layout.row_count() / 2;
|
||||
for (label, off) in [("top", 0), ("mid", mid)] {
|
||||
let vp = Viewport {
|
||||
row_offset: off,
|
||||
col_offset: 0,
|
||||
rows: 45,
|
||||
cols: 12,
|
||||
};
|
||||
sample(&format!("browser_grid_of_{label}"), 5, 6000.0, |_| {
|
||||
std::hint::black_box(project::grid_of(&app, vp));
|
||||
});
|
||||
}
|
||||
}
|
||||
if want("setcell") && !recs.is_empty() {
|
||||
let existing = recs[recs.len() / 2].clone().with("_Measure", "Rev");
|
||||
sample("setcell_existing", 5, 6000.0, |r| {
|
||||
app.apply_effects(vec![Box::new(effect::SetCell(
|
||||
existing.clone(),
|
||||
CellValue::Number(10.0 + r as f64),
|
||||
))]);
|
||||
});
|
||||
let mut s = 99u64;
|
||||
sample("setcell_new_record", 5, 6000.0, |_| {
|
||||
let mut coords: Vec<(String, String)> = (0..c.cats)
|
||||
.map(|ci| (format!("C{ci}"), format!("i{}", lcg(&mut s, c.k))))
|
||||
.collect();
|
||||
coords.push(("_Measure".into(), "Rev".into()));
|
||||
app.apply_effects(vec![Box::new(effect::SetCell(
|
||||
CellKey::new(coords),
|
||||
CellValue::Number(1.0),
|
||||
))]);
|
||||
});
|
||||
}
|
||||
if want("reshape") && c.mode == "grid" {
|
||||
let v = || ViewTarget::Active;
|
||||
sample("reshape_hide_item", 3, 6000.0, |r| {
|
||||
app.apply_effects(vec![Box::new(effect::HideItem {
|
||||
view: v(),
|
||||
category: "C1".into(),
|
||||
item: format!("i{r}"),
|
||||
})]);
|
||||
});
|
||||
if c.groups {
|
||||
sample("reshape_toggle_group", 3, 6000.0, |_| {
|
||||
app.apply_effects(vec![Box::new(effect::ToggleGroup {
|
||||
view: v(),
|
||||
category: "C0".into(),
|
||||
group: "G0".into(),
|
||||
})]);
|
||||
});
|
||||
}
|
||||
let last = format!("C{}", c.cats - 1);
|
||||
sample("reshape_set_axis", 4, 6000.0, |r| {
|
||||
let axis = if r % 2 == 0 { Axis::Page } else { Axis::Row };
|
||||
app.apply_effects(vec![Box::new(effect::SetAxis {
|
||||
view: v(),
|
||||
category: last.clone(),
|
||||
axis,
|
||||
})]);
|
||||
});
|
||||
sample("reshape_toggle_prune", 2, 6000.0, |_| {
|
||||
app.apply_effects(vec![Box::new(effect::TogglePruneEmpty(v()))]);
|
||||
});
|
||||
}
|
||||
if want("io") {
|
||||
let wbr = &app.model_state.workbook;
|
||||
let text = once("format_md", || format_md(wbr));
|
||||
println!("improv_mb={:.2}", text.len() as f64 / 1048576.0);
|
||||
once("parse_md", || parse_md(&text).expect("parses"));
|
||||
let fs = MemoryFs::new();
|
||||
sample("persist_per_command_save_to", 3, 6000.0, |_| {
|
||||
save_to(&fs, wbr, std::path::Path::new("d.improv")).expect("write");
|
||||
});
|
||||
}
|
||||
if want("host") && !recs.is_empty() {
|
||||
let mut host = Host::new(app);
|
||||
let (id, snap) = once("host_connect_snapshot", || host.connect(45, 12));
|
||||
sample("json_encode_snapshot", 3, 4000.0, |_| {
|
||||
std::hint::black_box(serde_json::to_vec(&snap.msg).expect("json"));
|
||||
});
|
||||
println!(
|
||||
"snapshot_kb={:.1}",
|
||||
serde_json::to_vec(&snap.msg).expect("json").len() as f64 / 1024.0
|
||||
);
|
||||
let rec = recs[recs.len() / 2].clone();
|
||||
let cell = RecordCell {
|
||||
record: RecordHash::of(&rec),
|
||||
measure: Some("Rev".into()),
|
||||
};
|
||||
let mut enc: Vec<(f64, usize)> = Vec::new();
|
||||
sample("host_handle_setcell_and_project", 4, 8000.0, |r| {
|
||||
let wire = caught_up(
|
||||
host.app(),
|
||||
ModelCommand::SetCell {
|
||||
view: name.clone(),
|
||||
cell: cell.clone(),
|
||||
value: CellValue::Number(50.0 + r as f64),
|
||||
},
|
||||
);
|
||||
let out = host.handle(id, ClientFrame::command(wire));
|
||||
let t = Instant::now();
|
||||
let bytes: usize = out
|
||||
.iter()
|
||||
.map(|a| serde_json::to_vec(&a.msg).expect("json").len())
|
||||
.sum();
|
||||
enc.push((t.elapsed().as_secs_f64() * 1000.0, bytes));
|
||||
});
|
||||
println!("json_encode_setcell_updates ms={:?}", enc);
|
||||
}
|
||||
println!("maxrss_mb={:.0}", maxrss_mb());
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
# Layout and keypress cost (improvise-felo.13)
|
||||
|
||||
Measured on an **Apple M4 Max, 64 GB**, macOS, release build, with
|
||||
`benches/scale_bisect.rs` (merged from `bench/scale-bisect`, `13918bb9`), one
|
||||
configuration per process, 3 categories of K items on Row, N = 100,000 capped at
|
||||
K^3/2 records, prune off unless stated. Baseline is this branch with only the
|
||||
tests added; "after" is the branch head. `key` is `App::dispatch_key(Down)`;
|
||||
"cold" is the first key after open or after an edit, which pays the one
|
||||
whole-grid measurement; "warm" is every key after it.
|
||||
|
||||
## Before and after
|
||||
|
||||
| rows | before key | after key warm | after key cold | before browser window | after browser window | SetCell before / after |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 729 (K=9) | 5.5 | 0.00 | 1.4 | 5.0 | 0.13 | 0.9 / 0.9 |
|
||||
| 4,913 (K=17) | 108 | 0.00 | 6.0 | 105 | 0.12 | 5.8 / 5.9 |
|
||||
| 5,832 (K=18) | | 0.00 | 7.7 | | 0.12 | 6.8 |
|
||||
| 6,859 (K=19) | | 0.00 | 8.8 | | 0.12 | 8.6 |
|
||||
| 19,683 (K=27) | 1,271 | 0.00 | 24.5 | 1,259 | 0.14 | 23.0 / 22.7 |
|
||||
| 64,000 (K=40) | 10,771 | 0.00 | 84 | 10,746 | 0.20 | 79 / 80 |
|
||||
|
||||
Prune on: K=17 (2,456 rows) key 264 ms -> 0.00 warm, 3.6 cold; prune toggle
|
||||
172 -> 8.8 ms; K=27 prune toggle 2,189 -> 41.5 ms. Records mode (3 x 1000, 14,500
|
||||
records): key 1,004 ms -> 0.00 warm, 2.8 cold; **SetCell 1,511 ms -> 1,512 ms,
|
||||
unchanged** (not this ticket's cost).
|
||||
|
||||
Boundaries, warm key and browser window: inside 8 ms at every size measured (to
|
||||
216,000 rows, 303 ms cold). Cold key: inside 8 ms to K=18 (5,832 rows), over 8 ms at
|
||||
K=19. Before: inside 8 ms to 729 rows, over 100 ms at 4,913, over 1 s at 19,683.
|
||||
The remaining cold cost is linear (1.2-1.4 us per row) and is the whole-grid width
|
||||
and total pass; the warm path does not repeat it.
|
||||
|
||||
## What the ticket's reading got right and what it did not predict
|
||||
|
||||
The O(rows^2) in `cell_key` was real and is one of three causes. Two more sat
|
||||
under it. First, `compute_col_widths` ran on **every** keypress, through
|
||||
`cmd_context`, and `dispatch_key` and `apply_effects` each rebuilt the layout
|
||||
whether or not anything had moved. Second, once the index was O(1) the width pass
|
||||
was still O(K^2) per cell: with `_Index`/`_Dim` on `Axis::None` the aggregation is
|
||||
not `Exact`, so each cell went through `DataStore::matching_values`, which
|
||||
narrows by the smallest index set (about N/K candidates) and probes the rest. A
|
||||
partial as wide as the widest stored cell can match only one cell, so
|
||||
`exact_positions` probes it directly. That second cause is why K=40's cold key
|
||||
stayed at 8 s after the first two fixes.
|
||||
|
||||
### a_cursor_key_rebuilds_neither_the_layout_nor_the_widths
|
||||
|
||||
`ui::app::tests`. Two keys after a warm-up leave `layout_builds` and
|
||||
`width_passes` where they were. Failed before the fix on the layout count (a
|
||||
cursor key rebuilt it). Rival 1 (`refresh_layout` always rebuilding): fails on
|
||||
`layout rebuilt on a cursor key`. The test seats the cursor at row 2, so "the
|
||||
keys moved the cursor" is asserted and a key that does nothing cannot pass.
|
||||
|
||||
### an_edit_rebuilds_and_remeasures_so_the_widths_follow_the_value
|
||||
|
||||
The control for the test above, and the guard on invalidation. A `SetCell` of a
|
||||
long text must rebuild the layout and remeasure, and the cached widths must equal
|
||||
a fresh `compute_col_widths`. Rival 2 (`rebuild_layout` not dropping the cached
|
||||
geometry): fails on `an edit must remeasure`. It also asserts the widths changed,
|
||||
so a fixture where the edit does not widen a column cannot pass.
|
||||
|
||||
### switching_view_rebuilds_though_the_effect_is_view_only
|
||||
|
||||
`SwitchView` is `ViewOnly` (gotcha 3), so skipping the rebuild by `EffectKind`
|
||||
alone would leave the old view's layout. The layout records the view it drew and
|
||||
the drill snapshot it froze, and rebuilds when either differs.
|
||||
|
||||
### an_axis_lookup_does_work_independent_of_the_row_index
|
||||
|
||||
`view::layout::lookup_cost_tests`. Counts the items `data_items` yields during
|
||||
`cell_key`, `row_tuple`, `row_label` and `column_key`; row 399 of 400 must cost no
|
||||
more than row 0 plus two. Failed before the fix: `cell_key: row 399 cost 402 steps
|
||||
against 3 at row 0`. Rival (`cell_key` going back to `data_items(..).nth(row)`):
|
||||
`cost 400 steps against 1 at row 0`. Limit: the counter lives in `data_items`, so
|
||||
a different linear walk written without it is not seen; the next test is what
|
||||
holds the index to the walk's answers.
|
||||
|
||||
### the_position_index_answers_what_the_walk_answered
|
||||
|
||||
Compares `row_tuple`, `data_row_to_visual` and `row_folds_a_group` against the
|
||||
walk over a grouped, collapsed and pruned axis, including two indices past the
|
||||
end. Rival (`data_index_to_visual` reading `data_idx + 1`): fails at `visual 0`.
|
||||
|
||||
### a_full_width_partial_probes_one_cell_instead_of_narrowing
|
||||
|
||||
`model::cell::exact_path_tests`. A full-width partial must not reach set
|
||||
narrowing, and an absent cell and an unseen item must match nothing. Rival
|
||||
(`exact_positions` returning `None`): the narrowing count is 1, not 0. Rival
|
||||
(`widest <= partial.len() + 1`): fails `a_wider_cell_turns_the_shortcut_off_until_it_is_removed`,
|
||||
which stores a cell one coordinate wider and expects it matched.
|
||||
`a_narrower_partial_still_narrows_and_matches_every_superset` is the control: the
|
||||
shortcut is not a blanket exact lookup. `an_unsorted_full_width_partial_is_still_answered`
|
||||
holds the canonical-order condition: a partial not in a stored key's order falls
|
||||
back.
|
||||
|
||||
### a_window_over_a_held_summary_equals_the_one_walk_grid_body
|
||||
|
||||
`ui::viewport::tests`. The host keeps one `GridSummary` (widths, total formats,
|
||||
totals) per layout and reads a window over it with `grid_window`; it must equal
|
||||
what `grid_body` builds for the same window, over the whole grid, an interior
|
||||
window, the last row and the empty window. Rival (`grid_window` reading column
|
||||
`ci + 1`): fails at `window 0..2 x 0..10`. Widths stay the **whole-grid** maximum,
|
||||
because the summary is the same walk `grid_body` did; nothing the user sees moves.
|
||||
|
||||
### a_frozen_records_snapshot_replaces_the_live_row_index
|
||||
|
||||
`view::layout::lookup_cost_tests`. Three records are frozen, two are then cleared,
|
||||
and `with_frozen_records` over the snapshot must draw 3 rows (the control: with no
|
||||
snapshot it draws 1). A drill held open while another client removes records is the
|
||||
production analogue. Rival (deleting `layout.reindex()` after the snapshot swap):
|
||||
fails `left: 1, right: 3`; the whole workspace passed it before this test.
|
||||
|
||||
### a_drill_snapshot_replaced_alone_rebuilds_the_layout
|
||||
|
||||
`ui::app::tests`. `StartDrill` is `ViewOnly`, so a batch of it alone has no `Model`
|
||||
effect to mark the layout stale; the drill pointer in `LayoutBasis` is the only
|
||||
thing that rebuilds it. The test starts one snapshot, then a second: the first
|
||||
step alone passes even with a constant pointer (drill absent to present changes the
|
||||
basis), which is why the second step is the test. Rival (pointer replaced by a
|
||||
constant): fails `no rebuild for a second drill snapshot`.
|
||||
|
||||
### Observation: the TUI frame
|
||||
|
||||
`tui_frame_grid_of_whole` is still 47-53 ms at 19,683 rows after this change: the
|
||||
draw path projects the whole grid, linear and out of scope for this ticket.
|
||||
@@ -0,0 +1,218 @@
|
||||
# Scale bisect: how big a document does improvise handle today (improvise-felo.10)
|
||||
|
||||
Measured on `gh/main` @ 8220a065, **Apple M4 Max, 64 GB** (`sysctl`: `hw.memsize 68719476736`), macOS,
|
||||
release build (`lto`, `opt-level 3`), native only (no wasm, no real OPFS, no terminal).
|
||||
Harness: `benches/scale_bisect.rs` (one configuration per process) driven by `bench/scale_bisect.py`,
|
||||
`bench/sweep.py`, `bench/edge.py`, `bench/full.py`. Reproduce:
|
||||
`nix develop --command cargo build --release --bench scale_bisect`, copy the binary from
|
||||
`target/release/deps/scale_bisect-*` to `/tmp/sb`, then `python3 bench/edge.py`.
|
||||
|
||||
Budgets (user ruling 2026-10-03): **8 ms is the headline** (120 FPS, hot path or defer to background);
|
||||
16 ms, 100 ms (noticeable) and 1 s (broken) are reference crossings.
|
||||
|
||||
## Method, and what is real
|
||||
|
||||
Every number goes through the production path: `App::new` (open: formula recompute +
|
||||
`GridLayout::with_frozen_records`), `App::dispatch_key(Down)` (**keypress/scroll**: it calls
|
||||
`rebuild_layout`, then `cmd_context`, which runs `compute_col_widths` over the whole grid, then applies effects),
|
||||
`project::grid_of` (TUI frame: whole grid; browser: a 45x12 window), `App::apply_effects` with `effect::SetCell`,
|
||||
`SetAxis`/`HideItem`/`ToggleGroup`/`TogglePruneEmpty` (reshape), `Host::connect` and `Host::handle` with a
|
||||
`caught_up` `ModelCommand::SetCell` (browser path incl. projection), `format_md`/`parse_md`/`save_to(MemoryFs)`.
|
||||
Document: C0..C{cats-1} with K items, N distinct random tuples ("records", capped at K^cats/2), two measures
|
||||
each (2N cells), `_Measure` on Column. Timings are the **median** of up to 3-8 in-process repeats; boundary
|
||||
rows are the median of **3 separate processes** run sequentially (spread shown). Earlier exploratory sweeps
|
||||
ran 2-3 processes in parallel and agree with the sequential runs within ~5%.
|
||||
|
||||
**`View::prune_empty` defaults to false** (`view/types.rs`), so "prune off" is what a user gets until they
|
||||
toggle it; "prune on" is reported next to it. Rows below are the layout's `row_count`.
|
||||
|
||||
**Dominant cost (reasoned, not profiled):** the release profile strips symbols, so `sample` could not
|
||||
symbolicate. The reason is in the code and the timings agree with it: `GridLayout::cell_key`/`row_item` do
|
||||
`data_items(&self.row_items).nth(row)` (`view/layout.rs` ~253/292/807/870), O(row) per lookup, and
|
||||
`compute_col_widths` (every keypress, via `cmd_context`) and `grid_of` visit every cell, so both are
|
||||
O(rows^2). Evidence: with prune off, rows 729 -> 5.4 ms, 4096 -> 74.6 ms, 19683 -> 1248 ms (x6.7 rows ->
|
||||
x14 time -> x17 time). A 45x12 browser window costs **the same** as the whole-grid TUI frame (K=30:
|
||||
2274 vs 2298 ms) because the widths pass is whole-grid whatever the viewport says, so viewport narrowing buys
|
||||
nothing today. `GridLayout` build itself is cheap (~90 ns/row, ADR 008).
|
||||
|
||||
## Attribution: state+projection vs render/serialize (K=10, 1000 rows, prune off; 3 runs)
|
||||
|
||||
| step | ms |
|
||||
|---|---|
|
||||
| keypress (state: rebuild + widths + effect) | 9.5-9.7 |
|
||||
| `grid_of` whole grid (TUI projection) | 11.7-12.0 |
|
||||
| `GridWidget` render into a 200x50 ratatui `Buffer` | 0.12 |
|
||||
| `grid_of` 45x12 window (browser projection) | 9.1-9.3 |
|
||||
| JSON encode of one SetCell's projected updates | 0.001-0.005 (0.6-1.1 KB) |
|
||||
| JSON encode of the connect Snapshot | 0.02 (21 KB) |
|
||||
|
||||
Render and serialize are **<=0.5 ms in every row measured** (GridWidget 0.08-0.53 ms up to 14,500 records;
|
||||
snapshot encode <=0.07 ms; records-mode SetCell updates 21 KB in 0.02 ms). The 8 ms is entirely state and
|
||||
projection. Not measured: the rest of `draw()` (panels, status; `draw` is private to the binary), terminal I/O,
|
||||
browser JSON *decode* and DOM paint.
|
||||
|
||||
## Axis B: items per category K, 3 categories on Row, N = 100k (capped at K^3/2), prune OFF
|
||||
|
||||
| K | rows | open | keypress | browser window | SetCell | maxrss MB |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 5 | 125 | 0.1 | 0.5 | 0.5 | 0.2 | 8 |
|
||||
| 9 | 729 | 0.2 | **5.4** (5.3-5.5) | 5.0 | 0.9 | 13 |
|
||||
| 10 | 1,000 | 0.2 | **9.6** (9.5-9.7) | 9.2 | 1.2 | 14 |
|
||||
| 11 | 1,331 | 0.3 | 12.7 | 12.7 | 1.6 | 10 |
|
||||
| 12 | 1,728 | 0.3 | 20.0 | 19.3 | 2.1 | 11 |
|
||||
| 15 | 3,375 | 0.5 | 65 | 64 | 4.0 | 14 |
|
||||
| 16 | 4,096 | 0.6 | 74.6 (74.3-75.2) | 73.4 | - | - |
|
||||
| 17 | 4,913 | 0.7 | **105** (105.0-105.5) | 103 | - | - |
|
||||
| 20 | 8,000 | 1.1 | 302 | 301 | 9.4 | 21 |
|
||||
| 25 | 15,625 | 1.9 | 773 | 771 | 18.8 | 33 |
|
||||
| 26 | 17,576 | 2.2 | 967-977 | 962 | - | - |
|
||||
| 27 | 19,683 | 2.4 | **1,248** (1,244-1,251) | 1,244 | - | - |
|
||||
| 30 | 27,000 | 3.1 | 2,262 | 2,274 | 31 | 49 |
|
||||
| 40 | 64,000 | 6.7 | 10,858 | 10,833 | 73 | 235 |
|
||||
| 60 | 216,000 | 21.6 | timeout (>60 s) | - | - | - |
|
||||
| 1000 | 10^9 | **refused** (see below) | | | | |
|
||||
|
||||
Hot-path keypress/scroll and browser window: **largest inside 8 ms: K=9 (729 rows)**; inside 16 ms: K=11;
|
||||
first over 100 ms: **K=17 (4,913 rows)**; first over 1 s: **K=27 (19,683 rows)**. SetCell alone
|
||||
(no widths pass; layout rebuild only, ~linear in rows): inside 8 ms to K~19 (K=15 4.0, K=20 9.4), 16 ms ~K=24,
|
||||
100 ms ~K=45 (K=40 = 73 ms), 1 s extrapolated ~K=95 (**extrapolated, not measured**). Open stays <25 ms to K=60.
|
||||
|
||||
**3 x 1,000 on Row cannot be opened at any N**: the layout materializes the cross product (10^9 rows, ~150 B
|
||||
each = ~150 GB, ADR 008). The harness refuses above 30M rows (`rows_cap`) rather than swap the machine, so
|
||||
the 10^9 number is the ADR's estimate, not a measurement here.
|
||||
|
||||
### Prune ON (same documents)
|
||||
|
||||
| K | rows | open | keypress | browser window | SetCell |
|
||||
|---|---|---|---|---|---|
|
||||
| 7 | 171 | 2.0 | 4.5 (4.4-4.6) | 1.1 | 5.4 |
|
||||
| 8 | 256 | 3.0 | **7.0** (7.0-7.1) | 1.6 | 8.5 |
|
||||
| 9 | 364 | 5.3 | 12.8 | 2.7 | 15.3 |
|
||||
| 11 | 665 | 12.2 | 29.6 | 6.2 | ~28 |
|
||||
| 13 | 1,098 | 29.0 | 71 | 14.5 | ~70 |
|
||||
| 14 | 1,372 | 48 | **119** | 24 | - |
|
||||
| 20 | 4,000 | 300 | 747 | 145 | 903 |
|
||||
| 21 | 4,630 | 401 | 968-992 | 190 | - |
|
||||
| 25 | 7,812 | 767 | 1,899 | 366 | 2,328 |
|
||||
| 30 | 13,500 | 2,255 | 5,555 | 1,047 | 6,804 |
|
||||
|
||||
Prune on: keypress inside 8 ms to **K=8**, first over 100 ms **K=14**, ~1 s at **K~21-22** (K=22 measured
|
||||
0.93-0.95 s, a different N cap; first clearly over: K=25). Pruning shrinks the grid, so the browser window is
|
||||
cheaper than prune off at the same K (K=20: 145 ms against 302 ms), but **every keypress re-runs
|
||||
the prune** (`dispatch_key` calls `rebuild_layout`), so keypress and SetCell are *worse* with prune on
|
||||
(K=10: 23 vs 9.9 ms). Prune is also quadratic (`nth`). Conclusion: prune on/off moves the boundary by about
|
||||
one K at 8 ms and makes 100 ms/1 s *earlier* (K=14/21 vs 17/27); it never rescues the 3x1000 case.
|
||||
|
||||
## Axis A: records N at fixed K (K=30, 27,000 rows)
|
||||
|
||||
| N | prune off keypress | prune on rows | prune on keypress | prune on window |
|
||||
|---|---|---|---|---|
|
||||
| 100 | 451 | 100 | 861 | 0.3 |
|
||||
| 1,000 | 527 | 1,000 | 1,059 | 6.4 |
|
||||
| 5,000 | 1,013 | 5,000 | 2,178 | 140 |
|
||||
| 13,500 (cap) | 2,267 | 13,500 | 5,555 | 1,047 |
|
||||
|
||||
Prune off: ~450 ms of widths over 27k empty rows + ~1.3 us per stored cell; **N never gets the keypress
|
||||
under 100 ms at K=30**, and at K=1000 N is irrelevant because the layout cannot be built (rows = K^3).
|
||||
The N-axis has no bisect point in the 3x1000 regime; N only matters once K is small enough that rows fit, and
|
||||
there it is a second-order term. Open is flat (3.1-3.6 ms) in N with prune off.
|
||||
|
||||
## Axis C: categories on Row (prune off)
|
||||
|
||||
| K | cats | rows | open | keypress | note |
|
||||
|---|---|---|---|---|---|
|
||||
| 10 | 2 | 100 | 0.1 | 0.2 | |
|
||||
| 10 | 3 | 1,000 | 0.2 | 9.9 | over 8 ms |
|
||||
| 10 | 4 | 10,000 | 1.4 | **880** | ~1 s |
|
||||
| 10 | 5 | 100,000 | 14 | timeout >100 s | |
|
||||
| 3 | 6 | 729 | 0.3 | 27.9 | 5x worse than K=9 x 3 cats at the same rows |
|
||||
| 3 | 8 | 6,561 | 2.0 | 2,798 | |
|
||||
| 3 | 10-12 | 59k-531k | 18-210 | timeout | open alone passes 100 ms at 11 cats |
|
||||
| 2 | 10 | 1,024 | 0.6 | 172 | |
|
||||
| 2 | 12 | 4,096 | 2.2 | 3,190 | |
|
||||
| 2 | 14 | 16,384 | 9 | 63,100 | |
|
||||
|
||||
The cap of 24 is unreachable: rows = product of items, so a view with c Row categories of K items dies by
|
||||
K^c ~ 10^4 rows (1 s) regardless of N, and with two items per category by 12-14 categories. Per-row cost
|
||||
also grows with category count (each row_item is a Vec of c items). Not measured: Row categories with prune
|
||||
on, other axes absorbing categories (the realistic mitigation: only 1-2 on Row).
|
||||
|
||||
## Axis D: records mode over N records (`with_frozen_records`, the improvise-sk32 quadratic)
|
||||
|
||||
| N | open | keypress | browser window | SetCell (apply) | host SetCell+project | maxrss MB |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 300 | 1.5 | 0.7 | 0.1 | 1.6 | - | 10 |
|
||||
| 1,000 | 2.9 | 5.6 (5.5-5.7) | 0.3 | 10 | - | 12 |
|
||||
| 1,200 | 3.9 | **7.9** (7.7-8.0) | 0.3 | - | - | - |
|
||||
| 1,300 | 4.5 | 9.3 | 0.3 | 15.5 | 17 | 26 |
|
||||
| 1,800 | 8.4 | 16.5 | 0.4 | - | - | - |
|
||||
| 3,000 | 22.9 | 47 | 0.7 | 74 | - | 20 |
|
||||
| 4,400-4,600 | 47-51 | **95-102** | 0.9 | 149 | 158 | 55 |
|
||||
| 10,000 | 248 | 492 | 1.8 | 734 | - | 46 |
|
||||
| 14,000-14,500 | 472-500 | **940-1,002** | 2.6-2.8 | 1,435 | 1,459 | 135-148 |
|
||||
| 30,000 | 2,198 | 4,314 | 5.6 | 6,467 | - | 125 |
|
||||
| 100,000 | **25,420** | **51,234** | 19.3 | not reached (killed at 100 s) | - | not reached |
|
||||
|
||||
Records mode, keypress/scroll: inside 8 ms **to N~1,200**; 16 ms N~1,800; 100 ms N~4,500 (first over: 4,600 =
|
||||
102 ms); 1 s N~14,500. Open crosses 8 ms ~1,750, 16 ms ~2,300, 100 ms ~6,500, 1 s ~20,000. Time ~ N^1.95.
|
||||
SetCell is ~1.5x keypress (it rebuilds and re-groups). The *browser window* is cheap (<20 ms even at 100k)
|
||||
because records mode has two columns and the width walk is short, so the 51 s is `rebuild_layout`, not
|
||||
projection. Prune on/off does not change records mode (prune applies to pivoted axes; the sweep ran off).
|
||||
Records per KB: ~4 KB/record process-wide at 14k (maxrss includes the document generator, so read as an
|
||||
upper bound); the 100k memory was not obtained.
|
||||
|
||||
## Axis E: save / load and per-command persistence
|
||||
|
||||
Mode `none` (all categories off-axis, so layout is trivial and only the store scales), 3 x 1,000, N records
|
||||
= 2N cells:
|
||||
|
||||
| cells | format_md | text MB | parse_md | persist per accepted command (`save_to` MemoryFs) | maxrss MB |
|
||||
|---|---|---|---|---|---|
|
||||
| 2,000 | 13.8 | 0.1 | 4.5 | 12.4 | 18 |
|
||||
| 20,000 | 156 | 0.86 | 37 | 161 | 80 |
|
||||
| 60,000 | 527 | 2.5 | 119 | 539 | 236 |
|
||||
| **200,000 (the target)** | **1,974** | 8.4 | **401** | **1,984** | 663 |
|
||||
| 600,000 | 6,470 | 25 | 1,320 | 6,460 | 1,894 |
|
||||
| 2,000,000 | 23,120 | 84 | 4,668 | 23,088 | 5,611 |
|
||||
|
||||
Linear: ~10 us/cell to format, ~2 us/cell to parse. Crossings, format_md: 8 ms ~700 cells, 16 ms ~1.5k,
|
||||
100 ms ~11k, 1 s ~100k cells. parse_md: 8 ms ~4k cells, 100 ms ~50k, 1 s ~450k. **At the target (200k cells)
|
||||
every accepted command in the browser worker costs ~2 s of format alone** before OPFS write time (not
|
||||
measured: MemoryFs only). Persist per command also appears in smaller documents inside the grid axes
|
||||
(K=10 grid: 5.5 ms; records N=4,400: 63 ms).
|
||||
|
||||
## Other operations (3 runs each; "grouped" fixtures start with C0 groups collapsed, so rows are not K^3)
|
||||
|
||||
Reshape (prune off, K=10, 1000 rows): hide item 0.25 ms, set axis 0.19 ms, **toggle prune 12.7 ms**.
|
||||
K=26 grouped, 2,028 rows: hide 0.5, toggle group 1.8, set axis 1.8, **toggle prune 727-750 ms**.
|
||||
Prune on K=13 grouped, 321 rows: hide 9.4, toggle group 37, set axis 37, toggle prune 37-39 ms.
|
||||
`Host::connect` snapshot: K=10 6.5 ms, K=26 375 ms, records N=14,000 2.9 ms. Host SetCell+project: K=10 7.7 ms,
|
||||
K=26 ~400 ms, records N=4,400 158 ms (equal to apply + a whole-grid width walk).
|
||||
|
||||
## Classification and feasibility of yielding
|
||||
|
||||
| operation | class | today's first size over 8 ms | dominated by | chunked/yielding form |
|
||||
|---|---|---|---|---|
|
||||
| keypress / scroll | **hot (<=8 ms)** | 1,000 rows (K=10) / N=1,300 records | `rebuild_layout` + whole-grid `compute_col_widths` (O(rows^2) `nth`), recomputed though nothing changed | not a slicing problem: cache widths/layout across keys, O(1) row lookup (ADR 008/009). Slicing would only hide it |
|
||||
| browser window projection | **hot** | 1,000 rows | same width walk, whole grid regardless of window | needs the same fix; then window cost is O(window) |
|
||||
| SetCell apply + project | **hot** | K~19 (prune off), 1,300 records | layout rebuild O(rows) + widths + projection | incremental update of one row/total, not a rebuild |
|
||||
| column totals | hot | inside `grid_of` (not separable here) | whole-grid sum in the projection | maintained total (ADR 009) |
|
||||
| open / layout build | deferrable | 40k rows (prune off); N~1,750 (records) | build is ~90 ns/row (sliceable by outer index); the prune/records grouping is the quadratic part | build is feasible in slices once the quadratic is removed; the quadratic itself is not sliceable |
|
||||
| reshape (hide/collapse/axis) | deferrable | cheap prune-off (<2 ms to ~2k rows); 37 ms prune-on at 321 rows | layout rebuild + prune | one sort/remap at 100k occupied tuples was 2.3 ms in ADR 008's prototype, so a reshape can fit inline; prune toggle needs the quadratic fixed first |
|
||||
| save (format_md) | deferrable | ~700 cells | per-cell formatting, linear | the Data section is a flat per-cell line stream, so chunking by cell range with a yield every ~400 cells looks feasible, but needs a consistent snapshot (clone or journal); not profiled by function |
|
||||
| load (parse_md) | deferrable | ~4k cells | pest parse + tree walk, linear | tree walk is chunkable by line; the pest parse of one text is not, unless the Data section is parsed line by line (grammar is line oriented); not profiled by function |
|
||||
| persistence per command | deferrable (debounce) | ~700 cells | the whole-document `format_md` | coalesce/debounce or append-journal: formatting 2 s per command at target is unusable inline at any slicing |
|
||||
|
||||
## What I could not measure
|
||||
|
||||
* wasm: all numbers are native; the browser worker is typically slower. Real OPFS I/O, JSON *decode* and
|
||||
DOM paint, the full `draw()` frame, and Safari are not covered.
|
||||
* Records mode at 100k: SetCell, host and RSS (killed after open and keypress, ~76 s).
|
||||
* Per-function profile: symbols are stripped in `[profile.release]`; attribution is reasoned from the
|
||||
code and the timing exponents, not a flame graph.
|
||||
* SetCell 100 ms/1 s for prune off past K=40 are extrapolations.
|
||||
* Memory (`maxrss`) includes the generator's hash set and `recs` vector; it is an upper bound on the
|
||||
document. Layout cost ~150 B/row per ADR 008, consistent with K=40 (64k rows, 235 MB peak with prune off
|
||||
including the generator and a second layout during rebuild).
|
||||
* Noise: boundary rows are 3 sequential processes, spread <=3% (e.g. K=17: 105.0-105.5 ms). Non-boundary
|
||||
rows are single runs, some taken with 2-3 concurrent processes on a 16+ core machine; treat them as +-5-10%.
|
||||
@@ -24,7 +24,7 @@ use crate::persistence;
|
||||
use crate::ui::effect::{EffectKind, ModelChange, ViewTarget};
|
||||
use crate::ui::project::Dirty;
|
||||
use crate::ui::viewport::{
|
||||
compute_col_widths, compute_row_header_width, compute_visible_cols, grid_width,
|
||||
GridSummary, compute_row_header_width, compute_visible_cols, grid_summary, grid_width,
|
||||
};
|
||||
use crate::view::{GridLayout, View};
|
||||
use crate::workbook::Workbook;
|
||||
@@ -434,6 +434,20 @@ impl Default for ModelState {
|
||||
}
|
||||
}
|
||||
|
||||
/// What a layout was built from other than the model.
|
||||
#[derive(Debug, Default, PartialEq, Eq)]
|
||||
struct LayoutBasis {
|
||||
view: String,
|
||||
/// The address of the frozen records, never dereferenced.
|
||||
drill: Option<usize>,
|
||||
}
|
||||
|
||||
/// What the keypress path measures of a built layout.
|
||||
pub(crate) struct GridGeometry {
|
||||
pub summary: GridSummary,
|
||||
pub row_header_width: u16,
|
||||
}
|
||||
|
||||
/// Top-level application state: `model_state`, `view_state`, and a residue of
|
||||
/// fields tagged below. The five residue tags are in `context/code/command.md`.
|
||||
pub struct App {
|
||||
@@ -453,8 +467,21 @@ pub struct App {
|
||||
pub term_width: u16,
|
||||
pub term_height: u16,
|
||||
/// **Derived cache**: a pure function of the slices, rebuilt by
|
||||
/// `rebuild_layout()`. Owned by `App` because the rebuild is the host's job.
|
||||
/// `rebuild_layout()`; a writer that bypasses effects must call it (felo.13).
|
||||
pub layout: GridLayout,
|
||||
/// How many times `rebuild_layout` has run: what a keypress that changes
|
||||
/// neither the model nor the view must leave alone (improvise-felo.13).
|
||||
layout_builds: usize,
|
||||
/// How many times the grid's column widths were measured, for the same reason.
|
||||
width_passes: std::cell::Cell<usize>,
|
||||
/// The widths of the layout above, measured on first use and dropped with it.
|
||||
geometry: std::cell::OnceCell<GridGeometry>,
|
||||
/// What the layout above was built from besides the model: the view it drew
|
||||
/// and the drill snapshot it froze. `layout_current` compares it.
|
||||
layout_basis: LayoutBasis,
|
||||
/// The model changed since the layout was built. Set by the one writer that
|
||||
/// does not rebuild at once.
|
||||
layout_stale: bool,
|
||||
/// **Transient**: set during `apply_effects` to skip the rest of the batch,
|
||||
/// reset at the start of every call. Reach it through `AbortChain`.
|
||||
pub abort_effects: bool,
|
||||
@@ -553,6 +580,11 @@ impl App {
|
||||
term_width: 80,
|
||||
term_height: 24,
|
||||
layout,
|
||||
layout_builds: 0,
|
||||
width_passes: std::cell::Cell::new(0),
|
||||
geometry: std::cell::OnceCell::new(),
|
||||
layout_basis: LayoutBasis::default(),
|
||||
layout_stale: false,
|
||||
abort_effects: false,
|
||||
batch_aborted: false,
|
||||
batch_refused: false,
|
||||
@@ -566,6 +598,7 @@ impl App {
|
||||
keymap_set: KeymapSet::default_keymaps(),
|
||||
storage,
|
||||
};
|
||||
app.layout_basis = app.current_layout_basis();
|
||||
app.settle_normal_mode();
|
||||
app
|
||||
}
|
||||
@@ -610,6 +643,7 @@ impl App {
|
||||
self.view_state.view_back_stack.clear();
|
||||
self.view_state.view_forward_stack.clear();
|
||||
self.model_state.workbook = workbook;
|
||||
self.layout_stale = true;
|
||||
// Every client's mirror is scoped to the outgoing document too, and an
|
||||
// effect cannot reach the session list. The generation is how it hears.
|
||||
self.document_generation += 1;
|
||||
@@ -900,6 +934,42 @@ impl App {
|
||||
let frozen = self.drill_state.as_ref().map(|s| Rc::clone(&s.records));
|
||||
self.layout =
|
||||
GridLayout::with_frozen_records(&self.model_state.workbook.model, view, frozen);
|
||||
self.layout_builds += 1;
|
||||
self.layout_stale = false;
|
||||
self.layout_basis = self.current_layout_basis();
|
||||
self.geometry = std::cell::OnceCell::new();
|
||||
}
|
||||
|
||||
/// Rebuild only if the model, the view drawn or the drill snapshot moved; a
|
||||
/// cursor key moves none of them (layout-cost.md).
|
||||
fn refresh_layout(&mut self) {
|
||||
if self.layout_stale || self.layout_basis != self.current_layout_basis() {
|
||||
self.rebuild_layout();
|
||||
}
|
||||
}
|
||||
|
||||
fn current_layout_basis(&self) -> LayoutBasis {
|
||||
LayoutBasis {
|
||||
view: self.view_name().to_string(),
|
||||
drill: self
|
||||
.drill_state
|
||||
.as_ref()
|
||||
.map(|d| Rc::as_ptr(&d.records) as usize),
|
||||
}
|
||||
}
|
||||
|
||||
/// The measurements a keypress needs of the grid: column widths and the row
|
||||
/// header. Counted, so a test can see whether a keypress paid for them.
|
||||
pub(crate) fn grid_geometry(&self) -> &GridGeometry {
|
||||
self.geometry.get_or_init(|| {
|
||||
self.width_passes.set(self.width_passes.get() + 1);
|
||||
let model = &self.model_state.workbook.model;
|
||||
let plan = FormatPlan::of(model, self.view());
|
||||
GridGeometry {
|
||||
summary: grid_summary(model, &self.layout, &plan),
|
||||
row_header_width: compute_row_header_width(&self.layout),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Editable text for the cell under the cursor: staged drill edit, else the
|
||||
@@ -980,12 +1050,10 @@ impl App {
|
||||
display_value: self.display_value_at_cursor(),
|
||||
visible_rows: (self.term_height as usize).saturating_sub(8),
|
||||
visible_cols: {
|
||||
let model = &self.model_state.workbook.model;
|
||||
let col_widths = compute_col_widths(model, layout, &FormatPlan::of(model, view));
|
||||
let row_header_width = compute_row_header_width(layout);
|
||||
let geometry = self.grid_geometry();
|
||||
compute_visible_cols(
|
||||
&col_widths,
|
||||
row_header_width,
|
||||
&geometry.summary.col_widths,
|
||||
geometry.row_header_width,
|
||||
self.grid_width(),
|
||||
cursor.col_offset,
|
||||
)
|
||||
@@ -1116,7 +1184,8 @@ impl App {
|
||||
// is read only behind the epoch gate — `context/code/command.md`.
|
||||
let reshaped = self.last_reshaped_views.clone();
|
||||
self.last_reframed_views.extend(reshaped);
|
||||
self.rebuild_layout();
|
||||
self.layout_stale |= touches_model;
|
||||
self.refresh_layout();
|
||||
// After the rebuild, or a batch that crosses into or out of records mode
|
||||
// settles against the grid it started on — `context/code/command.md`.
|
||||
self.settle_normal_mode();
|
||||
@@ -1196,7 +1265,7 @@ impl App {
|
||||
/// Resolve one keypress through the keymap and apply what it produces. The
|
||||
/// projection half is factored out; `ui::reduce` is the named door (improvise-gxi).
|
||||
pub fn dispatch_key(&mut self, key: KeyEvent) -> Dirty {
|
||||
self.rebuild_layout();
|
||||
self.refresh_layout();
|
||||
|
||||
// An armed prefix key takes priority. Resolve it back to its
|
||||
// sub-keymap through the keymap set (improvise-jc8).
|
||||
@@ -1594,6 +1663,32 @@ mod tests {
|
||||
app
|
||||
}
|
||||
|
||||
/// improvise-felo.13: `StartDrill` is `ViewOnly`, so a batch of it alone moves
|
||||
/// the layout with no `Model` effect to mark it stale. Evidence: layout-cost.md.
|
||||
#[test]
|
||||
fn a_drill_snapshot_replaced_alone_rebuilds_the_layout() {
|
||||
let mut app = records_app_for_drill();
|
||||
assert_eq!(app.layout.row_count(), 3);
|
||||
let rows: Vec<_> = app.layout.records.as_ref().unwrap().to_vec();
|
||||
let start = |app: &mut App, n: usize| {
|
||||
let snapshot = rows.iter().take(n).cloned().collect();
|
||||
app.apply_effects(vec![Box::new(crate::ui::effect::StartDrill(snapshot))]);
|
||||
};
|
||||
start(&mut app, 1);
|
||||
assert_eq!(
|
||||
app.layout.row_count(),
|
||||
1,
|
||||
"the layout draws the frozen snapshot"
|
||||
);
|
||||
let builds = app.layout_builds;
|
||||
start(&mut app, 2);
|
||||
assert!(
|
||||
app.layout_builds > builds,
|
||||
"no rebuild for a second drill snapshot"
|
||||
);
|
||||
assert_eq!(app.layout.row_count(), 2);
|
||||
}
|
||||
|
||||
/// The opening basis may be read off `App::layout` only when no drill froze
|
||||
/// it. Evidence: `context/test-evidence/command.md`.
|
||||
#[test]
|
||||
@@ -1898,6 +1993,71 @@ mod tests {
|
||||
App::new(wb, None)
|
||||
}
|
||||
|
||||
/// improvise-felo.13: context/test-evidence/layout-cost.md.
|
||||
#[test]
|
||||
fn a_cursor_key_rebuilds_neither_the_layout_nor_the_widths() {
|
||||
let mut app = two_col_model();
|
||||
let down = || KeyEvent::new(KeyCode::Down, KeyModifiers::NONE);
|
||||
app.dispatch_key(down());
|
||||
let (builds, widths) = (app.layout_builds, app.width_passes.get());
|
||||
app.dispatch_key(down());
|
||||
app.dispatch_key(down());
|
||||
assert_eq!(app.cursor().selected.0, 2, "the keys moved the cursor");
|
||||
assert_eq!(app.layout_builds, builds, "layout rebuilt on a cursor key");
|
||||
assert_eq!(app.width_passes.get(), widths, "widths remeasured");
|
||||
}
|
||||
|
||||
/// improvise-felo.13: the control for the test above, and the guard on
|
||||
/// invalidation: an edit must rebuild, and the widths must follow the value.
|
||||
#[test]
|
||||
fn an_edit_rebuilds_and_remeasures_so_the_widths_follow_the_value() {
|
||||
use crate::ui::effect::SetCell;
|
||||
let mut app = two_col_model();
|
||||
let down = || KeyEvent::new(KeyCode::Down, KeyModifiers::NONE);
|
||||
app.dispatch_key(down());
|
||||
let narrow = app.grid_geometry().summary.col_widths.clone();
|
||||
let (builds, widths) = (app.layout_builds, app.width_passes.get());
|
||||
let key = CellKey::new(vec![("Row".into(), "A".into()), ("Col".into(), "X".into())]);
|
||||
let long = CellValue::Text("a value far wider than a column".into());
|
||||
app.apply_effects(vec![Box::new(SetCell(key, long))]);
|
||||
assert!(
|
||||
app.layout_builds > builds,
|
||||
"an edit must rebuild the layout"
|
||||
);
|
||||
app.dispatch_key(down());
|
||||
assert!(app.width_passes.get() > widths, "an edit must remeasure");
|
||||
let model = &app.model_state.workbook.model;
|
||||
let fresh = crate::ui::viewport::compute_col_widths(
|
||||
model,
|
||||
&app.layout,
|
||||
&FormatPlan::of(model, app.view()),
|
||||
);
|
||||
assert_eq!(
|
||||
app.grid_geometry().summary.col_widths,
|
||||
fresh,
|
||||
"stale widths"
|
||||
);
|
||||
assert_ne!(
|
||||
fresh, narrow,
|
||||
"the edit widened a column, or this proves nothing"
|
||||
);
|
||||
}
|
||||
|
||||
/// improvise-felo.13: a `ViewOnly` effect that changes which view is drawn
|
||||
/// must still rebuild, so skipping the rebuild is not keyed on `EffectKind` alone.
|
||||
#[test]
|
||||
fn switching_view_rebuilds_though_the_effect_is_view_only() {
|
||||
let mut app = two_col_model();
|
||||
app.model_state.workbook.create_view("Second");
|
||||
app.rebuild_layout();
|
||||
let builds = app.layout_builds;
|
||||
app.apply_effects(vec![Box::new(crate::ui::effect::SwitchView(
|
||||
"Second".into(),
|
||||
))]);
|
||||
assert_eq!(app.view_state.active_view.as_str(), "Second");
|
||||
assert!(app.layout_builds > builds, "the layout still draws Default");
|
||||
}
|
||||
|
||||
/// improvise-htl6. Evidence: `context/test-evidence/command.md`.
|
||||
#[test]
|
||||
fn the_view_history_walks_both_ways_from_the_keyboard() {
|
||||
@@ -2134,6 +2294,7 @@ mod tests {
|
||||
.unwrap()
|
||||
.add_item(format!("R{i}"));
|
||||
}
|
||||
app.rebuild_layout();
|
||||
app.term_height = 28; // ~20 visible rows → delta = 15
|
||||
app.cursor_mut().selected = (0, 0);
|
||||
app.handle_key(KeyEvent::new(KeyCode::PageDown, KeyModifiers::NONE))
|
||||
@@ -2158,6 +2319,7 @@ mod tests {
|
||||
.unwrap()
|
||||
.add_item(format!("R{i}"));
|
||||
}
|
||||
app.rebuild_layout();
|
||||
app.term_height = 28;
|
||||
app.cursor_mut().selected = (20, 0);
|
||||
app.handle_key(KeyEvent::new(KeyCode::PageUp, KeyModifiers::NONE))
|
||||
@@ -2177,6 +2339,7 @@ mod tests {
|
||||
.unwrap()
|
||||
.add_item(format!("R{i}"));
|
||||
}
|
||||
app.rebuild_layout();
|
||||
app.term_height = 13; // ~5 visible rows
|
||||
app.cursor_mut().selected = (0, 0);
|
||||
// G jumps to last row (row 12)
|
||||
@@ -2204,6 +2367,7 @@ mod tests {
|
||||
.unwrap()
|
||||
.add_item(format!("R{i}"));
|
||||
}
|
||||
app.rebuild_layout();
|
||||
app.term_height = 13; // ~5 visible rows
|
||||
app.cursor_mut().selected = (0, 0);
|
||||
// Ctrl+d scrolls by 5 rows
|
||||
|
||||
@@ -25,7 +25,7 @@ use improvise_protocol::{
|
||||
use crate::ui::app::App;
|
||||
use crate::ui::effect::{EffectKind, ModelChange};
|
||||
use crate::ui::reduce::{Checked, apply_command};
|
||||
use crate::ui::viewport::{GridBody, grid_body};
|
||||
use crate::ui::viewport::{GridBody, grid_body, grid_window};
|
||||
|
||||
/// Which widgets a batch of effects could have changed. A gate, not a description:
|
||||
/// over-reporting costs work, under-reporting is a correctness bug (gotcha 35).
|
||||
@@ -142,9 +142,19 @@ pub fn grid_in(app: &App, view_name: &str, viewport: Viewport) -> GridCache {
|
||||
let plan = FormatPlan::of(model, view);
|
||||
let rows = viewport.rows_in(layout.row_count());
|
||||
let cols = viewport.cols_in(layout.col_count());
|
||||
// ONE walk: the widths, the totals and this window's values all come off the
|
||||
// same evaluation — `context/code/command.md` (improvise-jmjz).
|
||||
let body = grid_body(model, layout, &plan, rows.clone(), cols.clone());
|
||||
// One walk gives widths, totals and window (improvise-jmjz); the host's layout
|
||||
// keeps the first two until rebuilt, so its window costs its own cells.
|
||||
let hosted = elsewhere.is_none() && derived.is_none();
|
||||
let body = match hosted {
|
||||
true => grid_window(
|
||||
model,
|
||||
layout,
|
||||
&app.grid_geometry().summary,
|
||||
rows.clone(),
|
||||
cols.clone(),
|
||||
),
|
||||
false => grid_body(model, layout, &plan, rows.clone(), cols.clone()),
|
||||
};
|
||||
|
||||
GridCache {
|
||||
row_frame: axis_frame(&layout.row_cats, &layout.row_items, rows.clone()),
|
||||
|
||||
@@ -101,6 +101,50 @@ pub fn grid_body(
|
||||
}
|
||||
}
|
||||
|
||||
/// What of a grid build no window changes: the widths, each column's total
|
||||
/// format and the totals. Measured over every cell, so a host keeps one per layout.
|
||||
pub struct GridSummary {
|
||||
pub col_widths: Vec<u16>,
|
||||
pub col_formats: Vec<NumberFormat>,
|
||||
pub totals: Vec<Option<CellValue>>,
|
||||
}
|
||||
|
||||
/// The whole-grid walk, answering only what a window cannot.
|
||||
pub fn grid_summary(model: &Model, layout: &GridLayout, plan: &FormatPlan) -> GridSummary {
|
||||
let body = grid_body(model, layout, plan, 0..0, 0..0);
|
||||
GridSummary {
|
||||
col_widths: body.col_widths,
|
||||
col_formats: body.col_formats,
|
||||
totals: body.totals,
|
||||
}
|
||||
}
|
||||
|
||||
/// [`grid_body`] for a window, given the `summary` of the same layout: evaluates
|
||||
/// only the window's cells. Equal to `grid_body` where `summary` is current.
|
||||
pub fn grid_window(
|
||||
model: &Model,
|
||||
layout: &GridLayout,
|
||||
summary: &GridSummary,
|
||||
rows: std::ops::Range<usize>,
|
||||
cols: std::ops::Range<usize>,
|
||||
) -> GridBody {
|
||||
let eval = layout.evaluator(model);
|
||||
let records = layout.is_records_mode();
|
||||
let window = rows
|
||||
.map(|ri| {
|
||||
cols.clone()
|
||||
.map(|ci| cell_value(layout, &eval, records, ri, ci).1)
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
GridBody {
|
||||
col_widths: summary.col_widths.clone(),
|
||||
col_formats: summary.col_formats.clone(),
|
||||
totals: summary.totals.clone(),
|
||||
window,
|
||||
}
|
||||
}
|
||||
|
||||
/// The one evaluation. Records mode reads its wide row; pivot mode aggregates.
|
||||
fn cell_value(
|
||||
layout: &GridLayout,
|
||||
@@ -146,7 +190,7 @@ fn widen(widths: &mut [u16], col: usize, width: usize) {
|
||||
/// Adaptive column widths alone, for the keypress path, which needs no cells.
|
||||
/// It is [`grid_body`] with an empty window so there is one width rule.
|
||||
pub fn compute_col_widths(model: &Model, layout: &GridLayout, plan: &FormatPlan) -> Vec<u16> {
|
||||
grid_body(model, layout, plan, 0..0, 0..0).col_widths
|
||||
grid_summary(model, layout, plan).col_widths
|
||||
}
|
||||
|
||||
/// The total row-header width for a layout. The rule is the protocol's; this is the
|
||||
@@ -272,4 +316,33 @@ mod tests {
|
||||
"F0..F8 once for the Q1 row, once for the totals"
|
||||
);
|
||||
}
|
||||
|
||||
/// improvise-felo.13: a window read off a held summary is the window `grid_body`
|
||||
/// builds. Evidence: `context/test-evidence/layout-cost.md`.
|
||||
#[test]
|
||||
fn a_window_over_a_held_summary_equals_the_one_walk_grid_body() {
|
||||
let wb = folded_chain();
|
||||
let view = wb.active_view();
|
||||
let layout = GridLayout::new(&wb.model, view);
|
||||
let plan = FormatPlan::of(&wb.model, view);
|
||||
let summary = grid_summary(&wb.model, &layout, &plan);
|
||||
let (rows, cols) = (layout.row_count(), layout.col_count());
|
||||
assert!(
|
||||
rows >= 2 && cols >= 3,
|
||||
"a window strictly inside is possible"
|
||||
);
|
||||
for (r, c) in [
|
||||
(0..rows, 0..cols),
|
||||
(1..2, 1..3),
|
||||
(rows - 1..rows, 0..1),
|
||||
(0..0, 0..0),
|
||||
] {
|
||||
let walked = grid_body(&wb.model, &layout, &plan, r.clone(), c.clone());
|
||||
let held = grid_window(&wb.model, &layout, &summary, r.clone(), c.clone());
|
||||
assert_eq!(held.window, walked.window, "window {r:?} x {c:?}");
|
||||
assert_eq!(held.col_widths, walked.col_widths);
|
||||
assert_eq!(held.totals, walked.totals);
|
||||
assert_eq!(held.col_formats, walked.col_formats);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,6 +194,12 @@ impl RecordEntry {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// How many lookups fell through to set narrowing, for the exact-path test.
|
||||
static NARROWED: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct DataStore {
|
||||
/// Primary storage: interned keys, insertion-ordered so records mode can
|
||||
@@ -206,6 +212,9 @@ pub struct DataStore {
|
||||
/// Reverse index for the client-facing surrogate (ADR 002), plus each
|
||||
/// record's write counter. Why it lives here: core.md §8.
|
||||
records: HashMap<RecordHash, RecordEntry>,
|
||||
/// How many stored cells have each number of coordinates; its largest key
|
||||
/// says when a partial can match only one exact cell.
|
||||
arities: std::collections::BTreeMap<usize, usize>,
|
||||
}
|
||||
|
||||
/// Resolved through the symbol table and sorted wherever the storage is a
|
||||
@@ -217,6 +226,7 @@ impl std::fmt::Debug for DataStore {
|
||||
symbols,
|
||||
index,
|
||||
records,
|
||||
arities: _,
|
||||
} = self;
|
||||
let cells: Vec<_> = cells
|
||||
.iter()
|
||||
@@ -291,7 +301,11 @@ impl DataStore {
|
||||
for pair in &ikey.0 {
|
||||
self.index.entry(*pair).or_default().insert(ikey.clone());
|
||||
}
|
||||
let width = ikey.0.len();
|
||||
let is_new_cell = self.cells.insert(ikey, value).is_none();
|
||||
if is_new_cell {
|
||||
*self.arities.entry(width).or_default() += 1;
|
||||
}
|
||||
let entry = self.records.entry(hash).or_insert(RecordEntry {
|
||||
record,
|
||||
cells: 0,
|
||||
@@ -327,6 +341,12 @@ impl DataStore {
|
||||
return;
|
||||
};
|
||||
if self.cells.shift_remove(&ikey).is_some() {
|
||||
if let Some(n) = self.arities.get_mut(&ikey.0.len()) {
|
||||
*n -= 1;
|
||||
if *n == 0 {
|
||||
self.arities.remove(&ikey.0.len());
|
||||
}
|
||||
}
|
||||
for pair in &ikey.0 {
|
||||
if let Some(set) = self.index.get_mut(pair) {
|
||||
set.remove(&ikey);
|
||||
@@ -378,11 +398,28 @@ impl DataStore {
|
||||
/// Positions in `cells` of every cell matching `partial`, **in insertion
|
||||
/// order**. The index narrows; the primary map orders (§8).
|
||||
fn matching_positions(&self, partial: &[(String, String)]) -> Vec<usize> {
|
||||
if let Some(exact) = self.exact_positions(partial) {
|
||||
return exact;
|
||||
}
|
||||
self.intern_partial(partial)
|
||||
.map(|pairs| self.positions_of(&pairs))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The answer without narrowing, when `partial` is as wide as the widest cell
|
||||
/// stored: only an equal cell can match (layout-cost.md). `None` otherwise.
|
||||
fn exact_positions(&self, partial: &[(String, String)]) -> Option<Vec<usize>> {
|
||||
let widest = *self.arities.keys().next_back()?;
|
||||
// Strictly sorted by category, the order a stored key is in, so one probe is the lookup.
|
||||
let canonical = partial.windows(2).all(|w| w[0].0 < w[1].0);
|
||||
(!partial.is_empty() && widest <= partial.len() && canonical).then(|| {
|
||||
let found = self
|
||||
.intern_partial(partial)
|
||||
.and_then(|pairs| self.cells.get_index_of(&InternedKey(pairs)));
|
||||
found.into_iter().collect()
|
||||
})
|
||||
}
|
||||
|
||||
/// `partial` as symbols, or `None` when a name was never interned, which
|
||||
/// no stored cell can then match.
|
||||
fn intern_partial(&self, partial: &[(String, String)]) -> Option<Vec<(Symbol, Symbol)>> {
|
||||
@@ -412,6 +449,8 @@ impl DataStore {
|
||||
}
|
||||
|
||||
sets.sort_by_key(|s| s.len());
|
||||
#[cfg(test)]
|
||||
NARROWED.with(|n| n.set(n.get() + 1));
|
||||
|
||||
let mut positions: Vec<usize> = match sets.split_first() {
|
||||
Some((first, rest)) => first
|
||||
@@ -1356,3 +1395,105 @@ mod record_index {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod exact_path_tests {
|
||||
use super::{CellKey, CellValue, DataStore, NARROWED};
|
||||
|
||||
fn key(a: &str, b: &str, c: &str) -> CellKey {
|
||||
CellKey::new(vec![
|
||||
("A".into(), a.into()),
|
||||
("B".into(), b.into()),
|
||||
("C".into(), c.into()),
|
||||
])
|
||||
}
|
||||
|
||||
fn store() -> DataStore {
|
||||
let mut d = DataStore::new();
|
||||
for i in 0..6 {
|
||||
for j in 0..6 {
|
||||
d.set(
|
||||
key(&format!("a{i}"), &format!("b{j}"), "c"),
|
||||
CellValue::Number((i * 6 + j) as f64),
|
||||
);
|
||||
}
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
fn narrowings(f: impl FnOnce()) -> usize {
|
||||
NARROWED.with(|n| n.set(0));
|
||||
f();
|
||||
NARROWED.with(|n| n.get())
|
||||
}
|
||||
|
||||
/// improvise-felo.13: context/test-evidence/layout-cost.md.
|
||||
#[test]
|
||||
fn a_full_width_partial_probes_one_cell_instead_of_narrowing() {
|
||||
let d = store();
|
||||
let full = key("a2", "b3", "c").0;
|
||||
let mut got = Vec::new();
|
||||
assert_eq!(
|
||||
narrowings(|| got = d.matching_values(&full).into_iter().cloned().collect()),
|
||||
0
|
||||
);
|
||||
assert_eq!(got, vec![CellValue::Number(15.0)]);
|
||||
let missing = key("a2", "b9", "c").0;
|
||||
assert!(
|
||||
d.matching_values(&missing).is_empty(),
|
||||
"an absent cell matches nothing"
|
||||
);
|
||||
let unknown = key("a2", "b3", "zzz").0;
|
||||
assert!(
|
||||
d.matching_values(&unknown).is_empty(),
|
||||
"an unseen item matches nothing"
|
||||
);
|
||||
}
|
||||
|
||||
/// The control: a narrower partial still sums its supersets, so the shortcut
|
||||
/// is not a blanket exact lookup.
|
||||
#[test]
|
||||
fn a_narrower_partial_still_narrows_and_matches_every_superset() {
|
||||
let d = store();
|
||||
let narrow = vec![
|
||||
("A".to_string(), "a2".to_string()),
|
||||
("C".to_string(), "c".to_string()),
|
||||
];
|
||||
let mut n = 0;
|
||||
assert_eq!(narrowings(|| n = d.matching_values(&narrow).len()), 1);
|
||||
assert_eq!(n, 6);
|
||||
}
|
||||
|
||||
/// A wider cell appearing, or leaving, moves the line the shortcut is allowed at.
|
||||
#[test]
|
||||
fn a_wider_cell_turns_the_shortcut_off_until_it_is_removed() {
|
||||
let mut d = store();
|
||||
let wide = CellKey::new(vec![
|
||||
("A".into(), "a2".into()),
|
||||
("B".into(), "b3".into()),
|
||||
("C".into(), "c".into()),
|
||||
("D".into(), "d".into()),
|
||||
]);
|
||||
d.set(wide.clone(), CellValue::Number(100.0));
|
||||
let full = key("a2", "b3", "c").0;
|
||||
assert_eq!(
|
||||
d.matching_values(&full).len(),
|
||||
2,
|
||||
"the exact cell and the wider cell under it"
|
||||
);
|
||||
d.remove(&wide);
|
||||
assert_eq!(
|
||||
narrowings(|| assert_eq!(d.matching_values(&full).len(), 1)),
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
/// An unsorted partial is not a stored key's order, so it must not be probed.
|
||||
#[test]
|
||||
fn an_unsorted_full_width_partial_is_still_answered() {
|
||||
let d = store();
|
||||
let mut full = key("a2", "b3", "c").0;
|
||||
full.reverse();
|
||||
assert_eq!(d.matching_values(&full).len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -235,23 +235,47 @@ impl AxisEntry {
|
||||
|
||||
/// The data-item tuples on an axis, skipping group headers. Every public
|
||||
/// `row`/`col` argument indexes this sequence, not the visual entry list.
|
||||
#[cfg(test)]
|
||||
fn data_items(items: &[AxisEntry]) -> impl Iterator<Item = &Vec<String>> {
|
||||
items.iter().filter_map(|e| match e {
|
||||
AxisEntry::DataItem { tuple, .. } => Some(tuple),
|
||||
AxisEntry::GroupHeader { .. } | AxisEntry::GroupEnd { .. } => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether the data item at `idx` folds a collapsed group.
|
||||
fn folds(items: &[AxisEntry], idx: usize) -> bool {
|
||||
items
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
AxisEntry::DataItem { collapsed, .. } => Some(*collapsed),
|
||||
AxisEntry::DataItem { tuple, .. } => Some(tuple),
|
||||
AxisEntry::GroupHeader { .. } | AxisEntry::GroupEnd { .. } => None,
|
||||
})
|
||||
.nth(idx)
|
||||
.unwrap_or(false)
|
||||
.inspect(|_| DATA_ITEM_STEPS.with(|n| n.set(n.get() + 1)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// Items `data_items` has yielded on this thread: the work a lookup did.
|
||||
static DATA_ITEM_STEPS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// Entry `positions(items)[n]` is data item `n`: a lookup by data index is O(1),
|
||||
/// not a walk past every header before it (layout-cost.md).
|
||||
fn positions(items: &[AxisEntry]) -> Vec<usize> {
|
||||
items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(at, e)| matches!(e, AxisEntry::DataItem { .. }).then_some(at))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The data item at `idx`, by the axis's position index.
|
||||
fn data_item<'a>(items: &'a [AxisEntry], pos: &[usize], idx: usize) -> Option<&'a Vec<String>> {
|
||||
match items.get(*pos.get(idx)?)? {
|
||||
AxisEntry::DataItem { tuple, .. } => Some(tuple),
|
||||
AxisEntry::GroupHeader { .. } | AxisEntry::GroupEnd { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the data item at `idx` folds a collapsed group.
|
||||
fn folds(items: &[AxisEntry], pos: &[usize], idx: usize) -> bool {
|
||||
match pos.get(idx).and_then(|at| items.get(*at)) {
|
||||
Some(AxisEntry::DataItem { collapsed, .. }) => *collapsed,
|
||||
Some(AxisEntry::GroupHeader { .. } | AxisEntry::GroupEnd { .. }) | None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The data index of the first item in the run `cat`/`group` opens under
|
||||
@@ -287,21 +311,15 @@ fn run_start(
|
||||
}
|
||||
|
||||
/// The display label of the data item at `idx`, or empty when out of bounds.
|
||||
fn axis_label(items: &[AxisEntry], idx: usize) -> String {
|
||||
data_items(items)
|
||||
.nth(idx)
|
||||
fn axis_label(items: &[AxisEntry], pos: &[usize], idx: usize) -> String {
|
||||
data_item(items, pos, idx)
|
||||
.map(|v| v.join("/"))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Visual index of the nth data entry (skipping group headers).
|
||||
fn data_index_to_visual(items: &[AxisEntry], data_idx: usize) -> Option<usize> {
|
||||
items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, e)| matches!(e, AxisEntry::DataItem { .. }))
|
||||
.map(|(vi, _)| vi)
|
||||
.nth(data_idx)
|
||||
fn data_index_to_visual(pos: &[usize], data_idx: usize) -> Option<usize> {
|
||||
pos.get(data_idx).copied()
|
||||
}
|
||||
|
||||
/// Where a run sits: `(level, depth)`, ordered, so "inside" is "greater than".
|
||||
@@ -468,6 +486,10 @@ pub struct GridLayout {
|
||||
pub page_coords: Vec<PageAxis>,
|
||||
pub row_items: Vec<AxisEntry>,
|
||||
pub col_items: Vec<AxisEntry>,
|
||||
/// `positions(row_items)` / `positions(col_items)`: derived, so every writer of
|
||||
/// the items calls `reindex`, and nothing outside this module writes them.
|
||||
row_pos: Vec<usize>,
|
||||
col_pos: Vec<usize>,
|
||||
/// Categories on `Axis::None` — hidden, implicitly aggregated.
|
||||
pub none_cats: Vec<String>,
|
||||
/// Records mode: one entry per row, grouping the cells that share every
|
||||
@@ -497,6 +519,7 @@ impl GridLayout {
|
||||
.collect();
|
||||
layout.row_items = row_items;
|
||||
layout.records = Some(records);
|
||||
layout.reindex();
|
||||
}
|
||||
if view.prune_empty {
|
||||
layout.prune_empty(model);
|
||||
@@ -556,6 +579,8 @@ impl GridLayout {
|
||||
row_cats,
|
||||
col_cats,
|
||||
page_coords,
|
||||
row_pos: positions(&row_items),
|
||||
col_pos: positions(&col_items),
|
||||
row_items,
|
||||
col_items,
|
||||
none_cats,
|
||||
@@ -590,6 +615,8 @@ impl GridLayout {
|
||||
row_cats: vec![INDEX_CATEGORY.to_string()],
|
||||
col_cats: vec![DIM_CATEGORY.to_string()],
|
||||
page_coords,
|
||||
row_pos: positions(&row_items),
|
||||
col_pos: positions(&col_items),
|
||||
row_items,
|
||||
col_items,
|
||||
none_cats,
|
||||
@@ -728,6 +755,13 @@ impl GridLayout {
|
||||
prune_axis_items(&mut self.col_items, |ci| {
|
||||
(0..rc).any(|ri| has_value[ri][ci])
|
||||
});
|
||||
self.reindex();
|
||||
}
|
||||
|
||||
/// Rebuild the position indexes after the items change.
|
||||
fn reindex(&mut self) {
|
||||
self.row_pos = positions(&self.row_items);
|
||||
self.col_pos = positions(&self.col_items);
|
||||
}
|
||||
|
||||
/// Whether this layout is in records mode.
|
||||
@@ -737,20 +771,20 @@ impl GridLayout {
|
||||
|
||||
/// Number of data rows (group headers excluded).
|
||||
pub fn row_count(&self) -> usize {
|
||||
data_items(&self.row_items).count()
|
||||
self.row_pos.len()
|
||||
}
|
||||
|
||||
/// Number of data columns (group headers excluded).
|
||||
pub fn col_count(&self) -> usize {
|
||||
data_items(&self.col_items).count()
|
||||
self.col_pos.len()
|
||||
}
|
||||
|
||||
pub fn row_label(&self, row: usize) -> String {
|
||||
axis_label(&self.row_items, row)
|
||||
axis_label(&self.row_items, &self.row_pos, row)
|
||||
}
|
||||
|
||||
pub fn col_label(&self, col: usize) -> String {
|
||||
axis_label(&self.col_items, col)
|
||||
axis_label(&self.col_items, &self.col_pos, col)
|
||||
}
|
||||
|
||||
/// The value of a pivot key as THIS grid reads it: aggregated over its None
|
||||
@@ -804,8 +838,8 @@ impl GridLayout {
|
||||
(DIM_CATEGORY.to_string(), col.to_string()),
|
||||
]));
|
||||
}
|
||||
let row_item = data_items(&self.row_items).nth(row)?;
|
||||
let col_item = data_items(&self.col_items).nth(col)?;
|
||||
let row_item = data_item(&self.row_items, &self.row_pos, row)?;
|
||||
let col_item = data_item(&self.col_items, &self.col_pos, col)?;
|
||||
let mut coords = page_constraints(&self.page_coords);
|
||||
for (cat, item) in self.row_cats.iter().zip(row_item.iter()) {
|
||||
coords.push((cat.clone(), item.clone()));
|
||||
@@ -819,7 +853,7 @@ impl GridLayout {
|
||||
/// The coordinates a COLUMN answers for once its rows are aggregated away:
|
||||
/// the page/filter constraints and the column's own tuple (core.md §6).
|
||||
pub fn column_key(&self, col: usize) -> Option<CellKey> {
|
||||
let col_item = data_items(&self.col_items).nth(col)?;
|
||||
let col_item = data_item(&self.col_items, &self.col_pos, col)?;
|
||||
let mut coords = page_constraints(&self.page_coords);
|
||||
for (cat, item) in self.col_cats.iter().zip(col_item.iter()) {
|
||||
coords.push((cat.clone(), item.clone()));
|
||||
@@ -829,12 +863,12 @@ impl GridLayout {
|
||||
|
||||
/// Visual index of the nth data row (skipping group headers).
|
||||
pub fn data_row_to_visual(&self, data_row: usize) -> Option<usize> {
|
||||
data_index_to_visual(&self.row_items, data_row)
|
||||
data_index_to_visual(&self.row_pos, data_row)
|
||||
}
|
||||
|
||||
/// Visual index of the nth data column (skipping group headers).
|
||||
pub fn data_col_to_visual(&self, data_col: usize) -> Option<usize> {
|
||||
data_index_to_visual(&self.col_items, data_col)
|
||||
data_index_to_visual(&self.col_pos, data_col)
|
||||
}
|
||||
|
||||
/// The `(cat_name, group_name)` of the innermost group the Nth data row
|
||||
@@ -846,12 +880,12 @@ impl GridLayout {
|
||||
/// Whether the cell at `(row, col)` folds a collapsed group: an aggregate
|
||||
/// with no record under it. The one home of that answer (§14).
|
||||
pub fn folds_a_group(&self, row: usize, col: usize) -> bool {
|
||||
self.row_folds_a_group(row) || folds(&self.col_items, col)
|
||||
self.row_folds_a_group(row) || folds(&self.col_items, &self.col_pos, col)
|
||||
}
|
||||
|
||||
/// The row half of [`Self::folds_a_group`], for a command about the row alone.
|
||||
pub fn row_folds_a_group(&self, row: usize) -> bool {
|
||||
folds(&self.row_items, row)
|
||||
folds(&self.row_items, &self.row_pos, row)
|
||||
}
|
||||
|
||||
/// The first data row of `cat`/`group`'s run under `prefix` — where a toggle
|
||||
@@ -867,12 +901,12 @@ impl GridLayout {
|
||||
|
||||
/// The coordinate tuple of the Nth data row.
|
||||
pub fn row_tuple(&self, row: usize) -> Option<&[String]> {
|
||||
data_items(&self.row_items).nth(row).map(Vec::as_slice)
|
||||
data_item(&self.row_items, &self.row_pos, row).map(Vec::as_slice)
|
||||
}
|
||||
|
||||
/// The coordinate tuple of the Nth data column.
|
||||
pub fn col_tuple(&self, col: usize) -> Option<&[String]> {
|
||||
data_items(&self.col_items).nth(col).map(Vec::as_slice)
|
||||
data_item(&self.col_items, &self.col_pos, col).map(Vec::as_slice)
|
||||
}
|
||||
|
||||
/// The `(cat_name, group_name)` of the innermost group the Nth data
|
||||
@@ -2970,3 +3004,171 @@ mod hierarchy {
|
||||
assert_eq!(l.row_items[0], header(0, "2024", false));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod lookup_cost_tests {
|
||||
use super::{DATA_ITEM_STEPS, GridLayout};
|
||||
use crate::view::Axis;
|
||||
use crate::workbook::Workbook;
|
||||
|
||||
fn tall(rows: usize) -> GridLayout {
|
||||
let mut wb = Workbook::new("T");
|
||||
wb.add_category("Row").unwrap();
|
||||
wb.add_category("Col").unwrap();
|
||||
for i in 0..rows {
|
||||
wb.add_item("Row", format!("r{i}")).unwrap();
|
||||
}
|
||||
wb.add_item("Col", "c0").unwrap();
|
||||
wb.add_item("Col", "c1").unwrap();
|
||||
wb.active_view_mut().set_axis("Row", Axis::Row);
|
||||
wb.active_view_mut().set_axis("Col", Axis::Column);
|
||||
GridLayout::new(&wb.model, wb.active_view())
|
||||
}
|
||||
|
||||
fn steps(f: impl FnOnce()) -> usize {
|
||||
DATA_ITEM_STEPS.with(|n| n.set(0));
|
||||
f();
|
||||
DATA_ITEM_STEPS.with(|n| n.get())
|
||||
}
|
||||
|
||||
/// improvise-felo.13: context/test-evidence/layout-cost.md.
|
||||
#[test]
|
||||
fn an_axis_lookup_does_work_independent_of_the_row_index() {
|
||||
let layout = tall(400);
|
||||
let last = layout.row_count() - 1;
|
||||
assert_eq!(last, 399, "the fixture is as tall as the test needs");
|
||||
let lookups: [(&str, &dyn Fn(usize)); 4] = [
|
||||
("cell_key", &|r| {
|
||||
let _ = layout.cell_key(r, 1);
|
||||
}),
|
||||
("row_tuple", &|r| {
|
||||
let _ = layout.row_tuple(r);
|
||||
}),
|
||||
("row_label", &|r| {
|
||||
let _ = layout.row_label(r);
|
||||
}),
|
||||
("column_key", &|r| {
|
||||
let _ = layout.column_key(r % 2);
|
||||
}),
|
||||
];
|
||||
for (name, lookup) in lookups {
|
||||
let first = steps(|| lookup(0));
|
||||
let deep = steps(|| lookup(last));
|
||||
assert!(
|
||||
deep <= first + 2,
|
||||
"{name}: row {last} cost {deep} steps against {first} at row 0"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// improvise-felo.13: the index answers what the walk it replaced did, through
|
||||
/// groups, a collapsed fold and a pruned axis. Evidence: layout-cost.md.
|
||||
#[test]
|
||||
fn the_position_index_answers_what_the_walk_answered() {
|
||||
use super::{AxisEntry, data_items};
|
||||
use crate::model::cell::{CellKey, CellValue};
|
||||
let mut wb = Workbook::new("T");
|
||||
for cat in ["Row", "Col"] {
|
||||
wb.add_category(cat).unwrap();
|
||||
}
|
||||
for (i, g) in ["Q1", "Q1", "Q2", "Q2", "Q3"].iter().enumerate() {
|
||||
wb.add_item_in_group("Row", format!("r{i}"), *g).unwrap();
|
||||
}
|
||||
wb.add_item("Row", "ghost").unwrap();
|
||||
wb.add_item("Col", "c0").unwrap();
|
||||
wb.active_view_mut().set_axis("Row", Axis::Row);
|
||||
wb.active_view_mut().set_axis("Col", Axis::Column);
|
||||
wb.active_view_mut().set_expand_depth("Row", 1);
|
||||
wb.active_view_mut().toggle_group_collapse("Row", &["Q2"]);
|
||||
wb.active_view_mut().prune_empty = true;
|
||||
for i in 0..5 {
|
||||
let key = CellKey::new(vec![
|
||||
("Row".into(), format!("r{i}")),
|
||||
("Col".into(), "c0".into()),
|
||||
]);
|
||||
wb.model.set_cell(key, CellValue::Number(1.0));
|
||||
}
|
||||
let layout = GridLayout::with_frozen_records(&wb.model, wb.active_view(), None);
|
||||
assert!(
|
||||
layout
|
||||
.row_items
|
||||
.iter()
|
||||
.any(|e| !matches!(e, AxisEntry::DataItem { .. }))
|
||||
);
|
||||
assert!(layout.row_items.iter().any(|e| matches!(
|
||||
e,
|
||||
AxisEntry::DataItem {
|
||||
collapsed: true,
|
||||
..
|
||||
}
|
||||
)));
|
||||
let walked: Vec<&Vec<String>> = data_items(&layout.row_items).collect();
|
||||
assert_eq!(layout.row_count(), walked.len());
|
||||
let visual: Vec<usize> = layout
|
||||
.row_items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, e)| matches!(e, AxisEntry::DataItem { .. }))
|
||||
.map(|(at, _)| at)
|
||||
.collect();
|
||||
for i in 0..walked.len() + 2 {
|
||||
assert_eq!(
|
||||
layout.row_tuple(i),
|
||||
walked.get(i).map(|t| t.as_slice()),
|
||||
"row {i}"
|
||||
);
|
||||
assert_eq!(
|
||||
layout.data_row_to_visual(i),
|
||||
visual.get(i).copied(),
|
||||
"visual {i}"
|
||||
);
|
||||
let folded = matches!(
|
||||
data_items_entry(&layout.row_items, i),
|
||||
Some(AxisEntry::DataItem {
|
||||
collapsed: true,
|
||||
..
|
||||
})
|
||||
);
|
||||
assert_eq!(layout.row_folds_a_group(i), folded, "fold {i}");
|
||||
}
|
||||
}
|
||||
|
||||
/// improvise-felo.13: a drill held open while records are removed elsewhere.
|
||||
/// Evidence: layout-cost.md.
|
||||
#[test]
|
||||
fn a_frozen_records_snapshot_replaces_the_live_row_index() {
|
||||
use crate::model::cell::{CellKey, CellValue};
|
||||
let mut wb = Workbook::new("T");
|
||||
wb.add_category("Region").unwrap();
|
||||
wb.add_item("_Measure", "Revenue").unwrap();
|
||||
let cell = |i: usize| {
|
||||
CellKey::new(vec![
|
||||
("Region".into(), format!("r{i}")),
|
||||
("_Measure".into(), "Revenue".into()),
|
||||
])
|
||||
};
|
||||
for i in 0..3 {
|
||||
wb.add_item("Region", format!("r{i}")).unwrap();
|
||||
wb.model.set_cell(cell(i), CellValue::Number(1.0));
|
||||
}
|
||||
let v = wb.active_view_mut();
|
||||
v.set_axis("_Index", Axis::Row);
|
||||
v.set_axis("_Dim", Axis::Column);
|
||||
let frozen = GridLayout::new(&wb.model, wb.active_view()).records;
|
||||
wb.model.clear_cell(&cell(1));
|
||||
wb.model.clear_cell(&cell(2));
|
||||
let live = GridLayout::with_frozen_records(&wb.model, wb.active_view(), None);
|
||||
assert_eq!(live.row_count(), 1, "the control: one record is live");
|
||||
let held = GridLayout::with_frozen_records(&wb.model, wb.active_view(), frozen);
|
||||
assert_eq!(held.row_count(), 3);
|
||||
assert_eq!(held.row_tuple(2), Some(&["2".to_string()][..]));
|
||||
assert!(held.cell_key(2, 0).is_some());
|
||||
}
|
||||
|
||||
fn data_items_entry(items: &[super::AxisEntry], n: usize) -> Option<&super::AxisEntry> {
|
||||
items
|
||||
.iter()
|
||||
.filter(|e| matches!(e, super::AxisEntry::DataItem { .. }))
|
||||
.nth(n)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,6 +81,8 @@ const SECTIONS = [
|
||||
["context/test-evidence/cli.md", "test-evidence/cli", "The headless CLI"],
|
||||
["context/test-evidence/gp.md", "test-evidence/gp", "gp-tree and improvise-gp"],
|
||||
["context/test-evidence/tooling.md", "test-evidence/tooling", "The repository's own gates"],
|
||||
["context/test-evidence/layout-cost.md", "test-evidence/layout-cost", "Layout and keypress cost"],
|
||||
["context/test-evidence/scale-bisect.md", "test-evidence/scale-bisect", "Scale bisect"],
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user