Files
improvise/context/test-evidence/core.md
T
Edward Langley 2329dc6204 fix: resolve a cell's format through the formula that owns it
Both findings from ChatGPT Codex's review of PR #125. Both are real, and both
are the same mistake in two places: answering "whose format is this" from
something other than the rule that answers "whose value is this".

**1. `FormatPlan` indexed formats in a `HashMap` keyed by target.** A target
category need not be `_Measure` — `[CustomCat]` is in the format's own
documentation — so a key can carry coordinates two formulas claim, and
iterating the map answered from whichever category hashed first: the displayed
format was nondeterministic per process while the value was not. That is
gotcha 18's shape. Worse, only *formatted* formulas were indexed, so a later
formula's `FORMAT` reached a cell whose value an earlier unformatted formula
produces.

`FormatPlan` now holds every formula's `(target_category, target,
Option<format>)` in the model's own order and takes the first coordinate match
— `Model::formula_for`'s rule and order exactly. A match naming no format falls
through to the view; it is a match, not a miss. The list is dropped entirely
when no formula names a format, so the common document still pays one
`is_empty` per cell.

**2. A column total took its format from row 0.** `context/code/core.md` argued
that was safe because "where `_Measure` is on the row axis a column is not a
measure at all". Wrong: a formula's target category is any category, so a
formula can own a row-axis item — the total then inherited a row-local override
for a row it had summed away, and re-ordering the rows changed its spelling.
New `GridLayout::column_key(col)` gives the coordinates that survive the row
aggregation (page/filter constraints plus the column's tuple), and `col_formats`
is resolved from that, once per column, outside the row loop.

Three regression tests, each with the rejected version injected as the fault:

- `the_formula_that_owns_a_cell_names_its_format_even_when_it_names_none` puts
  the same two formulas in both orders, so preferring the formatted one fails
  one half and ignoring formats fails the other.
- `the_owner_is_the_one_model_order_names_and_not_the_one_a_hash_does` rebuilds
  the plan five times, since each `HashMap` gets its own `RandomState`.
- `a_column_total_does_not_inherit_a_row_local_format` builds one grid with the
  owned row first and one with it last and requires the strips to agree. Every
  raw cell holds the same value per measure, so the totals are numerically
  identical and only the format can differ.

`context/code/core.md`'s wrong paragraph is replaced rather than amended, and
says what it got wrong.

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

60 KiB

Test evidence — improvise-core

What broke, what fault was injected, and why each test can fail. Keyed by exact test name (design-principles §7). A renamed test orphans its entry; scripts/check-comment-runs.py --check-evidence catches a heading naming a test that does not exist.

Only tests whose narrative is longer than the code appear here. Most tests need none.


model/types.rs

model::types::formula_tests::removing_a_measure_row_that_is_only_a_formula_target_removes_the_formula

improvise-g3no, demonstrated twice in a real browser and once in the TUI. _Measure's items are "explicit items UNION formula targets" (gotcha 7), so a Profit that exists only because a Formula does is not in the category's own item list. Model::remove_item called cat.remove_item, which no-opped, then deleted cells naming it, of which there are none because the value is derived; effective_item_names then re-synthesised the row from the surviving formula and it came straight back. Both front ends meanwhile composed an unconditional past-tense status beside the mutation, so the user was told it had worked.

The diagnosis the bug report and the ticket both reached was one level too high. They read it as a truthfulness defect — the status is emitted before the fallible effect, the opposite of gotcha 20's CommitBuffer ordering — and asked for a Removed answer on Model::remove_item that the front ends could let win. That is unimplementable on the browser side and would have been discovered late: SetStatus is ViewOnly and never crosses the wire, so a host-side refusal cannot reach a client at all; and a client cannot decide for itself, because nothing in the projection stream says which _Measure rows are derived — FormulaCache::formulas is Vec<String> of Formula::raw, and recovering a target from it means a second home for the unquoting gotcha 34 says has exactly one.

The actual defect is an asymmetry in the model: remove_category has always ended by dropping every formula targeting the category, and the item level did not. Restoring it makes the row genuinely go away, which makes the status both front ends already compose true, with no second classification and nothing new on the wire. Inject the fault by deleting the retain.

The fixture builds its own model rather than using revenue_cost_model(), which carries an explicit Profit item — against that fixture the test passes before the fix, because there really is an item to remove. That is the whole distinction the bug turns on, so the precondition is asserted rather than assumed.

model::types::formula_tests::removing_a_regular_item_removes_the_formula_targeting_it

The same asymmetry one level up, and the reason the sweep is not restricted to _Measure. A non-measure target is a real item (add_formula creates it), so removing it left a formula aimed at nothing. A _Measure-only fix would have satisfied the ticket and left this live.

model::types::formula_tests::removing_an_item_keeps_a_formula_that_only_references_it

The direction the fix must not overreach in, and it passes before the fix as well as after — it is there to stop the next change, not to catch this one. retain on f.target == item alone, or on target without target_category, both look plausible and both would take formulas the user still wants: deleting Cost must not delete Profit = Revenue - Cost.

model::types::model_tests::a_required_virtual_category_cannot_be_removed

improvise-uej8's second half, which is improvise-3b2b's subject. The panel deliberately lists _Index/_Dim/_Measure (improvise-vjms — a decision about visibility), and d at the panel's default cursor 0 deleted _Index outright. The invariant that the virtual categories always exist (gotcha 7) was reachable by one keypress from a fresh session, and GridLayout's records-mode detection keys off _Index on Row, so the symptom is that records mode silently stops working.

The guard is in Model::remove_category rather than in the two Cmds, because hiding the delete key leaves the wire mutation (ModelCommand::RemoveCategory) unguarded — 3b2b asks for exactly this boundary. It reads CategoryKind::of_name, the one home of the reserved-name mapping (improvise-2lh), so a Label category stays removable.

The second assertion is the one that earns its place: putting the category back is not enough, since remove_category sweeps cells and formulas after removing the key from the map. It asserts a stored Revenue still evaluates, so a guard that returned too late fails. The opposite direction is already held by remove_category_deletes_category_and_cells immediately below.

a_chained_formula_still_converges_after_the_circular_marker

The anti-clamp half of improvise-7iww. recompute_formulas now publishes Error("circular") for any cell still moving after MAX_EVAL_DEPTH passes, and a guard that refuses everything would satisfy the reproduction just as well as the fix does. This test runs a three-deep chain — Cost, Profit, Margin — that converges in two passes and asserts the numbers, so a marker that fired on a converging chain fails here. Inject the fault by marking every cell evaluated in the last pass rather than only the ones that changed.

A second, free cross-check lives in an_unknown_name_inside_an_aggregate_errors_for_every_function: it asserts the error text ref:Typo after recompute_formulas, so a formula that errors identically on every pass must be seen to converge rather than be overwritten with circular. The two fixes check each other.

One arm of the loop is worth naming because it looks reachable and is not. A pass that computes None where the cache holds Some counts as moving and stores nothing, so it would spin to the bound and now be marked circular. The only None evaluate_formula_cell can produce is a WHERE filter that does not match falling through to aggregate_raw over no data — and aggregate_raw reads self.data, never the cache, while the formulas do not change during a recompute. So for a given key that branch answers the same thing on every pass, and a Some-to-None transition cannot occur. The pre-existing code spun the same sixteen passes here and left a stale value; of the two, publishing circular is the safer, so the arm is documented rather than changed.

a_derived_measure_with_stored_cells_does_not_leak_the_raw_value

improvise-s2w3's stated edge case. The fix routes a bare SUM(Double) through the per-record fold when Double is a formula target, and the interesting case is a target that also has stored cells: formula_for is the one home of "is this cell derived", so the fold reads 20 (the formula) and not 999 (the raw cell). Fails if the fast path is reinstated for a derived name, or if the fold were built to prefer raw data over formula_for.

an_aggregate_over_a_derived_measure_sees_the_formula_in_both_evaluators

improvise-s2w3, widened past the reproduction. eval_agg specialised Expr::Ref into a raw DataStore lookup; a formula target has no raw cell behind it (improvise-nrs), so SUM(Double) summed nothing and answered 0 while SUM(Double + 0) folded per record and answered 20. core.md §13 defends that fast path and the defence still holds — it is the fold specialised to a ref naming a stored cell. What failed is the premise, not the specialisation, so the fix asks formula_for rather than deleting the path.

The test runs SUM/AVG/MIN/MAX and both evaluators (direct evaluate, and evaluate_aggregated(OverHidden) after recompute_formulas), because §3 says a rule stated in one evaluator and not the other is a live bug. COUNT is excluded deliberately: COUNT(Double) and COUNT(Double + 0) both count members and agreed even before the fix, so including it would have added a passing case that discriminates nothing. Inject the fault by hard-coding derived = false in eval_agg.

an_unknown_name_inside_an_aggregate_errors_for_every_function

improvise-s08t. eval_agg narrowed its partial key only if let Some(cat) = find_item_category(name); when the lookup failed the key was left unconstrained, so SUM(Typo) aggregated every numeric measure matching the context and returned a plausible wrong total. A typo is silent and looks like a number. The test asserts the error text (ref:Typo) across all five aggregate functions and both evaluators, so a fix that errors with some other message — or that errors only where the raw scan happened to be empty — still fails. Inject the fault by restoring unwrap_or("_Measure") in place of the ok_or_else.

both_evaluators_answer_every_comparison_the_same_way

improvise-vyrx's behavioural half, and it is deliberately the smaller half. The ticket's defect is that eval_bool_cached wildcarded BinOp where eval_bool listed its arms, so a new variant would error at runtime in one evaluator and fail to compile in the other — an asymmetry no runtime test can see today, because both paths answer Err("type") for every arithmetic operator. The guard is the compiler: with the wildcard removed, adding a BinOp::Mod fails the build at four sites (eval_expr, eval_bool, eval_expr_cached, eval_bool_cached) where it previously failed at three, and that was verified by adding the variant and reading the errors.

A sweep for the shape found a second instance one level up, in the same two functions: both eval_bool and eval_bool_cached also wildcarded the outer Expr match as _ => Err("type"), so a new Expr variant that ought to be usable as a condition — an And, say — would have become a silent type error in both. That is not vyrx's asymmetry (the two agreed), but it is vyrx's defect: a new variant answered wrongly with nothing to notice. Both now list the six non-comparison variants, and adding an Expr::And fails the build at four sites where it previously failed at two. Verified the same way.

What the sweep also settled is which wildcards to leave, which is the more useful half. eval_agg's own match inner { Expr::Ref(..) => …, _ => false } is deliberately not exhaustive: it falls through to the general path — the per-record fold — which is the right answer for any expression that is not a bare ref, including one that does not exist yet. The dangerous wildcard is the one that falls through to a refusal or to a specialisation; a wildcard that falls through to the general case is safe by construction. The same reading clears CellValue::as_f64's _ => None and CategoryKind::of_name's _ => Regular, whose inputs are a value that is not a number and a name that is not reserved.

What this test adds is the neighbouring property the exhaustive arms do not give: that the six comparison arms agree in value between the two evaluators. It runs each operator against a true and a false right-hand side, so an arm mistyped in one path (Lt => lv <= rv) fails. Both directions matter — a single right-hand side is satisfied by an evaluator that answers a constant.

re_adding_an_existing_category_at_the_limit_creates_nothing_and_succeeds

improvise-f86q. Model::add_category checked MAX_CATEGORIES before contains_key, so at twelve regular categories re-adding an existing name was refused with "Maximum of 12 categories reached" — a false statement, since the category exists and nothing would be created. Per gotcha 21 effect::AddCategory sets abort_effects on refusal, so typing an existing name into the records-mode +cat column at the limit aborted the rest of the batch: the improvise-2zq failure mode by another route.

The fix is a hoist. The test asserts three things, and the third is what stops it from being satisfied by deleting the limit: the returned id is the existing category's, the category count did not move, and add_category("C12") — a name that really would create something — is still refused.

a_hidden_dimension_list_reduces_to_one_of_two_states

improvise-5jm5. evaluate_aggregated took none_cats: &[String] and consulted it once, for emptiness — so two lists with different contents and the same emptiness gave the same answer, while the name promised a filter. Nothing misbehaved; the signature described something the body never did.

This test is the smaller half of the guard and says so. The real guard is the signature: the defect the ticket describes is no longer constructible, exactly as Checked's constructor is the guard for the basis check rather than a test that can fail. What this test does add is that Aggregation::of is the only place the list is read, and that a list of two hidden dimensions is not somehow a third state — the reduction is emptiness, and the enum is not a count.

apply_agg_over_nothing_is_absent_for_every_function

An empty cell must read as absent, not as zero — otherwise every blank intersection in a pivot renders 0 and a sparse model looks fully populated. Fails if apply_agg ever gains a Sum => 0.0 identity arm for the empty case. eval_agg deliberately has the opposite rule; see the next entry.

eval_agg_over_no_rows_is_zero_only_where_an_identity_exists

A formula aggregate over no rows is 0 for SUM/COUNT, which have an identity, and an error for AVG/MIN/MAX, which do not. This is deliberately not apply_agg's rule, and the two are separate functions precisely so the difference is visible. improvise-kdp named both after they had been duplicated inline in two evaluators; the test pins the contract directly rather than only through formula eval.

Its vehicle changed with improvise-s08t. It reached "no rows" through Expr::Ref("Nonexistent") — a name that resolves to nothing — which was precisely the bug s08t names: an unresolvable ref left the selection unconstrained instead of erroring. So the test was standing on the defect to demonstrate a neighbouring property. It now uses a name that resolves and has no cells (Revenue, declared and never written), which is the emptiness the entry is about; an unknown name is now an error and is pinned separately by an_unknown_name_inside_an_aggregate_errors_for_every_function.

find_item_category_falls_back_to_formula_targets

add_formula never inserts its target into _Measure (the item is implied by the formula definition). So find_item_category must fall back to the formula list, or Expr::Ref("Profit") fails to resolve and every cell of a chained formula errors ref:Profit (improvise-nrs). The test asserts the setup — that the target really is not a stored item — before asserting the lookup, so it cannot pass by the item having been added after all.

stored_virtual_categories_match_their_names

CategoryKind::of_name is used all over the codebase in place of looking the Category up. That substitution is only sound because (a) a fresh Model really does store each reserved name under its virtual kind and (b) add_category hands back the existing virtual category rather than shadowing a reserved name with a Regular one. The second half is the arm that would break silently: a shadowed _Measure would still answer of_name correctly while the stored category had the wrong kind (improvise-2lh).

aggregating_one_text_value_yields_that_text

The head of the improvise-l4qx family. aggregate_cell_values used to be filter_map(as_f64), which dropped text. _Index/_Dim sit on Axis::None in every default view, so none_cats is non-empty almost always and the aggregating path was almost always taken — making every text cell in every document render blank. .improv carries pipe-quoted text as a first-class value, so a user could type a text value, save, load, and never see it again.

aggregating_differing_text_values_is_an_explicit_error

Two different words have no reduction. The user is told mixed rather than shown one of them arbitrarily, or a blank.

aggregating_text_among_numbers_is_an_explicit_error

The case that produced a plausible number before: the text was dropped and the rest summed, so the cell read as authoritative while under-reporting. A blank would have been noticed; a wrong total is not.

a_lone_number_still_counts_as_one_rather_than_passing_through

The regression guard for the hot path, and the test that pins the branch order in aggregate_cell_values. The all-numeric branch is checked first, which is the entire difference between this rule and "a single value passes through". Count is the only function that can tell them apart — every other function is the identity on a one-element set — so this test sets measure_agg["Status"] = Count and requires 1, not 7. Reordering the two branches passes every other test in the family.

aggregating_nothing_is_still_absent

The text rule must not turn apply_agg's emptiness rule into an error or a zero. Fails if aggregate_cell_values answers on an empty slice at all.

where_filter_absent_category_does_not_apply_formula

Profit = 42 WHERE Region = "East" evaluated against a key with no Region coordinate must not apply the formula: the condition cannot be satisfied, so the raw cell value (absent) is the answer. The bug was an if let Some(item_val) that failed to bind when the category was absent and fell through, applying the formula unconditionally — so a grand-total cell showed the filtered formula's value.

sum_inner_expression_constrains_which_cells_are_summed

SUM(Revenue) ignored its inner expression and summed every numeric cell matching the partial key. With Revenue=100 and Cost=50 both stored for Region=East, Total = SUM(Revenue) returned 150. The fixture needs two measures on one coordinate or the bug is invisible.

add_formula_same_target_name_different_category_both_coexist

add_formula deduplicated by target name alone, ignoring target_category, so Profit in _Measure and Profit in KPI could not coexist — the second silently replaced the first.

add_formula_same_target_name_different_category_evaluates_independently

The consequence of the same bug seen through evaluation rather than through formulas.len(): the dropped formula's cell evaluated to absent. Worth keeping beside the count assertion because a fix that deduplicates correctly but dispatches on name alone would pass the first and fail this one.

remove_formula_only_removes_specified_target_category

The mirror image: remove_formula matched by target name alone, so removing Profit from _Measure also destroyed the Profit formula in KPI.

near_zero_divisor_consistent_with_equality_epsilon

improvise-0bf. =/!= compare with FLOAT_EQ_EPSILON = 1e-10, but the division-by-zero guard checked rv == 0.0 exactly. With X = 1e-11 the comparison X = 0 was true and 100 / X happily returned 1e13 — incoherent semantics for IF(X = 0, a, y / X), whose else branch divides by a value its own condition called zero. Any value the equality operator treats as zero must also trigger div/0. This exercises the direct eval path (eval_expr inside eval_formula_depth).

near_zero_divisor_consistent_in_cached_eval_path

The same bug in the fixed-point/cached path (eval_expr_cached inside eval_formula_with_cache), whose division guard also used exact == 0.0. Two tests because there are two evaluators and a rule stated in one and not the other is a live bug — which is exactly how this one shipped.

recompute_formulas_multi_stem_values_stable

improvise-6os replaced an O(n²) Vec::contains dedupe of coordinate stems with an IndexSet — a pure performance change. The test pins the observable contract instead of the mechanism: a multi-stem model (chained formulas, several measures per stem) produces the stated values, and a second recompute_formulas call reproduces the exact same cache, so a dedupe that lost or duplicated stems would show up as a changed value or a non-idempotent cache.

formula_chain_preserves_full_precision

Display rounds; eval must not. Tax = Price * 0.075 on Price = 10 is exactly 0.75, which format_f64(_, true, 0) displays as "1". The test asserts both: that the display really does round (so the fixture is discriminating) and that Total = Price + Tax is 10.75 and not 11. Without the first assertion the test would pass on a fixture where rounding changes nothing.

format.rs

a_finite_number_never_displays_as_infinity

improvise-pikk. round_half_away scales by 10^decimals before truncating, so n * factor overflows to infinity for a large finite n and any nonzero precision: format_f64(1e307, false, 2) rendered "inf". The model held the number correctly and the display invented an infinity. Gotcha 8 is why nothing else caught it — format_f64 is the whole display path and never feeds back into eval, so this is a wrong pixel with no wrong number behind it to trip a formula test.

Reachability is low and that is not a reason to leave it: the shipped default format is ",.0", whose factor is 1, so it needs both an absurd magnitude and an explicit .N format.

The test carries its own anti-clamp assertion, because "return n unchanged" satisfies the overflow case and every other case. 1e307 * 100 overflows and 1e306 * 100 does not, which pins the threshold; and format_f64(2.5, false, 0) == "3" pins that the rounding path is still reached at all — half-away-from- zero, where a passthrough would hand the value to {:.0} and get Rust's half-to-even "2". Inject the fault by dropping the is_finite arm.

model/cell.rs

matching_cells_yields_the_primary_insertion_order

improvise-shyt. matching_cells walked the secondary index's HashSet, so its order was hash order rather than entry order. DataStore::cells is an IndexMap precisely so records mode can list rows in entry order. The check is against iter_cells — the primary map's own order — and not against a literal, which would only pin whatever the hasher happened to do on this machine today.

the_same_cells_entered_in_opposite_orders_list_in_opposite_orders

The half a single-process test cannot reach by calling twice: two stores holding the same logical cells, built by opposite insertion histories. Each must list its own history, so one result is the reverse of the other. Under the index walk the two orders were unrelated to both histories and to each other, because RandomState keys each fresh HashMap differently. doctrine(an_order_test_needs_two_histories).

matching_values_yields_the_primary_insertion_order

The same guarantee for the value-only hot path, which aggregation folds f64 over. f64 addition is not associative, so an unstable order made a SUM reproducible only within one process — drift in the last bits, which is the kind of wrongness nobody reports and nobody can reproduce. Min/Max/Count and the text-unanimity rule are order-free and were never affected.

an_empty_partial_matches_every_cell_from_either_door

improvise-hkt6. matching_positions indexed sets[0] and panicked on an empty partial. Both public callers guard, so it was unreachable — but a guard one level away from the indexing is how this class of bug returns, so the input the guards prevent is fed in directly here.

It asserts the answer, not merely the absence of a panic: an empty partial constrains nothing, so every cell matches, and that must be the same answer the callers' fast paths give. Returning vec![] would also have been total and would have made deleting a fast path silently flip "all" to "none".

live_records_come_back_in_the_stores_own_insertion_order

improvise-2yc2. live_records walked the records: HashMap, so it yielded a different order in every process and in every instance — the improvise-shyt defect, caught here before a caller could inherit it. The order decided on is the store's own: a record sits where its first cell sits in the primary IndexMap. The fixture interleaves the measures deliberately — East's second measure arrives after West, and must not move East behind it.

the_same_records_entered_in_opposite_orders_list_in_opposite_orders

doctrine(an_order_test_needs_two_histories) applied to records: one history cannot tell "ordered" from "hashed that way today, on this machine".

a_recreated_record_takes_its_new_place_not_its_old_one

The claim live_records makes about why it derives the order from cells rather than memoising a position on RecordEntry — and which nothing else could see (Copilot, PR 20). A memoised position passes all three order tests above and still puts a re-created record back at its original place while its cells sit at the end of the store. The entry survives the deletion on purpose (it is a tombstone, below), which is exactly what makes the memoised position available to be wrong.

deleting_and_recreating_a_record_does_not_reset_its_version

The tombstone earns its place here. If RecordEntry were dropped when the last cell went, a delete-and-recreate would reset the counter to zero — and a client holding version 0 against the old record would be told its basis is current and its write applied. That is precisely the silent accept ADR 001's basis check exists to prevent.

a_surrogate_for_a_removed_record_fails_to_resolve

The first arm of staleness: a surrogate for a record whose cells are all gone must fail to resolve rather than resolve to some other record. The fixture holds two records, so a bug returning "any record" is visible; with one, the assertion would pass by naming the only record there is.

a_second_measure_on_a_record_resolves_to_the_same_record

The records-mode unit seen from the index side: two measures on one coordinate tuple are one record, so they share one entry. A cell-level unit would be violated by one legal edit (renaming a category coordinate moves every measure cell in the row).

a_live_records_surrogate_is_the_one_that_resolves_it

The pair live_records yields is not two facts that could drift apart: the surrogate is the record's own, so it is the one that resolves it back.

a_deserialized_store_can_resolve_its_records

The record index is derived state and is #[serde(skip)]-equivalent — it must come back with the data, rebuilt through set, rather than being written alongside it.

model/record.rs

a_records_surrogate_is_pinned_to_a_literal

The surrogate must mean the same thing in the process that minted it and in the one that restarts under it, so it is pinned to a literal rather than to "whatever this build computes". A failure here is not a bug in the test: it means the hash, the key or the byte serialization moved, and every surrogate any client holds is now a name for something else. That is a wire-format break.

the_two_kinds_do_not_share_a_hash

A record and an aggregate may be built from the same coordinates — an aggregate over a single record is the degenerate case. A client holding an aggregate id must not be able to spend it as a record id by accident, which is what the domain tag byte is for.

coordinates_that_concatenate_alike_do_not_hash_alike

The length prefixes in hash_coords earn their place: without them ("Ab","c") and ("A","bc") serialize to the same bytes, and a collision here is a silent write to the wrong record.

renaming_either_half_of_a_coordinate_changes_the_hash

Two categories, not one: a one-coordinate fixture cannot tell "the item moved" from "the category moved" — the same blind spot as the one-category fixture that could not distinguish TransposeAxes from CycleAxis.

a_surrogate_round_trips_through_json_as_a_string

serde_json writes a u128 as a bare number and a JavaScript host parses every JSON number as f64, so anything above 2^53 comes back changed — resolving to a different record or to none. The test asserts the wire shape (a quoted 32-hex string, 34 bytes with quotes), not merely that a round trip succeeds, because a numeric encoding round-trips fine inside Rust.

construction_order_does_not_reach_the_hash

CellKey is canonically sorted and without preserves that order, so "the normalized record" is a property the type guarantees rather than a convention to maintain.

view/types.rs

view::types::tests::moving_a_category_off_the_constraining_axes_forgets_its_page_selection

improvise-z336, measured under Chromium by cycling a category all the way round: Pag(Espresso) -> None (unfiltered, 63,700) -> Row -> Col -> Page and Espresso is back, unasked, at 18,400. set_axis never touched page_selections; only on_category_removed and on_item_removed ever cleared one. So every route out of a filter merely hid it and the selection was silently reinstated the moment the category returned to Page.

What the report and the ticket both concluded — that the browser had no way to clear a filter — was true, and the mechanism was not the one either named. There is no clear-a-page-selection operation anywhere: not in ModelCommand, not in the View API, and not in the TUI either, so this was a product gap at parity rather than a browser regression; [ and ] cycle among the category's items with no "All" entry to land on. Making set_axis forget the selection closes it with no new command, no new wire variant and no new key: cycling the axis round is now genuinely a way out.

Inject the fault by deleting the page_selections.remove. The test asserts both halves — that the selection is gone while off Page, and that it is still gone on the way back — because the first alone is satisfied by a View that merely stops reporting the selection while the category sits elsewhere, which is what the code already did.

view::types::tests::moving_a_category_from_page_to_filter_keeps_its_page_selection

The trap gotcha 27 exists to name, and the rival fix this separates out. The obvious spelling is "clear it unless the new axis is Page" — and Filter constrains too: a drill fixes its coordinates on Filter, not Page, and .improv round-trips a selection under it. That version passes the test above and silently breaks drill. The predicate is therefore Axis::filters_data(), which is filtering_cats()'s own membership — "what data is visible", never "what the user can act on".

It passes before the fix as well as after: it is there to stop the next change. roundtrip_tests.rs would also have caught it, but only as a persistence failure a long way from the line that caused it.

a_pivot_view_still_seats_categories_row_then_column_then_page

improvise-2zq, the negative arm, and the load-bearing one. The fix makes a records view seat a new category on Axis::None; without this test, "always seat on None" would pass every assertion about records mode while quietly deleting the auto-assignment on_category_added exists for.

Probing confirmed the asymmetry: seating every category on None fails 12 tests, while removing the virtual bump failed none at all — which is how the bump came to be wrong in two directions with nothing noticing.

a_records_view_seats_a_new_category_on_none_and_stays_a_records_view

on_category_added used to bump a virtual off Row or Column to make room. With _Index on Row and _Dim on Column that is records mode, so naming a category with N while in records mode silently ejected the user from the grid they were standing in and returned them to a pivot.

a_measure_on_column_is_not_bumped_off_it

The second thing the bump broke, and the reason the fix is about virtuals in general rather than about records mode: _Measure on Column is an ordinary configuration — it is what web/demo.improv ships — and adding a second regular category kicked it off the axis, making every measure vanish from the grid. The fixture is deliberately not a records layout, so it exercises the pivot path rather than the early return.

cycle_axis_unknown_category_is_noop

improvise-t8s: cycle_axis panicked through axis_of's expect when given a category not registered with the view, while set_axis silently no-ops on the same input. It now goes through try_axis_of.

tileable_cats_omits_filter_but_keeps_every_other_axis

improvise-dz8: a coordinate the drill fixed must not appear in tile select — the user cannot move it, so offering it offers an action that cannot be taken. The test flips the category back to Page afterwards, so it distinguishes "omits Filter" from "omits this particular category".

filtering_cats_covers_page_and_filter_but_not_the_rest

Filter constrains the data exactly as Page does; only Page is navigable. The fixture puts a category on each of Page, Filter, Row and None so the result separates the two questions rather than merely counting.

on_category_removed_forgets_a_collapsed_group

improvise-qx1z arm 2. View::on_category_removed cleared category_axes, page_selections and hidden_items — but not collapsed_groups. The other three lines being right there is what makes this an omission rather than a decision: collapse Month/Q1, delete the category, re-add it with Jan[Q1], and the group comes back pre-collapsed with nothing saying why. Reachable from shipped keys, through the category panel.

Fault injected: delete the added line; the test fails at is_group_collapsed("Month", "Q1"). The test re-adds the category after removing it, because a view that merely forgot the axis would also report false for a category it no longer knows — asking the question after the re-add is what separates "cleared" from "not currently registered".

on_item_removed_forgets_a_hidden_item

improvise-qx1z arm 1, at the View level. Model::remove_item removes the item and its cells and touches no view at all, so hidden_items["Region"] kept "East": re-adding the item put it in the model and in the category panel while leaving it absent from the grid, with no indication why.

The fixture hides two items and removes one. Without the second, a fix that dropped the whole category entry — hidden_items.remove(cat_name) — would pass while destroying unrelated state; the surviving West assertion is the input that separates "forget this item" from "forget this category".

on_item_removed_forgets_a_page_selection_naming_it

The third question improvise-qx1z asked to check while there, and the answer was the same: a page selection naming a removed item survived it. Asserted in both directions in one test — removing a different item must leave the selection alone — because a hook that cleared the selection unconditionally would satisfy the removal half and silently reset the user's page on every unrelated delete.

view/layout.rs

prune_axis_items_indexes_data_positions_not_visual_ones

prune_axis_items asks keep about data indices — the positions a row/col argument means — so a group header between two data items must not shift the numbering the predicate sees. The fixture places the header at visual index 1 and prunes data index 1: if the predicate saw visual indices, "A" would be the one dropped. improvise-n3wt also removed the &[bool] + debug_assert pair, whose length could disagree with the axis in release builds only.

a_text_cell_renders_in_a_default_view

The user-visible face of improvise-l4qx, asserted at the layout rather than at the aggregation helper. The test asserts its own premise first — !layout.none_cats.is_empty() — because if a default view ever stopped putting _Index/_Dim on Axis::None the test would pass without exercising the bug at all.

records_mode_gives_each_measure_its_own_value_column

Pins the whole column vocabulary in order: [Region, +cat, Revenue, Cost, +] — the regular categories, the new-category column (improvise-2zq, sitting where the column it creates will appear), one column per raw measure, then the new-measure column (improvise-c5v). It checks both the labels and the RecordCol variants, so a column drawn with the right label but resolved to the wrong kind fails.

records_mode_initial_layout_is_sorted

On entry into records mode the store is sorted by CellKey, so rows appear in a deterministic order regardless of insertion order. The fixture inserts East after North, the opposite of alphabetical, so "did nothing" fails.

records_mode_new_record_appends_at_bottom

The complement: once the layout exists, a new record appends rather than re-sorting into the middle. The new record's coordinate (AAA) sorts before every existing one, so a re-sort would move it to the top and move the row the user was looking at.

records_display_out_of_bounds_col_returns_none

improvise-byz: records_display for an out-of-bounds column called col_label(col), which returns ""; "" was then looked up as a category name in the record, never found, and the function returned Some("") — masking view/data mismatches as blank cells. It must return None, symmetric with cell_key.

view/axis.rs

the_label_vocabularies_are_unchanged

improvise-rml, characterization: these strings were scattered across four render/command sites before being pulled onto Axis. Pinned verbatim so the move could not quietly restyle the UI.

filter_is_outside_the_rotation

improvise-dz8: Filter names a coordinate the drill fixed, so it is outside the Row→Column→Page→None rotation entirely. Both directions are asserted — cycling a Filter must not hand the user a navigable axis, and no navigable axis may rotate into Filter, or tile select could hand one out.

next_is_a_four_cycle_over_the_navigable_axes

Applying next four times is the identity from every starting point and no navigable axis is a fixed point, so no variant is stranded or skipped. Together with the entry above this pins the rotation without enumerating it twice.

every_axis_gets_its_own_color

Distinct axes must be distinguishable at a glance; color() is the only reason AxisColor exists, so a collision defeats it.

a_routing_formula_over_a_group_sums_the_members_answers

improvise-4zl7, and the hand-computed half of its evidence. Every number in route_then_aggregate_tests is written out rather than obtained from a second call into the evaluator, because the bug being guarded is the evaluator answering consistently and wrongly.

The fixture is four records — Cost of 10 and 100 in North, 20 and 200 in South — and the formula is Pred = SUM(IF(Cost < 50, 1, 10)). Cost straddles the threshold inside each region, which is the whole design of the fixture:

cell route-then-aggregate aggregate-then-route
one record (Cost 10) 1 1
North (Tag away) 1 + 10 = 11 110 < 50 → 10
everything 1+10+1+10 = 22 330 < 50 → 10

A fixture whose records fall on the same side of the split would give the same answer either way at the record level and differ only by a factor of the record count, which is the kind of agreement that reads as correctness.

Two faults were injected and both were watched to fail. Restoring the old eval_agg — sum the matching raw values and ignore the inner expression — fails 7 of the 8 tests with 330 for 22, 110 for 11 and 10 for 1; the one survivor is is_is_one_when_the_cells_own_coordinate_matches_and_zero_otherwise, which has no SUM and so never reaches the fold. Making eval_is return 0.0 unconditionally fails exactly the 3 coordinate tests, with 400 for 214 and 200 for 14. The two faults failing disjoint-but-overlapping sets is itself the evidence that the tests separate the fold from the predicate.

a_coordinate_split_survives_aggregating_over_that_very_category

The case the ruling exists for, and the one the ticket's own text called the product question. With Region aggregated away the cell has no Region coordinate of its own, so a formula that read the cell's coordinate would see nothing and take Expr::Is's unwrap_or(false) branch — answering 100 once. The fold hands the expression a record key, so each of the four records answers for its own coordinate: 7 + 7 + 100 + 100 = 214.

a_member_whose_input_is_missing_errors_rather_than_being_dropped

The asymmetry between the Expr::Ref fast path and the fold, asserted rather than left to be discovered. SUM(Revenue) skips a record with no Revenue cell; SUM(<expr>) cannot, because the expression is what is being asked and an expression that will not evaluate is an error. The fixture clears one record's Cost and writes a different measure on the same record, so the record still exists and still has a member cell to be found by — otherwise the record would simply vanish from matching_cells and the test would prove nothing.

feature_roles_line_up_with_the_names_preprocess_emitted

Lives in gp-tree, recorded here because the rule it guards is improvise-4zl7's. feature_roles is the one statement of preprocess's expansion order and encode_row drives off it, so a drift is silent in both directions: the emitter would spell the wrong feature and a re-encoded row would land in the wrong column. The assertion rebuilds each feature's name from its role and compares it with the name preprocess emitted, which is the only thing that ties the two walks together.

The last four assertions exist because the loop is vacuous over a fixture that produces only one kind of feature. The fixture has a numeric column with a missing cell, a categorical column and a date column, so all four roles appear. Fault injected: swapping the Numeric and Missing pushes in feature_roles fails this test and encode_row_replays_the_training_expansion, which is the pair that would have disagreed.

a_formula_targeting_another_category_still_folds_over_its_records

Found by self-review, not by the ticket. The fold evaluates the inner expression at each member record's key, and the first version built that key by folding the record's coordinates onto context — which still carries the formula's own target coordinate. For the ordinary _Measure target that is harmless, because Expr::Ref overwrites _Measure on the way in. For a formula targeting any other category it is not: the record key keeps Levy=Tax, so evaluate_depth matches the formula again at that key and the whole cell converges to Error("circular") — a formula quietly self-referencing through its own aggregate.

The key is now folded onto partial (context.without(target_category), plus any inline WHERE), which drops the target coordinate by construction rather than by every caller remembering to. Fault injected by restoring the context fold: this test fails with Error("circular") against 11.0 and the other eight pass, which is why it is a separate test and not an extra assertion on one of them.

the_two_evaluators_agree_when_the_fold_reads_a_derived_measure

Adversarial review of PR #54, and the sharpest finding of the round: the first version of the fold reintroduced exactly the divergence the ruling exists to remove, through the cached path.

eval_expr_cached's Expr::Ref looks the value up in formula_cache at context.with(cat, name). Inside the fold, context is a member-record key carrying the none_cats coordinates, while formula_cache is keyed by stems with none_cats stripped. The lookup could never hit, fell through to aggregate_raw, found no raw cell behind a formula target, and answered Err("ref:…"). So a routing formula reading a derived measure gave Number(11.0) uncached and Error("ref:Margin") cached — and the cached path is the one a pivot grid runs whenever any category sits on Axis::None.

The fixture is the four-record one plus Margin = Cost * 2, so the fold's input is a formula rather than raw data. The test asserts the uncached value and that the two agree; asserting only agreement would pass if both broke.

The fix makes them agree by construction rather than by this test: eval_expr is hoisted to module level and both evaluators hand it to eval_agg as eval_at. Note the repair that was not taken — teaching the fold to hit the cache by stripping none_cats off the member key returns the group's value to every member, which is aggregate-then-route through a cache lookup, silent. Fault injected by restoring eval_expr_cached as the fold's evaluator: this test fails with Error("ref:Margin") and the other eleven pass.

the_cached_evaluator_has_its_own_working_coordinate_predicate

The same review's second finding, and a lesson about where to inject a fault. Expr::Is has an arm in each evaluator. The original evidence injected the fault into the shared eval_is, which both arms call — proving the tests see the predicate, not that they see the cached copy. Replacing only the cached arm with Ok(0.0) left the suite at 1330 passing.

The first attempt to close it — a coordinate split run through recompute_formulas + evaluate_aggregated — also stayed green, and that is the interesting part: once the fold routes through eval_expr, an Is inside a SUM never reaches the cached arm at all. The cached arm is now reachable only by a bare Is outside an aggregate, which is what this test uses. Fault injected into that arm alone: it fails, Number(0.0) against Number(1.0).

The general shape: an injected fault proves coverage only of the code path it is injected into. Two arms of the same rule need two injections, and if one of them turns out to be unreachable, that is a fact about the design worth writing down.

a_user_category_whose_name_leads_with_an_underscore_is_seated_as_regular

improvise-bidq. View::on_category_added's early return read cat_name.starts_with('_') || self.is_records_layout() — the same expression mixing two derivations one operator apart, the left a name prefix and the right CategoryKind. Four lines up, is_records_layout asks the enum.

Model::add_category does not validate names, so a user category literally named _Foo was CategoryKind::Regular to records_columns, cell_id and is_empty_model, and "virtual" to these two lines — seated on Axis::None, so it never appeared in a pivot while every other consumer treated it as ordinary. The second site (holds_a_regular) had the same split, which is why the test asserts a second category's seat too: fixing only the first leaves _Foo on Row while Region also takes Row.

Decided explicitly, per the ticket: add_category is not made to refuse a leading underscore. The three reserved names are the ones CategoryKind::of_name knows, _Foo is a legal document, and a user-visible restriction is a larger change than making one answer out of two. The fix is that both sites now ask CategoryKind::of_name(..).is_virtual().

Fault injected by restoring either starts_with('_'): the test fails, None against Row on the first site and Row against Column on the second.

an_ungrouped_item_after_a_grouped_run_belongs_to_no_group

a_grouped_run_does_not_leak_into_the_next_prefix

improvise-gd83, the two arms of one defect. expand_category emitted a GroupHeader on entering a group and no boundary on leaving one, so row_group_for/col_group_for's nearest-preceding walk answered with a group that had already ended.

The arms are separate because the two escapes are different and a fix can close one and miss the other. The first is within one expansion — Jan[Q1], Feb[Q1], Sep — closed by emitting AxisEntry::GroupEnd on a Some -> None transition. The second is across expansions: cross_product expands the inner category once per prefix, each call starting with last_group = None, so Food/Bob[Family] is immediately followed by Clothing/Carol, which is ungrouped and inherited Family with no transition anywhere to hook onto. That one needs a GroupEnd at the end of an expansion whose last item was grouped.

GroupEnd is deliberately NOT emitted on Some(A) -> Some(B): B's own header already ends A's run, and emitting one there moved data_row_to_visual_skips_headers and data_col_to_visual_skips_headers for no gain. Those two are untouched, which is the evidence the boundary is as narrow as it claims.

Fault injected by deleting either emission: the corresponding test fails with Some(("Month", "Q1")) / Some(("Recipient", "Family")) against None, and the_two_front_ends_draw_the_same_column_header fails with it.

a_collapsed_groups_header_survives_pruning

a_header_whose_items_all_prune_away_is_dropped

a_partly_pruned_group_keeps_the_header_its_survivors_sit_under

an_ungrouped_item_after_a_pruned_group_belongs_to_no_group

improvise-lhi4 and improvise-u6gc: two defects in one match block, and they pull in opposite directions, which is why they were fixed together. The naive repair of the first — stop pruning headers — deepens the second.

prune_axis_items kept a header only if a data item under it survived the predicate, and dropped a GroupEnd whenever the header it closed was still pending. Both readings are wrong about a case the entry stream can distinguish.

lhi4: a collapsed group contributes zero DataItems — expand_category emits the GroupHeader and then continues past every item — so its header was always pruned and the group vanished from the grid entirely. z (toggle-group-under-cursor) resolves the group from row_group_for(cursor), so with no header and no items no row named it: collapse became one-way from the grid, recoverable only through :toggle-group <cat> <group>.

u6gc: dropping a pruned header's GroupEnd re-opens an earlier run. With Jan[Q1], Feb[Q1], Mar[Q2], Sep and Mar empty, the kept list became [H(Q1), Jan, Feb, Sep] and the nearest-preceding walk answered Q1 for Sep — exactly the inheritance improvise-gd83 added GroupEnd to prevent, so the regression was live whenever prune_empty was on.

The fix distinguishes the two emptinesses without consulting the View, which GridLayout has already dropped: a run that contained no DataItem at all was never expanded, i.e. collapsed, and its header survives; a run that contained items the predicate rejected is genuinely empty and goes. A GroupEnd is kept whenever a run is open in the output, so a re-opened earlier header is closed even though the header between them was pruned. That keeps gotcha 1's shape — still a predicate over the data index, no &View parameter, no flag vector.

The tests are separate because each is satisfied by a fix that misses another. a_header_whose_items_all_prune_away_is_dropped passes before the change and is the discriminator: it is what stops "keep every header" from counting as a fix for lhi4, and it is the acceptance criterion lhi4 names.

a_partly_pruned_group_keeps_the_header_its_survivors_sit_under was added by self-review and covers the commonest case, which the other three do not reach: a group where some items prune and some do not. All three of the others put a group in an all-or-nothing state, so between them they said nothing about the mixed one — and it is the case a user meets first.

Reviewing it turned up a fact worth keeping about the shape of the code rather than a defect. Rewriting run_had_item to mean "some item was rejected" instead of "there was an item" is a no-op, and the whole workspace stays green: the header is pushed eagerly by the first surviving item, so pending_header is already None by the time any rejection can be recorded, and the flag is therefore only ever read in runs where nothing survived — where "no item at all" and "no item accepted" coincide. The eager push is what makes the collapsed/pruned distinction robust rather than delicate, and a reviewer trying to break it by attacking that flag is attacking the wrong half.

a_collapsed_groups_header_survives_pruning asserts the whole row_items sequence rather than any(|e| is Q1 header). An any() cannot see a header emitted in the wrong position or an extra entry beside it; the sequence can. Note what it still cannot claim: two consecutive GroupEnds are not reachable, because expand_category never emits them, so "no stray GroupEnd" is a structural property of the writer and not something any fixture falsifies.

Its fixture carries an unfunded Mar for a reason integration review found: the first version funded every item, so nothing was ever pruned and the test passed with prune_empty disabled entirely — the name claimed more than the test could see, which is the same vacuity class as the any() above. With Mar present, turning pruning off leaves DataItem(["Mar"]) in the sequence and the assertion fails, so the fixture now demonstrates that pruning ran.

Three faults injected against the fixed code, measured over all four tests (c = collapsed, p = prune-away, g = GroupEnd, m = partly-pruned; ✗ = fails, which is the point):

injected fault c p g m
stop keeping the GroupEnd (drop the run_open push) ✗ ok ✗ ✗
drop !had_item — keep every pending header ok ✗ ok ok
invert to had_item — keep only fully-pruned headers ✗ ✗ ok ok

Every column is distinct, so no test here stands in for another. In particular the third row separates the collapsed test from the GroupEnd test — integration review reported that no fault did, which does not reproduce against this tree; inverting the condition fails c while g passes, because a re-opened run is closed by the GroupEnd arm whatever the header condition says. The two membership failures read Some(("Month", "Q1")) against None.

Funding is per fixture and deliberate in both directions. The three all-or-nothing fixtures fund every item they expect to survive, because on a wholly unfunded fixture every row prunes and the tests would assert over an empty grid — row_count() is asserted first in each for that reason. The collapsed fixture instead carries one unfunded Mar, so that pruning is demonstrably running: see its own note above.

a_trailing_conversion_letter_does_not_erase_the_precision

improvise-711n. parse_number_format read the precision as fmt[i+1..].parse::<u8>().ok().unwrap_or(0) over everything after the last ., so any trailing conversion letter made the whole suffix unparseable and the format fell back to zero decimals, silently. format: ,.2f — the spelling context/repo-map.md's own .improv example carries, and the printf/Python convention a user reaches for first — displayed integers everywhere.

Fails before the fix with left: (true, 0), right: (true, 2).

The test also pins the reading against its nearest wrong neighbour. Two implementations both fix ,.2f: take the leading digit run, or trim the trailing non-digits off the tail. They differ only on a tail with digits on both sides of a letter, so ".2f3" is in the assertion. Injecting s.trim_end_matches(|c: char| !c.is_ascii_digit()) in place of leading_digits fails the test at left: (false, 0), right: (false, 2) — so the test sees the rule, not merely the bug.

a_zero_padded_picture_string_is_read_as_printf_not_as_excel

The companion to the above, and the reason the fix is not "be generous". A forgiving reader would have to answer .00, where printf says precision zero and an Excel picture says two places. This pins the printf reading — "0.00" is (false, 0) — so that "accept the trailing f" cannot quietly grow into "accept anything and guess". It passes before the fix as well as after: it is there to stop the next change, not to catch this one.

a_precision_too_large_to_name_reads_as_none

".300" does not fit u8, so it reads as no precision rather than as 300 decimal places. Unchanged by improvise-711n and pinned so the fallback is a stated rule rather than an artefact of the integer type.

a_format_naming_no_precision_at_all_reads_as_zero

",f" has no ., and ".f" has a . with no digits after it. Both are zero decimals. Pinned because leading_digits returns "" for the second, and an empty string reaching parse::<u8>() is the one path in the function with no digits to carry the answer.


workbook.rs

workbook::tests::removing_a_measure_row_that_is_only_a_formula_target_removes_the_formula

improvise-g3no through the facade both front ends actually reach — effect::RemoveItem calls Workbook::remove_item, never Model::remove_item — so this is the one that says the fix arrives where the keypress lands. Its leaf name is shared with the model/types.rs test below, hence the qualification.

It asserts effective_item_names("_Measure") by value rather than !contains("Profit"), so a fix that removed the formula and also lost Revenue or Cost on the way fails here rather than passing a negative.


format.rs — the decimal default and a formula's own format (improvise-e0ex)

a_ratio_keeps_its_decimals_under_a_fresh_views_format

The bug the ticket reports, at the smallest place it is visible. View::new's number_format was ,.0 — zero decimals — so 2/3 reached format_f64 and came back as "1", half-away-from-zero. Written before the fix and observed failing with left: "1" right: "0.67".

It asks the view's default rather than a literal ",.2", so the assertion is about the shipped default and not about parse_number_format, which already had its own tests. Reverting View::new to ,.0 fails it.

What it deliberately does not assert: that the default is exactly two places. 0.67 would also fail under .1 or .3, which is the point — the test names "a ratio keeps its decimals", and the precise number is a product decision recorded in context/code/core.md, not a property.

a_formulas_own_format_wins_for_its_target_and_nowhere_else

The override, and both halves of "nowhere else" — a raw measure beside the formula target, and a key with no _Measure coordinate at all (the aggregating cell). The fixture uses .4 against a ,.2 view so the two answers differ in digits, not merely in grouping.

Two faults injected, in opposite directions, because one alone is not enough:

  • FormatPlan::at returning self.view unconditionally (the override never applies) fails with left: "0.67" right: "0.6667".
  • FormatPlan::at returning the first entry in the plan for any key (the override leaks to every cell) fails with left: "0.6667" right: "0.67".

A test asserting only the target's own column passes the second fault, which is why the two neighbours are in the same test rather than in a sibling that could be deleted on its own.

a_formula_naming_no_format_leaves_its_target_on_the_views

The fallback, asserted where it is easiest to lose: a formula target whose formula carries no format must be indistinguishable from a raw measure. It keeps FormatPlan::of from indexing every target with some default and then "overriding" it back, which would be a second copy of the view's answer.

the_formula_that_owns_a_cell_names_its_format_even_when_it_names_none

Found by review (ChatGPT Codex, PR #125), not by the tests above, which is the entry's point: they used one formula and so could not see an ownership question at all.

A target category need not be _Measure — - Tax = Revenue * 0.1 [CustomCat] is in the format's own documentation — so a key may carry coordinates two formulas claim. The first FormatPlan indexed only the formatted formulas, so a later formula's FORMAT reached a cell whose value an earlier, unformatted formula produces: the number and its spelling came from different formulas.

The fixture puts the same two formulas in both orders and asserts the owner's answer each time, so a plan that prefers the formatted one fails the first half and a plan that ignores formats entirely fails the second.

Fault injected: .filter(|t| t.format.is_some()) before the find — precisely the behaviour of the version review rejected. Fails with left: "0.6667" right: "0.67".

the_owner_is_the_one_model_order_names_and_not_the_one_a_hash_does

The other half of the same finding, and the one a single run cannot see. by_target was a HashMap<category, HashMap<item, _>> and at iterated it, so with two claiming categories the answer came from whichever hashed first — a different answer per process, gotcha 18's shape exactly (an index whose values are hashed is for lookup, never for choosing between candidates).

It rebuilds the plan five times rather than asserting once, because each HashMap gets its own RandomState and a single construction can be right by luck. It is the same shape as analyze_records' five-iteration guard.

This one cannot be made to fail by re-introducing a HashMap reliably, which is why the test that carries the load is its sibling above; kept because the repetition is what documents that hash order was the defect.