787e354b9b
The `build` job fails on main and on every branch merged from it: doc-site: context/code/pr-review.md is neither published nor a named omission NOT a merge loss -- scripts/build-doc-site.mjs in this branch is byte-identical to main's, and neither lists the file. PR #96 added context/code/pr-review.md without a manifest line, which is exactly the gate improvise-res9 added: a doc added without a line publishes nothing and says nothing, so the generator refuses. Omitted rather than published, and the line is cloud-agents.md's rather than tooling.md's. tooling.md explains checkers a reader of the code will meet; pr-review.md is about THIS REPOSITORY'S PROCESS -- where a Codex review appears, how to tell it from our own agents' comments, why absence is not approval. Nobody visiting a website is about to read a PR thread. Both homes updated, because an omission is restated beside the manifest AND in doc-site.md section 2, and the prose there counted them ("Neither is referenced...") so it went stale at three. This strays into scripts/, which is Wave N's ground. Flagged in the hand-back; it is a one-line data entry either way if the orchestrator would rather publish it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
182 lines
64 KiB
HTML
182 lines
64 KiB
HTML
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<title>improvise-core — improvise docs</title>
|
|
<link rel="stylesheet" href="../style.css" />
|
|
</head>
|
|
<body>
|
|
<div class="shell">
|
|
<nav class="side"><a class="brand" href="../index.html">improvise docs</a><input type="checkbox" id="navtoggle" class="navtoggle" /><label class="navsummary" for="navtoggle">Pages</label><div class="navlinks"><ul><li><a href="../../index.html">← Project home</a></li><li><a href="../../app/">Browser demo</a></li><li><a href="https://github.com/fiddlerwoaroof/improvise">Source on GitHub</a></li></ul><h3>Start here</h3><ul><li><a href="../design-notes.html">Design notes</a></li><li><a href="../design-principles.html">Design principles</a></li><li><a href="../repo-map.html">Repository map</a></li></ul><h3>Decision records</h3><ul><li><a href="../adr/001-concurrent-edits.html">ADR 001 — Concurrent edits</a></li><li><a href="../adr/002-naming-a-cell.html">ADR 002 — Naming a cell</a></li><li><a href="../adr/003-live-backed-models.html">ADR 003 — Live-backed models</a></li></ul><h3>The arguments, by area</h3><ul><li><a href="../code/core.html">improvise-core</a></li><li><a href="../code/formula-and-io.html">improvise-formula and improvise-io</a></li><li><a href="../code/protocol.html">improvise-protocol</a></li><li><a href="../code/command.html">improvise-command</a></li><li><a href="../code/clients.html">The clients</a></li><li><a href="../code/tui.html">The TUI</a></li><li><a href="../code/gp.html">gp-tree and improvise-gp</a></li><li><a href="../code/kb-tool.html">The kb tool</a></li><li><a href="../code/tooling.html">The repository's own checkers</a></li></ul><h3>Test evidence</h3><ul><li><a href="../test-evidence/core.html" class="here">improvise-core</a></li><li><a href="../test-evidence/formula-and-io.html">improvise-formula and improvise-io</a></li><li><a href="../test-evidence/protocol.html">improvise-protocol</a></li><li><a href="../test-evidence/command.html">improvise-command</a></li><li><a href="../test-evidence/clients.html">The clients</a></li><li><a href="../test-evidence/tui.html">The TUI</a></li><li><a href="../test-evidence/gp.html">gp-tree and improvise-gp</a></li><li><a href="../test-evidence/tooling.html">The repository's own gates</a></li></ul><h3>About this site</h3><ul><li><a href="../doc-site.html">About this site</a></li></ul><h3>Reference</h3><ul><li><a href="../tickets.html">Ticket index</a></li></ul></div></nav>
|
|
<main>
|
|
<p class="src">Source: <a href="https://github.com/fiddlerwoaroof/improvise/blob/main/context/test-evidence/core.md">context/test-evidence/core.md</a></p><details class="toc" open><summary>On this page</summary><ul><li class="l1"><a href="#test-evidence-improvise-core">Test evidence — improvise-core</a></li><li class="l2"><a href="#model-types-rs">model/types.rs</a></li><li class="l3"><a href="#a-hidden-dimension-list-reduces-to-one-of-two-states">a_hidden_dimension_list_reduces_to_one_of_two_states</a></li><li class="l3"><a href="#apply-agg-over-nothing-is-absent-for-every-function">apply_agg_over_nothing_is_absent_for_every_function</a></li><li class="l3"><a href="#eval-agg-over-no-rows-is-zero-only-where-an-identity-exists">eval_agg_over_no_rows_is_zero_only_where_an_identity_exists</a></li><li class="l3"><a href="#find-item-category-falls-back-to-formula-targets">find_item_category_falls_back_to_formula_targets</a></li><li class="l3"><a href="#stored-virtual-categories-match-their-names">stored_virtual_categories_match_their_names</a></li><li class="l3"><a href="#aggregating-one-text-value-yields-that-text">aggregating_one_text_value_yields_that_text</a></li><li class="l3"><a href="#aggregating-differing-text-values-is-an-explicit-error">aggregating_differing_text_values_is_an_explicit_error</a></li><li class="l3"><a href="#aggregating-text-among-numbers-is-an-explicit-error">aggregating_text_among_numbers_is_an_explicit_error</a></li><li class="l3"><a href="#a-lone-number-still-counts-as-one-rather-than-passing-through">a_lone_number_still_counts_as_one_rather_than_passing_through</a></li><li class="l3"><a href="#aggregating-nothing-is-still-absent">aggregating_nothing_is_still_absent</a></li><li class="l3"><a href="#where-filter-absent-category-does-not-apply-formula">where_filter_absent_category_does_not_apply_formula</a></li><li class="l3"><a href="#sum-inner-expression-constrains-which-cells-are-summed">sum_inner_expression_constrains_which_cells_are_summed</a></li><li class="l3"><a href="#add-formula-same-target-name-different-category-both-coexist">add_formula_same_target_name_different_category_both_coexist</a></li><li class="l3"><a href="#add-formula-same-target-name-different-category-evaluates-independently">add_formula_same_target_name_different_category_evaluates_independently</a></li><li class="l3"><a href="#remove-formula-only-removes-specified-target-category">remove_formula_only_removes_specified_target_category</a></li><li class="l3"><a href="#near-zero-divisor-consistent-with-equality-epsilon">near_zero_divisor_consistent_with_equality_epsilon</a></li><li class="l3"><a href="#near-zero-divisor-consistent-in-cached-eval-path">near_zero_divisor_consistent_in_cached_eval_path</a></li><li class="l3"><a href="#recompute-formulas-multi-stem-values-stable">recompute_formulas_multi_stem_values_stable</a></li><li class="l3"><a href="#formula-chain-preserves-full-precision">formula_chain_preserves_full_precision</a></li><li class="l2"><a href="#model-cell-rs">model/cell.rs</a></li><li class="l3"><a href="#matching-cells-yields-the-primary-insertion-order">matching_cells_yields_the_primary_insertion_order</a></li><li class="l3"><a href="#the-same-cells-entered-in-opposite-orders-list-in-opposite-orders">the_same_cells_entered_in_opposite_orders_list_in_opposite_orders</a></li><li class="l3"><a href="#matching-values-yields-the-primary-insertion-order">matching_values_yields_the_primary_insertion_order</a></li><li class="l3"><a href="#an-empty-partial-matches-every-cell-from-either-door">an_empty_partial_matches_every_cell_from_either_door</a></li><li class="l3"><a href="#live-records-come-back-in-the-stores-own-insertion-order">live_records_come_back_in_the_stores_own_insertion_order</a></li><li class="l3"><a href="#the-same-records-entered-in-opposite-orders-list-in-opposite-orders">the_same_records_entered_in_opposite_orders_list_in_opposite_orders</a></li><li class="l3"><a href="#a-recreated-record-takes-its-new-place-not-its-old-one">a_recreated_record_takes_its_new_place_not_its_old_one</a></li><li class="l3"><a href="#deleting-and-recreating-a-record-does-not-reset-its-version">deleting_and_recreating_a_record_does_not_reset_its_version</a></li><li class="l3"><a href="#a-surrogate-for-a-removed-record-fails-to-resolve">a_surrogate_for_a_removed_record_fails_to_resolve</a></li><li class="l3"><a href="#a-second-measure-on-a-record-resolves-to-the-same-record">a_second_measure_on_a_record_resolves_to_the_same_record</a></li><li class="l3"><a href="#a-live-records-surrogate-is-the-one-that-resolves-it">a_live_records_surrogate_is_the_one_that_resolves_it</a></li><li class="l3"><a href="#a-deserialized-store-can-resolve-its-records">a_deserialized_store_can_resolve_its_records</a></li><li class="l2"><a href="#model-record-rs">model/record.rs</a></li><li class="l3"><a href="#a-records-surrogate-is-pinned-to-a-literal">a_records_surrogate_is_pinned_to_a_literal</a></li><li class="l3"><a href="#the-two-kinds-do-not-share-a-hash">the_two_kinds_do_not_share_a_hash</a></li><li class="l3"><a href="#coordinates-that-concatenate-alike-do-not-hash-alike">coordinates_that_concatenate_alike_do_not_hash_alike</a></li><li class="l3"><a href="#renaming-either-half-of-a-coordinate-changes-the-hash">renaming_either_half_of_a_coordinate_changes_the_hash</a></li><li class="l3"><a href="#a-surrogate-round-trips-through-json-as-a-string">a_surrogate_round_trips_through_json_as_a_string</a></li><li class="l3"><a href="#construction-order-does-not-reach-the-hash">construction_order_does_not_reach_the_hash</a></li><li class="l2"><a href="#view-types-rs">view/types.rs</a></li><li class="l3"><a href="#a-pivot-view-still-seats-categories-row-then-column-then-page">a_pivot_view_still_seats_categories_row_then_column_then_page</a></li><li class="l3"><a href="#a-records-view-seats-a-new-category-on-none-and-stays-a-records-view">a_records_view_seats_a_new_category_on_none_and_stays_a_records_view</a></li><li class="l3"><a href="#a-measure-on-column-is-not-bumped-off-it">a_measure_on_column_is_not_bumped_off_it</a></li><li class="l3"><a href="#cycle-axis-unknown-category-is-noop">cycle_axis_unknown_category_is_noop</a></li><li class="l3"><a href="#tileable-cats-omits-filter-but-keeps-every-other-axis">tileable_cats_omits_filter_but_keeps_every_other_axis</a></li><li class="l3"><a href="#filtering-cats-covers-page-and-filter-but-not-the-rest">filtering_cats_covers_page_and_filter_but_not_the_rest</a></li><li class="l2"><a href="#view-layout-rs">view/layout.rs</a></li><li class="l3"><a href="#prune-axis-items-indexes-data-positions-not-visual-ones">prune_axis_items_indexes_data_positions_not_visual_ones</a></li><li class="l3"><a href="#a-text-cell-renders-in-a-default-view">a_text_cell_renders_in_a_default_view</a></li><li class="l3"><a href="#records-mode-gives-each-measure-its-own-value-column">records_mode_gives_each_measure_its_own_value_column</a></li><li class="l3"><a href="#records-mode-initial-layout-is-sorted">records_mode_initial_layout_is_sorted</a></li><li class="l3"><a href="#records-mode-new-record-appends-at-bottom">records_mode_new_record_appends_at_bottom</a></li><li class="l3"><a href="#records-display-out-of-bounds-col-returns-none">records_display_out_of_bounds_col_returns_none</a></li><li class="l2"><a href="#view-axis-rs">view/axis.rs</a></li><li class="l3"><a href="#the-label-vocabularies-are-unchanged">the_label_vocabularies_are_unchanged</a></li><li class="l3"><a href="#filter-is-outside-the-rotation">filter_is_outside_the_rotation</a></li><li class="l3"><a href="#next-is-a-four-cycle-over-the-navigable-axes">next_is_a_four_cycle_over_the_navigable_axes</a></li><li class="l3"><a href="#every-axis-gets-its-own-color">every_axis_gets_its_own_color</a></li><li class="l3"><a href="#a-routing-formula-over-a-group-sums-the-members-answers">a_routing_formula_over_a_group_sums_the_members_answers</a></li><li class="l3"><a href="#a-coordinate-split-survives-aggregating-over-that-very-category">a_coordinate_split_survives_aggregating_over_that_very_category</a></li><li class="l3"><a href="#a-member-whose-input-is-missing-errors-rather-than-being-dropped">a_member_whose_input_is_missing_errors_rather_than_being_dropped</a></li><li class="l3"><a href="#feature-roles-line-up-with-the-names-preprocess-emitted">feature_roles_line_up_with_the_names_preprocess_emitted</a></li><li class="l3"><a href="#a-formula-targeting-another-category-still-folds-over-its-records">a_formula_targeting_another_category_still_folds_over_its_records</a></li><li class="l3"><a href="#the-two-evaluators-agree-when-the-fold-reads-a-derived-measure">the_two_evaluators_agree_when_the_fold_reads_a_derived_measure</a></li><li class="l3"><a href="#the-cached-evaluator-has-its-own-working-coordinate-predicate">the_cached_evaluator_has_its_own_working_coordinate_predicate</a></li><li class="l3"><a href="#a-user-category-whose-name-leads-with-an-underscore-is-seated-as-regular">a_user_category_whose_name_leads_with_an_underscore_is_seated_as_regular</a></li><li class="l3"><a href="#an-ungrouped-item-after-a-grouped-run-belongs-to-no-group">an_ungrouped_item_after_a_grouped_run_belongs_to_no_group</a></li><li class="l3"><a href="#a-grouped-run-does-not-leak-into-the-next-prefix">a_grouped_run_does_not_leak_into_the_next_prefix</a></li><li class="l3"><a href="#a-trailing-conversion-letter-does-not-erase-the-precision">a_trailing_conversion_letter_does_not_erase_the_precision</a></li><li class="l3"><a href="#a-zero-padded-picture-string-is-read-as-printf-not-as-excel">a_zero_padded_picture_string_is_read_as_printf_not_as_excel</a></li><li class="l3"><a href="#a-precision-too-large-to-name-reads-as-none">a_precision_too_large_to_name_reads_as_none</a></li><li class="l3"><a href="#a-format-naming-no-precision-at-all-reads-as-zero">a_format_naming_no_precision_at_all_reads_as_zero</a></li></ul></details><h1 id="test-evidence-improvise-core">Test evidence — <code>improvise-core</code></h1>
|
|
<p>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; <code>scripts/check-comment-runs.py --check-evidence</code> catches a heading naming a test that does not exist.</p>
|
|
<p>Only tests whose narrative is longer than the code appear here. Most tests need none.</p>
|
|
<hr />
|
|
<h2 id="model-types-rs"><a class="anchor" href="#model-types-rs">#</a><code>model/types.rs</code></h2>
|
|
<h3 id="a-hidden-dimension-list-reduces-to-one-of-two-states"><a class="anchor" href="#a-hidden-dimension-list-reduces-to-one-of-two-states">#</a>a_hidden_dimension_list_reduces_to_one_of_two_states</h3>
|
|
<p><a class="tid" href="../tickets.html#improvise-5jm5" title="Model::evaluate_aggregated takes a list it only tests for emptiness">improvise-5jm5</a>. <code>evaluate_aggregated</code> took <code>none_cats: &[String]</code> 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.</p>
|
|
<p>This test is the <em>smaller</em> half of the guard and says so. The real guard is the signature: the defect the ticket describes is no longer constructible, exactly as <code>Checked</code>'s constructor is the guard for the basis check rather than a test that can fail. What this test does add is that <code>Aggregation::of</code> 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.</p>
|
|
<h3 id="apply-agg-over-nothing-is-absent-for-every-function"><a class="anchor" href="#apply-agg-over-nothing-is-absent-for-every-function">#</a>apply_agg_over_nothing_is_absent_for_every_function</h3>
|
|
<p>An empty cell must read as <em>absent</em>, not as zero — otherwise every blank intersection in a pivot renders <code>0</code> and a sparse model looks fully populated. Fails if <code>apply_agg</code> ever gains a <code>Sum => 0.0</code> identity arm for the empty case. <code>eval_agg</code> deliberately has the opposite rule; see the next entry.</p>
|
|
<h3 id="eval-agg-over-no-rows-is-zero-only-where-an-identity-exists"><a class="anchor" href="#eval-agg-over-no-rows-is-zero-only-where-an-identity-exists">#</a>eval_agg_over_no_rows_is_zero_only_where_an_identity_exists</h3>
|
|
<p>A <em>formula</em> aggregate over no rows is <code>0</code> for <code>SUM</code>/<code>COUNT</code>, which have an identity, and an error for <code>AVG</code>/<code>MIN</code>/<code>MAX</code>, which do not. This is deliberately <strong>not</strong> <code>apply_agg</code>'s rule, and the two are separate functions precisely so the difference is visible. <a class="tid" href="../tickets.html#improvise-kdp" title="Duplicated aggregation dispatch block">improvise-kdp</a> named both after they had been duplicated inline in two evaluators; the test pins the contract directly rather than only through formula eval.</p>
|
|
<h3 id="find-item-category-falls-back-to-formula-targets"><a class="anchor" href="#find-item-category-falls-back-to-formula-targets">#</a>find_item_category_falls_back_to_formula_targets</h3>
|
|
<p><code>add_formula</code> never inserts its target into <code>_Measure</code> (the item is <em>implied</em> by the formula definition). So <code>find_item_category</code> must fall back to the formula list, or <code>Expr::Ref("Profit")</code> fails to resolve and every cell of a chained formula errors <code>ref:Profit</code> (<a class="tid" href="../tickets.html#improvise-nrs" title="Duplicated find_item_category helper in eval paths">improvise-nrs</a>). 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.</p>
|
|
<h3 id="stored-virtual-categories-match-their-names"><a class="anchor" href="#stored-virtual-categories-match-their-names">#</a>stored_virtual_categories_match_their_names</h3>
|
|
<p><code>CategoryKind::of_name</code> is used all over the codebase in place of looking the <code>Category</code> up. That substitution is only sound because (a) a fresh <code>Model</code> really does store each reserved name under its virtual kind and (b) <code>add_category</code> hands back the existing virtual category rather than shadowing a reserved name with a <code>Regular</code> one. The second half is the arm that would break silently: a shadowed <code>_Measure</code> would still answer <code>of_name</code> correctly while the stored category had the wrong kind (<a class="tid" href="../tickets.html#improvise-2lh" title="Migrate name-string comparisons to CategoryKind enum checks">improvise-2lh</a>).</p>
|
|
<h3 id="aggregating-one-text-value-yields-that-text"><a class="anchor" href="#aggregating-one-text-value-yields-that-text">#</a>aggregating_one_text_value_yields_that_text</h3>
|
|
<p>The head of the <a class="tid" href="../tickets.html#improvise-l4qx" title="[bug] A text cell is invisible whenever any category is on the None axis">improvise-l4qx</a> family. <code>aggregate_cell_values</code> used to be <code>filter_map(as_f64)</code>, which <strong>dropped</strong> text. <code>_Index</code>/<code>_Dim</code> sit on <code>Axis::None</code> in every default view, so <code>none_cats</code> is non-empty almost always and the aggregating path was almost always taken — making <strong>every text cell in every document render blank</strong>. <code>.improv</code> carries pipe-quoted text as a first-class value, so a user could type a text value, save, load, and never see it again.</p>
|
|
<h3 id="aggregating-differing-text-values-is-an-explicit-error"><a class="anchor" href="#aggregating-differing-text-values-is-an-explicit-error">#</a>aggregating_differing_text_values_is_an_explicit_error</h3>
|
|
<p>Two different words have no reduction. The user is told <code>mixed</code> rather than shown one of them arbitrarily, or a blank.</p>
|
|
<h3 id="aggregating-text-among-numbers-is-an-explicit-error"><a class="anchor" href="#aggregating-text-among-numbers-is-an-explicit-error">#</a>aggregating_text_among_numbers_is_an_explicit_error</h3>
|
|
<p>The case that produced a <em>plausible</em> 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.</p>
|
|
<h3 id="a-lone-number-still-counts-as-one-rather-than-passing-through"><a class="anchor" href="#a-lone-number-still-counts-as-one-rather-than-passing-through">#</a>a_lone_number_still_counts_as_one_rather_than_passing_through</h3>
|
|
<p>The regression guard for the hot path, and the test that pins the <strong>branch order</strong> in <code>aggregate_cell_values</code>. The all-numeric branch is checked <em>first</em>, which is the entire difference between this rule and "a single value passes through". <code>Count</code> is the only function that can tell them apart — every other function is the identity on a one-element set — so this test sets <code>measure_agg["Status"] = Count</code> and requires <code>1</code>, not <code>7</code>. Reordering the two branches passes every other test in the family.</p>
|
|
<h3 id="aggregating-nothing-is-still-absent"><a class="anchor" href="#aggregating-nothing-is-still-absent">#</a>aggregating_nothing_is_still_absent</h3>
|
|
<p>The text rule must not turn <code>apply_agg</code>'s emptiness rule into an error or a zero. Fails if <code>aggregate_cell_values</code> answers on an empty slice at all.</p>
|
|
<h3 id="where-filter-absent-category-does-not-apply-formula"><a class="anchor" href="#where-filter-absent-category-does-not-apply-formula">#</a>where_filter_absent_category_does_not_apply_formula</h3>
|
|
<p><code>Profit = 42 WHERE Region = "East"</code> evaluated against a key with <strong>no</strong> 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 <code>if let Some(item_val)</code> 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.</p>
|
|
<h3 id="sum-inner-expression-constrains-which-cells-are-summed"><a class="anchor" href="#sum-inner-expression-constrains-which-cells-are-summed">#</a>sum_inner_expression_constrains_which_cells_are_summed</h3>
|
|
<p><code>SUM(Revenue)</code> ignored its inner expression and summed every numeric cell matching the partial key. With <code>Revenue=100</code> and <code>Cost=50</code> both stored for <code>Region=East</code>, <code>Total = SUM(Revenue)</code> returned <code>150</code>. The fixture needs <strong>two</strong> measures on one coordinate or the bug is invisible.</p>
|
|
<h3 id="add-formula-same-target-name-different-category-both-coexist"><a class="anchor" href="#add-formula-same-target-name-different-category-both-coexist">#</a>add_formula_same_target_name_different_category_both_coexist</h3>
|
|
<p><code>add_formula</code> deduplicated by <code>target</code> name alone, ignoring <code>target_category</code>, so <code>Profit</code> in <code>_Measure</code> and <code>Profit</code> in <code>KPI</code> could not coexist — the second silently replaced the first.</p>
|
|
<h3 id="add-formula-same-target-name-different-category-evaluates-independently"><a class="anchor" href="#add-formula-same-target-name-different-category-evaluates-independently">#</a>add_formula_same_target_name_different_category_evaluates_independently</h3>
|
|
<p>The consequence of the same bug seen through evaluation rather than through <code>formulas.len()</code>: 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.</p>
|
|
<h3 id="remove-formula-only-removes-specified-target-category"><a class="anchor" href="#remove-formula-only-removes-specified-target-category">#</a>remove_formula_only_removes_specified_target_category</h3>
|
|
<p>The mirror image: <code>remove_formula</code> matched by target name alone, so removing <code>Profit</code> from <code>_Measure</code> also destroyed the <code>Profit</code> formula in <code>KPI</code>.</p>
|
|
<h3 id="near-zero-divisor-consistent-with-equality-epsilon"><a class="anchor" href="#near-zero-divisor-consistent-with-equality-epsilon">#</a>near_zero_divisor_consistent_with_equality_epsilon</h3>
|
|
<p><a class="tid" href="../tickets.html#improvise-0bf" title="Inconsistent float-equality tolerance in formula eval">improvise-0bf</a>. <code>=</code>/<code>!=</code> compare with <code>FLOAT_EQ_EPSILON = 1e-10</code>, but the division-by-zero guard checked <code>rv == 0.0</code> exactly. With <code>X = 1e-11</code> the comparison <code>X = 0</code> was true <em>and</em> <code>100 / X</code> happily returned <code>1e13</code> — incoherent semantics for <code>IF(X = 0, a, y / X)</code>, whose else branch divides by a value its own condition called zero. Any value the equality operator treats as zero must also trigger <code>div/0</code>. This exercises the <strong>direct</strong> eval path (<code>eval_expr</code> inside <code>eval_formula_depth</code>).</p>
|
|
<h3 id="near-zero-divisor-consistent-in-cached-eval-path"><a class="anchor" href="#near-zero-divisor-consistent-in-cached-eval-path">#</a>near_zero_divisor_consistent_in_cached_eval_path</h3>
|
|
<p>The same bug in the <strong>fixed-point/cached</strong> path (<code>eval_expr_cached</code> inside <code>eval_formula_with_cache</code>), whose division guard also used exact <code>== 0.0</code>. 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.</p>
|
|
<h3 id="recompute-formulas-multi-stem-values-stable"><a class="anchor" href="#recompute-formulas-multi-stem-values-stable">#</a>recompute_formulas_multi_stem_values_stable</h3>
|
|
<p><a class="tid" href="../tickets.html#improvise-6os" title="O(n^2) stem deduplication in recompute_formulas">improvise-6os</a> replaced an O(n²) <code>Vec::contains</code> dedupe of coordinate stems with an <code>IndexSet</code> — 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 <code>recompute_formulas</code> call reproduces the <strong>exact same cache</strong>, so a dedupe that lost or duplicated stems would show up as a changed value or a non-idempotent cache.</p>
|
|
<h3 id="formula-chain-preserves-full-precision"><a class="anchor" href="#formula-chain-preserves-full-precision">#</a>formula_chain_preserves_full_precision</h3>
|
|
<p>Display rounds; eval must not. <code>Tax = Price * 0.075</code> on <code>Price = 10</code> is exactly <code>0.75</code>, which <code>format_f64(_, true, 0)</code> displays as <code>"1"</code>. The test asserts both: that the display really does round (so the fixture is discriminating) and that <code>Total = Price + Tax</code> is <code>10.75</code> and not <code>11</code>. Without the first assertion the test would pass on a fixture where rounding changes nothing.</p>
|
|
<h2 id="model-cell-rs"><a class="anchor" href="#model-cell-rs">#</a><code>model/cell.rs</code></h2>
|
|
<h3 id="matching-cells-yields-the-primary-insertion-order"><a class="anchor" href="#matching-cells-yields-the-primary-insertion-order">#</a>matching_cells_yields_the_primary_insertion_order</h3>
|
|
<p><a class="tid" href="../tickets.html#improvise-shyt" title="[bug] Records-mode row order is not stable across processes">improvise-shyt</a>. <code>matching_cells</code> walked the secondary index's <code>HashSet</code>, so its order was hash order rather than entry order. <code>DataStore::cells</code> is an <code>IndexMap</code> <em>precisely so</em> records mode can list rows in entry order. The check is against <code>iter_cells</code> — the primary map's own order — and <strong>not</strong> against a literal, which would only pin whatever the hasher happened to do on this machine today.</p>
|
|
<h3 id="the-same-cells-entered-in-opposite-orders-list-in-opposite-orders"><a class="anchor" href="#the-same-cells-entered-in-opposite-orders-list-in-opposite-orders">#</a>the_same_cells_entered_in_opposite_orders_list_in_opposite_orders</h3>
|
|
<p>The half a single-process test cannot reach by calling twice: two stores holding the <em>same</em> 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 <strong>and to each other</strong>, because <code>RandomState</code> keys each fresh <code>HashMap</code> differently. <code>doctrine(an_order_test_needs_two_histories)</code>.</p>
|
|
<h3 id="matching-values-yields-the-primary-insertion-order"><a class="anchor" href="#matching-values-yields-the-primary-insertion-order">#</a>matching_values_yields_the_primary_insertion_order</h3>
|
|
<p>The same guarantee for the value-only hot path, which aggregation folds <code>f64</code> over. f64 addition is not associative, so an unstable order made a <code>SUM</code> reproducible only within one process — drift in the last bits, which is the kind of wrongness nobody reports and nobody can reproduce. <code>Min</code>/<code>Max</code>/<code>Count</code> and the text-unanimity rule are order-free and were never affected.</p>
|
|
<h3 id="an-empty-partial-matches-every-cell-from-either-door"><a class="anchor" href="#an-empty-partial-matches-every-cell-from-either-door">#</a>an_empty_partial_matches_every_cell_from_either_door</h3>
|
|
<p><a class="tid" href="../tickets.html#improvise-hkt6" title="Three small totality gaps found in review">improvise-hkt6</a>. <code>matching_positions</code> indexed <code>sets[0]</code> 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.</p>
|
|
<p>It asserts the <strong>answer</strong>, 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 <code>vec![]</code> would also have been total and would have made deleting a fast path silently flip "all" to "none".</p>
|
|
<h3 id="live-records-come-back-in-the-stores-own-insertion-order"><a class="anchor" href="#live-records-come-back-in-the-stores-own-insertion-order">#</a>live_records_come_back_in_the_stores_own_insertion_order</h3>
|
|
<p><a class="tid" href="../tickets.html#improvise-2yc2" title="DataStore::live_records walks a HashMap -- decide its order before writing the caller">improvise-2yc2</a>. <code>live_records</code> walked the <code>records: HashMap</code>, so it yielded a different order in every process and in every instance — the <a class="tid" href="../tickets.html#improvise-shyt" title="[bug] Records-mode row order is not stable across processes">improvise-shyt</a> defect, caught here before a caller could inherit it. The order decided on is the store's own: a record sits where its <strong>first</strong> cell sits in the primary <code>IndexMap</code>. The fixture interleaves the measures deliberately — East's second measure arrives after West, and must not move East behind it.</p>
|
|
<h3 id="the-same-records-entered-in-opposite-orders-list-in-opposite-orders"><a class="anchor" href="#the-same-records-entered-in-opposite-orders-list-in-opposite-orders">#</a>the_same_records_entered_in_opposite_orders_list_in_opposite_orders</h3>
|
|
<p><code>doctrine(an_order_test_needs_two_histories)</code> applied to records: one history cannot tell "ordered" from "hashed that way today, on this machine".</p>
|
|
<h3 id="a-recreated-record-takes-its-new-place-not-its-old-one"><a class="anchor" href="#a-recreated-record-takes-its-new-place-not-its-old-one">#</a>a_recreated_record_takes_its_new_place_not_its_old_one</h3>
|
|
<p>The claim <code>live_records</code> makes about <strong>why</strong> it derives the order from <code>cells</code> rather than memoising a position on <code>RecordEntry</code> — 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 <strong>original</strong> 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.</p>
|
|
<h3 id="deleting-and-recreating-a-record-does-not-reset-its-version"><a class="anchor" href="#deleting-and-recreating-a-record-does-not-reset-its-version">#</a>deleting_and_recreating_a_record_does_not_reset_its_version</h3>
|
|
<p>The tombstone earns its place here. If <code>RecordEntry</code> 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 <em>old</em> 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.</p>
|
|
<h3 id="a-surrogate-for-a-removed-record-fails-to-resolve"><a class="anchor" href="#a-surrogate-for-a-removed-record-fails-to-resolve">#</a>a_surrogate_for_a_removed_record_fails_to_resolve</h3>
|
|
<p>The first arm of staleness: a surrogate for a record whose cells are all gone must fail to resolve rather than resolve to <em>some other</em> record. The fixture holds <strong>two</strong> records, so a bug returning "any record" is visible; with one, the assertion would pass by naming the only record there is.</p>
|
|
<h3 id="a-second-measure-on-a-record-resolves-to-the-same-record"><a class="anchor" href="#a-second-measure-on-a-record-resolves-to-the-same-record">#</a>a_second_measure_on_a_record_resolves_to_the_same_record</h3>
|
|
<p>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).</p>
|
|
<h3 id="a-live-records-surrogate-is-the-one-that-resolves-it"><a class="anchor" href="#a-live-records-surrogate-is-the-one-that-resolves-it">#</a>a_live_records_surrogate_is_the_one_that_resolves_it</h3>
|
|
<p>The pair <code>live_records</code> 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.</p>
|
|
<h3 id="a-deserialized-store-can-resolve-its-records"><a class="anchor" href="#a-deserialized-store-can-resolve-its-records">#</a>a_deserialized_store_can_resolve_its_records</h3>
|
|
<p>The record index is derived state and is <code>#[serde(skip)]</code>-equivalent — it must come back <em>with</em> the data, rebuilt through <code>set</code>, rather than being written alongside it.</p>
|
|
<h2 id="model-record-rs"><a class="anchor" href="#model-record-rs">#</a><code>model/record.rs</code></h2>
|
|
<h3 id="a-records-surrogate-is-pinned-to-a-literal"><a class="anchor" href="#a-records-surrogate-is-pinned-to-a-literal">#</a>a_records_surrogate_is_pinned_to_a_literal</h3>
|
|
<p>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". <strong>A failure here is not a bug in the test</strong>: 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.</p>
|
|
<h3 id="the-two-kinds-do-not-share-a-hash"><a class="anchor" href="#the-two-kinds-do-not-share-a-hash">#</a>the_two_kinds_do_not_share_a_hash</h3>
|
|
<p>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.</p>
|
|
<h3 id="coordinates-that-concatenate-alike-do-not-hash-alike"><a class="anchor" href="#coordinates-that-concatenate-alike-do-not-hash-alike">#</a>coordinates_that_concatenate_alike_do_not_hash_alike</h3>
|
|
<p>The length prefixes in <code>hash_coords</code> earn their place: without them <code>("Ab","c")</code> and <code>("A","bc")</code> serialize to the same bytes, and a collision here is a silent write to the wrong record.</p>
|
|
<h3 id="renaming-either-half-of-a-coordinate-changes-the-hash"><a class="anchor" href="#renaming-either-half-of-a-coordinate-changes-the-hash">#</a>renaming_either_half_of_a_coordinate_changes_the_hash</h3>
|
|
<p>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 <code>TransposeAxes</code> from <code>CycleAxis</code>.</p>
|
|
<h3 id="a-surrogate-round-trips-through-json-as-a-string"><a class="anchor" href="#a-surrogate-round-trips-through-json-as-a-string">#</a>a_surrogate_round_trips_through_json_as_a_string</h3>
|
|
<p><code>serde_json</code> writes a <code>u128</code> as a bare number and a JavaScript host parses every JSON number as <code>f64</code>, so anything above 2^53 comes back <strong>changed</strong> — 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.</p>
|
|
<h3 id="construction-order-does-not-reach-the-hash"><a class="anchor" href="#construction-order-does-not-reach-the-hash">#</a>construction_order_does_not_reach_the_hash</h3>
|
|
<p><code>CellKey</code> is canonically sorted and <code>without</code> preserves that order, so "the normalized record" is a property the type guarantees rather than a convention to maintain.</p>
|
|
<h2 id="view-types-rs"><a class="anchor" href="#view-types-rs">#</a><code>view/types.rs</code></h2>
|
|
<h3 id="a-pivot-view-still-seats-categories-row-then-column-then-page"><a class="anchor" href="#a-pivot-view-still-seats-categories-row-then-column-then-page">#</a>a_pivot_view_still_seats_categories_row_then_column_then_page</h3>
|
|
<p><a class="tid" href="../tickets.html#improvise-2zq" title="Records mode has no inline way to add a category, unlike the + column for measures">improvise-2zq</a>, the <strong>negative arm</strong>, and the load-bearing one. The fix makes a records view seat a new category on <code>Axis::None</code>; without this test, "always seat on <code>None</code>" would pass every assertion about records mode while quietly deleting the auto-assignment <code>on_category_added</code> exists for.</p>
|
|
<p>Probing confirmed the asymmetry: seating every category on <code>None</code> fails 12 tests, while removing the virtual bump failed <strong>none at all</strong> — which is how the bump came to be wrong in two directions with nothing noticing.</p>
|
|
<h3 id="a-records-view-seats-a-new-category-on-none-and-stays-a-records-view"><a class="anchor" href="#a-records-view-seats-a-new-category-on-none-and-stays-a-records-view">#</a>a_records_view_seats_a_new_category_on_none_and_stays_a_records_view</h3>
|
|
<p><code>on_category_added</code> used to bump a virtual off Row or Column to make room. With <code>_Index</code> on Row and <code>_Dim</code> on Column that <em>is</em> records mode, so naming a category with <code>N</code> while in records mode silently ejected the user from the grid they were standing in and returned them to a pivot.</p>
|
|
<h3 id="a-measure-on-column-is-not-bumped-off-it"><a class="anchor" href="#a-measure-on-column-is-not-bumped-off-it">#</a>a_measure_on_column_is_not_bumped_off_it</h3>
|
|
<p>The second thing the bump broke, and the reason the fix is about virtuals in general rather than about records mode: <code>_Measure</code> on Column is an ordinary configuration — it is what <a href="https://github.com/fiddlerwoaroof/improvise/blob/main/web/demo.improv"><code>web/demo.improv</code></a> ships — and adding a second regular category kicked it off the axis, making every measure vanish from the grid. The fixture is deliberately <strong>not</strong> a records layout, so it exercises the pivot path rather than the early return.</p>
|
|
<h3 id="cycle-axis-unknown-category-is-noop"><a class="anchor" href="#cycle-axis-unknown-category-is-noop">#</a>cycle_axis_unknown_category_is_noop</h3>
|
|
<p><a class="tid" href="../tickets.html#improvise-t8s" title="View::axis_of panics on missing category">improvise-t8s</a>: <code>cycle_axis</code> panicked through <code>axis_of</code>'s <code>expect</code> when given a category not registered with the view, while <code>set_axis</code> silently no-ops on the same input. It now goes through <code>try_axis_of</code>.</p>
|
|
<h3 id="tileable-cats-omits-filter-but-keeps-every-other-axis"><a class="anchor" href="#tileable-cats-omits-filter-but-keeps-every-other-axis">#</a>tileable_cats_omits_filter_but_keeps_every_other_axis</h3>
|
|
<p><a class="tid" href="../tickets.html#improvise-dz8" title="Add Axis::Filter for drill-fixed coordinates">improvise-dz8</a>: 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 <code>Page</code> afterwards, so it distinguishes "omits <code>Filter</code>" from "omits this particular category".</p>
|
|
<h3 id="filtering-cats-covers-page-and-filter-but-not-the-rest"><a class="anchor" href="#filtering-cats-covers-page-and-filter-but-not-the-rest">#</a>filtering_cats_covers_page_and_filter_but_not_the_rest</h3>
|
|
<p><code>Filter</code> constrains the data exactly as <code>Page</code> does; only <code>Page</code> is navigable. The fixture puts a category on each of <code>Page</code>, <code>Filter</code>, <code>Row</code> and <code>None</code> so the result separates the two questions rather than merely counting.</p>
|
|
<h2 id="view-layout-rs"><a class="anchor" href="#view-layout-rs">#</a><code>view/layout.rs</code></h2>
|
|
<h3 id="prune-axis-items-indexes-data-positions-not-visual-ones"><a class="anchor" href="#prune-axis-items-indexes-data-positions-not-visual-ones">#</a>prune_axis_items_indexes_data_positions_not_visual_ones</h3>
|
|
<p><code>prune_axis_items</code> asks <code>keep</code> about <strong>data</strong> indices — the positions a <code>row</code>/<code>col</code> 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, <code>"A"</code> would be the one dropped. <a class="tid" href="../tickets.html#improvise-n3wt" title="prune_axis_items: keep-slice length invariant is only a debug_assert">improvise-n3wt</a> also removed the <code>&[bool]</code> + <code>debug_assert</code> pair, whose length could disagree with the axis in release builds only.</p>
|
|
<h3 id="a-text-cell-renders-in-a-default-view"><a class="anchor" href="#a-text-cell-renders-in-a-default-view">#</a>a_text_cell_renders_in_a_default_view</h3>
|
|
<p>The user-visible face of <a class="tid" href="../tickets.html#improvise-l4qx" title="[bug] A text cell is invisible whenever any category is on the None axis">improvise-l4qx</a>, asserted at the layout rather than at the aggregation helper. The test asserts its own premise first — <code>!layout.none_cats.is_empty()</code> — because if a default view ever stopped putting <code>_Index</code>/<code>_Dim</code> on <code>Axis::None</code> the test would pass without exercising the bug at all.</p>
|
|
<h3 id="records-mode-gives-each-measure-its-own-value-column"><a class="anchor" href="#records-mode-gives-each-measure-its-own-value-column">#</a>records_mode_gives_each_measure_its_own_value_column</h3>
|
|
<p>Pins the whole column vocabulary in order: <code>[Region, +cat, Revenue, Cost, +]</code> — the regular categories, the new-category column (<a class="tid" href="../tickets.html#improvise-2zq" title="Records mode has no inline way to add a category, unlike the + column for measures">improvise-2zq</a>, sitting where the column it creates will appear), one column per raw measure, then the new-measure column (<a class="tid" href="../tickets.html#improvise-c5v" title="Improve records view data entry by making each measure a normal value column (wide format instead of synthetic Value column)">improvise-c5v</a>). It checks both the labels and the <code>RecordCol</code> variants, so a column drawn with the right label but resolved to the wrong kind fails.</p>
|
|
<h3 id="records-mode-initial-layout-is-sorted"><a class="anchor" href="#records-mode-initial-layout-is-sorted">#</a>records_mode_initial_layout_is_sorted</h3>
|
|
<p>On entry into records mode the store is sorted by <code>CellKey</code>, so rows appear in a deterministic order regardless of insertion order. The fixture inserts <code>East</code> after <code>North</code>, the opposite of alphabetical, so "did nothing" fails.</p>
|
|
<h3 id="records-mode-new-record-appends-at-bottom"><a class="anchor" href="#records-mode-new-record-appends-at-bottom">#</a>records_mode_new_record_appends_at_bottom</h3>
|
|
<p>The complement: once the layout exists, a new record appends rather than re-sorting into the middle. The new record's coordinate (<code>AAA</code>) sorts <em>before</em> every existing one, so a re-sort would move it to the top and move the row the user was looking at.</p>
|
|
<h3 id="records-display-out-of-bounds-col-returns-none"><a class="anchor" href="#records-display-out-of-bounds-col-returns-none">#</a>records_display_out_of_bounds_col_returns_none</h3>
|
|
<p><a class="tid" href="../tickets.html#improvise-byz" title="records_display returns Some("") for out-of-bounds columns instead of None">improvise-byz</a>: <code>records_display</code> for an out-of-bounds column called <code>col_label(col)</code>, which returns <code>""</code>; <code>""</code> was then looked up as a category name in the record, never found, and the function returned <code>Some("")</code> — masking view/data mismatches as blank cells. It must return <code>None</code>, symmetric with <code>cell_key</code>.</p>
|
|
<h2 id="view-axis-rs"><a class="anchor" href="#view-axis-rs">#</a><code>view/axis.rs</code></h2>
|
|
<h3 id="the-label-vocabularies-are-unchanged"><a class="anchor" href="#the-label-vocabularies-are-unchanged">#</a>the_label_vocabularies_are_unchanged</h3>
|
|
<p><a class="tid" href="../tickets.html#improvise-rml" title="Consolidate Axis display into a method on Axis">improvise-rml</a>, characterization: these strings were scattered across four render/command sites before being pulled onto <code>Axis</code>. Pinned verbatim so the move could not quietly restyle the UI.</p>
|
|
<h3 id="filter-is-outside-the-rotation"><a class="anchor" href="#filter-is-outside-the-rotation">#</a>filter_is_outside_the_rotation</h3>
|
|
<p><a class="tid" href="../tickets.html#improvise-dz8" title="Add Axis::Filter for drill-fixed coordinates">improvise-dz8</a>: <code>Filter</code> names a coordinate the drill fixed, so it is outside the Row→Column→Page→None rotation entirely. Both directions are asserted — cycling a <code>Filter</code> must not hand the user a navigable axis, <strong>and</strong> no navigable axis may rotate into <code>Filter</code>, or tile select could hand one out.</p>
|
|
<h3 id="next-is-a-four-cycle-over-the-navigable-axes"><a class="anchor" href="#next-is-a-four-cycle-over-the-navigable-axes">#</a>next_is_a_four_cycle_over_the_navigable_axes</h3>
|
|
<p>Applying <code>next</code> 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.</p>
|
|
<h3 id="every-axis-gets-its-own-color"><a class="anchor" href="#every-axis-gets-its-own-color">#</a>every_axis_gets_its_own_color</h3>
|
|
<p>Distinct axes must be distinguishable at a glance; <code>color()</code> is the only reason <code>AxisColor</code> exists, so a collision defeats it.</p>
|
|
<h3 id="a-routing-formula-over-a-group-sums-the-members-answers"><a class="anchor" href="#a-routing-formula-over-a-group-sums-the-members-answers">#</a>a_routing_formula_over_a_group_sums_the_members_answers</h3>
|
|
<p><a class="tid" href="../tickets.html#improvise-4zl7" title="A fitted tree must route per record and aggregate the results, not route on the aggregate">improvise-4zl7</a>, and the hand-computed half of its evidence. Every number in <code>route_then_aggregate_tests</code> is written out rather than obtained from a second call into the evaluator, because the bug being guarded is <em>the evaluator answering consistently and wrongly</em>.</p>
|
|
<p>The fixture is four records — <code>Cost</code> of 10 and 100 in North, 20 and 200 in South — and the formula is <code>Pred = SUM(IF(Cost < 50, 1, 10))</code>. <code>Cost</code> straddles the threshold <strong>inside each region</strong>, which is the whole design of the fixture:</p>
|
|
<table><thead><tr><th>cell</th><th>route-then-aggregate</th><th>aggregate-then-route</th></tr></thead><tbody><tr><td>one record (<code>Cost</code> 10)</td><td>1</td><td>1</td></tr><tr><td>North (<code>Tag</code> away)</td><td>1 + 10 = <strong>11</strong></td><td><code>110 < 50</code> → <strong>10</strong></td></tr><tr><td>everything</td><td>1+10+1+10 = <strong>22</strong></td><td><code>330 < 50</code> → <strong>10</strong></td></tr></tbody></table>
|
|
<p>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.</p>
|
|
<p>Two faults were injected and both were watched to fail. Restoring the old <code>eval_agg</code> — sum the matching raw values and ignore the inner expression — fails <strong>7 of the 8</strong> tests with 330 for 22, 110 for 11 and 10 for 1; the one survivor is <code>is_is_one_when_the_cells_own_coordinate_matches_and_zero_otherwise</code>, which has no <code>SUM</code> and so never reaches the fold. Making <code>eval_is</code> return <code>0.0</code> unconditionally fails exactly the <strong>3</strong> 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.</p>
|
|
<h3 id="a-coordinate-split-survives-aggregating-over-that-very-category"><a class="anchor" href="#a-coordinate-split-survives-aggregating-over-that-very-category">#</a>a_coordinate_split_survives_aggregating_over_that_very_category</h3>
|
|
<p>The case the ruling exists for, and the one the ticket's own text called the product question. With <code>Region</code> aggregated away the cell has no <code>Region</code> coordinate of its own, so a formula that read the <em>cell's</em> coordinate would see nothing and take <code>Expr::Is</code>'s <code>unwrap_or(false)</code> branch — answering 100 once. The fold hands the expression a <strong>record</strong> key, so each of the four records answers for its own coordinate: 7 + 7 + 100 + 100 = 214.</p>
|
|
<h3 id="a-member-whose-input-is-missing-errors-rather-than-being-dropped"><a class="anchor" href="#a-member-whose-input-is-missing-errors-rather-than-being-dropped">#</a>a_member_whose_input_is_missing_errors_rather_than_being_dropped</h3>
|
|
<p>The asymmetry between the <code>Expr::Ref</code> fast path and the fold, asserted rather than left to be discovered. <code>SUM(Revenue)</code> skips a record with no <code>Revenue</code> cell; <code>SUM(<expr>)</code> 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 <code>Cost</code> 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 <code>matching_cells</code> and the test would prove nothing.</p>
|
|
<h3 id="feature-roles-line-up-with-the-names-preprocess-emitted"><a class="anchor" href="#feature-roles-line-up-with-the-names-preprocess-emitted">#</a>feature_roles_line_up_with_the_names_preprocess_emitted</h3>
|
|
<p>Lives in <code>gp-tree</code>, recorded here because the rule it guards is <a class="tid" href="../tickets.html#improvise-4zl7" title="A fitted tree must route per record and aggregate the results, not route on the aggregate">improvise-4zl7</a>'s. <code>feature_roles</code> is the one statement of <code>preprocess</code>'s expansion order and <code>encode_row</code> 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 <em>name</em> from its role and compares it with the name <code>preprocess</code> emitted, which is the only thing that ties the two walks together.</p>
|
|
<p>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 <code>Numeric</code> and <code>Missing</code> pushes in <code>feature_roles</code> fails this test <strong>and</strong> <code>encode_row_replays_the_training_expansion</code>, which is the pair that would have disagreed.</p>
|
|
<h3 id="a-formula-targeting-another-category-still-folds-over-its-records"><a class="anchor" href="#a-formula-targeting-another-category-still-folds-over-its-records">#</a>a_formula_targeting_another_category_still_folds_over_its_records</h3>
|
|
<p>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 <strong><code>context</code></strong> — which still carries the formula's own target coordinate. For the ordinary <code>_Measure</code> target that is harmless, because <code>Expr::Ref</code> overwrites <code>_Measure</code> on the way in. For a formula targeting any other category it is not: the record key keeps <code>Levy=Tax</code>, so <code>evaluate_depth</code> matches the formula again at that key and the whole cell converges to <code>Error("circular")</code> — a formula quietly self-referencing through its own aggregate.</p>
|
|
<p>The key is now folded onto <code>partial</code> (<code>context.without(target_category)</code>, plus any inline <code>WHERE</code>), which drops the target coordinate by construction rather than by every caller remembering to. Fault injected by restoring the <code>context</code> fold: this test fails with <code>Error("circular")</code> against <code>11.0</code> and the other eight pass, which is why it is a separate test and not an extra assertion on one of them.</p>
|
|
<h3 id="the-two-evaluators-agree-when-the-fold-reads-a-derived-measure"><a class="anchor" href="#the-two-evaluators-agree-when-the-fold-reads-a-derived-measure">#</a>the_two_evaluators_agree_when_the_fold_reads_a_derived_measure</h3>
|
|
<p>Adversarial review of PR #54, and the sharpest finding of the round: the first version of the fold reintroduced <strong>exactly the divergence the ruling exists to remove</strong>, through the cached path.</p>
|
|
<p><code>eval_expr_cached</code>'s <code>Expr::Ref</code> looks the value up in <code>formula_cache</code> at <code>context.with(cat, name)</code>. Inside the fold, <code>context</code> is a <strong>member-record key</strong> carrying the <code>none_cats</code> coordinates, while <code>formula_cache</code> is keyed by stems with <code>none_cats</code> <strong>stripped</strong>. The lookup could never hit, fell through to <code>aggregate_raw</code>, found no raw cell behind a formula target, and answered <code>Err("ref:…")</code>. So a routing formula reading a <em>derived</em> measure gave <code>Number(11.0)</code> uncached and <code>Error("ref:Margin")</code> cached — and the cached path is the one a pivot grid runs whenever any category sits on <code>Axis::None</code>.</p>
|
|
<p>The fixture is the four-record one plus <code>Margin = Cost * 2</code>, so the fold's input is a formula rather than raw data. The test asserts the uncached value <strong>and</strong> that the two agree; asserting only agreement would pass if both broke.</p>
|
|
<p>The fix makes them agree by construction rather than by this test: <code>eval_expr</code> is hoisted to module level and <strong>both</strong> evaluators hand it to <code>eval_agg</code> as <code>eval_at</code>. Note the repair that was <em>not</em> taken — teaching the fold to hit the cache by stripping <code>none_cats</code> off the member key returns the <strong>group's</strong> value to every member, which is aggregate-then-route through a cache lookup, silent. Fault injected by restoring <code>eval_expr_cached</code> as the fold's evaluator: this test fails with <code>Error("ref:Margin")</code> and the other eleven pass.</p>
|
|
<h3 id="the-cached-evaluator-has-its-own-working-coordinate-predicate"><a class="anchor" href="#the-cached-evaluator-has-its-own-working-coordinate-predicate">#</a>the_cached_evaluator_has_its_own_working_coordinate_predicate</h3>
|
|
<p>The same review's second finding, and a lesson about where to inject a fault. <code>Expr::Is</code> has an arm in <strong>each</strong> evaluator. The original evidence injected the fault into the shared <code>eval_is</code>, which both arms call — proving the tests see the predicate, not that they see the cached <em>copy</em>. Replacing only the cached arm with <code>Ok(0.0)</code> left the suite at 1330 passing.</p>
|
|
<p>The first attempt to close it — a coordinate split run through <code>recompute_formulas</code> + <code>evaluate_aggregated</code> — <strong>also</strong> stayed green, and that is the interesting part: once the fold routes through <code>eval_expr</code>, an <code>Is</code> inside a <code>SUM</code> never reaches the cached arm at all. The cached arm is now reachable only by a <strong>bare</strong> <code>Is</code> outside an aggregate, which is what this test uses. Fault injected into that arm alone: it fails, <code>Number(0.0)</code> against <code>Number(1.0)</code>.</p>
|
|
<p>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.</p>
|
|
<h3 id="a-user-category-whose-name-leads-with-an-underscore-is-seated-as-regular"><a class="anchor" href="#a-user-category-whose-name-leads-with-an-underscore-is-seated-as-regular">#</a>a_user_category_whose_name_leads_with_an_underscore_is_seated_as_regular</h3>
|
|
<p><a class="tid" href="../tickets.html#improvise-bidq" title="Two answers to 'is this category virtual', four lines apart in view/types.rs">improvise-bidq</a>. <code>View::on_category_added</code>'s early return read <code>cat_name.starts_with('_') || self.is_records_layout()</code> — the <strong>same expression</strong> mixing two derivations one operator apart, the left a name prefix and the right <code>CategoryKind</code>. Four lines up, <code>is_records_layout</code> asks the enum.</p>
|
|
<p><code>Model::add_category</code> does not validate names, so a user category literally named <code>_Foo</code> was <code>CategoryKind::Regular</code> to <code>records_columns</code>, <code>cell_id</code> and <code>is_empty_model</code>, and "virtual" to these two lines — seated on <code>Axis::None</code>, so it never appeared in a pivot while every other consumer treated it as ordinary. The second site (<code>holds_a_regular</code>) had the same split, which is why the test asserts a <em>second</em> category's seat too: fixing only the first leaves <code>_Foo</code> on Row while <code>Region</code> also takes Row.</p>
|
|
<p>Decided explicitly, per the ticket: <code>add_category</code> is <strong>not</strong> made to refuse a leading underscore. The three reserved names are the ones <code>CategoryKind::of_name</code> knows, <code>_Foo</code> 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 <code>CategoryKind::of_name(..).is_virtual()</code>.</p>
|
|
<p>Fault injected by restoring either <code>starts_with('_')</code>: the test fails, <code>None</code> against <code>Row</code> on the first site and <code>Row</code> against <code>Column</code> on the second.</p>
|
|
<h3 id="an-ungrouped-item-after-a-grouped-run-belongs-to-no-group"><a class="anchor" href="#an-ungrouped-item-after-a-grouped-run-belongs-to-no-group">#</a>an_ungrouped_item_after_a_grouped_run_belongs_to_no_group</h3>
|
|
<h3 id="a-grouped-run-does-not-leak-into-the-next-prefix"><a class="anchor" href="#a-grouped-run-does-not-leak-into-the-next-prefix">#</a>a_grouped_run_does_not_leak_into_the_next_prefix</h3>
|
|
<p><a class="tid" href="../tickets.html#improvise-gd83" title="AxisEntry carries no group-end boundary, so group_of gives an ungrouped item the preceding group">improvise-gd83</a>, the two arms of one defect. <code>expand_category</code> emitted a <code>GroupHeader</code> on <strong>entering</strong> a group and no boundary on leaving one, so <code>row_group_for</code>/<code>col_group_for</code>'s nearest-preceding walk answered with a group that had already ended.</p>
|
|
<p>The arms are separate because the two escapes are different and a fix can close one and miss the other. The first is <em>within</em> one expansion — <code>Jan[Q1]</code>, <code>Feb[Q1]</code>, <code>Sep</code> — closed by emitting <code>AxisEntry::GroupEnd</code> on a <code>Some -> None</code> transition. The second is <em>across</em> expansions: <code>cross_product</code> expands the inner category once per prefix, each call starting with <code>last_group = None</code>, so <code>Food/Bob[Family]</code> is immediately followed by <code>Clothing/Carol</code>, which is ungrouped and inherited Family with no transition anywhere to hook onto. That one needs a <code>GroupEnd</code> at the <strong>end</strong> of an expansion whose last item was grouped.</p>
|
|
<p><code>GroupEnd</code> is deliberately NOT emitted on <code>Some(A) -> Some(B)</code>: B's own header already ends A's run, and emitting one there moved <code>data_row_to_visual_skips_headers</code> and <code>data_col_to_visual_skips_headers</code> for no gain. Those two are untouched, which is the evidence the boundary is as narrow as it claims.</p>
|
|
<p>Fault injected by deleting either emission: the corresponding test fails with <code>Some(("Month", "Q1"))</code> / <code>Some(("Recipient", "Family"))</code> against <code>None</code>, and <code>the_two_front_ends_draw_the_same_column_header</code> fails with it.</p>
|
|
<h3 id="a-trailing-conversion-letter-does-not-erase-the-precision"><a class="anchor" href="#a-trailing-conversion-letter-does-not-erase-the-precision">#</a>a_trailing_conversion_letter_does_not_erase_the_precision</h3>
|
|
<p><a class="tid" href="../tickets.html#improvise-711n" title="parse_number_format silently yields 0 decimals for the ,.2f spelling our own docs show">improvise-711n</a>. <code>parse_number_format</code> read the precision as <code>fmt[i+1..].parse::<u8>().ok().unwrap_or(0)</code> over everything after the last <code>.</code>, so any trailing conversion letter made the whole suffix unparseable and the format fell back to <strong>zero decimals, silently</strong>. <code>format: ,.2f</code> — the spelling <a href="../repo-map.html"><code>context/repo-map.md</code></a>'s own <code>.improv</code> example carries, and the printf/Python convention a user reaches for first — displayed integers everywhere.</p>
|
|
<p>Fails before the fix with <code>left: (true, 0)</code>, <code>right: (true, 2)</code>.</p>
|
|
<p>The test also pins the reading against its nearest <em>wrong</em> neighbour. Two implementations both fix <code>,.2f</code>: 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 <code>".2f3"</code> is in the assertion. Injecting <code>s.trim_end_matches(|c: char| !c.is_ascii_digit())</code> in place of <code>leading_digits</code> fails the test at <code>left: (false, 0)</code>, <code>right: (false, 2)</code> — so the test sees the rule, not merely the bug.</p>
|
|
<h3 id="a-zero-padded-picture-string-is-read-as-printf-not-as-excel"><a class="anchor" href="#a-zero-padded-picture-string-is-read-as-printf-not-as-excel">#</a>a_zero_padded_picture_string_is_read_as_printf_not_as_excel</h3>
|
|
<p>The companion to the above, and the reason the fix is not "be generous". A forgiving reader would have to answer <code>.00</code>, where printf says precision zero and an Excel picture says two places. This pins the printf reading — <code>"0.00"</code> is <code>(false, 0)</code> — so that "accept the trailing <code>f</code>" cannot quietly grow into "accept anything and guess". It passes before the fix as well as after: it is there to stop the <em>next</em> change, not to catch this one.</p>
|
|
<h3 id="a-precision-too-large-to-name-reads-as-none"><a class="anchor" href="#a-precision-too-large-to-name-reads-as-none">#</a>a_precision_too_large_to_name_reads_as_none</h3>
|
|
<p><code>".300"</code> does not fit <code>u8</code>, so it reads as no precision rather than as 300 decimal places. Unchanged by <a class="tid" href="../tickets.html#improvise-711n" title="parse_number_format silently yields 0 decimals for the ,.2f spelling our own docs show">improvise-711n</a> and pinned so the fallback is a stated rule rather than an artefact of the integer type.</p>
|
|
<h3 id="a-format-naming-no-precision-at-all-reads-as-zero"><a class="anchor" href="#a-format-naming-no-precision-at-all-reads-as-zero">#</a>a_format_naming_no_precision_at_all_reads_as_zero</h3>
|
|
<p><code>",f"</code> has no <code>.</code>, and <code>".f"</code> has a <code>.</code> with no digits after it. Both are zero decimals. Pinned because <code>leading_digits</code> returns <code>""</code> for the second, and an empty string reaching <code>parse::<u8>()</code> is the one path in the function with no digits to carry the answer.</p>
|
|
</main>
|
|
</div>
|
|
</body>
|
|
</html>
|