110 Commits

Author SHA1 Message Date
Edward Langley d8843181f9 chore: upgrades 2026-10-09 23:37:42 -07:00
Claude (adr007-display) ba053fdaa5 test(protocol,wasm,io): displays drawn, keys composed, in the grid, page bar, panels and CSV (improvise-cq1q.12)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-10-08 21:47:39 -07:00
Claude (adr007-display) 7a4dc07642 feat(tui,wasm,io): the grid, page bar, item rows and CSV export draw displays (improvise-cq1q.12)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-10-08 21:46:12 -07:00
Claude (8jk63-fix) 581934886d Merge remote-tracking branch 'gh/main' into feat/8jk6.3-date-hierarchy
# Conflicts:
#	context/test-evidence/formula-and-io.md
#	crates/improvise-io/src/import/wizard.rs
#	src/main.rs
2026-10-02 22:00:32 -07:00
Claude (8jk63-fix) ec9747cc0e fix(import): the date step refuses a spelling clash where the split fix is (improvise-d0n6) 2026-10-02 21:29:30 -07:00
Claude (8jk63-fix) 22d10987bf test(import): the date step refuses a spelling clash (improvise-d0n6) 2026-10-02 21:29:21 -07:00
Claude (8jk63-fix) 08b7ab0f35 style(import): format and shorten the spelling-clash check (improvise-d0n6) 2026-10-02 20:58:51 -07:00
Claude (8jk63-fix) c3e12842c2 fix(import): refuse a raw date spelled like a generated item or group (improvise-d0n6) 2026-10-02 20:50:01 -07:00
Claude (8jk63-fix) b516567e58 test(import): a raw date spelled like a generated item or group must not merge (improvise-d0n6) 2026-10-02 20:49:26 -07:00
Claude (imp321) 8f93d4e14a Merge commit '8220a065819652bb224d0b25f5724d6e3a5fbbe8' into fix/headless-import-refusals 2026-10-02 20:30:44 -07:00
Edward Langley 0f116ff582 Merge commit '8220a065819652bb224d0b25f5724d6e3a5fbbe8' into feat/8jk6.3-date-hierarchy 2026-10-02 20:30:05 -07:00
Cursor Agent b872923331 fix(improvise-2mqz): export the requesting session view
Co-authored-by: Ed Langley <fiddlerwoaroof@users.noreply.github.com>
2026-09-27 16:51:50 +00:00
Cursor Agent e0f04ee334 feat(improvise-2mqz): add wire forms for SaveAs and ExportCsv downloads
This commit implements the download flow for SaveAs and ExportCsv per the
USER RULING on improvise-2mqz. The correct shape is:
- A request frame asks the host for BYTES
- ServerFrame returns the bytes (or Rejected)
- Browser triggers downloads via Blob + <a download>

Key changes:

Protocol layer (improvise-protocol):
- Add Request::DownloadDocument and Request::DownloadCsv variants
- Add ClientFrame::DownloadDocument and ClientFrame::DownloadCsv
- Add ServerFrame::DocumentBytes and ServerFrame::CsvBytes
- Add KeyOutcome::LocalThenRequest variant for operations that need
  both local fold (mode change, buffer clear) AND a server request
- Update stranding() to unstrand SavePrompt and ExportPrompt modes
- Update keymap to use rebind() instead of commit() for commit-save
  and commit-export (because LocalThenRequest can't be in a Sequence)

Host layer (improvise-command):
- Handle new ClientFrames in host.rs returning bytes or Rejected
- Add Rejection::ExportFailed variant for CSV export failures
- Update CommitSave and CommitExport to clear their buffers
- Make render_csv() public in improvise-io for host use

WASM client layer (improvise-wasm-client):
- Add PendingDownload type with filename, bytes, and kind
- Store pending downloads for JavaScript retrieval via take_pending_download()

Browser layer (web/app.js):
- Add triggerDownload() function using Blob + <a download>
- Poll for pending downloads after frame handling

This unstrands SavePrompt and ExportPrompt which were previously refused
by stranding() due to missing wire forms.

Refs: improvise-2mqz

Co-authored-by: Ed Langley <fiddlerwoaroof@users.noreply.github.com>
2026-09-25 19:15:40 +00:00
Claude (agent-headless-import) fbe83fcc57 feat(import): --strict refuses a skipped record, and the headless suite covers the reviewed gaps (improvise-8jk6.6, improvise-8jk6.17)
- 8jk6.6, the user's ruling on PR #321 ("Add a --strict flag"): the
  default stays exit 0 with the counted warning. With --strict, any
  skipped record is a refusal that exits 1 and writes nothing. The check
  runs before the build and reads ImportPipeline::skip_warning, the same
  function the build's warning now comes from.
- 8jk6.17: the headless suite now sees three faults that only io unit
  tests caught at efaa5050:
  - naming only the first bad formula (two bad --formula flags);
  - dropping CSV decimal measures;
  - import-json reading a CSV through the JSON constructor.
- Docs record the user's three answers.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-24 23:27:45 -07:00
Claude (agent-hier) 6b910889c0 Merge commit '44718e8a' into feat/8jk6.3-date-hierarchy
# Conflicts:
#	crates/improvise-io/src/import/wizard.rs
#	tests/headless_cli.rs
2026-09-24 22:47:28 -07:00
Claude (agent-hier) 44718e8ab3 Merge commit 'c91cc055c8ad81afd8f055b71c74af6819020637' into feat/felo6-group-parents
# Conflicts:
#	context/test-evidence/formula-and-io.md
2026-09-24 22:42:49 -07:00
Claude (agent-hier) 2f5db9ed8e Merge commit '8d3230e68535c78b4e1b56a96255f0cc4a94fae2' into feat/8jk6.3-date-hierarchy
# Conflicts:
#	context/test-evidence/formula-and-io.md
2026-09-24 22:29:50 -07:00
Claude (agent-hier) 8d3230e685 fix(core): an expanded fold's depth is non-zero by type, so every fold the API builds reads back (improvise-felo.6)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-24 22:29:02 -07:00
Claude (agent-hier) 195f3b44b8 feat(import): a date column imports as one hierarchical category, the flat split a choice (improvise-8jk6.3)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-24 22:15:14 -07:00
Claude (agent-headless-import) efaa505059 test(import): a folder of only header-only files kept no record and refuses (improvise-8jk6.6)
After merging PR #314, a header-only file's seeded File proposal means the
category check alone no longer refuses an import that kept no record.
nothing_to_import does, and this pins it. The docs now name from_merge
(switched to from_csv in the merge) among the CSV entry points.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-24 22:12:11 -07:00
Claude (agent-headless-import) 9b8b061a93 Merge commit 'c91cc055c8ad81afd8f055b71c74af6819020637' into fix/headless-import-refusals
# Conflicts:
#	context/test-evidence/cli.md
#	context/test-evidence/formula-and-io.md
#	crates/improvise-io/src/import/wizard.rs
#	src/main.rs
#	tests/folder_import_views.rs
#	tests/headless_cli.rs
2026-09-24 22:08:36 -07:00
Claude (agent-headless-import) 8bd4b1a77b fix(import): headless import refuses instead of silently succeeding (improvise-8jk6.4, improvise-8jk6.5, improvise-8jk6.6)
- 8jk6.4: the CSV parser yields source text (CsvRecords), and only the
  role reads it as a number. A forced category keeps 00565796 and
  220944e2 verbatim; measures and analysis read the text through
  csv_parser::number_in. CsvRecords reaches a pipeline only through
  ImportPipeline::from_csv, so handing CSV text to the JSON constructor
  no longer compiles.
- 8jk6.5: an import formula that does not parse is refused by name with
  its parse error: the build refuses (headless exits non-zero and writes
  nothing), and the wizard keeps it out of the list with the text still
  in the buffer.
- 8jk6.6: an import that kept no record refuses and says why, from the
  build and from the wizard's review step. A partial skip stays a
  warning with a count and exit 0.

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:39 -07:00
Claude (agent-lpzu) c43ee72402 fix(import): a header-only or measureless file keeps its identity and a view of its own (improvise-lpzu, improvise-8jk6.13, improvise-8jk6.12)
ImportPipeline::from_merge seeds the File items and every file's columns
from Merged::sources, so a header-only CSV keeps its File item and its
columns (unseen columns are proposed as Labels, as an all-null column
is). A file with no measure column gets a records view filtered to it
instead of paging onto another file's hidden measure. Four per-file-view
rules the review found untested now each have a test that fails when
the rule is broken.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-24 21:47:11 -07:00
Claude (agent-hier) e497aef110 feat: nested group runs reach the wire, and views persist expanded(N) and collapsed folds (improvise-felo.6)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-24 21:43:55 -07:00
Claude (agent-lpzu) 736e9e5e18 test(import): pin where an absent coordinate draws and how a saved file view projects (improvise-8jk6.2, improvise-lpzu)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-24 20:51:40 -07:00
Claude (agent-lpzu) bb505f8faf feat(import): give each merged file a view filtered to it (improvise-lpzu)
A CSV merge now records each file's columns, and the build adds one view
per file: File on Filter at that file's item, its own categories seated
as a fresh import seats them, the rest on None, and other files'
measures hidden. The merged Default view stays the initial view. The
headless and wizard paths both carry the sources.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-24 20:48:16 -07:00
Claude (agent-lpzu) e65d87fda9 feat(import): import a folder of CSVs as one sparse model (improvise-lpzu, improvise-8jk6.2)
A folder argument expands to its *.csv documents through Storage::list,
and a record with no value for a category keeps its cell without that
coordinate instead of being skipped.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-24 20:43:31 -07:00
Edward Langley 7b9e868bd2 test+docs: derive #304's cap fixtures from MAX_CATEGORIES; record the coupling found (improvise-felo.1)
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
2026-09-24 19:26:41 -07:00
Edward Langley bd957fd619 Merge commit '4f38a35e945f54e1a0bbde3155ba518d4f298b71' into feat/felo1-max-categories-24 2026-09-24 19:22:37 -07:00
Edward Langley 7d1f90dbf9 feat(core): raise MAX_CATEGORIES from 12 to 24, with one home (improvise-felo.1)
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
2026-09-24 19:19:39 -07:00
Edward Langley 22cbfa679e Wizard text is clipped to the panel and measured in cells; the list header says what is shown (review B1, B2, Codex P2 on #304)
Every wizard write goes through Panel, which clips to the panel and saturates
its height arithmetic, so no step panics on a small terminal. The bottom row
truncates in cells, so a wrapped message with a multibyte character is drawn
whole. A scrolling list's range is named on its header row, where no item can
cover it. Tests: a multibyte message at every width, wrap bytes vs cells, a
no-panic sweep of every step at 20-100 x 3-20, the first item filling the list.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-24 19:11:42 -07:00
Edward Langley f00ed3e979 A collision is a cell two records write, and every summed cell is reported (improvise-9bit, improvise-4klp)
Records with disjoint measures on one tuple no longer count as colliding, so
the Refuse default does not refuse a sparse-measure CSV. Only the records
that share a cell are listed and numbered. The Sum confirmation lists every
summed cell rather than three and a count. Adds the date-derived name test.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-24 19:00:29 -07:00
Edward Langley 1a2952058d Import asks what to do with records that share every coordinate (improvise-cgqt)
When source records collide on every coordinate, the import wizard now asks:
sum them, keep them separate with a generated occurrence category, or go
back. Collisions are found before the build by the same placement pass the
build uses, and are carried to the TUI as structured data (WizardCache) and
listed in full, scrollable. At the 12-category cap, "keep separate" is shown
disabled with its reason and remedy.

Every wizard warning and message now wraps to the panel instead of being
truncated, and long lists scroll.

Headless paths no longer sum silently: `import --no-wizard` takes
`--on-duplicate refuse|sum|separate` (default refuse), and `:import-json`
refuses and points at `:import`.

Reasoning: context/code/formula-and-io.md, "Colliding records are a
question, not a default"; context/code/tui.md; context/code/cli.md §11.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-24 18:43:19 -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 f176c2b8ca WIP: a legacy JSON workbook is refused by name (improvise-tt22) 2026-09-23 07:43:13 -07:00
Edward Langley 95a35e8cce Every JSON parser reads numbers exactly, and the check derives its crates (improvise-6yvg, improvise-dz8c)
improvise-6yvg reproduced. A JSON import in improvise-command and a legacy
JSON .improv load in improvise-io stored 0.24815714285714285 as
0.24815714285714283 in each crate's own test build. The root binary's
`improvise import` used the same lossy parser. Now float_roundtrip is on every
normal serde_json dependency in the workspace: root, improvise-command,
improvise-io, and the optional test-support dependency of improvise-protocol.

improvise-dz8c: the hand-listed cargo-tree check over three frame-parsing
crates is replaced by tests/json_numbers.rs. It reads
`cargo metadata --no-deps` and names every package, including target-specific
entries, whose normal serde_json dependency lacks the feature. The rule and why
it beats "parses a frame", the build graph, and enabling the feature once in
the protocol crate are in context/code/protocol.md.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 22:19:05 -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
Ed Langley c26e9d3488 Merge pull request #164 from fiddlerwoaroof/edl.review
Edl.review
2026-09-22 20:18:52 -07:00
Edward Langley d0f2bf9789 chore: update cargo deps 2026-09-22 20:18:13 -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 b9bc46a96f style(io): keep the new comment inside the two-line cap
The comment added with the previous commit ran to three lines and
scripts/check-comment-runs.py failed the `test` job on it. The reason it was not
caught locally is worth recording: --check-evidence is ADDITIVE, so the run does
answer the cap, but the output was read through `tail -1` and the cap's line is
the FIRST of the two. A gate filtered down to the line you expect is not a gate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-22 19:12:56 -07:00
Edward Langley d7ae99722b fix(io): charge a date mismatch only to a row that lands (improvise-yss5)
Found by ChatGPT Codex reviewing PR #150 at af7fae0e, and correct. The tally was
incremented while walking a record's categories, before the record was known to
survive that walk. A row whose Date is unparseable and whose LATER required
category is missing is skipped entirely — it never lands in the imported model —
yet it had already been charged to the derived column. So the warning counted a
row nothing else in the import reports, and its value depended on the order the
categories happen to be walked in.

Mismatches are now collected per record and merged only after missing.is_none().

The regression asserts the fixture's category order explicitly, because it only
discriminates while Date precedes the missing category: a later change to
proposal order would otherwise make the test pass by never reaching the bad
date. Before the fix it reports three warnings where one is correct; the rival —
merging before the missing check rather than after — reproduces exactly that.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-22 19:06:45 -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 af7fae0eba fix(io): a date that does not match the detected format is reported, not dropped in silence (improvise-yss5)
extract_date_component returns None for a value the detected format cannot
parse, and the caller's `if let Some(..)` simply did not push the derived
coordinate — so the row silently left the derived Date_Month/Date_Year category
with nothing reported. Same shape as improvise-2cw9/jc95/5hfh: a value the code
cannot interpret became an absence rather than a report.

Narrower than the ticket reads, and the reason is a property of one function.
detect_date_format accepts a format only when EVERY distinct non-empty sample
parses, and extract_date_component makes the identical parse call — so through
the shipped CLI and TUI wizard a mismatch cannot occur. The reachable door is
ImportPipeline's `pub proposals`, where a library caller sets date_format
itself. a_column_of_mixed_date_formats_is_not_proposed_as_a_time_category guards
that reachability claim rather than leaving it as prose.

One warning per derived column, in the house wording csv_parser already uses.
The row still lands with the coordinates it does have: this reports, it does not
relocate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-22 18:46:36 -07:00
Edward Langley d9f732c529 fix: persist a category's kind, so a label no longer unloads a document
improvise-dy3j (P1). CategoryKind::Label had no spelling in `.improv` at
all: format_md wrote every category as a bare `## Category: Name` and
build_category read every section back through add_category. The kind was
dropped on the way out AND on the way in, so a round-trip equality test
could not see it.

A label is outside MAX_CATEGORIES = 12 by design and the import wizard
scores it as zero, so twelve categories plus a label is legally
constructible. On load every label came back Regular, the thirteenth was
refused, and parse_md failed: any such document was permanently
unloadable. Below the limit the loss was quieter — a label silently
became a pivot category.

Grammar first. The ticket proposed `## Category: Note [label]`, and
improvise-pczv forbids it: a section header takes its name as the WHOLE
line, so the suffix is indistinguishable from a category named
`Note [label]`. The kind is therefore an entry inside the section,
`kind: label`, spelled with a real token rather than a lookahead — the
grammar-walking generator skips predicates. Absence means `regular`, so
every existing file parses unchanged and a pivot category's section is
byte-identical to before.

Tests assert the parsed Category::kind, never the emitted line. Both
rival faults were injected against the fixed tree and both fail two
tests: a writer that omits the line, and a reader that discards the kind
it parsed (whose file text is correct, so any text-comparing test passes
it). Reasoning in context/code/formula-and-io.md; evidence in
context/test-evidence/formula-and-io.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:43 -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 509e44485b Merge remote-tracking branch 'gh/main' into wave/n-browser-boot 2026-09-21 16:24:58 -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
Edward Langley f26cc2a22a fix(core,io): three findings from the code-review skill on this branch
Self-review by fault injection, not reading. Three findings, one a real bug
this branch would otherwise have shipped and two vacuous tests of my own.

1. A REAL BUG THIS BRANCH CREATED THE DURABILITY OF. Model::remove_item left
   measure_agg holding an entry for a measure it had just deleted -- exactly
   improvise-qx1z's defect one layer down, in the same branch that fixed the
   View half and did not look at the Model half. Harmless while improvise-ppru
   was unfixed, because nothing persisted the map; persisting it turned a
   stale entry into a permanent one, so the document grows an Aggregation line
   naming a measure it does not have and re-adding that measure brings back an
   aggregation nobody asked for. A fix that introduces a format for state that
   outlives what it names is how a latent bug becomes a permanent one, so the
   sweep ships with the writer.

2. A VACUOUS CONTROL. a_file_naming_no_view_keeps_the_seeded_default PASSED
   under the over-broad "always drop Default" it exists to catch, because
   delete_view refuses the LAST view -- a guard one level away from the
   condition under test. It answered "does delete_view refuse the last view"
   where it meant "does drop_unnamed_default decline to fire". Split in two:
   a discriminating case naming Default AND a sibling, where the wrong
   condition really deletes, and the legacy no-views case kept separate and
   marked as the weaker statement it is.

3. A VACUOUS NEW TEST. a_category_named_prune_is_not_read_as_the_prune_flag
   passed under the rejected presence-style grammar that swallows "prune: row"
   whole, because the fixture used Row and on_category_added RE-DERIVES Row for
   the first regular category on reload -- the axis matched by reinvention, not
   by being read. Now uses Page plus a page selection, neither of which any
   default supplies.

Also verified and unchanged: the generator reaches all three new grammar
branches, including the scalar data line, each proved by a separate injection;
awkward measure names (|A: B|, |sum|, true) round-trip through the Aggregation
section; and improvise-nvq4 still does not reproduce.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 15:17:06 -07:00
Edward Langley 7509417d0b test(import): three review findings — a rival fix, a missing injection, an order test with one history
Self-review with the code-review skill, against kb.pl's doctrine. All three are
tests that passed while the thing they named was not pinned.

F1. `doctrine(inject_the_rival_fix_not_only_the_bug)`. The jcfs test used one
twelve-value fixture, and `take(100)` — raising the cap rather than removing it
— passed the ENTIRE suite. That pins a behaviour, not the rule: the rule is that
detection does not depend on how many values there are, and any fixed fixture
pins one side of some cap. It now runs at 11 and 500 leading values, so every
`take(N)` fails one arm. Renamed to say what it asserts.

F2. `doctrine(an_injection_that_does_not_fail_names_an_untested_path)`.
`an_all_null_column_is_a_label` was never proved able to fail. Under the l1f5
fault it PASSES — an all-null column was Label before the fix too — so its
neighbours' injection says nothing about it. Injecting its own fault (dropping
the `!present.is_empty()` conjunct) fails it alone. The rival fix for l1f5 was
also injected: `is_number() || is_null()` over the full slice repairs Revenue
and leaves Region broken, which is why both `all_` bindings had to move and
`a_blank_value_does_not_demote_a_string_column` is the separating input.

F3. `doctrine(an_order_test_needs_two_histories)`. The cell-order test used one
insertion history five times, which cannot tell "listed its own order" from
"emitted one canonical order that matches this fixture". Same six regions now
imported forwards and backwards, each asserting its own. Reaching the second
direction needed the loop re-ordered under the fault, since an injection that
fails at iteration 1 leaves iteration 2 untested.

Also: `duplicate_csv_headers_not_silent` asserted its two values by map
ITERATION ORDER; now by key, which is also what it means.

Four controls were verified able to fail rather than assumed:
an_ordinary_float_is_still_a_number (float branch deleted),
distinct_headers_are_not_renamed_and_warn_nothing (rename every header),
an_all_null_column_is_a_label (F2), and the long-row control.

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