Files
improvise/benches/grid_projection.rs
T
Edward Langley d00cea2477 fix: show decimals by default, and let a formula name its own format
improvise-e0ex. `Margin = Profit / Revenue` drew as `1`: `View::new` seeded
`,.0` — zero decimals — so a 0.66 ratio rounded half-away-from-zero. The cause
is the format, not the arithmetic; exact decimal math would not change the
rendering by one character, so nothing here touches the evaluator and gotcha 8
is unchanged.

Two halves, per the user's ruling:

* `View::new` seeds `,.2`. Revenue draws `18,400.00` and Margin `0.66`. The
  noise on whole numbers is the cheaper error — a currency column with two
  redundant zeroes is legible, a ratio with none is wrong — and a format cannot
  be guessed from a value.
* A formula may carry its own format, which wins for its target; a raw measure
  keeps the view's. `formula.pest` gains
  `format_clause = ${ ^"FORMAT" ~ sp+ ~ format_spec }`, with `sp+` for `WHERE`'s
  reason (a predicate is invisible to the grammar-walking generator) and
  `FORMAT` added to `quote::KEYWORDS`.

`improv.pest` is deliberately unchanged: `formula_raw` already carries the
`## Formulas` line verbatim, so the format rides in the formula text with
nothing to add — and the generator, which reads `improv.pest`, sees no new
spelling to violate.

Resolution has one home. `FormatPlan::of(model, view)` replaces the
`(comma, decimals)` pair every render path threaded — `GridLayout::display_text`,
`viewport::grid_body`/`compute_col_widths`, `project::grid_in` and
`persistence/export.rs::render_csv` — rather than being added beside it, which
would be two answers to "what format is this cell". It is asked of the cell's
`CellKey`, so a cell aggregating over `_Measure` keeps the view's format by
construction. `GridBody::col_formats` carries the answer to the totals strip,
which is formatted on its own path.

Persistence is untouched except for metadata: `format_f64` is display,
`persistence/format.rs::format_number` writes round-trip precision, and the two
have no call between them. Newly created views write `format: ,.2`; existing
documents are unaffected, because `format_md` has always written the line
explicitly. `web/demo.improv` moves to `,.2` on both views.

The trap this sets is gotcha 34's: `format_md` writes `Formula::raw`, so a
`FORMAT` suffix round-trips whether or not the walker parsed it. The round-trip
test therefore asserts the parsed field — injecting a walker that drops the
clause fails that one test and nothing else in `improvise-io`, 242 tests
including the whole characterization suite and the grammar properties.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 23:33:52 -07:00

137 lines
5.1 KiB
Rust

//! Does the TUI's whole-grid `GridCache` dominate a frame? See improvise-i5tk,
//! improvise-jmjz and `context/code/command.md`. `cargo bench --bench grid_projection`.
use std::time::{Duration, Instant};
use improvise::format::FormatPlan;
use improvise::model::cell::{CellKey, CellValue};
use improvise::view::Axis;
use improvise::workbook::Workbook;
use improvise_command::ui::app::App;
use improvise_command::ui::project::{self, Viewport};
use improvise_command::ui::viewport::compute_col_widths;
/// A dense pivot grid: `regions * products` rows by `measures` columns, every
/// cell filled, which is the worst case for a per-cell evaluation.
fn dense_app(regions: usize, products: usize, measures: usize) -> App {
let mut wb = Workbook::new("Bench");
wb.add_category("Region").unwrap();
wb.add_category("Product").unwrap();
let region_names: Vec<String> = (0..regions).map(|i| format!("Region{i}")).collect();
let product_names: Vec<String> = (0..products).map(|i| format!("Product{i}")).collect();
let measure_names: Vec<String> = (0..measures).map(|i| format!("Measure{i}")).collect();
for r in &region_names {
wb.add_item("Region", r.clone()).unwrap();
}
for p in &product_names {
wb.add_item("Product", p.clone()).unwrap();
}
for m in &measure_names {
wb.add_item("_Measure", m.clone()).unwrap();
}
{
let v = wb.active_view_mut();
v.set_axis("Region", Axis::Row);
v.set_axis("Product", Axis::Row);
v.set_axis("_Measure", Axis::Column);
}
let mut n = 0.0f64;
for r in &region_names {
for p in &product_names {
for m in &measure_names {
n += 1.0;
let key = CellKey::new(vec![
("Region".to_string(), r.clone()),
("Product".to_string(), p.clone()),
("_Measure".to_string(), m.clone()),
]);
wb.model.set_cell(key, CellValue::Number(n));
}
}
}
let mut app = App::new(wb, None);
app.rebuild_layout();
app
}
fn whole_grid(app: &App) -> Viewport {
Viewport {
row_offset: 0,
col_offset: 0,
rows: app.layout.row_count(),
cols: app.layout.col_count(),
}
}
/// A terminal window: what the TUI would ask for if a cache position were
/// relative, which is the browser client's arrangement.
fn terminal_window() -> Viewport {
Viewport {
row_offset: 0,
col_offset: 0,
rows: 45,
cols: 12,
}
}
/// Min-of-N, not the mean: a mean over a handful of iterations is dominated by
/// whatever else the machine was doing, which is how a share above 100% happens.
fn time(iters: u32, app: &App, viewport: Viewport) -> Duration {
let mut best = Duration::MAX;
for _ in 0..iters.max(3) {
let start = Instant::now();
std::hint::black_box(project::grid_of(app, viewport));
best = best.min(start.elapsed());
}
best
}
/// `compute_col_widths` is whole-grid whatever the viewport says, so it is the part
/// narrowing the cells loop cannot reach. It must be timed with the arguments
/// `grid_of` passes it, or the share is of a call that never happens.
// Since improvise-jmjz it IS the fused walk, with an empty window, so its share
// rose while the frame fell: it is now the work, not the redundancy.
fn time_widths(iters: u32, app: &App) -> Duration {
let layout = &app.layout;
let model = &app.model_state.workbook.model;
let plan = FormatPlan::of(model, app.model_state.workbook.active_view());
let mut best = Duration::MAX;
for _ in 0..iters.max(3) {
let start = Instant::now();
std::hint::black_box(compute_col_widths(model, layout, &plan));
best = best.min(start.elapsed());
}
best
}
fn case(label: &str, regions: usize, products: usize, measures: usize, iters: u32) {
let app = dense_app(regions, products, measures);
let rows = app.layout.row_count();
let cols = app.layout.col_count();
let cells = rows * cols;
let full = time(iters, &app, whole_grid(&app));
let windowed = time(iters, &app, terminal_window());
let widths = time_widths(iters, &app);
let ratio = full.as_secs_f64() / windowed.as_secs_f64().max(f64::EPSILON);
let share = 100.0 * widths.as_secs_f64() / full.as_secs_f64().max(f64::EPSILON);
// A part cannot take longer than the whole that contains it. Say so rather
// than printing a number that reads like a measurement.
let share = match share > 100.0 {
true => "UNSTABLE".to_string(),
false => format!("{share:.0}%"),
};
println!(
"{label:<22} {rows:>5} x {cols:>3} = {cells:>7} cells whole-grid {full:>11.3?} \
windowed {windowed:>11.3?} ratio {ratio:>4.1}x col_widths {widths:>11.3?} ({share} of the frame)"
);
}
fn main() {
println!("project::grid_of, one call = one TUI frame's grid cache");
println!("a 60fps frame budget is 16.7ms; the TUI redraws on each keypress\n");
case("typical", 20, 5, 4, 200);
case("large", 60, 20, 8, 50);
case("18k cells (gotcha 41)", 150, 20, 6, 20);
case("pathological", 300, 30, 10, 5);
}