fix: review round — the Ok-but-no-op resets, the measure regression, and a

loop-of-one test

F1 (HIGH). Gating the reset on Ok is not the same as gating it on "something
changed". Six accepted commands changed nothing and reset every cursor anyway:
SetAxis to the axis already held, HideItem on an already-hidden item, ShowItem on
a never-hidden one, AddItem of an existing item (Category::add_item dedupes and
returns Ok), and RemoveItem/RemoveCategory of something absent. Each now asks the
state before mutating. The case that made it urgent needs no second client:
TileSelect binds `r` to set-axis-for-tile row, which emits SetAxis with no
comparison against the current axis, so `r` on a Row category sent the user's own
cursor to the origin.

F2 (REGRESSION this PR introduced). Naming a measure in the trailing `+` column
at the bottom-right of a records grid sent the cursor home: AddItem now resets,
inserts_a_column matches only NewCategory, and EnterAdvance at the bottom-right
returns AbortChain with no SetSelected. The restore moves to BEFORE the advance,
where an aborting advance cannot skip it -- gotcha 21's rule applied to the
cursor rather than the mode -- which also subsumes the +cat arm's copy.

F3. a_refused_model_command_invalidates_no_cursor named a class and tested one
member; injecting reset_cursor into AddCategory's Err arm left it green. Now a
table over all three refusable effects, and that injection fails it by name.

F5. The benchmark could not reproduce its own figures: it took the mean over a
few iterations, and review measured the same share at 8% and at 131% -- a part
slower than the whole containing it, printed flat. It also passed (false, 2)
where grid_of passes the view's (true, 0), so it timed a call that never happens.
Now min-of-N with the view's own format, and a share above 100% prints UNSTABLE.
Figures are stable across runs at ratio 1.2-1.5x, col_widths 45-49%.

F6. The drg counts omitted the 8 simple_effect! invocations and misclassified
AddItem at base through a regex that cannot see `app` newline `.model_state` --
doctrine(derive_then_verify) catching the person citing it. Correct: 57 effects,
narrow set 32 -> 25, not 25 -> 17 of 49. The direction holds and is still the
argument; the headline was overstated by about half again. Corrected in
context/code/command.md and on the bead, not quietly restated.

F9. a_model_command_that_leaves_the_active_view_alone_broadcasts_nothing asserts
a ResetCursor broadcast, so the name read as a lie: ..._broadcasts_no_switch.

F4 filed as improvise-pdvm: AddItem/RemoveItem declare CellData so layout_version
does not bump while cursor_epoch does, and this PR's own reshape assertion is the
first evidence for it. Related to improvise-75ld, same question other direction.

Faults injected for every new guard: SetAxis's moved check, AddItem's fresh
check, AddCategory's Err arm, and the pre-fix advance ordering ((0,0) vs (0,3)).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Edward Langley
2026-09-20 11:58:03 -07:00
parent 7368467ce9
commit ed412a0256
7 changed files with 360 additions and 103 deletions
+25 -15
View File
@@ -3,6 +3,7 @@
use std::time::{Duration, Instant};
use improvise::format::parse_number_format;
use improvise::model::cell::{CellKey, CellValue};
use improvise::view::Axis;
use improvise::workbook::Workbook;
@@ -73,30 +74,33 @@ fn terminal_window() -> Viewport {
}
}
/// 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 {
for _ in 0..3 {
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());
}
let start = Instant::now();
for _ in 0..iters {
std::hint::black_box(project::grid_of(app, viewport));
}
start.elapsed() / iters
best
}
/// `compute_col_widths` is whole-grid whatever the viewport says, so it is the part
/// narrowing the cells loop cannot reach. Timed on its own for exactly that reason.
/// 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.
fn time_widths(iters: u32, app: &App) -> Duration {
let layout = &app.layout;
let model = &app.model_state.workbook.model;
for _ in 0..3 {
std::hint::black_box(compute_col_widths(model, layout, false, 2));
let (comma, decimals) =
parse_number_format(&app.model_state.workbook.active_view().number_format);
let mut best = Duration::MAX;
for _ in 0..iters.max(3) {
let start = Instant::now();
std::hint::black_box(compute_col_widths(model, layout, comma, decimals));
best = best.min(start.elapsed());
}
let start = Instant::now();
for _ in 0..iters {
std::hint::black_box(compute_col_widths(model, layout, false, 2));
}
start.elapsed() / iters
best
}
fn case(label: &str, regions: usize, products: usize, measures: usize, iters: u32) {
@@ -109,9 +113,15 @@ fn case(label: &str, regions: usize, products: usize, measures: usize, iters: u3
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:>4.0}% of the frame)"
windowed {windowed:>11.3?} ratio {ratio:>4.1}x col_widths {widths:>11.3?} ({share} of the frame)"
);
}