- `Category`, `Item` and `View` each gain a private `display` that starts equal
to the key and changes only through `set_display` / `set_item_display`.
- `CategoryRow`, `TileEntry` and `ViewSummary` carry `display` beside their key
field. The key stays what commands are composed from.
- The category-panel headers, the tile bar and the view list paint the display
in the TUI and the browser. The active-view marker still compares keys.
- No production code sets a display yet, so behaviour is unchanged.
- This is the first slice of ADR 007 step 2. The grid, page bar, records labels,
item rows and CSV export are improvise-cq1q.12; messages and the view title
are improvise-cq1q.13.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
IdAllocator::suffixed recursed once per taken candidate, and the call was not
in tail position. A document claiming item-1 through item-N aborted the process
on a stack overflow at N of about 11,000 in debug. This answers the Codex P2 on
PR #318. The loop still advances the counter on every candidate it tries.
Three tests are added:
- a long claimed run (20,000), which aborted before the fix;
- claim refusing lookalike virtual ids (_indexer, _measure-1), which the
review's prefix-match rival passed before;
- a display with leading spaces that must not slug into the reserved `_` space.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
context/code/protocol.md gains section 10 and tui.md's colour section now
describes face_style; the pointers to axis_fg, minibuf_fg and AxisColor
elsewhere follow. Evidence for the new tests.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
context/code/core.md §19 gives the reasoning behind model::id.
context/test-evidence/core.md records, for each test, the rival that was
injected and the test that caught it.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
Review of #307 at 7b9e868b: the duplicate-step evidence and argument still
said 12, tui.md bounded a collision tuple by twelve, and the coupling
inventory named no beads. The page-bar entry now notes its unclipped
set_string can overwrite the right border.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
The duplicate step's cap check already reads the one constant after the
merge; its tests filled 12 categories and now fill to the cap. An
injected literal-12 check fails keeping_duplicates_separate_is_refused_at_the_category_cap.
core.md section 9 gains the inventory of what degrades as categories grow.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
The wizard's private copy of the limit is gone; it reads the model's
now-public constant. Tests that fill to the cap derive it. Why 12 was
chosen and what 24 costs: context/code/core.md section 9.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
Conflicts with #294 in render.rs and project.rs resolved as tested:
#250's side of every hunk (levelled GroupEnd { level }, groups()/group_of,
axis_frame's carried headers, the try_from level check), then #294's shape
(Item { tuple, .. }, FrameEntry::item(..), the DataItem { tuple, collapsed }
arm, and GroupEnd { .. } in folds). Evidence EOF appends keep both sides.
Two ChatGPT Codex findings on the #250 stack.
- #250, P2 (improvise-e265): a FrameGroup.level from the wire sized the
open-run stack in AxisFrame::groups, so a huge level drove a huge
allocation. AxisFrame now deserializes through UncheckedAxisFrame and
refuses any group or GroupEnd level not below cats.len(), naming it.
context/code/protocol.md §3b.
- #273, P3 (improvise-0r01): the lazy open_runs still cloned a stack per
skipped row under .nth(idx). walk_open_runs lends one stack to a visitor
that can stop the walk, so a lookup copies nothing for earlier rows.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
context/test-evidence/command.md: both sides inserted a block at the
same point, before the "improvise-i3o5: four declines" subsection. Both
kept: this branch's remove_formula_of_a_missing_formula_is_a_refusal,
then gh/main's totals-strip section (improvise-wm20).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
The build job failed on the PR head: build-doc-site.mjs refuses a context/
doc that is neither in the manifest nor a named omission. core.md §18,
landed with km71, still called this ADR 005.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
Carries #268 onto #258's new head, which now sits on #247's re-merge
(12f206fb) and so on #239's spanned_values and &Expansion signatures.
Code auto-merged: GridLayout::evaluator / GridEval stays the only public
path to a FoldMemo, and prune_empty still shares one memo. The one
conflict was benches/grid_projection.rs's main(): both new sections kept,
the pruned block kept directly after the folded chain it calls "the same".
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
Carries #258 onto #247's re-merged head, which now has #239's
spanned_values/spanned_positions and the &Expansion fold signatures.
Code auto-merged: FoldMemo::new(agg, covers) still owns both, and
fold_stored reads through spanned_values. The only conflicts were the
append-at-EOF evidence files (command.md, core.md); both sides kept.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
pb30: gz puts the cursor on the collapsed group through landing and
place_cursor, and their column arms were untested. Three rivals passed
all 1948 tests: the arm swapped, landing returning None for columns, and
landing reading col_tuple(0). A new test uses two column levels
(Region over Month) with the cursor at (1, 7), South/May. It requires
the cursor to land on South's Q2 at (1, 5). Each rival fails it and
nothing else. The col_tuple(0) rival needs the two column levels, since
with one level the prefix is empty everywhere.
0r01 (Codex's P2 on #263): innermost_group built open_runs for the
whole axis, cloning a level stack per row, to answer for one row on
every z. open_runs is now a lazy iterator, still the one reader, and
the lookup takes .nth(idx). a_lookup_does_not_read_past_its_row gives
it an unreadable header after row 0 (level usize::MAX). The eager rival
panics with capacity overflow.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
GridLayout::evaluate_in and column_total took any &FoldMemo, so a memo
from a layout showing Feb, handed to one hiding it, gave 5.0 where 2.0
was right. GridLayout::evaluator(model) now returns a GridEval that owns
a memo its layout built, with cell() and column_total(); the memo-taking
methods and the public fold_memo are gone. grid_body and prune_empty use
it, and the earlier rivals still fail when restated for the new API.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
uv9e: ToggleGroupAtCursor { is_row: false } (gz) had no command-level
test, and a version reading col_group_for(0) passed the workspace. A new
test seats the cursor at (0, 2) with Month on the columns and requires
Q2. Reading column 0, or reading the row half of the cursor, both fail
it. The row half was already closed: the grid tests sit on row 2, and
injecting row_group_for(0) fails five of them. The browser's z is still
Upstream with no resolver arm (improvise-uln5), so it has no cursor
reading to test.
6u6s: the e265 enumeration could not see run identity. A pruner keying
runs by (level, category, group name) instead of header position
passed. The enumeration now:
- compares every level's run name and whether each survivor shares a
run with the previous one;
- adds three fixtures: a non-contiguous group, a level with no groups,
and two adjacent outer groups;
- builds each layout once per collapse subset and drives
prune_axis_items with a keep predicate per row set: 4,992 cases.
The name-keyed rival now fails at fixture 2, mask 0b101. open_runs is
the one reader of the markers. improvise-core's lib went from
0.86-0.92s to 0.17-0.23s on an Apple M4 Max under load average 39-77.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
improvise-pntu: FoldMemo was keyed by (key, span) while the aggregation
and covers were call arguments, so one memo across two covers answered
the first (5.0 where 2.0 was right). FoldMemo::new(agg, covers) now owns
both and the _in functions read them off it; GridLayout::fold_memo builds
the grid's. The mismatched call no longer compiles.
improvise-0jw5: a test in improvise-command counts fold evaluations across
one grid_body call (18) against a fresh-memo control (90). A per-cell or
per-total fresh memo fails it. The counter is exposed through a new
test-support feature on improvise-core, enabled only as a dev-dependency.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
On an axis with two grouped categories, runs nest: North in Upper holds
Jan in Q1, and after Q1 ends North/Mar is still in Upper. The markers
carried no level, and every reader was flat, so the nearest marker
decided. Pruning an inner header dropped its GroupEnd too, and a
surviving row changed group. The unpruned answer was lossy as well:
North/Mar was in no group while it was drawn under Upper.
The user ruled per-level membership, with z toggling the innermost group
(the one nearest the cursor). So:
- AxisEntry::GroupHeader and GroupEnd carry a level. expand_category
reads it off its prefix, which holds one item per outer category.
Membership is one open run per level, and a row's group is the
innermost one (row_group_for/col_group_for, hence z).
- prune_axis_items re-marks the survivors instead of filtering the
markers. Before each kept item it emits the markers that make the
output's open runs equal the input's, closing deeper runs innermost-out
first. So each survivor keeps exactly its runs, no header is kept over
nothing, no trailing GroupEnd is left, and when nothing is pruned the
output equals the input. This replaces the pending/close_runs_of/
run_open machinery.
- The wire: FrameGroup gains level (an index into AxisFrame::cats), and
FrameEntry::GroupEnd becomes GroupEnd { level }. groups()/group_of
read the same per-level rule, and group_runs is unchanged (h0iv).
- axis_frame carries the open run at each level above a scrolled
window, not the single nearest header, so a window opening inside
North/Q1 still knows Upper.
Tests:
- an enumeration over every funded mask x collapse subset of a two- and
a three-level fixture (about 4,000 cases), asserting that pruning
moves no row's group, that no header is drawn over nothing and no axis
ends on a marker, and that nothing-pruned is marker-for-marker equal;
- the ruling at the layout and through z;
- frame/layout agreement at every window offset;
- the nested window carry.
Rivals, each injected and restored, are in the evidence files: flat
membership, a pruner that owes no GroupEnd, ends passed through eagerly,
headers kept eagerly, nearest-only window carry, and a flat frame.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
viewport::grid_body makes one FoldMemo and passes it to every cell and
total through GridLayout::evaluate_in / column_total, so a fold row's
formula chain is evaluated once across the row (9, not 45). The bench
gains a folded five-formula chain case; stored-only grids are unchanged.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
GridLayout::column_total asks Model::evaluate_spanning, whose Span adds to
#203's fold the categories a key omits. A formula is evaluated over the
pooled rows, a stored measure folds its records under its own aggregator,
text is unanimous or mixed, an error propagates, and _Measure on rows
leaves no strip (the user's rulings). Hidden rows stay out.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
Under Avg/Min/Max the strip sums each row's aggregate, so a Max
measure's total moves on z for the same reason a formula column's does
(improvise-wm20). Docs only, from the #203 review.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
The browser composes delete-formula-at-cursor now that FormulaCache carries
each formula's id. RemoveFormula names its formula by FormulaId on the wire and
in the effect, and a delete whose id no longer resolves is refused with the
batch stopped rather than silently doing nothing, so a frame naming a formula
another client removed is rolled back whole. The remove-formula script command
resolves its names to an id and refuses when nothing matches.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
Enter or e in the formula panel opens FormulaEdit on the formula under the
cursor, seeded with its text. The editor names the formula by a FormulaId
captured when it opens, and commits through a new ReplaceFormula effect and
ModelCommand. The edit keeps the formula's position, renames it in place when
the target changes, and refuses out loud if the formula was removed or
rewritten meanwhile, if the new target belongs to another formula, or if the
text does not parse. A refusal leaves the editor open over the user's text.
FormulaCache now carries each formula's id, so the browser composes the same
edit, and a formula-row click opens the editor on the clicked row.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
CellValue's Display writes a non-whole number {:.4}, and every editor seed
was that string, so i then Enter on an untouched 1.23456789 stored 1.2346,
in the TUI (pivot and records) and in the browser, which seeds from the
projected edit_text. CellValue::edit_text is now the one home of the seed:
the shortest decimal that reads back as the same f64 (non-finite values
take the core.md §16 token). display_value_in's two arms and
cell_display's records arm all go through it; the now-orphaned
GridLayout::resolve_display is removed.
Text that reads as a number (007) is exact in the seed but still
reclassified by typed(); filed as improvise-ixxx.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
A collapsed group is written to CSV as its own total row, because an export
writes what the grid draws (improvise-iot6). core.md §14 carries the argument;
the evidence entries record each rival and where it failed.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
serde_json writes a non-finite f64 as null, and null does not read back as one,
so CellValue::Number(INFINITY) serialized to {"Number":null} and the frame
carrying it was unreadable. The websocket reader skips an unparseable frame and
the worker logs a console error, so one poisoned cell silently dropped a whole
batch — and to_string returned Ok, so the send site could not notice either.
The ticket offered two answers and the format had already chosen. Measured
before changing anything: inf, -inf and nan all round-trip losslessly through
.improv — format_number writes them, the grammar's bare_value accepts them, the
walker maps them back. So refusing them before sending would make a legal saved
document unprojectable to a browser, which is a new failure rather than a fix.
It also settles the ticket's second location: resolve.rs's typed() accepting inf
is correct, not half a defect.
Number now serializes through an untagged adaptor: a JSON number when finite,
one of the three tokens when not. A finite cell's bytes are unchanged, and a
JSON integer still reads as a number rather than as a token.
The tokens are one home — nonfinite_token/parse_nonfinite beside CellValue,
called by the adaptor and by persistence/{format,parse}.rs. Three copies stood
before and a fourth was about to be written. format_f64 is deliberately not
folded in: it is the display layer and these three are a format.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
`DrillIntoCell::execute` and the records-mode command each spelled out what
their view must be as a run of `effect::SetAxis { view: ViewTarget::Active }`
pushes, interleaving the model question ("what is this view") with the UI
question ("how does a TUI keypress get there"). The two spellings shared the
`_Index`/`_Dim` spine and nothing kept them in agreement.
`view::layout` now answers the first question as data: `records_spine()`,
`records_steps(model)` and `drill_steps(key, none_cats)`, each a `Vec<ViewStep>`
of `Axis`/`PageSelection` assignments naming no effect, no `App` and no front
end. `cmd/grid.rs::steps_into_active_view` is the whole of the second question
for this front end -- a browser composing `ModelCommand`s against `_Drill` will
answer it differently without touching core.
`drill_constraint`/`drill_snapshot` were already model-side and are untouched;
core.md S8 records why their order cannot be derived a second time. `DrillState`
deliberately does not move: it holds an `Rc` and its `pending_edits` are
uncommitted writes belonging to whoever staged them, so it stays on `App` where
gotcha 30's session-local-residue tag puts it.
Behaviour is unchanged and no test expectation moved, which is the strongest
evidence a pure separation can produce. Measured on the orchestrator's machine,
before and after:
cargo test --workspace 1699 passed, 0 failed, 7 ignored over 38 binaries
node web/tests 56 tests over 6 files
node web/tests/bundle 4 tests over 1 file
playwright (harness) 68 passed, 4 skipped
--check-evidence 823 entries, 301 citing tests, 83 prose headings
Two rivals injected against the extracted functions, to show they are on the
live path rather than a parallel copy: `drill_steps` emitting `Axis::Page`
instead of `Axis::Filter` fails `drilling_fixes_its_coordinates_on_filter_not_page`,
and `records_steps` filtering on `is_measure()` instead of `is_index_or_dim()`
fails `save_with_unfinished_record_is_readable`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
improvise-vw47. Create View 2 and View 3, configure View 3, delete View 2,
then ask for a new view: both resolvers spelled `format!("View {}", len + 1)`,
which is the count and not a free name. `Workbook::create_view` replaces
whatever holds the name, so View 3's format reset to defaults and no view was
added -- a symptom that looks like nothing happened.
The name is derived at two sites, `cmd/panel.rs::CreateAndSwitchView` and
`resolve.rs`'s "create-and-switch-view" arm, so fixing one would have left the
TUI and the browser naming different views. `view::unused_view_name` in
improvise-core is the one derivation both now call. It takes an iterator of
names because the browser holds no `Workbook`, only the `ViewListCache` that
`view_summaries` maps one-for-one off `workbook.views`.
`Workbook::create_view` is deliberately untouched. Its replacement semantics is
what the `_Records`/`_Drill` rebuilders need -- both reach it through
`effect::CreateView` on every entry -- so refusing or renaming there would turn
"a view was reset" into "the key does nothing". The argument is in
`context/code/core.md` section 11.
Three tests, written failing first. The helper's own cases pin that a run of
taken names is searched rather than bumped once. The `Cmd` test uses the
ticket's fixture and asserts the count is below the highest name before asking
anything, because the pre-existing sibling test's fixtures all leave the
derived name free -- which is how the bug lived under it. `lockstep.rs`
witnesses the agreement: note that `both_ends` PASSES against the unfixed
source, both ends being wrong identically, so that test carries the count and
format assertions too.
Rivals injected: fixing panel.rs alone fails lockstep on the document text;
starting the search at 1 fails the pre-existing incremental-naming test.
Baseline 1667 passed / 0 failed / 7 ignored over 37 result lines; now 1670.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
improvise-cj4u (P2). View::on_category_added took a name and asked
CategoryKind::of_name, which reads Label as Regular by design.
Workbook::add_label_category papered over that with a set_axis(None)
straight afterwards, so views existing at that moment were right;
Workbook::create_view had only names to hand and had no such correction,
so every view created after a label pivoted it onto Row — and on an
already-pivoted document onto Page, filtering the whole document down to
one description.
on_category_added now takes the CategoryKind, and the two set_axis
corrections are deleted: one answer to "where does this category go"
rather than two, of which only one caller knew to write the second. The
signature change is deliberate — it is what made the compiler name every
call site. Every site outside Workbook is test code and passes
of_name(name), which preserves what those fixtures meant.
Shares a theme with improvise-dy3j — a Label kind that cannot cross a
boundary carrying only a name — but not a mechanism: dy3j is the file
format, this is a function signature. Fixed separately for that reason.
The control tests carry as much as the fix: a blanket `unseated = true`
satisfies every label assertion in the repository, so the direction the
bug cannot supply is asserted too, and a label is shown not to consume
the Row slot the next regular category wants. Reasoning in
context/code/core.md §9; evidence in context/test-evidence/core.md.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
Both findings from ChatGPT Codex's review of PR #125. Both are real, and both
are the same mistake in two places: answering "whose format is this" from
something other than the rule that answers "whose value is this".
**1. `FormatPlan` indexed formats in a `HashMap` keyed by target.** A target
category need not be `_Measure` — `[CustomCat]` is in the format's own
documentation — so a key can carry coordinates two formulas claim, and
iterating the map answered from whichever category hashed first: the displayed
format was nondeterministic per process while the value was not. That is
gotcha 18's shape. Worse, only *formatted* formulas were indexed, so a later
formula's `FORMAT` reached a cell whose value an earlier unformatted formula
produces.
`FormatPlan` now holds every formula's `(target_category, target,
Option<format>)` in the model's own order and takes the first coordinate match
— `Model::formula_for`'s rule and order exactly. A match naming no format falls
through to the view; it is a match, not a miss. The list is dropped entirely
when no formula names a format, so the common document still pays one
`is_empty` per cell.
**2. A column total took its format from row 0.** `context/code/core.md` argued
that was safe because "where `_Measure` is on the row axis a column is not a
measure at all". Wrong: a formula's target category is any category, so a
formula can own a row-axis item — the total then inherited a row-local override
for a row it had summed away, and re-ordering the rows changed its spelling.
New `GridLayout::column_key(col)` gives the coordinates that survive the row
aggregation (page/filter constraints plus the column's tuple), and `col_formats`
is resolved from that, once per column, outside the row loop.
Three regression tests, each with the rejected version injected as the fault:
- `the_formula_that_owns_a_cell_names_its_format_even_when_it_names_none` puts
the same two formulas in both orders, so preferring the formatted one fails
one half and ignoring formats fails the other.
- `the_owner_is_the_one_model_order_names_and_not_the_one_a_hash_does` rebuilds
the plan five times, since each `HashMap` gets its own `RandomState`.
- `a_column_total_does_not_inherit_a_row_local_format` builds one grid with the
owned row first and one with it last and requires the strips to agree. Every
raw cell holds the same value per measure, so the totals are numerically
identical and only the format can differ.
`context/code/core.md`'s wrong paragraph is replaced rather than amended, and
says what it got wrong.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
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