Commit Graph

62 Commits

Author SHA1 Message Date
Ed Langley 7dc34e8458 Merge branch 'main' into feat/nn4s-faces 2026-09-25 00:01:32 -07:00
Claude (agent-faces) 92f8ac2113 docs(faces): where faces live and what the first two steps settled (improvise-nn4s)
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
2026-09-24 22:05:24 -07:00
Claude (agent-hier) e09ec75226 docs: group hierarchies and fold lines, with the evidence for each rival (improvise-felo.6)
The harness fixtures open their groups explicitly now that groups start closed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-24 21:54:23 -07:00
Edward Langley 46896a7512 Merge PR #250 (3e417729) into the 2026-09-24 integration wave
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.
2026-09-24 02:49:32 -07:00
Edward Langley 903d824e38 Merge PR #254 (3c513540) into the 2026-09-24 integration wave
# Conflicts:
#	context/test-evidence/command.md
#	context/test-evidence/core.md
#	crates/improvise-command/src/ui/app.rs
2026-09-24 02:48:03 -07:00
Edward Langley 88598c6f7e Merge PR #292 (3f98a17c) into the 2026-09-24 integration wave
# Conflicts:
#	context/test-evidence/command.md
#	crates/improvise-command/src/ui/app.rs
#	tests/shipped_documents_load.rs
2026-09-24 02:46:37 -07:00
Edward Langley cfa8bf1e0a Refuse a frame whose level names no category; lend the open-run stack
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
2026-09-23 23:41:48 -07:00
Edward Langley e44b1491ca Merge gh/main (cc95cfbf) into fix/e265-levelled-groups
# Conflicts:
#	context/test-evidence/command.md
#	context/test-evidence/core.md
2026-09-23 23:26:55 -07:00
Edward Langley 1629944eac Merge gh/main into fix/gk51-non-item-record-projection (#292)
Conflicts:
- cmd/core.rs committable_cell_key: took main's form, which asks
  `uneditable` (#277), and gave `uneditable` a declining NonItem arm.
  That keeps this branch's refusal of a commit on a non-item cell.
- resolve.rs reopened_at (auto-merged, non-exhaustive after the merge):
  a declining NonItem arm, the sentence `i` already says.
  The #277 review asked for both arms to decline, never to open.
- tests/shipped_documents_load.rs: kept both, this branch's
  every_record_a_shipped_document_projects_is_writable and main's git()
  helper.
- context/test-evidence/{command,core}.md: both sides appended at EOF; kept
  both, the branch's first.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-23 23:19:46 -07:00
Edward Langley 86afdb0a36 Merge gh/main (cc95cfbf) into test/4h8a-sfwy-guards
# Conflicts:
#	context/test-evidence/command.md
#	context/test-evidence/core.md
2026-09-23 23:19:23 -07:00
Edward Langley 5b7e0223aa Merge fold-memo/pntu-0jw5 (fa3350ac) into fold-memo/metf-opbo
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
2026-09-23 19:08:07 -07:00
Edward Langley fa3350acfb Merge collapse-fu/h6z1 (12f206fb) into fold-memo/pntu-0jw5
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
2026-09-23 19:02:39 -07:00
Edward Langley 12f206fb0a Merge collapse-fu/wm20 (500d4609) into collapse-fu/h6z1
# Conflicts:
#	benches/grid_projection.rs
2026-09-23 18:33:53 -07:00
Edward Langley 500d46092b improvise-jq0m: pin both narrowings against the per-member definition; record the bench and the evidence
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-23 18:32:33 -07:00
Edward Langley 519c8ddefd Model::is_item: membership without building the item list (improvise-4daa)
`first_non_item` asked `effective_item_names(cat).contains(name)` per
coordinate per projected cell, cloning every item name each time, and the
TUI projects the whole grid every frame. `is_item` asks `contains_key`,
plus the formula targets for a measure category, and is pinned equal to
the list it replaces. #252's write path already goes through
`first_non_item`, so both callers get it.

Measured on an Apple M4 Max, interleaved 5x, minimum per build: pivot
18k cells 193 ms -> 136 ms (base 131 ms); records with a 2000-item label
145 ms -> 2.8 ms (base 1.9 ms).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-23 18:11:18 -07:00
Edward Langley 0c3ec183d3 Pin the column toggle's cursor-follow; a group lookup stops at its row (improvise-pb30, improvise-0r01)
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
2026-09-23 17:49:05 -07:00
Edward Langley be99391818 Merge collapse-fu/wm20 (f668ddc9) into collapse-fu/h6z1
# Conflicts:
#	context/code/command.md
#	context/test-evidence/core.md
#	crates/improvise-core/src/model/types.rs
2026-09-23 17:34:11 -07:00
Edward Langley d7dceef549 a grid evaluates through its own evaluator, never another's memo (opbo)
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
2026-09-23 17:30:41 -07:00
Edward Langley fd221c6c6c prune_empty shares one fold memo per build (metf)
prune_empty evaluated every cell with a fresh memo, h6z1's shape on the
same keypress path. Measured on an M4 Max: a pruned build of the folded
chain went 705-900 ms -> ~350 ms at 18k and 6.4-6.6 s -> 3.0-5.0 s at
90k; the dense grid is unchanged within noise. A new bench line times
the pruned build. The test counts 9 evaluations where a fresh memo per
cell counts 45, and checks the pruned rows are unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-23 17:24:34 -07:00
Edward Langley 076ead7c7c improvise-yb4b/b5dp: pin the scan against the per-member fold; record the ruling, the bench and the evidence
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-23 17:18:55 -07:00
Edward Langley 2ea2407456 Pin the column group toggle to the cursor; guard run identity in e265's enumeration (improvise-uv9e, improvise-6u6s)
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
2026-09-23 17:08:02 -07:00
Edward Langley 6b3577a09e fold memo owns its agg and covers; grid_body's sharing is guarded (pntu, 0jw5)
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
2026-09-23 16:57:06 -07:00
Edward Langley 67438b8f66 test: guard every field App::restore puts back; assert the records coordinate by value
improvise-4h8a: restore_puts_back_every_field_the_snapshot_took writes all
thirteen AppSnapshot fields, asserts each moved, restores, and asserts each
is back. Dropping any one assignment from App::restore, or restoring
view_state as only active_view + status_msg, fails it naming the field.

improvise-sfwy: records_mode_records_display_returns_values asserted the
Region column was non-empty; it now asserts Some("North"). A GARBAGE or
category-name fault passed the old assertion and fails the new one.

Test-only; production code is unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-23 11:23:28 -07:00
Edward Langley d6e366dceb Group runs nest: markers name their level, pruning re-marks (improvise-e265)
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
2026-09-23 11:20:03 -07:00
Edward Langley 4f75fffcb9 improvise-h6z1: one fold memo per grid build, keyed by fold key and span
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
2026-09-23 09:09:34 -07:00
Ed Langley aa96cd6404 Merge pull request #224 from fiddlerwoaroof/wave/tt22-no-legacy-json
.improv is the only native format: remove the legacy JSON document loader (improvise-tt22)
2026-09-23 08:41:29 -07:00
Edward Langley 993418f143 improvise-d7aj: pin the fold memo to the whole key, and the diamond
A formula met at two fold keys in one evaluation (an IF branch naming an
item of another category) must be evaluated at each. A memo keyed by the
formula alone read circular there and passed the whole suite; this test
fails under it and nothing else does. The diamond A1 = B2 + C3 over D is
pinned too.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-23 08:12:48 -07:00
Edward Langley 7fb6960cf8 WIP: drop the serde derives only the legacy loader used; docs (improvise-tt22) 2026-09-23 07:50:26 -07:00
Edward Langley b3379d1a17 Merge remote-tracking branch 'gh/main' into collapse-fu/eccl 2026-09-22 23:38:33 -07:00
Edward Langley fb20d1c73a Merge remote-tracking branch 'gh/main' into collapse-fu/eccl
# Conflicts:
#	context/test-evidence/command.md
#	context/test-evidence/core.md
#	crates/improvise-core/src/view/layout.rs
2026-09-22 23:33:52 -07:00
Edward Langley e131ae99ff improvise-o1y0/lnkb: §14 states the memo, the IS ruling and the totals limit; evidence
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 23:33:08 -07:00
Edward Langley 0ecb1d89a9 Merge gh/main (4b88084) into collapse-fu/b5zh
Resolved context/test-evidence/core.md: gh/main's version, with #191's
two improvise-t0ma entries, plus this branch's improvise-b5zh entry.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 23:22:44 -07:00
Edward Langley 30187208d7 improvise-eccl: pool each distinct filtered context once (Codex review on #203)
An inline WHERE on the folded category rewrote every member context to the same item, so SUM(Revenue WHERE Month = Jan) on the Q1 fold counted Jan twice.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 22:35:12 -07:00
Edward Langley 79175bbb58 improvise-eccl/f2oo: correct §14's premise and state the fold's coverage; evidence
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 22:24:21 -07:00
Edward Langley 4ada5211a2 improvise-b5zh: an outer group's header is pruned with its rows
The 'saw no item, so collapsed' arm was not dead after 6qet: an outer header followed directly by an inner one reached it, and kept an orphan header over rows that had all pruned. Pending headers are now a stack closed per category.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 22:13:11 -07:00
Edward Langley 77893d830c improvise-t0ma: pin records_steps and drill_steps as steps, not as effects
records_steps was guarded by one incidental persistence test. Two tests now
assert the ViewStep lists directly: the records spine plus every non-index/dim
category on None, and a drill's Filter-fixed coordinates plus its freed
aggregated categories. The is_measure() rival fails the first; a Page-for-Filter
rival fails the second.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 21:44:27 -07:00
Edward Langley 1a359b4ae6 improvise-6qet: CSV export test, formatting, and the reasoning in context/
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
2026-09-22 21:06:41 -07:00
Edward Langley f94fbedb85 Merge gh/main; the wire adaptor round-trips through any deserializer
`Repr::Nonfinite(&'a str)` could only be produced by a deserializer that
borrows from its input, so the new wire spelling round-tripped through
`from_str` and `from_slice` and through nothing else. Measured on the
pre-fix head:

    from_str  {"Number":"inf"}       -> Ok
    from_str  {"Number":"inf"}  -> Err   (the SAME JSON document)
    to_value then from_value         -> Err   (does not round-trip its own output)
    from_reader                      -> Err

The escaped form is the serious half: escaping is not a distinction JSON
makes, so the wire accepted a subset of the encodings of the spelling it
claims to share with the file. `Cow<'a, str>` fixes all four; the serialized
form is unchanged.

The PR's own tests could not see this — both use `from_str` exclusively,
which is precisely the one path that borrows. A check adjacent to the
question it names, inside the change that defines the question.

Also tightened `a_string_that_is_not_one_of_the_three_tokens_is_refused`: its
second disjunct accepted `data did not match any variant`, which is exactly
the message the above bug produces, so it passed in both worlds.

Conflict was `context/test-evidence/core.md` only, against #143. Both entries
kept and verified line-by-line — neither test cites the file, so a dropped
entry would pass `--check-evidence`.

Gates on the merge: 1728 passed / 0 failed / 7 ignored over 38 binaries,
clippy -D warnings clean, fmt clean, and `cargo check --target
wasm32-unknown-unknown -p improvise-core` clean — the one gate the review
asked for and could not run.

Found by adversarial review (confirmed by injection, not reasoned).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-22 19:41:32 -07:00
Edward Langley 5280683f12 fix(core): a non-finite cell value crosses the wire, in the spelling the format already uses (improvise-55v2)
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
2026-09-22 19:00:13 -07:00
Edward Langley 7384a00117 test(core): count_aggregation asserts the count, not that it is at least one (improvise-a36c)
`assert!(val >= 1.0)` on a fixture whose answer is exactly 1.0 cleared the bar
for every inflated count. The ticket's fault (`len + 999.0`) is not the worst
case: it moves the empty count from 0 to 999 and so fails
eval_agg_over_no_rows_is_zero_only_where_an_identity_exists. Preserve the empty
case and all 1699 workspace tests pass while COUNT over any non-empty set is
wrong by 999.

Two assertions, because one does not discriminate. The exact 1.0 at Region=East
fails the +999 rival; a Count arm returning a constant 1.0 satisfies it, so the
unscoped COUNT over all three regions being 3.0 is what fails that one. Both
rivals injected; both fail, at different assertions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-22 18:23:47 -07:00
Edward Langley d0ec6a44a1 fix: a new view takes an unused name, derived once for both front ends
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
2026-09-22 08:13:12 -07:00
Edward Langley c80da1be36 fix: seat a category by its kind, not by its name
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
2026-09-22 00:57:48 -07:00
Edward Langley 2329dc6204 fix: resolve a cell's format through the formula that owns it
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
2026-09-21 23:45:07 -07:00
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
Edward Langley e2417414ad docs: the arguments the new comments point at, and the test evidence
context/code/core.md gets the two model rules (why removing a name removes
the formulas targeting it, and why a required virtual category refuses) and
the axis rule (why leaving the constraining axes forgets the page selection,
and why the predicate is filters_data rather than 'is it Page').

context/code/clients.md gets improvise-koh6's decision in full -- including
why the state-dependence rule is about Upstream and not about outcomes --
plus the cursor rule and the shared axis sentence.

Evidence entries are module-qualified where they must be: three leaf names
are answered by a test in both resolve.rs and panel.rs, and one in both
model/types.rs and workbook.rs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 19:24:47 -07:00
Ed Langley 7b8d7375af Merge pull request #108 from fiddlerwoaroof/wave/j-persistence
wave/j: .improv persistence — eight beads, and the format changed
2026-09-21 16:23:57 -07:00
Ed Langley 8d682bec1c Merge pull request #106 from fiddlerwoaroof/wave/l-prune-layout
Wave L: prune_empty drops collapsed headers and GroupEnds; column width overflows before clamping
2026-09-21 16:03:46 -07:00
Edward Langley 40b0574b4c test(protocol,core): assert gd83's property where the type lives, and make the collapsed fixture prune
Integration review of #106. Three findings; two applied, one does not reproduce.

1. AxisFrame::group_of had NO coverage of its GroupEnd arm. Replacing it with
   `FrameEntry::GroupEnd => None` left all 133 improvise-protocol lib tests
   passing — only tests/grid_frame_agreement.rs in the root crate caught it,
   and that file does no pruning. So improvise-gd83's property reached the
   protocol by construction through project.rs and never by assertion: a
   derivation living in two crates with one of them unchecked, which is what
   gotcha 44 and design-principles §5 exist to prevent.

   Two tests in render.rs over the exact frames prune_axis_items now produces
   — the re-opened run closed by a GroupEnd, and the collapsed header with no
   item under it. The second also asserts collapsed: true travels, because a
   header arriving without the flag is there and inert, which is lhi4 again
   one layer out. The injection now fails both where it failed nothing.

   This is the landed half of u6gc's AC3. The other half — that
   prune_axis_items -> axis_frame -> group_of really composes to these frames
   — still needs project.rs and stays filed rather than landed.

2. a_collapsed_groups_header_survives_pruning passed with prune_empty disabled
   entirely: its fixture funded every item, so nothing was ever pruned and the
   name claimed more than the test could see. It now carries an unfunded Mar,
   so turning pruning off leaves DataItem(["Mar"]) in the asserted sequence and
   the test fails. Same vacuity class as the any() -> whole-sequence fix in
   4075d66.

3. NOT APPLIED — "no fault separates the collapsed test from the GroupEnd
   test" does not reproduce. Re-measured all three faults over all four tests:
   inverting close_pending_run's condition to `had_item` fails the collapsed
   test while the GroupEnd test PASSES, because a re-opened run is closed by
   the GroupEnd arm whatever the header condition says. The evidence now
   carries the full 3x4 matrix rather than prose, so the claim is checkable
   instead of assertable, and every column is distinct.

Also moves wide_cell_clamps_without_overflow's evidence from core.md to
protocol.md. improvise-yae4 touches viewport.rs and render.rs and does not
touch improvise-core at all, so core.md was the wrong home; it was put there
by hand and the checker cannot see a misfiled entry — it asks whether a
heading names a real test, not whether it sits in the right file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 15:45:12 -07:00
Edward Langley 4075d66e6f test(core): cover the partly-pruned group, and pin the collapsed frame exactly
Self-review under the code-review skill, attacking my own three prune tests by
trying to build a wrong prune_axis_items that passes all of them.

The gap it found: all three fixtures put a group in an ALL-or-NOTHING state —
fully collapsed, fully pruned, or intact — so between them they said nothing
about the mixed case, which is the one a user meets first. A group where some
items prune and some survive now has its own test.

a_collapsed_groups_header_survives_pruning now asserts the whole row_items
sequence instead of any(|e| is the Q1 header). An any() cannot see a header
emitted in the wrong position or an extra entry beside it.

The attack itself found no defect, and what it established is worth more than
if it had. Rewriting run_had_item to mean "some item was REJECTED" rather than
"there was an item" is a no-op with the whole workspace green: the header is
pushed eagerly by the FIRST SURVIVING item, so pending_header is already None
before any rejection can be recorded, and the flag is only ever read in runs
where nothing survived — where the two readings coincide. The eager push is
what makes the collapsed/pruned distinction robust rather than delicate.

Also recorded, because it bounds what the new assertion may claim: two
consecutive GroupEnds are not reachable, since expand_category never emits
them, so "no stray GroupEnd" is a property of the writer and not something any
fixture can falsify. The commit that introduced the run_open tracking claimed
it as a virtue; it is one, but it is argued and not tested.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 15:14:32 -07:00
Edward Langley e3e89907ea review: pin that only an INVENTED infinity is fixed, and name the None arm
Self-review under the code-review skill, two findings, both narrow.

format.rs: the pikk test asserted a finite number never renders as inf and
that half-away rounding still happens, but said nothing about a genuine
f64::INFINITY. Proved the two assertions ask different questions by clamping
round_half_away to a bare passthrough: the rounding assertion fails (2.5 reads
"2"), the infinity one does not.

test-evidence: named the loop arm that looks reachable and is not -- a pass
computing None where the cache holds Some would spin to the bound and now be
marked circular. The only None evaluate_formula_cell can produce is a WHERE
filter that does not match falling through to aggregate_raw over no data, and
aggregate_raw reads self.data rather than the cache while the formulas do not
change during a recompute, so a Some-to-None transition cannot occur. Also
recorded the free cross-check between two of the fixes: the s08t test asserts
ref:Typo AFTER recompute_formulas, so a formula erroring identically every pass
must be seen to converge rather than be overwritten with circular.

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