Source: context/test-evidence/formula-and-io.md

On this page

Test evidence — improvise-formula and improvise-io

What each of these tests is evidence of: the bug it reproduced, the fault that was injected, and why it can fail. Keyed by exact test name — a rename orphans the entry, and scripts/check-comment-runs.py --check-evidence catches that. The mechanism arguments are in context/code/formula-and-io.md.

### is reserved for a name that really is an annotated test. A fixture, strategy or shared harness whose narrative is worth keeping gets a #### subordinate heading that says so. The checker as written accepts any fn, so a helper under ### would pass today and fail the moment that is tightened.


#improvise-formula/src/parser.rs

#each_primary_alternative_builds_its_own_expr

The anti-clamp companion to making the four Rule dispatches in parser.rs exhaustive. Converting an if rule == Rule::x { .. } else if .. { .. } else { .. } chain into a match is mechanical, and a mechanical conversion can transpose two arms and still compile — the compiler checks that every rule is named, never that it is named against the right body. This test walks each primary alternative (number, agg_call, if_expr, paren_expr, is_expr, ref_expr), plus unary_minus, and the three filter_value alternatives that share the other dispatch, and asserts each reaches its own Expr or its own unquoting. Proved discriminating by swapping the string and pipe_quoted arms: the test fails, and nothing else in the 85-test crate suite does.

Why the exhaustiveness is worth its noise, and how the defect was demonstrated, are in context/code/formula-and-io.md, "The tree walkers are infallible by construction" — including the injection trap, that an atomic new rule is caught by accident in first_inner and only a non-atomic one reaches the silent path.

#a_comparison_the_walker_does_not_know_is_not_ge

The cmp_op half of improvise-492x. parse_cmp_op ended _ => BinOp::Ge, so a <> alternative added to cmp_op in formula.pest would have parsed as >=: a filter that silently means something else. Same shape and same fix as the aggregate half above; the token <> is chosen because it is the spelling a future grammar would most plausibly add.

#an_aggregate_the_walker_does_not_know_is_not_count

improvise-492x, one of three beads that are the same defect — with improvise-vyrx (eval_bool_cached wildcarding BinOp where eval_bool lists it) and improvise-4lgj (the .improv pest walker's _ => {} arms silently skipping unhandled rules). design-principles §2 states the rule they all break: prefer an exhaustive match, so that adding a variant produces a compile error that names every site needing an update. Each of the three is a place where a wildcard turned that compile error into a silent wrong answer.

Here the wildcard was _ => AggFunc::Count. Adding | ^"MEDIAN" to agg_func in formula.pest would have made MEDIAN(Revenue) parse and evaluate as COUNT(Revenue) — a plausible number, no error, nothing to notice.

A compile error is not available and that is a property of the input, not a choice. agg_func is one pest rule matching five string alternatives, so pest_derive generates no per-alternative Rule variant for Rust to match exhaustively over; the walker's input is a &str. So the refusal is the next best thing and it is the idiom this file already uses for exactly this condition: GRAMMAR_INVARIANT, whose text is "parser out of sync with formula.pest". The module's own header comment — "tree walkers: infallible, because pest validated the pair first" — is what makes a panic here the honest shape rather than a Result threaded through every walker for a state no user input can reach.

The test injects the ticket's own fault: it calls parse_agg_func("MEDIAN") directly and requires a panic. On the pre-fix code that call returns AggFunc::Count and the test fails. Its companion, every_grammar_aggregate_and_comparison_is_spelled_out, is the anti-clamp direction — a walker that panicked on everything would satisfy the two should_panic tests, so the five aggregates and six comparisons are asserted to still map to what the grammar says they mean.

#every_grammar_aggregate_and_comparison_is_spelled_out

The anti-clamp half of improvise-492x — see an_aggregate_the_walker_does_not_know_is_not_count. It also stands as the list that must be edited when formula.pest gains an alternative: the walker refuses the new token, and this test is where the accepted set is written down in Rust rather than only in the grammar.

#parse_sum_with_inline_where_filter

Regression: a WHERE inside the aggregate's parens must parse as the aggregate's inline filter, not as a top-level WHERE clause on the formula. The two land in different places on the AST — Expr::Agg(_, _, Some(filter)) against Formula::filter — and the difference is silent at parse time.

#hyphen_in_bare_identifier_reference

Regression. The hand-rolled tokenizer that preceded the pest grammar did not allow - inside bare identifiers, so a reference to a persistence-legal name like east-coast was silently parsed as east - coast: a subtraction of two unrelated Expr::Refs, which evaluates rather than erroring. The pest grammar mirrors improv.pest's bare_name, which does allow -.

#hyphen_in_bare_identifier_inside_aggregate

The same fix through the aggregate path, because SUM(east-coast) reaches the identifier rule by a different route.

#pipe_quoted_target_is_unquoted_like_a_reference

Regression, and a data bug rather than a cosmetic one. The tree walker took the target's raw span, so a pipe-quoted target came back with its delimiters still attached while an Expr::Ref for the very same name came back unquoted. Downstream, Model::add_formula then created a _Measure item literally named |My Target|, which never matched the user's item My Target: the formula wrote into a ghost column and nothing referring to it by name resolved.

The test asserts both halves — the stripped target and that the reference spelled identically names the same item — because asserting only the target would pass under a walker that unquoted both wrongly in the same way.

Why no existing test caught it: the writer emits Formula::raw, not target, so the file text is identical either way and every round-trip test was blind to it. See context/code/formula-and-io.md § "A formula target is unquoted".

#pipe_quoted_target_applies_escapes

The escape table applies to the target too, not only to Expr::Ref — the companion that stops the fix above from being "strip the outer pipes".

#bare_target_is_unchanged

The negative arm: a bare target has nothing to strip. Without it, "always strip the first and last character" would pass the two tests above.


#improvise-formula/src/quote.rs

#unescaping_inverts_quoting

The reading half inverts the writing half over every escape both grammars define, plus the \X passthrough neither of them assigns a meaning to. The "42" case is there because pipe_quote is unconditional: a .improv text cell holding 42 must not read back as a number.

#a_trailing_lone_backslash_is_kept

Before improvise-i2m5 the unescape table existed twice — here (as parser.rs::unquote_pipe) and as improvise-io/src/persistence/quoting.rs::unescape_pipe. The two agreed on every input either grammar's pipe_inner can produce, and differed only on a trailing lone backslash, which pipe_inner cannot emit: io returned it, parser.rs panicked. This pins the surviving answer for that unreachable input, so the surviving function is total.

#a_quoted_name_round_trips_through_the_parser

The property the whole module exists for: a name quote_name spells parses, and the parser hands back the name that went in — as a target and as a reference, which after the target-unquoting fix are the same rule. The fixture list is the interesting part: keywords (WHERE, SUM), a leading digit (9lives), the escapes, and the empty name.

#a_keyword_is_quoted_even_though_it_looks_bare

Pins the keyword guard, which is deliberately stricter than the grammar requires and is user-confirmed — do not revert it as over-caution. The argument (PEG rule ordering, and ## Formulas carrying formula text verbatim) is in context/code/formula-and-io.md.


#improvise-io/src/storage.rs

#a_shared_backend_is_a_backend_and_a_clone_is_the_same_one

improvise-hmws. A worker must hold the concrete backend — for the four async methods the trait does not carry — and hand the App a Storage. Before the blanket impl Storage for Rc<T>, Rc<OpfsFs> was not a Storage and could not be made one outside this crate (orphan rule), so improvise-worker-server wrote a forwarding newtype, and so would every worker after it.

The regression guard is the signature: this does not compile without the impl. What it asserts beyond compiling is the part a newtype could still get wrong — that the clone the App holds and the handle the message loop keeps are one backend and not two.

#memory_fs_satisfies_the_storage_contract

#physical_fs_satisfies_the_storage_contract

Both run contract::storage_contract at a non-empty dir, which is the whole point of dir being a parameter: at Path::new("") a backend that answers list in the wrong path shape passes, because joining is the identity there. OpfsFs did exactly that and the disagreement was invisible until improvise-vhcz (see context/code/formula-and-io.md).


#improvise-io/src/persistence/mod.rs

#The shared harness: load_corrupt_gzip

Not a test, and so deliberately not a ### heading — the harness the two gzip-corruption tests below share. It asserts two preconditions before handing the damaged bytes to the loader: that the corruption actually changed the bytes, and that the result still looks like a gzip document by its magic bytes. Without the second, a corruption that also broke the routing would make the loader fail for a different reason and the test would pass while proving nothing.

#a_truncated_gzip_document_fails_as_an_unexpected_eof

A .gz document cut short — the disk filled, or the process died mid-save. The kind is asserted (io::ErrorKind::UnexpectedEof), not merely that an error occurred, because "an error occurred" would also pass if the loader had stopped decompressing altogether and choked on the compressed bytes as text.

#a_gzip_document_with_a_flipped_byte_fails_its_checksum

The other way a .gz arrives damaged, and it is a different kind (InvalidInput), which is the reason to test both: one test against one corruption cannot tell "the decoder refuses damaged input" from "the decoder refuses short input". The first is incomplete, the second is wrong — "you lost the tail" versus "you cannot trust any of it".

The byte flipped is in the CRC32 field, the first four of the eight-byte gzip trailer, and not in the DEFLATE payload (Copilot, PR #22). A payload byte also yields InvalidInput, but it can do so for malformed compressed data rather than for the mismatch the test is named after, and the name would then be a guess about which. Corrupting the trailer leaves the stream decodable, so the only thing left to fail is the checksum — hence the assertion on the source error's message, since anyhow's to_string shows only the outermost context.

#a_valid_gzip_of_invalid_utf8_is_reported_as_not_utf8

The third failure, and the one that made the other two worth keeping apart in the code as well as in the tests (Copilot, PR #22). A gzip stream can be perfectly well-formed and still hold bytes that are not UTF-8; read_to_string decodes as it decompresses, so a single Context around it called that document unreadable gzip — a wrong diagnosis of a document that decompressed without complaint.

The precondition asserts the payload really is invalid UTF-8; without it the test would also pass on a document that merely failed to decompress, which is the case the other two already own. It also asserts the message does not mention gzip: the gzip stream was fine, and blaming it is the bug.

#The fixture: storage_fixture

Not a test. A workbook with a category, items, a formula and data — enough that a round-trip exercises every section of the format. Every gzip test above and every storage round-trip below reuses it, so that "the document survived" is a claim about a real document rather than about an empty one.

#round_trips_through_memory_storage

improvise-6mq: persistence goes through Storage, so a workbook can be saved and loaded with no filesystem at all — no tempfile, no disk. This is the shape the browser backend plugs into.

#gzip_round_trips_through_memory_storage

The .gz suffix selects gzip regardless of backend: a property of the document name, not of the filesystem.

#physical_and_memory_backends_write_identical_bytes

Swapping the backend must not change the bytes written. This is what stops a backend from quietly normalising or re-encoding what it is handed.


#improvise-io/src/persistence/parse.rs

#two_sections_naming_one_category_round_trip_to_a_stable_group_order

improvise-349m: format_md(parse_md(text)) was not idempotent when a file carried two ## Category: sections with the same name — the two outputs differed only in the order of the > group lines. It looked like a flake because only grammar_prop_tests::generated_file_roundtrips saw it, and only when the generator happened to emit a duplicate header; taking proptest's minimal input and running it deterministically is what turned it into a bug.

The fixture is the smallest shape that shows it: a first section defining a group by a > line only, and a second whose item names a different group that also gets a > line. Both groups must have parents, or format_md writes only one > line and the differing internal order is invisible in the text — which is the trap when shrinking this fixture further. Fault injected: reverting ParsedFile::push_category to a plain Vec::push fails it, and the ticket's own minimal input was checked against the fix as well.

Stability is only half of what the merge owes, and the first version of this test could see only that half. Adversarial review injected the other fault — Some(_existing) => {}, which drops the duplicate section outright — and the whole improvise-io suite stayed green at 2000 proptest cases while the fixture's - X[G2] and > G2[P2] vanished from the document without a word. Main's behaviour was unstable-but-lossless; a test that compares two printed forms cannot tell that from stable-and-lossy, because both sides of the equality lose the same thing. The three contains assertions run before the equality for exactly that reason, and they fail under the drop.

#parse_md_formula_category_suffix_without_space

Bug improvise-zgb (b). The formulas walker located the [Category] suffix with raw.rfind(" ["), so a hand-written line with no space before the bracket — - Total = Food + Gas[Type] — was not recognised and the formula was silently retargeted to _Measure. The suffix is now part of the grammar (formula_line) and walked structurally.

#parse_md_formula_ending_in_bracketed_pipe_name_keeps_raw

Companion to improvise-zgb: a formula ending in a pipe-quoted identifier that contains " [" must not have the quoted name mistaken for a category suffix. It stays a _Measure formula with its raw text intact. Without this arm, "look for the last [" would pass the test above.

#unparseable_number_token_yields_error_not_zero

Bug improvise-4yc. The Rule::number arm used as_str().parse().unwrap_or(0.0), so any numeric token the grammar admitted but f64::from_str rejected silently became Number(0.0) — a fabricated value in a spreadsheet.

No token the current grammar admits actually fails f64 parsing (overflow saturates to inf), so the test exercises parse_number_cell directly, with the forms the grammar could plausibly drift toward admitting. That is the point: the guard exists for the drift, not for today.

#malformed_numbers_in_data_never_silently_zero

The same bug through the grammar, covering all three routings: --5 fails the number rule entirely and becomes bare_value → Text; 3.14.15 matches the 3.14 prefix, commits, and the rest fails → a whole-file parse error naming the text; 1e999 is admitted and saturates to +inf. None of the three is 0.0.

#parse_error_names_line_number_and_contents

Bug improvise-6kj: parse failures were wrapped as Parse error: {e}, leaning on pest's raw rendering instead of stating the location in plain words. A user fixing a hand-edited file needs the line number and the offending line.

#roundtrip_preserves_an_awkward_category_name

improvise-pczv as a property rather than a list. quote_header decides which names the header line cannot carry, and that decision is a small set of clauses — so the property is what earns its place: for any name, save-then-load must give the name back. It runs against populated_model rather than a fresh one because a workbook with no data cannot show a coordinate losing its category, which is the silent half of the bug.

#Why populated_model and not a fresh workbook

Not a test — the fixture the property above runs against. It already holds categories, items and cells, and the reason is the evidence: a default-constructed workbook makes the round-trip test vacuous for the data-coordinate half, because there is no coordinate to lose its category.

#The strategy: awkward_name

Not a test — the input strategy the property above draws from. The generated shapes are the whole-line contract's stress points: edge whitespace, embedded line breaks, pipes, and text that looks like another section marker (\n## Data).


#improvise-io/src/persistence/format.rs

#virtual_views_are_not_written

improvise-60z. _Records and _Drill are scratch views the app builds on demand. They put _Index/_Dim on axes, and those categories are never written to the file — so persisting the views produces axis lines referring to categories the loader will not find. The test also asserts a real view is written, so "write no views at all" cannot pass it.


#improvise-io/src/persistence/roundtrip_tests.rs

#initial_view_skips_a_virtual_active_view

improvise-60z's writing half: saving while in records mode must not leave Initial View: _Records pointing at a view the file no longer contains. It asserts the loaded workbook's active view exists in the loaded workbook, not merely that the string changed.

#virtual_views_in_an_existing_file_are_dropped_on_load

The reading half: a file written by an older build may already carry the scratch views, and loading one must not reinstate them.

#filter_axis_round_trips_with_its_selection

improvise-dz8. Axis::Filter arises only inside the _Drill view, which is never written, so this is defensive — but the alternative to supporting it in the format is silently writing a Filter axis as something else.

#parse_md_round_trips_formula_category_with_special_chars

Bug improvise-zgb (a). rfind(" [") mis-split a suffix whose category name itself contains " [": the writer emitted [Margin [%]] unquoted and the parser then split inside the name, producing garbage raw text and a category called %]. Category names in the suffix are now pipe-quoted on write and parsed via the grammar's name rule.

#a_keyword_named_category_is_pipe_quoted

improvise-i2m5. .improv now spells names through improvise_formula::quote_name, which pipe-quotes a formula keyword. Before the switch, io's own copy of the rule had no keyword guard and wrote ## Category: SUM, - WHERE and SUM=WHERE = 1 bare — that was the only output difference the whole persistence suite showed.

The names round-trip either way (improv.pest accepts pipe_quoted wherever a name goes), so this pins the spelling, not the survival. It also pins the header exception: category_section takes the whole line and never goes through quote_name on either side, so only references are quoted.

#a_keyword_named_category_round_trips_everywhere_it_appears

The round trip the quoting exists for, at every position a keyword-named category or item reaches the file: the axis line, the page selection, hidden:, the category section and the data coordinates. A guard that covered only some of them would pass a single-position test.

#a_formula_targeting_a_keyword_named_measure_round_trips

Why the keyword guard is worth its cosmetic cost even though bare keywords parse: a ## Formulas line carries formula text verbatim, so a name written by format_md is later read by parse_formula. The two grammars share one namespace, and quote_name is the one rule spelling names for both.

#a_category_name_the_header_line_cannot_carry_round_trips

improvise-pczv. A section header was the one name position that never went through a quoting rule, so a name the header line could not carry was written raw into it. The table NAMES_THE_LINE_CANNOT_CARRY is the whole rule, and each entry is a different failure:

This is reachable and not theoretical — see a_csv_header_with_an_embedded_newline_survives_a_save.

#a_model_and_view_name_the_header_line_cannot_carry_round_trip

The same rule at the other three header positions: the model title, the view title, and the Initial View: reference that must still resolve to the view.

#an_ordinary_spaced_header_stays_bare

The negative arm, and the one that stops quote_header from quietly becoming quote_name. The header carries its name as the whole line — that is the contract — and quoting a name the line carries fine would rewrite the header of every existing document for nothing. It also asserts that a reference to the same name is still quoted, so the two rules stay distinguishable.

#pipes_that_do_not_close_the_header_line_are_part_of_the_name

The one genuinely ambiguous line, decided by the grammar's &NEWLINE: ## Category: |a| and |b| is a category called |a| and |b|. Without the lookahead the pipe-quoted arm would match a and silently drop the rest of the line.

#a_header_written_by_an_older_build_still_loads

A header written before improvise-pczv is a bare line and must still load as one — spaces, pipes in the middle and all. The compatibility direction of the same grammar change.

#a_csv_header_with_an_embedded_newline_survives_a_save

The bug as a user meets it, and the reason improvise-pczv is a corruption bug rather than tidiness. An RFC 4180 CSV may quote a header field containing a newline; nothing on the import path forbids it, so the category carried one — and the .improv file the user then saved could not be reopened. The test asserts the precondition (the import still admits the header verbatim) so it cannot pass by the import having quietly started rejecting it.

#an_item_name_the_item_line_cannot_carry_round_trips

improvise-odbc. bank-info.improv in the repo root failed to load at - JORDAN MARTINEZ (line 293) with expected pipe_quoted. The ticket named two readings — a stale file, or a writer/reader gap in which improvise emits an item line the grammar rejects — and said to decide by asking whether improvise can write that line today.

It cannot. item_list's name is pipe_quoted | bare_name, and bare_name is [A-Za-z_][A-Za-z0-9_-]* with no space; quote_name mirrors it character for character, so every one of those item names is emitted pipe-quoted and read straight back. The file was hand-edited in c48a5cd — the v2025-04-09 migration quoted the Date items and left the Entity ones bare — and never round-tripped. So the reading is "stale file", and the standing finding was that nothing exercised it. This test is what that finding turned into: the item names from the file that motivated the ticket, driven through the writer and back. It asserts both directions — the spaced name is quoted, the bare-legal name stays bare.

#a_bare_item_name_with_a_space_is_rejected

The other half of the same finding: the exact line the file carries is not something the reader accepts, so the file really was broken rather than the error message being misleading. Asserting the failure keeps the test above honest — without it, "the writer quotes it" would be consistent with the reader also accepting the unquoted form. The trailing precondition (the same line quoted does parse) stops it passing because of some unrelated syntax error.

#prune_empty_survives_the_roundtrip

improvise-qu4a. View::prune_empty was never written to disk: format.rs wrote format:, hidden: and collapsed: for a view and nothing else, and improv.pest's view_entry had no prune alternative. Pressing P, saving and reopening brought every pruned row and column back — and the toggle marks the document dirty, so the user is invited to save the setting that is about to be discarded.

The reason nothing caught it is the shape worth remembering: it is invisible to fmt(parse(x)) == fmt(parse(fmt(parse(x)))), because both sides drop it (doctrine(a_roundtrip_equality_is_blind_to_a_loss_both_sides_share)). So this test asserts the flag, never the output's stability. Fault injected: delete the two-line w! in format_md; the test fails at the prune: true assertion, and the two controls below still pass — which is exactly why they are controls and not the evidence.

#a_view_that_does_not_prune_writes_no_prune_line

The control, stated separately because it is the direction the default supplies. A writer that emitted prune: true unconditionally passes the test above and fails this one. Not evidence of the bug; evidence the fix is not one-directional.

#a_file_without_a_prune_line_does_not_prune

The compatibility reading, decided rather than defaulted: a file written before the line existed means false, which is what happens today. improv.pest spells prune: true|false rather than making the line's presence the boolean, so the grammar-walking generator can emit prune: false and it round-trips to false through a writer that omits it.

#a_file_naming_its_own_views_gains_no_default

improvise-m4rv. Workbook::new seeds a Default view and on_category_added assigns every parsed category into every existing view, so a file naming only Plan parsed into two views — the second auto-laid-out and never in the file. Two consequences, the second worse: the shipped demo's view panel showed a phantom row at index 0, so the first V+Enter a visitor pressed took them off the curated view; and open-and-save with no edits rewrote the user's document, appending the whole ## View: Default block permanently.

examples/demo.improv was unaffected only because its view is named Default, which is why the bug survived the shipped-document tests.

Fault injected: remove the drop_unnamed_default call in build_workbook; the test fails with ["Default", "Plan"]. The fix is narrow on purpose — it drops Default only when the file named at least one view and none of them was Default, so the two cases in the control below are untouched.

#a_file_that_names_default_itself_keeps_it_alongside_its_siblings

The control the fix must not break, and the second view is the whole point. This entry replaces a vacuous one, found by review injecting default_is_a_phantom = true — the over-broad "always drop Default" — and watching the control pass. It passed because Workbook::delete_view refuses the last view: with Default alone in the map the drop was refused by a guard one level away from the condition under test, so the test answered does delete_view refuse the last view where it meant does drop_unnamed_default decline to fire. Naming Default and Other gives the map two entries, so the wrong condition really does delete and the test really can fail. (Two pre-existing tests, parse_md_round_trips_multiple_views and parse_md_order_independent_new_view_before_categories, also catch it — the repo was covered, but not by the test that claimed to be the control.)

#a_file_naming_no_view_keeps_the_seeded_default

The legacy reading, kept separate and deliberately weaker: a file with no ## View section at all still opens on the seeded view. It cannot be made discriminating the way its neighbour was — with no view named there is only ever one in the map, so delete_view's last-view refusal masks any condition. It is a statement of behaviour, not evidence of the fix.

#a_category_named_prune_is_not_read_as_the_prune_flag

prune: true and prune: row differ only in the value, and view_entry tries prune_line before axis_line — so a category literally named prune round-trips only because bool_kw is "true" | "false" and fails on an axis keyword. Safety rests on the disjointness of two keyword sets, which nothing else checks, which is why the test exists.

Its first version was vacuous, and the injection is what showed it. Feeding the rejected presence-style design — prune_line = { "prune: " ~ rest_of_line }, which swallows prune: row whole — left the suite green, because the fixture put the category on Row and Workbook::add_category → View::on_category_added re-derives Row for the first regular category on reload. The axis matched by reinvention rather than by being read. The fixture now uses Page plus a page selection, neither of which any default supplies, and the same injection fails it.

#the_aggregation_section_is_written_in_a_stable_order

improvise-ppru's companion. Model::measure_agg is a HashMap, and an index whose iteration order is hash order is not an order (gotcha 18) — writing it unsorted would give a different .improv file per process, the same class of defect as analyze_records. The test pins the three lines in name order and then asserts format(parse(text)) == text. Fault injected: drop the sort_by_key; the assertion fails on most runs, which is itself the argument for pinning it rather than trusting a single green run.

#a_removed_measures_aggregation_is_not_written

Found by review of this branch, not by a ticket, and it is improvise-qx1z's own defect one layer down: Model::remove_item removed the item and its cells and left measure_agg holding an entry for a measure that no longer exists.

It was harmless while improvise-ppru was unfixed, because nothing persisted the map — the stale entry evaporated on reload. Persisting it made it durable, so the document grew a ## Aggregation line naming a measure it does not have, and re-adding that measure brought 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; the sweep had to ship with the writer.

The fixture carries two measures and removes one, so the assertion separates "forget this measure" from "clear the map" — the same shape on_item_removed_forgets_a_hidden_item needs for the view half. Fault injected: delete the measure_agg.remove line; the test fails on the written text before it ever reaches the reader.

#an_interleaved_category_keeps_its_item_order

improvise-3yv3. format_md collected every ungrouped item into bare and emitted that whole list before any grouped item, so a category built as [grouped, ungrouped] came back [ungrouped, grouped]. Grid axis ordering consumes item insertion order (gotcha 18), so reopening a document silently rearranged its rows and columns.

The fixture interleaves deliberately — a, b, g1[G], c, g2[G] — because the ticket's own two-item reproduction cannot tell "writes grouped items last" from "writes each run in place": with one item of each kind, both orders are a single bare run followed by a single grouped one. The test asserts the four emitted lines, not just the parsed order, so a writer that happened to re-sort correctly by accident still fails. Fault injected: restore the two-vector split; the line assertion fails with ["- a, b, c", "- g1[G]", "- g2[G]"].

#a_cell_with_no_coordinates_round_trips

improvise-pnf5, the P1. A cell keyed by the empty CellKey is a legal model state — it is what AddRecordRow leaves while a records row is unfinished, and it is reachable from the shipped r then o keys. format_md wrote it as " = 0" and coord_list required at least one coordinate, so saving in records mode overwrote a good document with one the application could not reopen. CleanEmptyRecords runs on leaving records mode, not before Save.

The fix is a spelling rather than a discard, because the ticket is explicit that legitimate data must not be dropped: data_coords = _{ (coord_list ~ " ")? } gives the scalar cell the line = 42. It is unambiguous under PEG because coord needs a name, and bare_name cannot start with =, so the optional group fails cleanly and the "= " literal matches.

Fault injected: revert the writer's match to the old "{} = {}"; the test fails at the \n= 42\n assertion, and parse_md then rejects its own writer's output. The trailing format_md(&back) == text is what makes this a round trip rather than a parse check.

#slash_in_hidden_name_survives_roundtrip

Not a bug. improvise-nvq4 (external report) claimed a / in an item or group name corrupts the hidden:/collapsed: line, whose format is Cat/Item. It does not reproduce: hidden_line reads two names, a name is pipe_quoted | bare_name, and bare_name excludes / — so quote_name already writes hidden: C/|A/B| and extract_name_pair reads it back. The test is kept as a passing control over both maps, so the claim cannot be re-filed without someone first watching this pass.


#improvise-io/src/persistence/export.rs

#export_csv_quotes_commas_per_rfc4180

Bug improvise-1cz. export_csv joined labels and values with bare commas — no RFC 4180 quoting — and ignored the view's number format. Under a comma-grouping format a value of 1200 renders as 1,200 and a label like Smith, Jr. splits into two fields, corrupting every row. The test reads the file back with a real CSV reader and asserts the field count, which is the thing a string contains check cannot see.

Strengthened by improvise-711n. The assertion on the value was records[1][1].starts_with("1,200"), and the fixture's format is ,.2f — so the check could not tell 1,200 from 1,200.00 and sat green while parse_number_format threw the .2 away. It is now an exact equality. Reverting the format.rs fix fails it with left: "1,200", right: "1,200.00", which is the cross-crate demonstration that the format bug reached a user's exported file and not only the screen.


#improvise-io/src/import/analyzer.rs

#detect_short_year_format

Two-digit years are ambiguous with the four-digit format, so %m/%d/%Y matches first. Expected, not a defect: the wizard shows the detected format and the user can override it. The same argument as the ambiguity below.

#an_ambiguous_date_is_read_american_first

01/02/2025 and 02/03/2025 parse under %m/%d/%Y and %d/%m/%Y, and nothing in the data can tell them apart. The heuristic is first-match over DATE_FORMATS, so the American reading wins — a European CSV imports with its days and months swapped, silently, and the derived _Month/_Quarter categories inherit the error. improvise-l8rh ruled this a known limit rather than a defect, so this test is what makes changing the answer a decision somebody makes rather than a side effect of reordering the table. The argument is in context/code/formula-and-io.md.

The loop at the end is the precondition that makes this a statement about ambiguity rather than about one format merely fitting: the other reading has to fit too, or there is nothing to resolve.

#a_day_first_only_value_settles_the_ambiguity

Where the two readings genuinely can be told apart, the unambiguous value decides — first match still, but %m/%d/%Y now fails. Without this, "always American" would pass the test above.

#the_distinct_values_are_in_first_seen_order

improvise-7yx, found while pinning the date heuristic. analyze_records built its distinct values by collecting into a HashSet and iterating it, so the order was different in every process. This is gotcha 18's bug in a second place, and it reaches further than a tidy-up: build_model adds items in exactly this order, IndexMap preserves insertion order, and persistence relies on it — so the same CSV imported twice produced two different .improv files and two different row orders on screen.

The answer is first-seen order: deterministic, and the order the records themselves are in, which is the one a user can predict. Five runs is enough to see it, because each HashSet gets its own RandomState; the pre-fix code produced five different orders here.

#the_detected_date_format_does_not_depend_on_the_hash_seed

The consequence of the same bug that a user would have found hardest to explain. detect_date_format tests only the first ten samples, so when the sample order was a hash order, which ten it saw varied — and with it the detected format, whenever the eleventh-and-later values are the ones that rule a format out.

Twelve values are ambiguous and one (25/12/2025) is day-first only. In hash order it landed inside the first ten about half the time, so the column imported as %d/%m/%Y on some runs and %m/%d/%Y on others — every date in it off by the difference. In first-seen order it is last, deliberately, and the answer is stable, which is the property asserted.

The expected value moved with improvise-jcfs, and the reason is worth keeping. This entry used to say the answer was %m/%d/%Y and that the ten-sample truncation "is still a real limit". That is exactly backwards as a test: the fixture was built so the disambiguating value sits outside the window, so the assertion was pinning the truncation as correct behaviour — a test written to prove determinism had, as a side effect, made the neighbouring defect a specification. With the window gone the twelfth value is seen and the answer is %d/%m/%Y. Nothing about hash-seed independence changed; only the constant did. The general form: a fixture chosen to exercise bug A can silently enshrine bug B, and the tell is that the expected value is surprising for a reason the entry does not explain.

#a_disambiguating_date_decides_however_many_values_precede_it

improvise-jcfs. detect_date_format took samples.into_iter().take(10), so a date that can only be read day-first was never examined if it appeared after the tenth distinct value. %m/%d/%Y precedes %d/%m/%Y in DATE_FORMATS, so the column silently imported month-first and every derived _Month/_Quarter value was wrong. The cap is removed; all() short-circuits on the first failure, so a format that does not fit costs one parse either way.

Fault injected: take(10) restored. The test fails, as does the_detected_date_format_does_not_depend_on_the_hash_seed. The leading values are asserted to parse under both readings first, so this is a statement about a value outside the old window deciding, not about one format merely fitting — the same precondition an_ambiguous_date_is_read_american_first uses.

The rival fix is what shaped this test (doctrine(inject_the_rival_fix_not_only_the_bug)). A first version used a single twelve-value fixture, and take(100) — raising the cap instead of removing it — passed the entire suite. That is not the rule: the rule is that detection does not depend on how many values there are, and a fixed fixture can only ever pin one side of some cap. So the test runs the same shape at 11 and 500 leading values, an order of magnitude apart. Any take(N) fails one of them for every N, because the two leads straddle whatever N a rival picks — and the failure message names which lead hid the decider.

The 500-value arm costs microseconds: all() abandons %m/%d/%Y at the last element and %d/%m/%Y succeeds, so it is two passes over 501 short strings.

#a_blank_value_does_not_demote_a_numeric_column

improvise-l1f5. all_numeric was values.iter().all(|v| v.is_number()) over every value including Value::Null, and parse_csv_field maps a blank CSV field to Null. So one blank cell in a thousand-row Revenue column proposed Label instead of Measure, and on a default import the numbers then arrived as category item names rather than as cell data. Inference now runs over the values that are present.

Fault injected: both all_ bindings restored to the full values slice. This test and its string sibling fail; an_all_null_column_is_a_label does not, which is correct — it is the control.

#a_blank_value_does_not_demote_a_string_column

The same defect one branch over, and the reason the fix touched two lines rather than one. all_string had the identical shape, so a Region column with one blank became a Label: not merely a wrong proposal, but a column whose distinct_values were then computed over a slice the branch had already rejected. Fixing only the numeric half would have left a bug with the same cause, the same shape and no entry here.

#an_all_null_column_is_a_label

Stated because all() is vacuously true over an empty slice: with the Nulls filtered out, a column that is entirely blank would satisfy all_numeric and all_string and be proposed Measure on the strength of no evidence at all. The !present.is_empty() guard is what makes it fall through to Label, and this test is the only thing that names that as a decision.

Fault injected: the !present.is_empty() && conjunct dropped from both bindings. Only this test fails. It needed its own injection: under the l1f5 fault (both all_ bindings back over the full values) it passes, because an all-null column was Label before the fix too — so the injection that proves its neighbours discriminating says nothing about this one (doctrine(an_injection_that_does_not_fail_names_an_untested_path)).

The rival fix was injected too, and it is what justifies fixing both all_ bindings rather than the numeric one the ticket named: v.is_number() || v.is_null() over the full slice repairs the Revenue case exactly as well and leaves the Region case broken. a_blank_value_does_not_demote_a_string_column is the input that separates them.


#improvise-io/src/import/csv_parser.rs

#short_rows_pad_missing_trailing_columns_with_null

Bug improvise-k8i. The reader is configured .flexible(true), so rows shorter than the header parsed without error — but the missing trailing columns were simply absent from the record map. No error, no warning, and field-kind analysis saw skewed value counts. Short rows must keep every header column, missing ones as Null.

#short_rows_produce_warning_naming_column_and_count

Companion to improvise-k8i: the warning must name the affected column and say how many rows were short. A warning that said only "some rows were short" would pass a weaker test and be useless to the user.

#full_rows_produce_no_warnings

The negative arm: padding must not fire on a complete file.

#a_leading_utf8_bom_is_not_part_of_the_first_header

A header name becomes a category name and survives into the .improv file, so a byte-order mark riding along invisibly is silent corruption rather than cosmetics. Excel writes one; the csv crate strips a leading one, so the first header is Region and not \u{feff}Region.

The control at the end is what makes this an assertion rather than a tautology: a BOM in the second header is not leading, is not stripped, and shows up in the key — so the check demonstrably can see a BOM where there is one.

#crlf_line_endings_leave_no_carriage_return_in_the_data

The \r belongs to the terminator, not to the last field of the row: a Revenue of 1\r would be a string rather than a number, and every row would import as text.

The control: a lone \r is itself a record terminator to this reader, so Ea\rst splits one row into two. That is what a stray carriage return does when it is not part of a CRLF pair, and it is why "two records, clean values" is a real observation rather than a coincidence.

Since improvise-8jk6.4 the parser yields text, so the assertion is that Revenue is the text 1. A kept \r would make it 1\r, which fails that assertion, and which number_in would not read as a number where a measure wanted one.

#an_empty_file_is_no_records_and_no_warnings

An empty file is no records and no complaint. The wizard's own preconditions (wizard.rs::validation_error) are what refuse to build a model from nothing; the parser's job is to say truthfully that there was nothing there.

#a_header_with_no_rows_is_no_records

The same answer, and the case a user actually hits — an export filtered down to nothing.

#a_single_column_file_yields_one_field_per_record

The degenerate shape for everything downstream that splits a row into coordinates and a measure: one field, present, and not mistaken for a short row.

#a_nonfinite_numeric_token_stays_text_rather_than_becoming_zero

improvise-jc95, rated P1. serde_json::Number::from_f64 returns None for NaN and the infinities — JSON has no spelling for them — and parse_csv_field answered that None with .unwrap_or(serde_json::Number::from(0)). So NaN, inf, -inf and any overflowing literal such as 1e999 imported as a real measured zero: not a blank, not an error, a number that sums and averages and is indistinguishable from data. The token now falls through to the existing string arm, which is what an unparseable field already does.

Fault injected: the unwrap_or restored. The test fails on the first token. The precondition inside the loop asserts each token really does parse as a non-finite f64, so a future f64 parser that rejected inf outright would fail the precondition rather than pass the test vacuously.

The consequence downstream is deliberate and worth stating: a column carrying NaN is no longer all-numeric, so the analyzer proposes it as a Category or Label rather than a Measure. That is the honest answer — the column is not numeric — and it is visible in the wizard, where a fabricated zero was not.

Since improvise-8jk6.4 the numeric rule is csv_parser::number_in, applied through typed only where a number is wanted, and the parser itself yields text. So this test asserts that typed leaves each token as text. The unwrap_or fault would be injected into number_in now.

#an_ordinary_float_is_still_a_number

The control for the entry above. Guarding non-finiteness by deleting the float branch entirely would also pass that test, so this one pins that 1000.50 is still a Number and that 0 — the value the bug invented — is still reachable when the file really says so. Since improvise-8jk6.4 both go through typed.

#a_repeated_header_is_renamed_rather_than_overwriting_its_twin

improvise-5hfh. Headers key a serde_json::Map, so A,B,A inserted the third column's value over the first column's under one key: the record kept two fields where the file had three, with no error and no warning, and the analyzer then saw a column that did not exist in the source. Later occurrences are now renamed A_2, A_3, … and each rename is reported.

Fault injected: disambiguate_headers short-circuited to (raw, vec![]). The test fails on map["A_2"] being absent, and so does duplicate_csv_headers_not_silent.

The warning names the original, the 1-based column and the new name, because a user's next question is which of two identically-named columns became A_2. Note the enabled reproduction asserts the observable outcome first — three fields, values 1, 2, 3 in order — and only then that a warning mentions it; its original form asserted prose alone, against a module that emitted no warnings at all and so had no wording convention to match.

#a_rename_skips_a_suffix_the_file_already_claims

The repair's own failure mode. A,A_2,A renaming the third column to A_2 would collide with the real second column and reintroduce exactly the overwrite the fix exists to prevent — a bug strictly worse than the original, because the warning would say the data was preserved. The candidate suffix is checked against every name the file claims, so the third column becomes A_3.

#distinct_headers_are_not_renamed_and_warn_nothing

The negative arm: an ordinary file must not acquire suffixes or warnings. Without it, renaming every column would satisfy the two tests above.

#a_row_wider_than_the_header_is_reported

The half of improvise-k8i that was not closed with it. .flexible(true) also permits a row longer than the header, and the insert loop guards with headers.get(i), which returns None — so every field past the header width was dropped with no error, no warning and no record of it. The short-row half got padding and a per-column warning in f0b9227; the long-row half got nothing, because the ticket's title said "row widths mismatch" and only one direction was reproduced.

The values are still dropped — preserving them means inventing column names, which changes the imported model's shape and is a wizard decision rather than a parser one. What changed is that it is no longer silent: the warning counts the affected rows, names the header width and gives the widest row seen, which is enough for a user to tell a stray delimiter from a genuinely different file.

Fault injected: the record.len() > headers.len() counter disabled. Both this test and long_csv_row_is_not_silently_dropped fail.


#improvise-io/src/import/wizard.rs

#wizard_previous_reverses_each_advance

Bug improvise-907 (from improvise-o1c): no step bound a back key, so reaching a later step and wanting to change a field kind meant Esc — discarding every proposal toggle, formula and name. previous walks back through the path actually taken, including the steps a conditional transition skipped on the way forward, which is why it is a history stack and not a reverse table.

#wizard_build_parks_the_workbook_on_done

Bug improvise-907 (from improvise-c6j): advance mapped NameModel → Done but nothing ever called it there, so Done was reachable only from tests. build now parks the workbook on Done for confirmation.

#wizard_build_failure_stays_on_name_model

A build that fails despite the earlier gates leaves the user on NameModel with the error, rather than moving to a Done step with nothing to confirm.

#wizard_blocks_advance_when_over_category_limit

Bug improvise-mzv: with more than MAX_CATEGORIES (12 then, 24 since improvise-felo.1) accepted Category proposals, the wizard let the user advance through every remaining step and build_model() only failed at the final confirm. Advancing past ReviewProposals must be blocked, with a message telling the user to mark fields as Measure or toggle them off — the actions still available on that step.

#wizard_blocks_advance_when_no_category_accepted

Bug improvise-907 (from improvise-gfq): build_model raised "At least one category must be accepted" only on Enter at NameModel, the final step. An import whose fields all classify as Measure walked the user through DefineFormulas and NameModel before refusing. The gate belongs at ReviewProposals, where c can still change a field's kind — the same treatment improvise-mzv gave the too-many-categories case.

#build_model_null_category_value_is_missing_not_the_item_null

improvise-ych. A category value of JSON null used to be stringified by the v.to_string() fallback, inventing an item literally named "null" and placing the record under it. A CSV empty cell parses to Value::Null too, so every blank column value in a CSV import grew a phantom "null" item. Null means absent: the record must be skipped and counted, exactly as if the key were missing.

The skip is superseded by improvise-8jk6.2. Null still means absent, and still invents no item. But an absent coordinate no longer drops the record: it keeps its cell without that coordinate. So the test now asserts both cells (region=East and the bare _Measure=revenue) and no warning. The rival that restores the skip (a present-but-blank category returns the record as unplaceable) fails here, beside a_record_with_no_value_for_a_category_is_kept_without_that_coordinate.

#build_model_literal_string_null_is_a_real_category_item

The other side of improvise-ych: the string "null" is an ordinary value — a region really called "null" — and must survive as its own item. Only Value::Null means absent. Without this arm, "drop anything that stringifies to null" would pass the test above.

#build_model_empty_string_category_value_is_missing

improvise-ych, third arm. An empty string is the JSON spelling of a blank cell — the CSV parser already turns a blank field into Value::Null, so the two inputs must agree. An item with an empty name is unnameable in the grid, so it is treated as missing rather than created. Since improvise-8jk6.2 "missing" means the record keeps its cell without a region coordinate. It is no longer skipped, and the same skip-restoring rival fails here.

#build_model_sums_records_that_share_every_coordinate

improvise-2cw9, rated P1. Each record called set_cell directly, so two source rows with identical category values — East,10 then East,20 — were one cell and the second overwrote the first. A sum over the import read 20 where the file said 30, with no error and no warning, and nothing downstream could tell that a row had existed.

The policy is a decision, and the ticket deliberately left it open: preserve, aggregate, or reject. The model cannot preserve — a cell is named by its coordinate tuple and there is no second address for a second record, so the only lossless options are aggregating or inventing a row-identity category, and the latter changes the shape of every imported document. Summing is what a pivot table does to repeated records and is measure_agg's own default, so it is the answer here; the warning is what keeps it from being silent, because it is not right for a measure the user will later set to Avg (that value is now pre-summed). If that case matters, the follow-up is a wizard step that asks, not a change to this default.

Fault injected: the accumulate arm changed to total = val; contributors = 1 — last-row-wins again. This test fails on Some(30.0), as does repeated_import_coordinates_not_silently_overwritten.

The West cell is asserted alongside, because a repair that summed every cell into one, or that dropped the singleton path, would otherwise pass. The warning is asserted not to name West, so the collision report cannot be a list of every cell in the document.

#build_model_writes_cells_in_first_seen_order

Not a reported bug — the hazard the 2cw9 fix introduced. Accumulating means a map keyed by CellKey, and gotcha 18 is exactly this: Model::set_cell writes into an IndexMap whose order persistence depends on, so writing the cells in a HashMap's iteration order would have given the same CSV a different .improv file per process. That is improvise-7yx's bug, in a third place, introduced by the fix for a different one. The HashMap here only deduplicates; the order is carried by a Vec beside it.

Fault injected: cells.sort_by_key(|c| c.key.to_string()) before the write loop — a deterministic reordering, which is the weaker fault and still caught. Five iterations, because each HashMap gets its own RandomState and one run of a hash order can happen to be right.

Two insertion histories, per doctrine(an_order_test_needs_two_histories). A single-history order test cannot tell "listed its own insertion order" from "emitted one canonical order that happens to match this fixture" — a writer that always sorted, or always emitted the order of some other structure, is indistinguishable from the right one against one input. The same six regions are imported forwards and backwards and each run must list its own order.

Reaching the second direction needed care, because an injection that fails at iteration 1 leaves iteration 2 untested (the corollary in doctrine(a_precondition_derived_from_its_own_expectation_guards_nothing)). The loop was therefore re-ordered to [backward, forward] under the sort fault and observed failing on the backward history specifically, with left: ["alpha", …] (sorted) against right: ["charlie", …] (backward).

#every_summed_cell_is_reported_in_full

This replaces a_long_collision_list_is_truncated_with_a_count, which pinned 2cw9's three-then-"and N more" warning. After the Sum choice, the Confirm step still showed "…; and 28 more". The user's rule is that a message they need is never truncated, so each summed cell is now its own warning, and the step wraps and scrolls them. It was red at compile time against the old Option<String> signature, then red against a Vec that still held one elided sentence. The rival elided after three fails it.

#improvise-5j9's skip warning, retired by improvise-8jk6.2

improvise-5j9: skipping a record for a missing category value used to be silent, so a 1000-row import could drop rows with no user-visible sign. The warning had to name the count, the total, and the offending field(s); a bare "some records were skipped" is not actionable. Its test, build_model_warns_about_records_skipped_for_missing_category, was deleted by improvise-8jk6.2. A record missing a category value is now kept without that coordinate, so there is no skip left to report. Its successor is a_record_with_no_value_for_a_category_is_kept_without_that_coordinate, which asserts no warning at all. build_model_record_with_missing_category_value_skipped became build_model_record_with_missing_category_field_is_kept and now asserts the kept cell rather than only the East one.

#is_does_not_swallow_the_start_of_a_longer_word

Adversarial review of PR #54, low severity and fixed anyway. is_expr was identifier ~ ^"IS" ~ filter_value, so Region ISLAND parsed as Is(Region, "LAND") and Region isotope as Is(Region, "otope") — inputs that were parse errors before the production existed, now silently-wrong formulas. Gotcha 34's whole theme is that a silently wrong parse costs more than a refusal.

The repair went through two wrong shapes before the right one, and both are worth keeping. A negative lookahead ^"IS" ~ !(ASCII_ALPHANUMERIC | …) in an ordinary rule cannot work: pest auto-inserts WHITESPACE between sequence elements, so it tested the character after the space — rejecting Region IS East while still accepting Region ISLAND, i.e. exactly backwards. Making is_expr compound-atomic ($) stops the auto-insertion and forces the separator to be written out as sp+.

At which point the lookahead was redundant and was removed, which only re-injecting the fault revealed: deleting !bare_char left the suite green, because sp+ after IS already refuses ISLAND — IS matches, then sp+ demands whitespace and finds L. A guard nobody has seen fail is indistinguishable from one that always passes (doctrine(check_the_checker)), so it went. sp+ is now the mechanism, and relaxing it to sp* — the obvious "let people write Region IS|East|" change — reopens the hole; that substitution is what this test was finally proved against. The negative arm is paired with a positive one, or "reject everything" would pass.

^"WHERE" had the identical hole. It is closed now — see below.

#where_does_not_swallow_the_start_of_a_longer_word

improvise-r08u, the WHERE half of the same defect. where_clause and inline_where were ordinary rules, so pest auto-inserted WHITESPACE after ^"WHERE" and SUM(Rev WHEREAS x = y) parsed as a WHERE on a category AS: a silently wrong filter where it should have been a syntax error.

The test carries two negative shapes because the repair has two independent halves, and this was found by injecting each separately. Making the rules compound-atomic alone already refuses WHEREAS x = y — once implicit whitespace is gone, AS x cannot be one identifier — so a test built only on the ticket's reproducer passes over a rule still spelled sp*, and WHEREAS=y is what that rule still reads as a filter. Faults injected: the original non-atomic rule (fails on the first assertion) and sp+ relaxed to sp* (fails on the second). The positive arms are there so "refuse every WHERE" cannot pass.

#where_requires_a_space_after_the_keyword

The one spelling sp+ costs, stated rather than discovered later: WHERE|Region Name|=|East Coast| used to parse and now does not. IS has required its space since improvise-4zl7, so this is the two keywords agreeing. The paired positive arm — the same text with the space — is what stops it degrading into "any WHERE with a quoted category fails".

#the_grammar_is_parsed_once_however_many_files_the_strategy_yields

improvise-8shu. improvise-io's lib binary was the run's outlier — 1.42s here against a 0.01s median, measured at 1.40–1.45s, 1.50s and 3.70–5.24s on three machines. The ticket's diagnosis was that the grammar walk had no size bound, so a few deeply nested documents dominated. That was wrong, and measuring before fixing is the only reason it did not ship as the fix: instrumenting the two halves separately gave load_grammar() ×1000 = 2.50s and the walk itself ×1000 = 15.7ms. The walk was 0.6% of the cost. improv_file() called load_grammar() inside its prop_map, so pest_meta re-parsed improv.pest once for every generated case — 1000 times across the two properties, plus shrink attempts.

The strategy's input space was never the problem and is unchanged: the same entropy range, the same 0..N repetitions, the same properties over groups, formulas, views, hidden items and pipe quoting. The grammar is now read once through a OnceLock.

The guard counts parses rather than calls, which is what makes it order-independent: other tests in the binary reach grammar() too, and the count stays 1 however many of them run first. It drives improv_file() itself through a TestRunner rather than re-implementing what the strategy does, so a future edit that reverts the call site is what it catches. Fault injected: Gen::new(grammar(), …) restored to let rules = load_grammar(); — the test fails with left: 64, right: 1.

Case counts went 500 → 256 in the same file, which is the default context/design-principles.md §7 prescribes and never below it; that is worth a further 0.39s → 0.21s but it is not the fix, and reporting it as one would be the mistake this entry exists to record. Net: 1.42s → 0.21s, and improvise-io is no longer the run's longest binary.

#parser::generator::the_grammar_is_parsed_once_however_many_formulas_the_strategies_yield

improvise-i5z6 is improvise-8shu's twin, in improvise-formula. Both generator strategies, formula_string() and expr_string(), called load_grammar() inside their prop_map. So pest_meta re-parsed formula.pest for every generated case: three 256-case properties plus shrinks. The improvise-io fix never reached this crate, which has its own copy of the generator.

All figures were measured on an Apple M4 Max (16 cores) under heavy parallel load (load average 15–33), because an unloaded window was not available. The ratios are more trustworthy than the seconds.

The guard copies 8shu's shape. It counts parses, not calls, so it does not depend on test order. It drives both strategies through a TestRunner rather than re-implementing them. Red before the fix with left: 64, right: 1.

Rivals, one per strategy: restoring let rules = load_grammar(); in either formula_string or expr_string alone fails the test with left: 33. So the two strategies are guarded independently.

The case count stays 256. It is already the default that context/design-principles.md §7 prescribes, and it was never the cost.

#export_csv_omits_the_rows_the_view_prunes

improvise-iot6. render_csv built its layout with GridLayout::new, which does not apply prune_empty — only GridLayout::with_frozen_records does. So :export in the TUI and the CLI export path both wrote rows the grid does not draw. User-visible, in production, and shipping for as long as the two constructors have differed.

This is the fourth instance of the same trap; the third was project::grid_in, fixed in PR #78, and this fix is that one line for line. No fault needed injecting: on the unfixed tree the test fails with

CSV wrote a row the grid does not draw: left ["Food", "Gas", "Rent"], right ["Food", "Gas"]

Why the fixture is discriminating, which is the whole difficulty here. The sibling property written for improvise-t6db passed on the buggy tree in its first version, because it made its two views differ by turning pruning off — plenty of differing config, and vacuous, since with nothing to prune both code paths agree. So this fixture does two things that one cannot fake:

None of export.rs's three pre-existing tests mentions pruning at all, which is exactly why this shipped.

Both axes, because prune_empty prunes both. A first version of this fixture had a single Month, so it demonstrated the row half and left the column half asserted by nothing -- the same defect one axis over. Feb is now the empty column, the precondition is a pair on each layout ((2, 1) drawn against (3, 2) unpruned), and the CSV header is asserted to carry Jan alone.

#persistence::parse::parser_edge_cases::parse_duplicate_categories

Found by an external Grok Bot adversarial review (improvise-jkxr), and the finding was verified by injection rather than taken on report.

The test parses two ## Category: Type sections — - A then - B — and asserted only !item_names.is_empty(). That cannot fail for the defect the test's name and placement imply. ParsedFile::push_category concatenates on a duplicate section name (invariant(duplicate_category_sections_merge_in_pass_one)), and a merge that kept either half alone still leaves the list non-empty.

Fault injected: existing.items.extend(items) → existing.items = items, so a second section replaces the first instead of merging. The old assertion passed. The strengthened assertion fails with left: ["B"], right: ["A", "B"]. Only a total wipe of both sections could ever have failed the original.

The assertion is now assert_eq!(item_names, ["A", "B"]), which pins order as well as membership — insertion order is load-bearing here (gotcha 18), and the neighbouring two_sections_naming_one_category_round_trip_to_a_stable_group_order already holds groups to that standard. This test was the item-list half of the same invariant, held to a weaker one.


#A formula's own number format on disk (improvise-e0ex)

#parse_md_round_trips_the_format_a_formula_carries

format_md writes Formula::raw, so a FORMAT .4 suffix inside the formula text survives a save/load whether or not the tree-walker parsed it into Formula::format. That is gotcha 34's bug wearing new clothes: a target that kept its pipe delimiters round-tripped perfectly and still created a ghost _Measure item, precisely because raw was unaffected.

So this test asserts the parsed field, and its raw assertion is there to show why raw cannot be the witness rather than to check the writer.

Fault injected: build_formula's Rule::format_clause arm discarded instead of recorded (let _ = first_inner(inner);). It fails with left: None right: Some(".4") — and every other test in improvise-io passes, 242 of them, including the whole format_md↔parse_md characterization suite and the grammar-walking generator properties. That measurement is the entry's point: no text-comparing test can see this field go missing.

Note what the injected fault does not do: it leaves the grammar alone, so the line still parses and the file is still well-formed. A fault that also broke the parse would fail the test for the wrong reason.

#parse_md_reads_a_formula_with_no_format_as_none

The absent case, so the field above is something the file carried rather than a constant the walker invents. Without it, a walker hard-coding Some(".4") would pass the test above.

#a_label_category_survives_the_round_trip_as_a_label

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 Workbook::add_category. The kind was dropped on the way out and on the way in, so the loss was symmetric and no round-trip equality test could see it — the same shape the Formula::format entry above records. Reproduced at d46ec59: a workbook with Region (regular) and Note (label) came back with Note.kind == Regular.

The test asserts the parsed Category::kind, never the emitted line, for that reason. Its second assertion — that Region still reads back Regular — is the control: a reader that made every category a Label satisfies the first assertion and fails this one.

Faults injected, both against the fixed tree:

#a_document_at_the_category_limit_plus_a_label_reloads

The P1 half, and the reason improvise-dy3j was not merely a lost flag. Label is outside MAX_CATEGORIES (12 then) by design — Model::add_label_category has no limit check, and the import wizard's proposed_category_count scores a label as zero — so a document with twelve categories and one label is legally constructible and the wizard will build one from any CSV carrying a free-text column. Every label came back Regular on load, the thirteenth category was refused, and parse_md returned Err("Maximum of 12 categories reached"): the document was permanently unloadable. Reproduced at d46ec59 with exactly that error.

The second assertion (regular_category_names().len() == crate::model::MAX_CATEGORIES) is what keeps the first honest. A "fix" that simply raised or removed the limit would let the document load while putting a high-cardinality label into the pivot count, and this assertion fails on it.

#a_category_section_with_no_kind_line_loads_as_regular

The absent case: every .improv file written before this commit carries no kind: line, and must still load. Without it, a walker that defaulted PCategory::kind to Label would pass both tests above.

The input is hand-written text rather than a round trip, deliberately — a round trip cannot exhibit an absent line, because the writer under test is the thing that would have written it.

#a_loaded_label_category_is_not_on_a_pivot_axis

The end-to-end check that the two halves of improvise-dy3j/cj4u agree: a label is off every pivot axis in every view after a save and a load, including a view created after the label.

Worth stating what it does and does not witness. It is not a second witness for the kind: format_md writes each view's axes explicitly, so Note: none carries the seating across the file whatever the kind does. What it witnesses is the seating — with the improvise-cj4u fix reverted, create_view pivots Note onto Column in the source workbook, the file faithfully records Note: column, and this test fails. It was written while cj4u was still open and failed then, which is how that was established rather than assumed.

#import::wizard::tests::build_model_warns_about_values_that_do_not_match_the_date_format

improvise-yss5, and the ticket said plainly it had not been reproduced. It is real and it is narrower than it reads. extract_date_component returns None for a value the detected format cannot parse, and the caller's if let Some(..) simply does not push the derived coordinate — so the row silently leaves the derived Date_Month/Date_Year category with nothing reported, the same shape improvise-2cw9/jc95/5hfh closed elsewhere in this path.

Through the shipped CLI and TUI wizard it is unreachable, and the reason is worth writing down because it is a property of one function. detect_date_format accepts a format only when every distinct non-empty sample parses, and extract_date_component then makes the identical NaiveDate::parse_from_str call — so a mismatch requires the format to come from somewhere other than detection over the same values. That door is ImportPipeline's pub proposals: a library caller may set date_format itself, and the test does. a_column_of_mixed_date_formats_is_not_proposed_as_a_time_category guards the reachability claim rather than leaving it as prose, per doctrine(an_unreachability_claim_is_only_as_wide_as_the_routes_enumerated).

The fixture derives two columns (Month and Year) from one Date whose three values include two the format rejects, and the assertion is on the full warning vector rather than on a substring — so "one located warning per derived column" is separable from "one line saying some dates did not parse".

Three rivals, each caught at a different assertion:

The fix reports and does not relocate: the row still lands with the coordinates it does have, exactly as before. Changing where such a row is placed is a behaviour change the ticket did not ask for.

#import::wizard::tests::a_row_kept_without_a_category_is_charged_to_the_date_tally

Renamed from a_row_dropped_for_a_missing_category_is_not_charged_to_the_date_tally by improvise-8jk6.2. Its premise went with the skip. A row missing Region now lands, so its unparseable Date is a derived column's loss, and it is charged once to each of Date_Month and Date_Year. The test asserts the landed cell (Date=2025-02-05, _Measure=Amount = 200, with no Region and no derived part) and exactly those two warnings. The history below is kept because the rule it established (charge only landed rows) still holds. Every object row now lands.

improvise-yss5, found by ChatGPT Codex reviewing PR #150 at af7fae0e and correct. The first fix incremented unparsed_dates 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's tally. 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.

The mismatches are now collected per record and merged only after missing.is_none(). The fixture only discriminates while Date precedes the missing category, so the test asserts that ordering explicitly rather than relying on it — a later change to proposal order would otherwise make the test pass by walking the missing category first and never reaching the bad date.

Before the fix it reported three warnings where one is correct, naming Date_Month and Date_Year for a row that does not exist in the model. Injecting the rival — merging the per-record tally before the missing check rather than after — reproduces exactly that and fails here.

This is the second time in this ticket that the interesting question was which rows the count is over. The first was one warning per derived column rather than one overall; this is one warning per landed row rather than per attempted one.

#export_writes_a_collapsed_group_as_its_own_total_row

improvise-6qet (context/code/core.md §14). An export writes what the grid draws (improvise-iot6), and the grid now draws a collapsed group as one row whose cell folds the group, so the CSV body is exactly ["Q1,7.00"] (Jan 3 + Feb 4). Decided rather than defaulted: the alternative — expanding the group in the export — would reintroduce the iot6 divergence between what the user sees and what they export. On the pre-6qet tree the body is empty (the group had no row, and pruning left nothing), so the test fails there.

#Legacy JSON documents (improvise-6yvg, improvise-tt22)

a_legacy_json_workbook_loads_its_numbers_bit_for_bit (improvise-6yvg) pinned that the legacy JSON load path read numbers exactly. improvise-tt22 removed that path, so the test went with it. Its other role was to witness improvise-io's own float_roundtrip declaration. The manifest rule in every_crate_that_can_parse_json_enables_float_roundtrip (tests/json_numbers.rs) still covers that, because improvise-io keeps a normal serde_json dependency for its import types (context/test-evidence/tooling.md).

#a_legacy_json_workbook_is_refused_by_name

improvise-tt22, on the user's direction on PR #210: .improv text is the only native format. A document starting with { used to be deserialized as a Workbook. The test writes a JSON workbook in the shape the old loader read, both plain and gzipped, and asserts the error names "legacy JSON" and says "no longer supported".

Red at 52c6a05: Failed to deserialize workbook: missing field next_category_id``. That is a parse error, which is exactly what the ticket said the refusal must not be. A complete legacy document instead loaded successfully, which the since-deleted bit-for-bit test above demonstrated.

Rivals, each run:

#Colliding records (improvise-cgqt)

The user's decision on improvise-cgqt: when records collide on every coordinate, the wizard asks whether to sum them, keep them separate, or go back. The fixture colliding_records has two colliding tuples of different sizes (three records and two), interleaved with a record that collides with nothing. A one-collision fixture cannot tell a per-tuple occurrence counter from a global row number, and a fixture with only one group cannot tell a filter from no filter. Every rival below was injected into wizard.rs, the io suite run, and the file restored.

#collisions_are_known_before_the_model_is_built

Red before the feature: collisions() was a stub returning nothing. It asserts the structured result exactly: two groups, first-seen order, the coordinates in build order, and the 1-based record numbers [1, 3, 5] and [2, 6]. The rival every tuple reported, colliding or not (the retain removed) fails it and thirty-odd other tests.

#summing_duplicates_adds_their_values_into_one_cell

A characterization of 2cw9's sum, now chosen explicitly. It was green before the feature, and its role is to be the other half of the Sum/Separate discrimination: it asserts three cells (70, 6, 7) and no extra category, where Separate must give six cells. It was first red for a test bug: CellKey displays with braces, which the expected strings lacked.

#keeping_duplicates_separate_keeps_every_record_distinct

Red before the feature. It asserts all six cells with their occurrence numbers, the items 1, 2, 3 in order, the category on Axis::None, and the confirmation warning. The rivals each fail it:

#an_unchosen_policy_refuses_to_build_colliding_records

The rival Refuse treated as Sum fails it. This is what keeps a caller that never asked, like ImportJsonHeadless or a future path, from summing silently.

#a_policy_is_moot_when_nothing_collides

Separate and Refuse must add nothing and refuse nothing when nothing collides. It was green against the stub, because the stub ignored the policy entirely. It guards against a Separate that always adds its category.

#the_duplicates_step_is_absent_when_no_records_collide

Green against the stub, which never showed the step, so its evidence is the rival. step always shown fails it along with eight existing wizard walks. Its first version looped while step != NameModel and hung under that rival, because advancing from the step with Refuse is blocked. It is now bounded to four advances, so the rival fails it rather than stalling the suite.

#keeping_duplicates_separate_is_refused_at_the_category_cap

A full set of categories plus a collision, which is the shape of the reported import. The fixture is category_records(MAX_CATEGORIES), so it tracks the cap (12 when the test was written, 24 since improvise-felo.1). It asserts option (b) is disabled with a reason naming the cap (reason.contains(&cap), where cap is MAX_CATEGORIES spelled out) and Skip, that choosing it leaves the step and the policy (Refuse) untouched, and that the message is the reason. The rival cap checked with > fails it.

#choosing_separate_asks_for_a_name_then_moves_on

Also choosing_go_back_returns_to_the_step_before, back_retraces_the_duplicates_steps, a_name_that_is_already_a_category_is_refused and the_suggested_name_avoids_a_header_that_already_uses_it. The cursor sits at 1 and 2, never only 0, so "the option under the cursor" and "the first option" are different. The rival chooser ignores the cursor fails six of them. suggestion ignores headers and name check accepts a taken name each fail their own test.

#records_with_disjoint_measures_on_one_tuple_do_not_collide

improvise-9bit, found by the adversarial review of PR #304 at 92233cff. collisions_among keyed on the tuple alone, so {East, revenue} and {East, cost} were reported as colliding although no cell sums. Under the Refuse default, a sparse-measure CSV that imported cleanly before was refused. It was red before the fix: collisions() returned the East group. The rival tuple-only rule fails it, and fails only_the_records_that_share_a_cell_are_listed_and_numbered too.

#a_record_with_no_measure_collides_with_nothing

The rule the reviewer's injection if false && measures.is_empty() showed was unguarded. It asserts records [1, 3] with the measureless #2 excluded, and that the two measureless West records form no collision at all. It was green before the fix, since the rule already held, so its evidence is the rival: a measureless record counts as writing fails it.

#only_the_records_that_share_a_cell_are_listed_and_numbered

A tuple with one cost record and two revenue records. Only #2 and #3 are listed. Under Separate, the cost record keeps occurrence 1, beside the first revenue record, and the second revenue record gets 2. The rivals tuple-only rule and a non-colliding record numbered past 1 each fail it.

#the_name_check_covers_categories_derived_from_dates

improvise-4klp, filed by the same review. The name step's check against date-derived categories was untested. The test extracts when_Year and types that name, which must be refused. It was green on arrival, so its evidence is the reviewer's rival, date-derived names dropped from the taken names, which fails it. That fault would have let add_category reuse the existing category, and every cell key would then name when_Year twice.

#a_group_both_expanded_and_collapsed_is_refused_naming_view_and_group

The user's ruling on improvise-felo.6. Three spellings of one group given two folds, each refused as Refusal::FoldConflict naming the view and the group, plus a category given two depths. Rival: a second fold for a group silently wins. Only this test fails. Its control is a_collapsed_group_inside_an_expanded_region_loads: a DIFFERENT group collapsed inside an expanded region, and the same line twice, both load.

#a_cycle_among_group_parents_is_refused_naming_it

> Q1-2025[|2025|] and > |2025|[Q1-2025]. Rival: the cycle check disabled; only this test fails. The fixture's first version spelled 2024-01 bare, which the grammar refuses (a bare name starts with a letter), so every test on it passed or failed for the parse error, a_malformed_depth_is_a_parse_error vacuously. It now carries a control that parses.

#an_axis_line_for_a_category_named_expanded_is_still_an_axis_line

axis_line is tried before expanded_line, so the writer must never spell a category depth bare. Rival: the writer writing expanded: Cat for depth 1. This test fails: the category row at depth 1 reads back as an axis line for a category named expanded.

#an_old_file_with_only_collapsed_lines_loads_closed_and_keeps_its_lines

"Everything opens closed, including old files." Every group of the file opens closed, the one its collapsed: line names and the three it does not, and the line is written back. Rival: the old open default; this test fails with the other eleven in that rival's list.

#a_date_column_imports_as_one_category_of_months_in_quarters_in_years

improvise-8jk6.3, the user's "dates should usually be one category". The fixture has two days of one month, so that month is one summed cell, and January of the next year, so a month must stay unique across years. Rivals, each run against the date_hierarchy module, and each caught here:

#a_level_left_unchosen_is_skipped_not_emptied

Year + Month nests months straight into years, and Quarter alone is a flat category of quarters. Caught the finest-first rival and the raw-dates rival.

#an_imported_date_hierarchy_opens_folded_to_its_years

The import sets nothing: the hierarchy opens folded, one row per year, because every group starts closed (improvise-felo.6). The 2024 row is the fold of its months, 111. Caught three of the rivals above.

#an_unparsed_date_keeps_its_value_and_is_warned_about

A format that reads none of the values keeps each raw value as an ungrouped item, and the build says so. Its first version passed on the base commit: the shape was ignored there and the SPLIT's warning happened to contain the same words. It now also asserts there is no Date_Year. Rival: the unparsed value not counted; this test fails.

#a_headless_date_import_splits_when_told_to

--split-date is the wizard's s, headless. It failed first as an unknown argument. Rival: the flag parsed and ignored; this test fails, and a_headless_date_import_is_one_hierarchical_category does not, which is the point of having both.

#every_fold_the_api_can_build_reads_back_from_the_file_it_writes

ChatGPT Codex, P2 on #317 at e09ec752: GroupFold::Expanded(usize) and View::set_group_fold are public, so Expanded(0) could be built, written as expanded(0): … and then refused by the grammar that wrote it. Confirmed by reading: the writer formatted any usize, and depth_number starts at 1. The fix makes the state unrepresentable (Expanded(NonZeroUsize)), so the regression guard is the type, and the old failing input no longer compiles. This test covers every fold the API CAN build (Collapsed and depths 1 to 3, beside a category depth), each saved and read back.

#a_record_with_no_value_for_a_category_is_kept_without_that_coordinate

improvise-8jk6.2, reported by the user from the wizard: "Skipped 6 of 342 records missing category values: Settlement Date (6)". Plan::place returned Placement::Missing for a category with no value, so the build dropped the whole record, measures and all. The fixture has one complete record, one with "Settlement Date": null and one with "", for two different payees. At e65d87fd's parent the build stored only the first cell. Now the stored cells are exactly the three records, the two blanks with no date coordinate, and there is no warning. Settlement Date holds only its one real item, so neither "null" nor "" became one. Rival R1, a record with a present-but-blank category treated as unplaceable, fails this test and the two improvise-ych tests.

#files_with_different_headers_import_as_one_sparse_model

improvise-lpzu. code-maat's age.csv (entity,age-months) and coupling.csv (entity,coupled,degree,average-revs) merged through merge_csvs_from. Before the fix, the age records had no coupled key, so they took the same Missing path and every age record was dropped. The build stored the four coupling cells and nothing else. The test pins the proposals (each column's kind, judged over its present values), all six stored cells sorted, no empty-named item in any category, and no warning. Rival R2, a record whose file lacks a category column skipped, fails it along with the two CLI folder tests, the projection test and the per-file view test.

#a_label_column_a_record_lacks_is_no_coordinate_not_an_empty_item

improvise-lpzu, the label half. Labels were attached with scalar_name(..).unwrap_or_default(), so an age record, whose file has no coupled, stored coupled= as a coordinate, and coupled grew an item named "". Before the fix the first sorted cell was File=age, _Measure=age-months, coupled=, entity=src/a.rs = 2. Rival R3 (unwrap_or_default restored) fails it.

#a_folder_expands_to_its_csv_files_in_name_order_and_skips_the_rest

improvise-lpzu. The folder holds three CSVs written out of name order, one with an upper-case .CSV, plus a .txt, an .improv (the output a second run finds) and a CSV in another folder. Only the three are returned, sorted. MemoryFs stores in a HashMap, so the order comes from Storage::list's contract and not from insertion. Rival R5 (the extension filter dropped) and rival R6 (the listing passed through a HashSet) each fail it.

#a_folder_with_no_csv_files_is_refused_by_name

improvise-lpzu. Both an empty folder and a folder holding only non-CSV files must refuse with no CSV files in folder maat. Importing nothing would write an empty document. Rival R4 (the ensure! removed) fails it, and so does R5, because the text-only folder then "has" files.

#files_and_folders_expand_in_argument_order

improvise-lpzu. [z.csv, b/, a.csv] must expand to [z.csv, b/one.csv, b/two.csv, a.csv]. Argument order is kept, and a folder is sorted within itself. A fixture listing only folders, or files in name order, could not tell "argument order" from "sort everything". R6 fails it.

#two_files_with_one_stem_union_their_columns

improvise-8jk6.12 (3). a/x.csv (entity,one) and b/x.csv (entity,two) are one File item, x, whose columns are entity, one, two. Rival F3 (the reviewer's fault: clear the stem's columns before each file) leaves entity, two, and the x view would then hide the measure one that half its records carry.

#files_with_different_headers_merge_without_inventing_the_missing_columns

improvise-lpzu, at the parser. Each merged record carries exactly its own file's keys plus File: no key, null or 0 for a column another file has. Each SourceFile lists its file's columns in header order. The per-file views are built from those lists.

#a_record_with_no_coordinate_is_drawn_only_where_that_category_is_off_the_grid

improvise-8jk6.2's downstream question, asked of the real layout rather than argued. Bolt has no Date:

This is a characterization of the model's existing sparse semantics. It found no bug. Its purpose is to make the Row/Column answer ("invisible") a stated fact the user can overrule, rather than an accident. R2 fails it.

#a_merge_gives_each_file_a_view_projected_to_its_columns

improvise-lpzu, the user's "one view filtered by the file column and projected to the columns in that file". The test asserts:

The age view draws [2, 7]. The coupling view draws [5, 3], because its first measure in model order is average-revs: serde_json's map orders keys alphabetically. Failed at bb505f8f's parent, where no view existed. Rivals R7 (File on None), R9 (every category seated) and R10 (nothing hidden) each fail it.

#a_files_view_holds_only_that_files_records

improvise-lpzu. The code-maat fixture cannot show that the filter works, because its files share no measure, so the measure page alone would isolate them. Two quarterly files with the same Region,Revenue schema can: without the filter, q1 would draw 30 and 3. Rival R7 fails it. R9 and R10 do not, and are not meant to.

#a_files_view_name_skips_names_already_held

improvise-lpzu. Two stems collide with names already held: Default, the merged view, and _Drill, a virtual view that is never written and is dropped on load. The first becomes Default 2 and the second _Drill 2, and each still filters to its own item. Rival R8 (the virtual names not checked) fails it: the file's view would have been saved as _Drill and lost.

#per_file_views_survive_a_round_trip_and_repeat_byte_for_byte

improvise-lpzu. Two builds from two independently written MemoryFs give identical .improv text, and format(parse(text)) == text. The reloaded document still has the three views in order, File: filter at the file's item, and the same drawn values. This is the first persisted, non-virtual use of Axis::Filter (gotcha 27). R7 and R9 fail it.

#a_csv_column_read_as_a_category_keeps_its_text_and_as_a_measure_its_number

improvise-8jk6.4, at the pipeline. It checks three things in one fixture, one per reader of a CSV value:

The rivals, each run:

R2 and R3 also fail the five lpzu merge tests in this module and five headless imports. That is how much of the suite leans on CSV measures reading as numbers.

This test uses from_csv and so cannot compile on the base. The base failure for 8jk6.4 is the headless test a_csv_column_forced_to_category_keeps_its_text_verbatim.

#a_formula_that_does_not_parse_stops_the_build_and_is_named

improvise-8jk6.5. On the base the build returned Ok, keeping Good and silently dropping Bad. Two formulas fail to parse here, so the test proves every failure is named. R5, naming only the first, fails it on Worse, and since improvise-8jk6.17 the headless an_import_formula_that_does_not_parse_refuses_names_it_and_writes_nothing sees R5 too (it passes two bad formulas). It also asserts Good is not named, so a message that lists every formula cannot pass. R4 (drop, as the base did) fails it.

#confirming_a_formula_that_does_not_parse_keeps_it_out_and_the_text_in

improvise-8jk6.5, the wizard half. On the base confirm_formula pushed Bad = revenue + into the list, which failed on pipeline.formulas. The test asserts the text is still in the buffer and the editor still open. A fix that refused but cleared the buffer would throw away what the user typed, the same hazard improvise-v4hg fixed in the TUI formula minibuffer. R6, confirming regardless, fails it. The headless suite cannot see R6.

#a_build_that_keeps_no_record_refuses_and_says_why

improvise-8jk6.6. On the base both halves got At least one category must be accepted, a refusal right only by accident. It asserts the exact message for the two cases nothing_to_import distinguishes: all skipped, and no records at all.

#the_review_step_says_when_no_record_can_be_imported

improvise-8jk6.6, the wizard half (gotcha 22: the check lives on the step where the user can still choose another array path). On the base the step refused with No categories: … Press c to cycle a field's kind, which offers a key that has no field to act on. R8, the step without the check, fails it. The build test does not see R8.

#a_header_only_file_keeps_its_file_item_its_columns_and_its_view

The ChatGPT Codex P2 on PR #314 at 4e68372c, confirmed by the adversarial review. age.csv (one row), a header-only coupling.csv and rev.csv (one row). At 005b7a76 the test failed at the first assertion: File held only age and rev, so the coupling view filtered to an item that did not exist, and coupled, degree and average-revs were not in the model. Now File reads age, coupling, rev in file order, the three columns exist (as Labels), and the coupling view is a zero-row records view filtered to coupling, while the age view still draws 2. Rival F5 (sources not seeded) and F6 (unseen columns not proposed) each fail it, and so do F7 and F8 below.

#a_file_with_no_measure_gets_a_records_view_not_another_files_measure

improvise-8jk6.13, from the review of PR #314. a.csv (entity,x) and tags.csv (entity,tag). At 005b7a76 the tags view had _Measure on Page with no selection, so the layout paged onto x, a measure the same view hides. The test failed there, with row_count() == 1: a's record, drawn under File=tags's view. Now the view has no _Measure page, is a records layout, and draws zero rows, while a's view is still a pivot. Rival F7 (the old paging) fails it, and F8 (the File selection lost when records_steps moves File off Filter) fails it with the same 1. F8 is the mistake the first version of the fix made.

#a_files_view_seats_the_date_parts_of_its_own_date_column

improvise-8jk6.12 (1). The reviewer replaced the date-parts clause of source_view's own with false and every test passed, because no folder fixture extracted a date part. Here abs-churn.csv has a date column, extracted to date_Month, and age.csv does not. date_Month is seated in the abs-churn view and on None in the age view. Rival F1 (the reviewer's fault) puts it on None in both and fails the first assertion.

#two_files_whose_names_collide_in_one_batch_keep_a_view_each

improvise-8jk6.12 (2). Default.csv and Default 2.csv. The first takes Default 2, so the second must take Default 2 2. Without the same-batch check both are named Default 2, and IndexMap::insert silently replaces the first, leaving two views where three are owed. The test asserts all three names with their filters. Rival F2 (the reviewer's fault) fails it.

#a_whitespace_only_category_value_is_no_coordinate

improvise-8jk6.12 (4). A region of " " is kept as a record with no region coordinate, and region holds only East. The trim in scalar_name predates this PR (improvise-ych), but the sparse-record rule now rests on it: without it the value becomes an item named " ". Rival F4 (the reviewer's fault) fails it.

#a_raw_date_spelled_like_a_generated_item_is_refused

improvise-d0n6 (Codex P2 on PR #322). A %Y-%m-%d column imported Year > Month with 2024-01-15 and a raw 2024-01. Before the refusal the build succeeded, put both records on the grouped item 2024-01 (summing them) and warned that the raw value was "kept ungrouped", which was false. Fault injected: removing the spelling_clash call makes this test fail with unwrap_err() on an Ok.

#a_raw_date_spelled_like_a_generated_group_is_refused

improvise-d0n6. The same with a raw 2024, a generated group. Removing the spelling_clash call fails it the same way. The control, a_raw_date_spelled_like_nothing_generated_is_kept_ungrouped, passes either way, so a blanket refusal of every unparsed value fails it.

#the_date_step_will_not_advance_over_a_spelling_clash

improvise-d0n6 (Copilot on PR #322, and the adversarial review's B1). The spelling-clash refusal sat only in the build, so a wizard with a raw 2024 in a %Y-%m-%d column passed ConfigureDates and failed on NameModel, three steps from the s that fixes it. Before the check joined validation_error the test failed with the step already DefineFormulas; removing that call again reproduces it. It also toggles to the split shape and requires the step to advance, so a check that blocks forever fails.

#split_date_naming_an_unknown_or_non_date_field_is_refused

improvise-tpm8 class. --split-date Nope and --split-date Revenue (a measure) exited 0 and did nothing. Both now exit 1 naming the flag and the field; removing the check in run_headless_import fails both rows.

#an_axis_naming_a_category_the_import_did_not_build_is_refused

improvise-8jk6.3 review. --extract Date:Month --axis Date_Month:column used to build one Date category and drop the --axis for the vanished Date_Month, exit 0. It now exits 1 naming the category and --split-date. This also refuses any --axis naming an absent category, which was silently ignored before this PR as well.

#a_folder_of_only_header_only_files_keeps_no_record_and_refuses

improvise-8jk6.6 meeting PR #314's rule that a header-only file is still a file. Written after merging main at c91cc055, where the two rules first met. The file seeds give the pipeline a File Category proposal even with no records. So without nothing_to_import the build succeeds and saves File items and empty views: the category check alone cannot refuse. The setup assertion proves that seeded proposal exists, so the test is not passing only because there was no category. R7, the check removed from the build, fails it. The user confirmed the refusal on PR #321: "Refuse (as #321 does)".

#an_absent_axis_category_that_is_no_date_part_names_what_was_built

improvise-8jk6.3 review (B2). The README's own recipe passed --axis Measure:column, a silent no-op because the category is _Measure; the first refusal blamed dates for it. The test uses a non-date absent name and asserts the message lists the built categories and carries no date hint. Fault injected: making the hint unconditional fails it.

#the_readmes_import_recipe_runs

improvise-8jk6.3 review (B2). Runs the README's recipe as printed. Fault injected: restoring --axis Measure:column in the README fails it with the refusal.

#split_date_on_a_time_field_with_no_date_format_is_refused

improvise-8jk6.3 review. --time Revenue --split-date Revenue made the field a time category with no date format and the split was a silent no-op. Without the date_format.is_some() condition the import exits 0 and this fails.