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
233 lines
82 KiB
HTML
233 lines
82 KiB
HTML
<!doctype html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||
<title>gp-tree and improvise-gp — 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">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" class="here">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/gp.md">context/test-evidence/gp.md</a></p><details class="toc" open><summary>On this page</summary><ul><li class="l1"><a href="#test-evidence-gp-tree-and-improvise-gp">Test evidence: gp-tree and improvise-gp</a></li><li class="l2"><a href="#gp-tree-src-lib-rs">gp-tree/src/lib.rs</a></li><li class="l3"><a href="#splitmix64-matches-the-reference-vectors">splitmix64_matches_the_reference_vectors</a></li><li class="l3"><a href="#splitmix64-matches-the-reference-vectors-from-a-nonzero-seed">splitmix64_matches_the_reference_vectors_from_a_nonzero_seed</a></li><li class="l2"><a href="#gp-tree-src-fit-rs">gp-tree/src/fit.rs</a></li><li class="l3"><a href="#mse-averages-over-exactly-the-rows-it-was-given">mse_averages_over_exactly_the_rows_it_was_given</a></li><li class="l3"><a href="#scoring-no-rows-is-zero-error-rather-than-nan">scoring_no_rows_is_zero_error_rather_than_nan</a></li><li class="l3"><a href="#asking-for-no-folds-yields-one-rather-than-dividing-by-zero">asking_for_no_folds_yields_one_rather_than_dividing_by_zero</a></li><li class="l3"><a href="#cross-validating-without-a-held-out-fold-pools-nothing">cross_validating_without_a_held_out_fold_pools_nothing</a></li><li class="l3"><a href="#the-training-split-is-what-the-contains-scan-gave">the_training_split_is_what_the_contains_scan_gave</a></li><li class="l3"><a href="#a-fit-improves-on-the-tree-it-started-from">a_fit_improves_on_the_tree_it_started_from</a></li><li class="l3"><a href="#fit-detailed-starts-from-the-best-of-generation-zero">fit_detailed_starts_from_the_best_of_generation_zero</a></li><li class="l3"><a href="#a-tiny-lone-importance-is-still-the-whole-of-it">a_tiny_lone_importance_is_still_the_whole_of_it</a></li><li class="l3"><a href="#denormalize-rescales-the-leaves-and-leaves-the-splits-alone">denormalize_rescales_the_leaves_and_leaves_the_splits_alone</a></li><li class="l2"><a href="#gp-tree-src-data-rs">gp-tree/src/data.rs</a></li><li class="l3"><a href="#an-entirely-missing-column-is-skipped-and-reported-as-data">an_entirely_missing_column_is_skipped_and_reported_as_data</a></li><li class="l3"><a href="#seed-zero-is-distinguishable-from-no-seed">seed_zero_is_distinguishable_from_no_seed</a></li><li class="l3"><a href="#a-saved-model-round-trips-with-its-whole-ga-config">a_saved_model_round_trips_with_its_whole_ga_config</a></li><li class="l3"><a href="#a-model-saved-before-configs-still-loads">a_model_saved_before_configs_still_loads</a></li><li class="l2"><a href="#gp-tree-tests-scoring-known-answers-rs">gp-tree/tests/scoring_known_answers.rs</a></li><li class="l3"><a href="#standardize-is-bit-for-bit-what-it-was">standardize_is_bit_for_bit_what_it_was</a></li><li class="l3"><a href="#cv-regress-pools-the-same-predictions-and-leaves-the-rng-where-it-was">cv_regress_pools_the_same_predictions_and_leaves_the_rng_where_it_was</a></li><li class="l3"><a href="#cv-classify-pools-the-same-codes-and-leaves-the-rng-where-it-was">cv_classify_pools_the_same_codes_and_leaves_the_rng_where_it_was</a></li><li class="l2"><a href="#improvise-gp-src-lib-rs">improvise-gp/src/lib.rs</a></li><li class="l3"><a href="#improvise-evaluation-matches-gp-tree-eval">improvise_evaluation_matches_gp_tree_eval</a></li><li class="l3"><a href="#an-awkward-target-name-is-quoted-and-round-trips">an_awkward_target_name_is_quoted_and_round_trips</a></li><li class="l3"><a href="#an-infinite-threshold-is-refused-rather-than-emitted-as-inf">an_infinite_threshold_is_refused_rather_than_emitted_as_inf</a></li><li class="l3"><a href="#a-nan-threshold-is-refused-by-name">a_nan_threshold_is_refused_by_name</a></li><li class="l3"><a href="#a-threshold-is-the-shortest-decimal-that-reads-back-the-same">a_threshold_is_the_shortest_decimal_that_reads_back_the_same</a></li><li class="l3"><a href="#a-value-sitting-on-the-threshold-falls-the-same-side">a_value_sitting_on_the_threshold_falls_the_same_side</a></li><li class="l3"><a href="#a-split-on-a-one-hot-feature-is-spelled-as-a-coordinate">a_split_on_a_one_hot_feature_is_spelled_as_a_coordinate</a></li><li class="l3"><a href="#a-coordinate-naming-no-category-of-this-document-is-refused">a_coordinate_naming_no_category_of_this_document_is_refused</a></li><li class="l3"><a href="#a-measure-that-merely-looks-like-a-one-hot-is-accepted">a_measure_that_merely_looks_like_a_one_hot_is_accepted</a></li><li class="l3"><a href="#a-formula-the-emitter-accepted-evaluates-everywhere">a_formula_the_emitter_accepted_evaluates_everywhere</a></li><li class="l3"><a href="#a-tree-indexing-past-the-spec-says-so">a_tree_indexing_past_the_spec_says_so</a></li><li class="l3"><a href="#a-record-added-after-the-fit-is-scored-by-the-formula-alone">a_record_added_after_the_fit_is_scored_by_the_formula_alone</a></li><li class="l3"><a href="#breeding-improves-on-generation-zero">breeding_improves_on_generation_zero</a></li><li class="l2"><a href="#improvise-gp-src-dataset-rs">improvise-gp/src/dataset.rs</a></li><li class="l3"><a href="#a-measure-on-the-page-axis-does-not-filter-away-the-training-rows">a_measure_on_the_page_axis_does_not_filter_away_the_training_rows</a></li><li class="l3"><a href="#a-paged-measure-that-is-not-the-target-does-not-empty-the-table">a_paged_measure_that_is_not_the_target_does_not_empty_the_table</a></li><li class="l3"><a href="#the-feature-table-is-one-row-per-record-with-the-target-last">the_feature_table_is_one_row_per_record_with_the_target_last</a></li><li class="l3"><a href="#a-formula-measure-is-not-a-feature">a_formula_measure_is_not_a_feature</a></li><li class="l2"><a href="#improvise-gp-src-fit-rs">improvise-gp/src/fit.rs</a></li><li class="l3"><a href="#the-fitted-formula-predicts-in-the-documents-own-units">the_fitted_formula_predicts_in_the_documents_own_units</a></li><li class="l3"><a href="#a-category-and-a-measure-of-the-same-name-are-refused">a_category_and_a_measure_of_the_same_name_are_refused</a></li><li class="l3"><a href="#an-empty-category-before-a-measure-does-not-break-the-role-check">an_empty_category_before_a_measure_does_not_break_the_role_check</a></li><li class="l3"><a href="#a-coordinate-is-spelled-as-one-even-when-an-item-shares-the-one-hots-name">a_coordinate_is_spelled_as_one_even_when_an_item_shares_the_one_hots_name</a></li><li class="l3"><a href="#a-high-cardinality-numeric-measure-is-one-feature-not-many">a_high_cardinality_numeric_measure_is_one_feature_not_many</a></li><li class="l3"><a href="#a-category-with-a-distinct-value-per-record-is-refused-not-searched">a_category_with_a_distinct_value_per_record_is_refused_not_searched</a></li><li class="l3"><a href="#more-records-than-the-budget-is-refused-before-any-search">more_records_than_the_budget_is_refused_before_any_search</a></li><li class="l3"><a href="#a-document-with-no-usable-feature-is-refused-rather-than-panicking">a_document_with_no_usable_feature_is_refused_rather_than_panicking</a></li><li class="l3"><a href="#a-non-finite-cell-is-refused-rather-than-panicking">a_non_finite_cell_is_refused_rather_than_panicking</a></li><li class="l3"><a href="#a-name-containing-a-newline-cannot-split-the-status-line">a_name_containing_a_newline_cannot_split_the_status_line</a></li><li class="l3"><a href="#a-fit-that-needs-a-category-now-spells-it-as-a-coordinate">a_fit_that_needs_a_category_now_spells_it_as_a_coordinate</a></li><li class="l3"><a href="#the-fold-count-moves-the-champion-and-so-is-recorded">the_fold_count_moves_the_champion_and_so_is_recorded</a></li><li class="l3"><a href="#the-same-seed-under-a-different-config-is-a-different-search">the_same_seed_under_a_different_config_is_a_different_search</a></li><li class="l3"><a href="#a-fit-named-after-a-category-item-is-refused">a_fit_named_after_a_category_item_is_refused</a></li><li class="l3"><a href="#a-fitted-rule-at-an-aggregated-cell-routes-per-record-and-sums">a_fitted_rule_at_an_aggregated_cell_routes_per_record_and_sums</a></li><li class="l3"><a href="#a-missing-flag-a-date-part-and-a-measures-value-are-refused-differently">a_missing_flag_a_date_part_and_a_measures_value_are_refused_differently</a></li><li class="l3"><a href="#fit-tests-a-category-whose-items-are-numbers-is-still-spelled-as-a-coordinate">fit::tests::a_category_whose_items_are_numbers_is_still_spelled_as_a_coordinate</a></li><li class="l3"><a href="#fit-tests-a-category-whose-items-are-dates-is-still-spelled-as-a-coordinate">fit::tests::a_category_whose_items_are_dates_is_still_spelled_as_a_coordinate</a></li><li class="l3"><a href="#fit-tests-the-preflight-counts-a-numeric-category-at-its-cardinality">fit::tests::the_preflight_counts_a_numeric_category_at_its_cardinality</a></li><li class="l3"><a href="#fit-tests-a-wide-numeric-category-now-refuses-a-fit-that-used-to-ignore-it">fit::tests::a_wide_numeric_category_now_refuses_a_fit_that_used_to_ignore_it</a></li><li class="l3"><a href="#fit-tests-a-duplicate-column-name-is-refused-before-the-matrix-is-built">fit::tests::a_duplicate_column_name_is_refused_before_the_matrix_is_built</a></li><li class="l3"><a href="#the-preflight-counts-the-missing-category-a-forced-column-materialises">the_preflight_counts_the_missing_category_a_forced_column_materialises</a></li><li class="l3"><a href="#a-split-on-a-numeric-category-read-as-a-number-cannot-be-stored">a_split_on_a_numeric_category_read_as_a_number_cannot_be_stored</a></li><li class="l3"><a href="#an-ordinal-split-on-a-category-is-spellable-as-an-indicator-sum">an_ordinal_split_on_a_category_is_spellable_as_an_indicator_sum</a></li><li class="l3"><a href="#the-preflight-counts-the-parts-a-date-measure-expands-into">the_preflight_counts_the_parts_a_date_measure_expands_into</a></li></ul></details><h1 id="test-evidence-gp-tree-and-improvise-gp">Test evidence: <code>gp-tree</code> and <code>improvise-gp</code></h1>
|
||
<p>What each test's bug was, what fault was injected to prove the test can fail, and why the obvious weaker version of it proved nothing. Keyed by exact test name — a rename orphans the entry, which <code>scripts/check-comment-runs.py --check-evidence</code> catches.</p>
|
||
<p>The architectural arguments these tests defend are in <a href="../code/gp.html"><code>context/code/gp.md</code></a>.</p>
|
||
<hr />
|
||
<h2 id="gp-tree-src-lib-rs"><a class="anchor" href="#gp-tree-src-lib-rs">#</a><code>gp-tree/src/lib.rs</code></h2>
|
||
<h3 id="splitmix64-matches-the-reference-vectors"><a class="anchor" href="#splitmix64-matches-the-reference-vectors">#</a>splitmix64_matches_the_reference_vectors</h3>
|
||
<p>The vendored RNG's constants had nothing pinning them. Changing the gamma from <code>0x9E3779B97F4A7C15</code> to <code>…7C17</code> left <strong>every other test in the workspace green</strong>, because they only ever assert that two runs at one seed agree.</p>
|
||
<p>The crate's promise is stronger: a fit's seed is printed to the user and saved in <code>data::Model</code>, so a <em>later</em> run at an old seed must reproduce the old fit. These are therefore the published SplitMix64 vectors for seed 0, not a golden capture of this code — which is what makes them evidence rather than a tautology.</p>
|
||
<h3 id="splitmix64-matches-the-reference-vectors-from-a-nonzero-seed"><a class="anchor" href="#splitmix64-matches-the-reference-vectors-from-a-nonzero-seed">#</a>splitmix64_matches_the_reference_vectors_from_a_nonzero_seed</h3>
|
||
<p>Seed 0 alone cannot catch a change to how the seed enters the state (<code>state = seed</code> versus <code>state = 0</code>), because both give the same stream at 0. Also asserts <code>seed()</code> still answers the seed after the generator has advanced.</p>
|
||
<hr />
|
||
<h2 id="gp-tree-src-fit-rs"><a class="anchor" href="#gp-tree-src-fit-rs">#</a><code>gp-tree/src/fit.rs</code></h2>
|
||
<h3 id="mse-averages-over-exactly-the-rows-it-was-given"><a class="anchor" href="#mse-averages-over-exactly-the-rows-it-was-given">#</a>mse_averages_over_exactly_the_rows_it_was_given</h3>
|
||
<p><code>mse</code> used to take parallel <code>x</code>/<code>y</code> slices, sum over <code>zip(x, y)</code> — which stops at the shorter — and divide by <code>x.len()</code>. A caller that got the two out of step was handed a mean that was quietly <strong>too small</strong> rather than an error: the <a class="tid" href="../tickets.html#improvise-n3wt" title="prune_axis_items: keep-slice length invariant is only a debug_assert">improvise-n3wt</a> hazard, a count supplied alongside the data rather than taken from it.</p>
|
||
<p>The remedy was not a length check but a type: one slice of <code>Row</code>, so the shorter slice cannot be built (<a class="tid" href="../tickets.html#improvise-dkqn" title="gp-tree::fit should take one slice of rows, not parallel x/y slices">improvise-dkqn</a>). This test is what is left to say about it — numerator and denominator count the same rows, for any prefix.</p>
|
||
<h3 id="scoring-no-rows-is-zero-error-rather-than-nan"><a class="anchor" href="#scoring-no-rows-is-zero-error-rather-than-nan">#</a>scoring_no_rows_is_zero_error_rather_than_nan</h3>
|
||
<p>Every mean in the module reached <code>0.0 / 0.0</code> on an empty slice and handed back a NaN a caller could not tell from a score: it propagates through <code>FitConfig::cost</code> and sorts unpredictably against real costs. One answer, in <code>mean_over</code>, covers <code>mse</code>, <code>misclass_rate</code> and <code>standardize</code>, and the test asserts all three plus <code>std == 1e-6</code> (a degenerate spread still never divides by zero).</p>
|
||
<h3 id="asking-for-no-folds-yields-one-rather-than-dividing-by-zero"><a class="anchor" href="#asking-for-no-folds-yields-one-rather-than-dividing-by-zero">#</a>asking_for_no_folds_yields_one_rather_than_dividing_by_zero</h3>
|
||
<p><code>k</code> reaches <code>folds</code> from a CLI flag, so zero is a value a caller can supply rather than an impossible state. <code>out[pos % k]</code> divided by it and panicked — a public library function taking its host down with it.</p>
|
||
<h3 id="cross-validating-without-a-held-out-fold-pools-nothing"><a class="anchor" href="#cross-validating-without-a-held-out-fold-pools-nothing">#</a>cross_validating_without_a_held_out_fold_pools_nothing</h3>
|
||
<p>The sharper half of the same parameter (<a class="tid" href="../tickets.html#improvise-ry89" title="cv_regress/cv_classify with k=1 now return plausible numbers instead of NaN">improvise-ry89</a>). A cross-validated prediction is one made by a tree that never saw the row it scores, so <code>k < 2</code> asks for something that cannot exist: the single fold holds every index, the training complement is empty, and the tree is fit and standardized on nothing before predicting every row.</p>
|
||
<p><strong>This used to announce itself</strong> — <code>standardize(&[])</code> gave a NaN mean and every pooled prediction came back NaN. Once <code>mean_over</code> answered the empty row set with <code>0.0</code>, the same call handed back near-zero numbers indistinguishable from a model's output, and an R² computed off them is a number rather than a NaN. So fixing one bug hid another, and the count is the contract: no held-out row means no pair. The test also asserts the generator is <strong>untouched</strong>, because declining before the shuffle is what keeps a caller's next draw the one it would have made.</p>
|
||
<h3 id="the-training-split-is-what-the-contains-scan-gave"><a class="anchor" href="#the-training-split-is-what-the-contains-scan-gave">#</a>the_training_split_is_what_the_contains_scan_gave</h3>
|
||
<p>The O(n²) fix (<a class="tid" href="../tickets.html#improvise-t3ux" title="cv_regress and cv_classify are O(n-squared)">improvise-t3ux</a>) replaced <code>test.contains(i)</code> per row with a bool mask. It is only safe because the mask yields the <em>same indices in the same order</em> — the training rows are what every downstream draw from the generator depends on. Pinned directly, at every fold count, rather than inferred from a fit that happened to agree.</p>
|
||
<h3 id="a-fit-improves-on-the-tree-it-started-from"><a class="anchor" href="#a-fit-improves-on-the-tree-it-started-from">#</a>a_fit_improves_on_the_tree_it_started_from</h3>
|
||
<p>Without it, a <code>fit</code> that returned its seed tree unchanged would satisfy both <code>the_same_seed_and_config_fit_the_same_tree</code> and <code>a_different_seed_fits_a_different_tree</code>, because the initial <code>random_tree</code> already differs across seeds.</p>
|
||
<h3 id="fit-detailed-starts-from-the-best-of-generation-zero"><a class="anchor" href="#fit-detailed-starts-from-the-best-of-generation-zero">#</a>fit_detailed_starts_from_the_best_of_generation_zero</h3>
|
||
<p>What <code>start</code> has to mean for the question it exists to answer: the best of generation 0, not the seed ancestor it grew from (<a class="tid" href="../tickets.html#improvise-ut7p" title="improvise-gp fit_from_gen0 is a third copy of the GA loop">improvise-ut7p</a>). The two differ whenever a mutated member of the initial population beats member 0, and only the former makes "the search improved on where it started" a claim about <strong>breeding</strong>. Before this, <code>improvise-gp</code> kept a second copy of the whole GA loop just to reach that starting point.</p>
|
||
<h3 id="a-tiny-lone-importance-is-still-the-whole-of-it"><a class="anchor" href="#a-tiny-lone-importance-is-still-the-whole-of-it">#</a>a_tiny_lone_importance_is_still_the_whole_of_it</h3>
|
||
<p>Copilot on PR #25. <code>fit_csv</code> divided by <code>total.max(1e-9)</code>, which is right for the only input it ever had and wrong as a contract: one feature carrying an importance of <code>1e-10</code> carries all of what the model knows and was reported at <strong>10%</strong>. Importances this small are ordinary — they are error differences over a <em>standardized</em> target. <code>the_shares_sum_to_a_hundred_at_any_scale</code> is the general form across four orders of magnitude.</p>
|
||
<h3 id="denormalize-rescales-the-leaves-and-leaves-the-splits-alone"><a class="anchor" href="#denormalize-rescales-the-leaves-and-leaves-the-splits-alone">#</a>denormalize_rescales_the_leaves_and_leaves_the_splits_alone</h3>
|
||
<p>The negative arm is the load-bearing one: <code>standardize</code> rescales the <strong>target</strong>, so a threshold is already in its feature's own units and re-scaling it with the leaves would be the bug. <code>a_tree_fitted_on_standardized_rows_predicts_original_units_after_denormalize</code> pins the arithmetic against a hand-built tree rather than a search, so it does not depend on the GA.</p>
|
||
<hr />
|
||
<h2 id="gp-tree-src-data-rs"><a class="anchor" href="#gp-tree-src-data-rs">#</a><code>gp-tree/src/data.rs</code></h2>
|
||
<h3 id="an-entirely-missing-column-is-skipped-and-reported-as-data"><a class="anchor" href="#an-entirely-missing-column-is-skipped-and-reported-as-data">#</a>an_entirely_missing_column_is_skipped_and_reported_as_data</h3>
|
||
<p><code>preprocess</code> used to say so with an <code>eprintln!</code>, which was correct while this code was <code>examples/fit_csv.rs</code>. A library does not own its caller's stderr: a TUI on an alternate screen, a wasm build, or a caller wanting the notice in its own status line could none of them suppress, redirect or translate it. The notice now comes back as data.</p>
|
||
<h3 id="seed-zero-is-distinguishable-from-no-seed"><a class="anchor" href="#seed-zero-is-distinguishable-from-no-seed">#</a>seed_zero_is_distinguishable_from_no_seed</h3>
|
||
<p><code>0</code> is a legal seed, so "unknown" and "fitted at 0" have to be two different values — anything reporting the seed of a pre-seed model would otherwise state a seed that never ran. That is why <code>Model::seed</code> is an <code>Option</code> and not a sentinel.</p>
|
||
<h3 id="a-saved-model-round-trips-with-its-whole-ga-config"><a class="anchor" href="#a-saved-model-round-trips-with-its-whole-ga-config">#</a>a_saved_model_round_trips_with_its_whole_ga_config</h3>
|
||
<p>The seed alone is necessary and <strong>not sufficient</strong>: the same seed under a different <code>FitConfig</code> searches differently and lands somewhere else, so a model carrying only a seed looks reproducible while being reproducible only by whoever still remembers the flags.</p>
|
||
<p>The fixture <code>a_fit_config()</code> differs from <code>FitConfig::default()</code> in <strong>every</strong> field, and the test asserts that explicitly. A round trip that silently substituted the default would otherwise pass against a default-constructed value — the failure mode this test exists for.</p>
|
||
<h3 id="a-model-saved-before-configs-still-loads"><a class="anchor" href="#a-model-saved-before-configs-still-loads">#</a>a_model_saved_before_configs_still_loads</h3>
|
||
<p>A backward-compatibility test aimed at the <strong>absent field</strong>, not at the attribute. <code>#[serde(default)]</code> on an <code>Option</code> is documentation rather than mechanism — serde defaults an <code>Option</code> anyway, so a test that merely deletes the attribute fails nothing and is not discriminating. This one removes <code>config</code> from the serialized JSON and asserts it loads as <code>None</code> (unknown), not as <code>Some(FitConfig::default())</code>. <code>a_model_saved_before_seeds_still_loads</code> is the same shape for <code>seed</code>.</p>
|
||
<hr />
|
||
<h2 id="gp-tree-tests-scoring-known-answers-rs"><a class="anchor" href="#gp-tree-tests-scoring-known-answers-rs">#</a><code>gp-tree/tests/scoring_known_answers.rs</code></h2>
|
||
<p>Whole-file note: these are <code>f32</code> <strong>bit patterns</strong>, not approximate values, because a change that merely moves an accumulation around silently re-points every seed already saved at a different tree. <code>assert!((a - b).abs() < 1e-6)</code> cannot see that, and neither can the CLI's output, rounded to three or four decimals. The numbers were captured from the parallel-<code>x</code>/<code>y</code> code <em>before</em> <a class="tid" href="../tickets.html#improvise-dkqn" title="gp-tree::fit should take one slice of rows, not parallel x/y slices">improvise-dkqn</a> reshaped the signatures and <a class="tid" href="../tickets.html#improvise-t3ux" title="cv_regress and cv_classify are O(n-squared)">improvise-t3ux</a> replaced the <code>contains</code> scan, and are unchanged by both. If you are changing the search on purpose these numbers change with it — but then every seed recorded by an older <code>--save</code> names a different tree, and that is the decision this file forces you to make out loud.</p>
|
||
<h3 id="standardize-is-bit-for-bit-what-it-was"><a class="anchor" href="#standardize-is-bit-for-bit-what-it-was">#</a>standardize_is_bit_for_bit_what_it_was</h3>
|
||
<p><strong>A witness that could not see the bit.</strong> The first and last rows plus <code>mean</code>/<code>std</code> were the whole witness, and four numbers cannot separate two ways of computing the same quantity: injecting <code>r.target / std - mean / std</code> for <code>(r.target - mean) / std</code> — algebraically identical, a different rounding — left this test <strong>and both cross-validation vectors</strong> green (<a class="tid" href="../tickets.html#improvise-ry89" title="cv_regress/cv_classify with k=1 now return plausible numbers instead of NaN">improvise-ry89</a>). All thirty-seven values do separate them: that reassociation moves 21 of the rows, and none of them were rows 0, 36 or 19.</p>
|
||
<p>Note what is <em>not</em> evidence here. Reversing the mean's accumulation order also left every vector green, but on this fixture the two orders sum to the same <code>f32</code> — so that injection changed no behaviour to catch. <strong>A perturbation has to move a bit before a bit-level witness can be blamed for missing it.</strong></p>
|
||
<h3 id="cv-regress-pools-the-same-predictions-and-leaves-the-rng-where-it-was"><a class="anchor" href="#cv-regress-pools-the-same-predictions-and-leaves-the-rng-where-it-was">#</a>cv_regress_pools_the_same_predictions_and_leaves_the_rng_where_it_was</h3>
|
||
<p>The <code>rng_after</code> witness is the part that pins the <em>order</em> of the generator's calls: the next <code>u64</code> after a cross-validation depends on exactly how many draws that run made, so a reshaped loop that drew differently would move it even if every pooled prediction happened to agree. 37 rows is a prime, so no fold count divides it evenly and the last fold is short — where an off-by-one in the train/test split shows.</p>
|
||
<h3 id="cv-classify-pools-the-same-codes-and-leaves-the-rng-where-it-was"><a class="anchor" href="#cv-classify-pools-the-same-codes-and-leaves-the-rng-where-it-was">#</a>cv_classify_pools_the_same_codes_and_leaves_the_rng_where_it_was</h3>
|
||
<p>The classification half of the same guard. The mask replaced <code>contains</code> in both loops, and one arm passing says nothing about the other (<code>doctrine(one_arm_of_a_symmetric_rule_is_evidence_about_that_arm_only)</code>).</p>
|
||
<hr />
|
||
<h2 id="improvise-gp-src-lib-rs"><a class="anchor" href="#improvise-gp-src-lib-rs">#</a><code>improvise-gp/src/lib.rs</code></h2>
|
||
<h3 id="improvise-evaluation-matches-gp-tree-eval"><a class="anchor" href="#improvise-evaluation-matches-gp-tree-eval">#</a>improvise_evaluation_matches_gp_tree_eval</h3>
|
||
<p>The acceptance property: over random trees and random feature vectors, improvise's evaluation of the emitted formula matches <code>gp_tree::eval</code> <strong>exactly</strong> — widening f32 to f64 is lossless and the thresholds are exact, so there is no tolerance to allow.</p>
|
||
<h4 id="the-improvise-eval-helper-and-the-shape-to-remember"><a class="anchor" href="#the-improvise-eval-helper-and-the-shape-to-remember">#</a>The <code>improvise_eval</code> helper, and the shape to remember</h4>
|
||
<p>This test and <code>a_value_sitting_on_the_threshold_falls_the_same_side</code> both run through the <code>improvise_eval</code> helper, and its scope is what was once read as covering more than it does. <strong>It fabricates a <code>_Measure</code> item per feature name</strong>, so it asserts the emitter and <code>gp_tree::eval</code> agree <strong>against a model that could not exist</strong>. It says <code>Ok</code> to <code>outlook=rainy</code> because it just made an item called that. A helper that builds the world the code wants is not evidence that the world exists.</p>
|
||
<p>So the awkward names above are a <em>quoting</em> fixture and nothing more: <code>outlook=rainy</code> is there because it is awkward to spell, not because a document could hold it — it could not, and <code>fitted_formula</code> refuses it. The helper is still the right tool for the arithmetic-and-quoting claims; the property it could not state is asserted separately, against a real document, in <code>evaluability_in_a_real_document</code>.</p>
|
||
<h3 id="an-awkward-target-name-is-quoted-and-round-trips"><a class="anchor" href="#an-awkward-target-name-is-quoted-and-round-trips">#</a>an_awkward_target_name_is_quoted_and_round_trips</h3>
|
||
<p>This test used to assert <code>parsed.target == "|Total Revenue|"</code> and call the pipes correct. They are not: the target names a <code>_Measure</code> item, so a target carrying its delimiters writes the fit into an item literally named <code>|Total Revenue|</code>, which is not the item the user has. What the emitter owes its caller is a <em>semantic</em> round trip — the name goes in, the same name comes back out. <code>an_awkward_target_names_the_users_measure_item</code> is the consequence that makes it a data bug rather than a cosmetic one.</p>
|
||
<h3 id="an-infinite-threshold-is-refused-rather-than-emitted-as-inf"><a class="anchor" href="#an-infinite-threshold-is-refused-rather-than-emitted-as-inf">#</a>an_infinite_threshold_is_refused_rather_than_emitted_as_inf</h3>
|
||
<p><code>number_text(f32::INFINITY)</code> emitted <code>"inf"</code>, which <code>"inf".parse::<f64>()</code> accepts — so the round-trip loop returned it and a <code>formula_text</code> caller got text the grammar rejects, with nothing on the way saying so. <code>fitted_formula</code> would surface it as a parse error; a bare <code>formula_text</code> caller would write it into a file.</p>
|
||
<h3 id="a-nan-threshold-is-refused-by-name"><a class="anchor" href="#a-nan-threshold-is-refused-by-name">#</a>a_nan_threshold_is_refused_by_name</h3>
|
||
<p>NaN fell through the loop to <code>unreachable!</code>, so the panic blamed the decimal expansion instead of the caller's value.</p>
|
||
<h3 id="a-threshold-is-the-shortest-decimal-that-reads-back-the-same"><a class="anchor" href="#a-threshold-is-the-shortest-decimal-that-reads-back-the-same">#</a>a_threshold_is_the_shortest_decimal_that_reads_back_the_same</h3>
|
||
<p>The doc used to overclaim the emitted text as the f32's full decimal expansion. It is the <em>shortest</em> decimal that reads back as the same <code>f64</code> — the weaker claim, and the one the routing argument actually needs: equal text means equal <code>f64</code> means the same branch.</p>
|
||
<h3 id="a-value-sitting-on-the-threshold-falls-the-same-side"><a class="anchor" href="#a-value-sitting-on-the-threshold-falls-the-same-side">#</a>a_value_sitting_on_the_threshold_falls_the_same_side</h3>
|
||
<p>The boundary case the whole <code>number_text</code> design exists for. <code>0.7f32</code> is really 0.699999988…; the shortest text that reads back as that <strong>f32</strong> is <code>"0.7"</code>, which parses as an <code>f64</code> strictly <em>greater</em> than it. A row whose feature value equals the threshold routes <code>hi</code> under <code>eval</code> and would route <code>lo</code> through a formula carrying the rounded threshold. The test asserts the trap is live — <code>"0.7".parse::<f64>() > thresh as f64</code> — before relying on it.</p>
|
||
<h3 id="a-split-on-a-one-hot-feature-is-spelled-as-a-coordinate"><a class="anchor" href="#a-split-on-a-one-hot-feature-is-spelled-as-a-coordinate">#</a>a_split_on_a_one_hot_feature_is_spelled_as_a_coordinate</h3>
|
||
<p><a class="tid" href="../tickets.html#improvise-8xhy" title="[bug] A fit over a categorical feature emits an unevaluable formula">improvise-8xhy</a>'s defect, and <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 answer to it. <code>preprocess</code> one-hots a categorical column into features named <code>Region=North</code>, an encoding artefact no document has an item for, so <code>find_item_category</code> returned <code>None</code> and every cell of the fitted measure came back as <code>Error("ref:Region=North")</code> — silently, with the emitter having said nothing. 8xhy shipped a refusal; 4zl7 replaced the refusal with a spelling, <code>Region IS North</code>.</p>
|
||
<p>The assertion is the emitted <strong>text</strong>, exactly, and not merely that the call is <code>Ok</code>: the whole failure mode here was a well-formed formula meaning the wrong thing, so "it parsed" is the assertion that missed it the first time. The fault to inject is <code>Spelling::Coordinate</code> rendering as <code>quote_name(name)</code> — byte-identical for every measure, and wrong for exactly this family.</p>
|
||
<h3 id="a-coordinate-naming-no-category-of-this-document-is-refused"><a class="anchor" href="#a-coordinate-naming-no-category-of-this-document-is-refused">#</a>a_coordinate_naming_no_category_of_this_document_is_refused</h3>
|
||
<p>The negative arm of the same rule, and it is not redundant with the artefact refusal: a <code>Coordinate</code> is <em>spellable</em> and may still be unresolvable. Without it, <code>resolves_in</code>'s <code>Coordinate</code> arm could return <code>true</code> unconditionally and every test above would still pass.</p>
|
||
<h3 id="a-measure-that-merely-looks-like-a-one-hot-is-accepted"><a class="anchor" href="#a-measure-that-merely-looks-like-a-one-hot-is-accepted">#</a>a_measure_that_merely_looks_like_a_one_hot_is_accepted</h3>
|
||
<p>The negative arm, and why the check asks the <strong>document</strong> rather than refusing names that look like encoding artefacts: a measure literally named <code>Region=North</code> is a legal document. The two are indistinguishable as strings. <code>the_missing_flag_and_the_date_sub_features_are_refused_too</code> covers the other two artefact families with nothing enumerating them — the check asks the exact precondition <code>Expr::Ref</code> has, so a future naming scheme is covered without this crate learning about it.</p>
|
||
<h3 id="a-formula-the-emitter-accepted-evaluates-everywhere"><a class="anchor" href="#a-formula-the-emitter-accepted-evaluates-everywhere">#</a>a_formula_the_emitter_accepted_evaluates_everywhere</h3>
|
||
<p>The property the lying test could not state: <strong>if <code>fitted_formula</code> says <code>Ok</code>, no cell of the target is a <code>ref:</code> error.</strong> It is also what stops the refusal tests above from passing by refusing everything.</p>
|
||
<h3 id="a-tree-indexing-past-the-spec-says-so"><a class="anchor" href="#a-tree-indexing-past-the-spec-says-so">#</a>a_tree_indexing_past_the_spec_says_so</h3>
|
||
<p><a class="tid" href="../tickets.html#improvise-hkt6" title="Three small totality gaps found in review">improvise-hkt6</a>. A tree whose <code>feat</code> runs past the spec violates the same precondition in two places, and the two answered differently: <code>expr_text</code> said what was wrong, <code>mark_features</code> said <code>index out of bounds</code>. Whichever ran first decided what the caller was told, and <code>fitted_formula</code> runs <code>mark_features</code> first — so the legible message was the one nobody ever saw. Driven through <code>fitted_formula</code> because both private functions are reached that way, and the <strong>message</strong> is asserted rather than merely the panic, since an out-of-bounds index panics too.</p>
|
||
<h3 id="a-record-added-after-the-fit-is-scored-by-the-formula-alone"><a class="anchor" href="#a-record-added-after-the-fit-is-scored-by-the-formula-alone">#</a>a_record_added_after_the_fit_is_scored_by_the_formula_alone</h3>
|
||
<p>The load-bearing half of <a class="tid" href="../tickets.html#improvise-7na" title="Decide whether fitted gp-tree models are persisted alongside the formula">improvise-7na</a>'s answer: scoring a <em>new</em> row needs no saved encoding plan. <code>fit_csv --predict</code> calls <code>encode_row</code> because its new row arrives as untyped CSV text; improvise's new row is a record in the same document, scored through the stored formula from its own measures. So nothing about the <code>--predict</code> path argues for putting a <code>gp_tree::data::Model</code> in the file.</p>
|
||
<h3 id="breeding-improves-on-generation-zero"><a class="anchor" href="#breeding-improves-on-generation-zero">#</a>breeding_improves_on_generation_zero</h3>
|
||
<p>What rules out a GA whose <code>breed</code> returned its input unchanged. <code>a_different_seed_fits_a_different_formula</code> is weak evidence on its own, since the initial <code>random_tree</code> already differs across seeds.</p>
|
||
<hr />
|
||
<h2 id="improvise-gp-src-dataset-rs"><a class="anchor" href="#improvise-gp-src-dataset-rs">#</a><code>improvise-gp/src/dataset.rs</code></h2>
|
||
<h3 id="a-measure-on-the-page-axis-does-not-filter-away-the-training-rows"><a class="anchor" href="#a-measure-on-the-page-axis-does-not-filter-away-the-training-rows">#</a>a_measure_on_the_page_axis_does_not_filter_away_the_training_rows</h3>
|
||
<p><a class="tid" href="../tickets.html#improvise-vx3" title="fit-measure command: fit a tree and add it as a formula">improvise-vx3</a>. <code>Workbook::new</code>'s default view puts <code>_Measure</code> on <code>Axis::Page</code>, so <code>filtering_cats()</code> named it, <code>filter_coords</code> pinned it to the first measure, and every "record" then carried exactly one measure cell — never the target. The whole table came back as "no visible record has a Revenue value to learn from" on a document plainly full of them, which reads as a data problem rather than as an axis one.</p>
|
||
<p><strong>Every test in the module used <code>View::new</code> + <code>on_category_added</code></strong>, under which a leading-underscore category lands on <code>Axis::None</code> — so the fixture was quietly building the one world the code worked in, and no test could see the default a real <code>Workbook</code> hands out.</p>
|
||
<p>The assertion compares the <strong>contents</strong>, not the shape. Pinning only the header and the row count passes on this fixture even with the bug present: <code>_Measure</code>'s first item is <code>Revenue</code>, so every row still has a target and only the feature columns come back blank — the shape survives while the data does not.</p>
|
||
<h3 id="a-paged-measure-that-is-not-the-target-does-not-empty-the-table"><a class="anchor" href="#a-paged-measure-that-is-not-the-target-does-not-empty-the-table">#</a>a_paged_measure_that_is_not_the_target_does_not_empty_the_table</h3>
|
||
<p>The sharper half: <code>filter_coords</code> pinned to the <em>first</em> item, so on a document whose target is not first the table came back empty.</p>
|
||
<h3 id="the-feature-table-is-one-row-per-record-with-the-target-last"><a class="anchor" href="#the-feature-table-is-one-row-per-record-with-the-target-last">#</a>the_feature_table_is_one_row_per_record_with_the_target_last</h3>
|
||
<p>The literal row <strong>order</strong> is half of what this pins, and f32 accumulation over the rows is why: a reordered table makes a seeded fit irreproducible in a way nothing prints. Since <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> yields insertion order, so the assertion now holds by construction; before it, a test in one process could not tell "sorted" from "this process happened to hash them in that order".</p>
|
||
<h3 id="a-formula-measure-is-not-a-feature"><a class="anchor" href="#a-formula-measure-is-not-a-feature">#</a>a_formula_measure_is_not_a_feature</h3>
|
||
<p>The fixture lists <code>Profit</code> as an explicit <code>_Measure</code> item <strong>as well as</strong> making it a formula target, which is legal and is the only case this test can see: a target that is only implied by its formula never reaches the explicit item list, so a fixture without the explicit item passes whether the rule is implemented or not.</p>
|
||
<hr />
|
||
<h2 id="improvise-gp-src-fit-rs"><a class="anchor" href="#improvise-gp-src-fit-rs">#</a><code>improvise-gp/src/fit.rs</code></h2>
|
||
<p>Whole-module fixture note — <code>document()</code>. <strong>Every category is a <code>Label</code>, and that is the only thing that makes the success path deterministic.</strong> An earlier version left <code>Region</code> regular with a single item and claimed the constant one-hot had a degenerate <code>(1, 1)</code> range that <code>simplify</code> would prune. <strong>That claim is false</strong>: <code>data.rs::build_categorical</code> pushes <code>(0.0, 1.0)</code> for every one-hot whatever the column contains, so <code>simplify</code> cannot know it is constant and the champion was free to split on <code>Region=North</code>. These tests were passing on seed luck, and the comment asserting otherwise was the worst part (Copilot on PR #25). The targets are in the <strong>thousands</strong> so a fit that forgets to de-normalize is visibly wrong rather than subtly so. The view puts <code>_Measure</code> on <code>Axis::Page</code> because that is what <code>Workbook::new</code> does — fitting under the real default is not an extra case, it is the only honest one.</p>
|
||
<h3 id="the-fitted-formula-predicts-in-the-documents-own-units"><a class="anchor" href="#the-fitted-formula-predicts-in-the-documents-own-units">#</a>the_fitted_formula_predicts_in_the_documents_own_units</h3>
|
||
<p>The de-normalization regression. <code>fit</code> runs against a standardized target, so a champion written down untouched predicts standard deviations: a well-formed formula, parsing cleanly, evaluating to ±5 where the user's data is 1000–4000. Nothing downstream can see that — it is a number in a cell.</p>
|
||
<p><strong>Fault injected:</strong> dropping the <code>denormalize</code> call in <code>fit_measure</code> fails this and only this test. The loop <code>panic!</code>s rather than <code>continue</code>s on a non-numeric cell, because a loop that skips every record asserts nothing and "no cell evaluated" is itself the <a class="tid" href="../tickets.html#improvise-8xhy" title="[bug] A fit over a categorical feature emits an unevaluable formula">improvise-8xhy</a> failure. The claim is then read a second way off the formula <strong>text</strong>, which does not depend on evaluation at all.</p>
|
||
<h3 id="a-category-and-a-measure-of-the-same-name-are-refused"><a class="anchor" href="#a-category-and-a-measure-of-the-same-name-are-refused">#</a>a_category_and_a_measure_of_the_same_name_are_refused</h3>
|
||
<p>Copilot on PR #25, and the sharpest finding of the round: asking the <em>document</em> whether it can resolve a feature name is not the same question as asking whether that name means the column the tree was fitted on. A numeric category called <code>Cost</code> encodes to a feature called <code>Cost</code>; with a <code>_Measure</code> item of the same name the emitted <code>Expr::Ref("Cost")</code> resolves cleanly — <strong>to the measure</strong> — and the stored rule silently reads a different column from the one it learned.</p>
|
||
<p><strong>The first version of this test never built the collision.</strong> Adding a category <code>Cost</code> with items puts no <code>Cost</code> coordinate on the existing records, so <code>feature_table</code> emits an empty column, <code>preprocess</code> drops it, and there was no duplicate name to find; it passed on the <em>positional</em> role bug instead. The records here carry the coordinate, and the test asserts the fixture really produces two columns named <code>Cost</code> before asserting the refusal.</p>
|
||
<h3 id="an-empty-category-before-a-measure-does-not-break-the-role-check"><a class="anchor" href="#an-empty-category-before-a-measure-does-not-break-the-role-check">#</a>an_empty_category_before_a_measure_does_not_break_the_role_check</h3>
|
||
<p>Copilot on PR #25. <code>preprocess</code> <code>continue</code>s past an entirely-missing column without pushing a <code>ColumnSpec</code>, so <code>Dataset::columns</code> is not index-aligned with the header once anything is dropped. A positional <code>i >= category_columns</code> role test then read the surviving numeric measure as a category and refused a perfectly good fit. The test asserts the fixture really drops a column first.</p>
|
||
<h3 id="a-coordinate-is-spelled-as-one-even-when-an-item-shares-the-one-hots-name"><a class="anchor" href="#a-coordinate-is-spelled-as-one-even-when-an-item-shares-the-one-hots-name">#</a>a_coordinate_is_spelled_as_one_even_when_an_item_shares_the_one_hots_name</h3>
|
||
<p>The collision a <em>name</em> cannot resolve, now closed by construction rather than by a refusal. The fixture adds a <code>_Measure</code> item literally named <code>Region=North</code> and asserts <code>find_item_category</code> resolves it — so a check that matched the one-hot's feature name against the document would have found the wrong column.</p>
|
||
<p>Under <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> the one-hot is never spelled by its feature name at all: <code>Features::of</code> reads the <strong>role</strong> off <code>preprocess</code>'s plan and emits <code>Region IS North</code>, which cannot collide with any measure however the document is named. So the assertion is the <em>absence</em> of <code>|Region=North|</code> in the emitted text as much as the presence of <code>Region IS</code>. Injecting the fault means having <code>spell</code> fall through to <code>Spelling::Measure</code> for a <code>OneHot</code> role: the fit then succeeds, the formula reads <code>|Region=North|</code>, and it silently evaluates against the wrong column — which is the original bug wearing the new API.</p>
|
||
<h3 id="a-high-cardinality-numeric-measure-is-one-feature-not-many"><a class="anchor" href="#a-high-cardinality-numeric-measure-is-one-feature-not-many">#</a>a_high_cardinality_numeric_measure_is_one_feature_not_many</h3>
|
||
<p>The preflight approximates <code>preprocess</code>'s type inference, so it can disagree in both directions. A numeric measure with more distinct values than <code>MAX_FEATURES</code> encodes to <strong>one</strong> feature and must not be refused for having many values.</p>
|
||
<h3 id="a-category-with-a-distinct-value-per-record-is-refused-not-searched"><a class="anchor" href="#a-category-with-a-distinct-value-per-record-is-refused-not-searched">#</a>a_category_with_a_distinct_value_per_record_is_refused_not_searched</h3>
|
||
<p><code>FitConfig</code> bounds the search; nothing bounded the width. A regular category with one distinct value per record one-hots to one column per record, and permutation importance is <code>repeats × rows × features</code> evaluations on the draw thread.</p>
|
||
<p><strong>The fixture had to be pushed past <code>MAX_FEATURES</code></strong>, and the assertion names the <em>width</em> refusal specifically. At twelve records this document is refused too — but by <code>fitted_formula</code> noticing the champion split on <code>Tag=r0</code>, a different rule, which would have made the test pass with the guard removed entirely.</p>
|
||
<h3 id="more-records-than-the-budget-is-refused-before-any-search"><a class="anchor" href="#more-records-than-the-budget-is-refused-before-any-search">#</a>more_records_than_the_budget_is_refused_before_any_search</h3>
|
||
<p>The width bound does not bound the work: every stage scales with rows. The assertion also requires the message to say <em>what to do</em> (<code>Page or Filter</code>).</p>
|
||
<h3 id="a-document-with-no-usable-feature-is-refused-rather-than-panicking"><a class="anchor" href="#a-document-with-no-usable-feature-is-refused-rather-than-panicking">#</a>a_document_with_no_usable_feature_is_refused_rather_than_panicking</h3>
|
||
<p><code>preprocess</code> drops an entirely-missing column, so a document whose only candidate feature is empty encodes to <strong>zero</strong> features — and <code>random_tree</code> then draws <code>rng.below(0)</code>, which <code>SplitMix64</code> answers with <code>0</code>, and <code>sample_thresh</code> indexes <code>spec.ranges[0]</code>. A panic, on a legal document, for any seed that does not happen to open on a leaf.</p>
|
||
<p>This needs the all-<code>Label</code> fixture rather than merely an empty measure: with a regular category present its one-hot survives — range <code>(0.0, 1.0)</code> whatever it holds — and the fit returns a constant leaf instead of crashing.</p>
|
||
<h3 id="a-non-finite-cell-is-refused-rather-than-panicking"><a class="anchor" href="#a-non-finite-cell-is-refused-rather-than-panicking">#</a>a_non_finite_cell_is_refused_rather_than_panicking</h3>
|
||
<p>A <code>.improv</code> cell holding <code>inf</code> parses as an <code>f64</code>, so this is reachable from a document rather than only from a bug. <code>standardize</code> turns an infinite target into NaN leaves, an <code>inf..inf</code> range makes <code>sample_thresh</code> produce a NaN threshold, and <code>number_text</code> then <strong>asserts</strong> — a panic out of a function whose signature promised a <code>Result</code>.</p>
|
||
<p><strong>Verified by disabling the guard, and the two directions differ</strong>: an infinite <em>target</em> really does panic in <code>number_text</code> ("wants a finite value, got inf"), while an infinite <em>feature</em> poisons its range and quietly yields a constant leaf. A panic and a meaningless rule; both are refused.</p>
|
||
<p><code>NaN</code> is deliberately absent from the cases: <code>f64::NAN.to_string()</code> is <code>"NaN"</code>, which <code>is_missing</code> already spells as <em>missing</em>, so it is imputed away and never reaches the search.</p>
|
||
<h3 id="a-name-containing-a-newline-cannot-split-the-status-line"><a class="anchor" href="#a-name-containing-a-newline-cannot-split-the-status-line">#</a>a_name_containing_a_newline_cannot_split_the_status_line</h3>
|
||
<p><code>.improv</code> pipe quoting makes a newline legal inside a name, so a document can hold a measure whose name would split the TUI status bar in two.</p>
|
||
<h3 id="a-fit-that-needs-a-category-now-spells-it-as-a-coordinate"><a class="anchor" href="#a-fit-that-needs-a-category-now-spells-it-as-a-coordinate">#</a>a_fit_that_needs_a_category_now_spells_it_as_a_coordinate</h3>
|
||
<p><a class="tid" href="../tickets.html#improvise-8xhy" title="[bug] A fit over a categorical feature emits an unevaluable formula">improvise-8xhy</a> reaching a real user, and what <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> bought: the same fixture, the same champion, and a formula instead of a refusal. The fixture clears <strong>both</strong> measures on every North record: clearing only <code>Cost</code> would leave North carrying both target values, so the one-hot would explain nothing and the champion would have no reason to split on it.</p>
|
||
<p>The second assertion is the one that earns its place. Checking only that the text says <code>Region IS</code> would pass for a rule that routed both groups to the same leaf — which is what a tree that could not see the coordinate would produce. The fixture's two groups are 1000 and 4000, so requiring one leaf under 2000 and one over 3000 separates "spelled it" from "spelled it and meant it".</p>
|
||
<p>The <code>\n</code>-and-double-space assertions moved with the refusal they guarded; the one-line rule itself is unchanged and is still asserted by <code>a_name_containing_a_newline_cannot_split_the_status_line</code>.</p>
|
||
<h3 id="the-fold-count-moves-the-champion-and-so-is-recorded"><a class="anchor" href="#the-fold-count-moves-the-champion-and-so-is-recorded">#</a>the_fold_count_moves_the_champion_and_so_is_recorded</h3>
|
||
<p>Copilot on PR #25, and it is right: the outcome used to carry <code>seed</code> plus <code>config</code>, which is the pair gotcha 37b names — and that pair is <strong>not sufficient here</strong>. Cross-validation draws from the same generator the fit then draws from, so changing <code>cv_folds</code> alone moves the champion. Recording two of four fields documented the reproducibility contract without meeting it. The test asserts the seed and config are <em>equal</em> across the two requests first, so the difference can only be the fold count.</p>
|
||
<h3 id="the-same-seed-under-a-different-config-is-a-different-search"><a class="anchor" href="#the-same-seed-under-a-different-config-is-a-different-search">#</a>the_same_seed_under_a_different_config_is_a_different_search</h3>
|
||
<p>The knob varied is <code>pop</code>, not <code>generations</code>. More generations over an exactly-learnable fixture can land on the same optimum and <code>simplify</code> normalises it, so that version of the test would fail one day for a reason that is not a regression. <code>pop</code> changes how many RNG draws each generation makes, so the two searches diverge from the first one.</p>
|
||
<h3 id="a-fit-named-after-a-category-item-is-refused"><a class="anchor" href="#a-fit-named-after-a-category-item-is-refused">#</a>a_fit_named_after_a_category_item_is_refused</h3>
|
||
<p>Copilot on PR #25. The check used to ask only about <em>measures</em>, and <code>Expr::Ref</code> does not: <code>find_item_category</code> finds a regular category's item <strong>before</strong> falling back into <code>_Measure</code> (gotcha 7). So naming the fit after some category's item installed cleanly and then made every later formula referring to it read the category coordinate. <code>a_later_formula_referring_to_the_fit_reads_the_fit</code> is the property that refusal protects, asserted against a real document.</p>
|
||
<h3 id="a-fitted-rule-at-an-aggregated-cell-routes-per-record-and-sums"><a class="anchor" href="#a-fitted-rule-at-an-aggregated-cell-routes-per-record-and-sums">#</a>a_fitted_rule_at_an_aggregated_cell_routes_per_record_and_sums</h3>
|
||
<p>The characterization PR #25 left here, now replaced by the rule it was waiting for. A tree is fitted on individual records; an improvise formula is evaluated at a <code>CellKey</code>, and <code>evaluate_aggregated</code> used to aggregate each <code>Expr::Ref</code> <em>first</em> and evaluate the <code>IF</code> once on the totals. Linear formulas are unaffected — <code>SUM(Revenue) - SUM(Cost)</code> is <code>SUM(Revenue - Cost)</code> — and a tree is not, so the stored rule meant one thing on a record cell and a different thing one row up. <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 ruling was to aggregate the rule over the members.</p>
|
||
<p>The old test asserted the <strong>negative</strong> and carried a message telling whoever broke it to replace it with the rule. It fired on the first run of the fold.</p>
|
||
<p>What this one asserts is the property the ticket names: the aggregated cell equals the sum of the per-record predictions. That compares two <em>different</em> code paths — a fully-specified key evaluates one member, an aggregating key folds twelve — rather than one path against itself, but it is still an end-to-end agreement rather than a hand-computed number. <strong>The hand-computed evidence is in <code>improvise-core</code></strong>, under <code>route_then_aggregate_tests</code>, on a four-record fixture whose every expected value is written out in the test name or the assertion.</p>
|
||
<p>The third assertion is what stops this one being satisfiable by accident: routing once on the total lands on a single leaf, so the test refuses a fixture in which a leaf happens to equal the right answer.</p>
|
||
<h3 id="a-missing-flag-a-date-part-and-a-measures-value-are-refused-differently"><a class="anchor" href="#a-missing-flag-a-date-part-and-a-measures-value-are-refused-differently">#</a>a_missing_flag_a_date_part_and_a_measures_value_are_refused_differently</h3>
|
||
<p><a class="tid" href="../tickets.html#improvise-wd6n" title="A fit may still not split on col.missing or a date sub-feature">improvise-wd6n</a>. <code>Spelling::Artefact</code> was one variant standing for <strong>five</strong> distinct families — <code>Missing</code>, <code>DatePart</code>, a one-hot over a measure column, the <code>(missing)</code> pseudo-item, and a <code>Numeric</code> role over a category column — the last five reached through a <code>_ =></code> wildcard in <code>spell</code>. The single refusal they all fed said the tree "splits on a <code>col.missing</code> flag or a date part", so three of the five were declined in words naming something they are not. A user fitting over a text-valued measure column was told about missing-value flags.</p>
|
||
<p>The test drives <code>Features::of</code> over a real <code>preprocess</code> output rather than fabricating <code>Spelling</code>s — which is the point, since what is under test is <code>spell</code>'s classification, and the helper <code>artefacts()</code> beside it fabricates exactly what <code>spell</code> decides. That helper now takes the family as an argument so a fabricated spelling at least names one on purpose; <code>Features::of</code> is still what really decides, and this test is the one that asks it.</p>
|
||
<p>Each family's assertion has two clauses, and the second is what earns it: the error must contain that family's reason <strong>and not the other two's</strong>. The first clause alone passes against one message naming every family at once, which is precisely the state the ticket describes. Fault injected: the five arms of <code>Artefact::reason</code> collapsed to one shared string — the test fails on the first family, and with the injection removed all three arms pass.</p>
|
||
<p>The fixture reaches three families from one <code>preprocess</code> call (a text measure column, a measure with a hole in it, a column of ISO dates), which is why the module is named for what it reaches rather than for "each family". The remaining two are not reachable from it and are not asserted here. <code>AbsentCoordinate</code> is a permanent refusal; <code>NumericCategory</code> was <strong>not</strong>, and the first draft of this entry and of <code>gp.md</code> §11 both called it one — review found that <code>fit_measure</code> simply never tells <code>preprocess</code> which columns are categories. <a class="tid" href="../tickets.html#improvise-zv8t" title="fit_measure never tells preprocess which columns are categories, so a numeric- or date-valued category is unspellable">improvise-zv8t</a> has since closed it (below). Worth recording as the failure mode: the branch that <em>invented</em> a classification also wrote the prose declaring one of its arms unfixable, and nothing but a reader could have caught that — the suite was green on either reading.</p>
|
||
<h3 id="fit-tests-a-category-whose-items-are-numbers-is-still-spelled-as-a-coordinate"><a class="anchor" href="#fit-tests-a-category-whose-items-are-numbers-is-still-spelled-as-a-coordinate">#</a>fit::tests::a_category_whose_items_are_numbers_is_still_spelled_as_a_coordinate</h3>
|
||
<h3 id="fit-tests-a-category-whose-items-are-dates-is-still-spelled-as-a-coordinate"><a class="anchor" href="#fit-tests-a-category-whose-items-are-dates-is-still-spelled-as-a-coordinate">#</a>fit::tests::a_category_whose_items_are_dates_is_still_spelled_as_a_coordinate</h3>
|
||
<p><a class="tid" href="../tickets.html#improvise-zv8t" title="fit_measure never tells preprocess which columns are categories, so a numeric- or date-valued category is unspellable">improvise-zv8t</a>. <code>fit_measure</code> handed <code>preprocess</code> an <code>Overrides::default()</code>, so it never said which header columns are document categories — although the very next line told <code>Features::of</code>. <code>preprocess</code> infers <code>Numeric</code> → <code>Date</code> → <code>Categorical</code> from the values, so a <strong><code>Year</code> category with items <code>2024</code>/<code>2025</code></strong> became a numeric column and a <strong>category of ISO dates</strong> became a date column, and both were refused as encoding artefacts with <code>Year IS 2024</code> spellable all along. User-visible: a fit over such a document could not be stored at all.</p>
|
||
<p>No fault had to be injected, because the bug was live. On the unfixed tree both tests fail inside <code>fit_measure</code> with the real refusals, which are the two arms the ticket named and are worth quoting:</p>
|
||
<ul>
|
||
<li><code>["Year"] names a category whose items all parse as numbers, so a reference to it would resolve to a </code>_Measure<code> item of the same name</code></li>
|
||
<li><code>["Year.epoch_day", "Year.year"] is a part of a date, and date arithmetic is something improvise does not have anywhere</code></li>
|
||
</ul>
|
||
<p>The second is why <code>gp.md</code> §11's date decline was narrower than it read.</p>
|
||
<p><strong>Why each fixture is discriminating.</strong> <code>year_document</code> gives the model <strong>one</strong> regular category and <strong>no other feature column</strong>, and makes the target a function of that category alone — so a tree that does not split on <code>Year</code> is a poor fit and the GA does not settle for one. That matters because the refusal is conditioned on <code>used_features</code>: a fit whose champion happened to be a bare leaf would be <em>accepted</em> on the unfixed tree and prove nothing. Asserting <code>formula.raw.contains("Year IS")</code> rather than merely <code>is_ok()</code> closes that hole in both directions. Note what the unfixed tree actually does, because a first draft of this line got it wrong: it produces <strong>no text at all</strong> — <code>fitted_formula</code> refuses before emitting, which is what the messages quoted above are. The only unfixed path that emits anything is the bare-leaf champion named in the previous paragraph, and its text is <code>SUM(<constant>)</code>, carrying no <code>Year</code> in any spelling. So the assertion closes the hole; "it would have said <code>Expr::Ref(Year)</code>" was a guess about a branch that does not run.</p>
|
||
<p>The numeric test then adds the property the refusal exists to protect: the formula goes in through <code>add_formula</code> and every record evaluates to a <code>Number</code>, not <code>Error("ref:…")</code> (<a class="tid" href="../tickets.html#improvise-8xhy" title="[bug] A fit over a categorical feature emits an unevaluable formula">improvise-8xhy</a>).</p>
|
||
<h3 id="fit-tests-the-preflight-counts-a-numeric-category-at-its-cardinality"><a class="anchor" href="#fit-tests-the-preflight-counts-a-numeric-category-at-its-cardinality">#</a>fit::tests::the_preflight_counts_a_numeric_category_at_its_cardinality</h3>
|
||
<p>The other half of <a class="tid" href="../tickets.html#improvise-zv8t" title="fit_measure never tells preprocess which columns are categories, so a numeric- or date-valued category is unspellable">improvise-zv8t</a>, and the one that is a <em>cost</em> rather than a fix. Forcing a category categorical widens it from one numeric feature to one per distinct value, so <code>encoded_width</code> — the cheap preflight that exists to refuse a too-wide table <strong>before</strong> <code>preprocess</code> materialises it (§14) — had to learn the same <code>header[..category_columns]</code> slice, or it would under-count exactly the column this ticket is about.</p>
|
||
<p>Fault injected: the <code>col >= category_columns</code> clause dropped from <code>encoded_width</code>, leaving the pre-ticket inference. The test fails by name, and the failure message is the evidence — it shows the <strong>exact</strong> check speaking where the preflight should:</p>
|
||
<blockquote><p>the PREFLIGHT must refuse it, before anything is materialised: fitting "Score" encoded 70 features, over the 64 this host will search.</p></blockquote>
|
||
<p>A test asserting only <code>expect_err</code> would have passed under that injection, since both guards refuse; the discriminator is <em>which message</em>, because the two guards differ in what they have already paid for. The fixture also asserts its own precondition — that <code>Year</code> really is the header's first column — so a <code>feature_table</code> change that reorders the header fails here rather than silently slicing the wrong names.</p>
|
||
<h3 id="fit-tests-a-wide-numeric-category-now-refuses-a-fit-that-used-to-ignore-it"><a class="anchor" href="#fit-tests-a-wide-numeric-category-now-refuses-a-fit-that-used-to-ignore-it">#</a>fit::tests::a_wide_numeric_category_now_refuses_a_fit_that_used_to_ignore_it</h3>
|
||
<p>The <strong>cost</strong> of <a class="tid" href="../tickets.html#improvise-zv8t" title="fit_measure never tells preprocess which columns are categories, so a numeric- or date-valued category is unspellable">improvise-zv8t</a>, pinned rather than argued — and it exists because the first draft of <code>gp.md</code> §11 claimed there was none. That draft said "no document that fitted before stops fitting", reasoning that a tree splitting on a numeric category was refused as an artefact anyway. The refusal is conditioned on <code>used_features</code>, so a fit that never split on the column succeeded, and the wide columns are exactly the ones a fit has least reason to split on.</p>
|
||
<p>Measured both ways with a throwaway <code>#[ignore]</code> probe before this test was written, on a fixture of 100 records with a 100-item numeric <code>Year</code> beside a <code>Cost</code> that predicts <code>Score</code> on its own:</p>
|
||
<ul>
|
||
<li>pre-fix: <code>FIT LANDS -> |Score (fit)| = SUM(IF(Cost < 48.0150032043457, 970.3…, 3684.7…))</code> — <code>Year</code> never appears.</li>
|
||
<li>post-fix: <code>REFUSED -> fitting "Score" would encode 101 features from 100 records</code>.</li>
|
||
</ul>
|
||
<p>So no fault needed injecting; the <em>change itself</em> is what the test observes, and it fails on either side if the behaviour moves. The second half is what keeps it from being a bare characterization: it marks <code>Year</code> a <code>Label</code> and asserts the fit then lands on <code>Cost</code>, which is the repair the refusal's own message names. A test asserting only the refusal would leave that advice unchecked — and advice in an error message is a promise, so it is the half worth asserting.</p>
|
||
<p>The generalisable part is the review finding, not the test: <strong>prose claiming a change has no cost is a claim, and it is checkable.</strong> The suite was green with the false sentence in place, exactly as it was green with <a class="tid" href="../tickets.html#improvise-wd6n" title="A fit may still not split on col.missing or a date sub-feature">improvise-wd6n</a>'s "permanent refusal" — the same section, the same failure mode, one paragraph apart. See <a class="tid" href="../tickets.html#improvise-e8no" title="A wide numeric category now refuses a fit that used to ignore it">improvise-e8no</a> for the case the trade genuinely loses.</p>
|
||
<h3 id="fit-tests-a-duplicate-column-name-is-refused-before-the-matrix-is-built"><a class="anchor" href="#fit-tests-a-duplicate-column-name-is-refused-before-the-matrix-is-built">#</a>fit::tests::a_duplicate_column_name_is_refused_before_the_matrix_is_built</h3>
|
||
<p>Found by adversarial review of <a class="tid" href="../tickets.html#improvise-zv8t" title="fit_measure never tells preprocess which columns are categories, so a numeric- or date-valued category is unspellable">improvise-zv8t</a>, and <strong>introduced by it</strong>: <code>as_categories</code> forces by column <strong>name</strong>, while <code>encoded_width</code> counts by column <strong>index</strong>. A document may legally name a regular category and a raw measure the same thing -- <code>a_category_and_a_measure_of_the_same_name_are_refused</code> already ships that fixture -- and then the <em>measure</em> column is forced categorical by the name match while the preflight still counts it numeric by index.</p>
|
||
<p>Measured on the fixture here (40 records, a 40-item <code>Dup</code> category beside a <code>Dup</code> measure): the preflight answers <strong>41</strong> where forcing materialises <strong>80</strong>. The refusal that catches the document, <code>no_ambiguous_column_names</code>, ran <em>after</em> <code>preprocess</code>, so the expansion the preflight exists to prevent was allocated and only then refused. At the default <code>max_records = 20_000</code> that is a 20k-row one-hot matrix built before the error -- <code>doctrine(guard_the_cost_not_the_result)</code> defeated by an inconsistency the ticket itself introduced. The bounded drift that <code>doctrine(a_budget_check_is_advisory_or_exact_and_must_say_which)</code> licenses is <em>approximation</em>, not this.</p>
|
||
<p><strong>The repair is a data dependency, not a moved line.</strong> <code>as_categories</code> now runs the check itself and returns <code>Result<Overrides></code>, so the <code>Overrides</code> that <code>preprocess</code> requires cannot exist until the header has been accepted. That is <code>doctrine(an_invariant_is_made_unrepresentable_not_validated)</code> -- the answer <code>prune_axis_items</code>, <code>Row<T></code> and <code>Checked</code> each got.</p>
|
||
<p><strong>So the regression guard is the signature, and the injection is a compile error.</strong> Swapping the two statements back gives <code>error[E0425]: cannot find value 'overrides' in this scope</code>, verified. What that does <em>not</em> cover, stated rather than implied: the check could still be deleted from <code>as_categories</code> deliberately. The accidental reordering -- the way this actually happened -- is what is now unrepresentable.</p>
|
||
<p>The test therefore asserts the <strong>hazard</strong> rather than the ordering: that the fixture really names one column twice, that the preflight's 41 really disagrees with what forcing would build, and that <code>as_categories</code> itself refuses. A test asserting only the error message would pass on either ordering, since the message is identical -- the whole defect was in work done, not in the result.</p>
|
||
<h3 id="the-preflight-counts-the-missing-category-a-forced-column-materialises"><a class="anchor" href="#the-preflight-counts-the-missing-category-a-forced-column-materialises">#</a>the_preflight_counts_the_missing_category_a_forced_column_materialises</h3>
|
||
<p><a class="tid" href="../tickets.html#improvise-x1ns" title="encoded_width undercounts a forced category that has a missing coordinate">improvise-x1ns</a>, found by ChatGPT Codex reviewing PR #90 and live on <code>main</code> at <code>ce895ff</code>. <code>encoded_width</code> computed <code>has_missing</code> and used it in the <strong>numeric</strong> arm only:</p>
|
||
<pre><code>true => 1 + usize::from(has_missing),
|
||
false => present.iter().collect::<HashSet<_>>().len().max(1),</code></pre>
|
||
<p><code>build_categorical</code> maps every missing cell to <code>MISSING_CAT</code> and includes it in <code>distinct(&mapped)</code>, so a categorical column with N distinct <em>present</em> values and at least one absent cell materialises <strong>N+1</strong> one-hots. The preflight promised N.</p>
|
||
<p>It under-reports by exactly one per such column, and it under-reports in the direction that costs: the preflight runs <em>before</em> <code>preprocess</code>, the exact check on <code>feature_names.len()</code> runs <em>after</em>, so the exact check bounds the error message and not the allocation — which is the whole reason the preflight exists. The previous wave's review recorded this shape as "read, not injected" and "bounded by the exact check"; the second half is false and is now corrected in <a href="../code/gp.html"><code>context/code/gp.md</code></a> §14.</p>
|
||
<p><strong>Fails before the fix</strong> with</p>
|
||
<pre><code>the preflight under-reported: it promised 5 features and `preprocess`
|
||
materialised 6 — ["Year=(missing)", "Year=2020", "Year=2021", "Year=2022",
|
||
"Year=2023", "Cost"]</code></pre>
|
||
<p>The fixture is Codex's suggested case, and the reason it is the interesting one: a <strong>forced numeric category with a missing coordinate</strong>. Forcing is what sends a numeric column down the categorical branch, so only a <em>forced</em> column reaches the arm that was wrong, and only a missing cell makes the arms disagree.</p>
|
||
<p>The test does not assert a number. It asks <code>preprocess</code> what it actually built and requires <code>encoded_width >= data.feature_names.len()</code>, so it cannot pass by agreeing with a stale constant, and it keeps holding if the encoding widens for some other reason. Three preconditions are asserted first, because each would make it vacuous: that exactly one record lacks the coordinate, that the present cells really are numeric (otherwise the column would be categorical by inference and forcing would prove nothing), and that a <code>Year=(missing)</code> one-hot is really among the materialised features.</p>
|
||
<p>The <code>.max(1)</code> is deliberately outside the addition. A column whose cells are <em>entirely</em> missing has no present values, and <code>preprocess</code> skips it with a warning rather than encoding it; keeping the clamp on the sum leaves that case counted as 1 exactly as before, so the fix moves only the case it names.</p>
|
||
<h3 id="a-split-on-a-numeric-category-read-as-a-number-cannot-be-stored"><a class="anchor" href="#a-split-on-a-numeric-category-read-as-a-number-cannot-be-stored">#</a>a_split_on_a_numeric_category_read_as_a_number_cannot_be_stored</h3>
|
||
<p>The evidence <a class="tid" href="../tickets.html#improvise-e8no" title="A wide numeric category now refuses a fit that used to ignore it">improvise-e8no</a> is declined on, and the only coverage <code>Artefact::NumericCategory</code> has ever had. It was <strong>one of three</strong> families with no test, not the only one: <code>MissingFlag</code> and <code>DatePart</code> are covered by <code>the_missing_flag_and_the_date_sub_features_are_refused_too</code>, and <code>ValueOfAMeasure</code> and <code>AbsentCoordinate</code> still have none — filed, not fixed here.</p>
|
||
<p><a class="tid" href="../tickets.html#improvise-e8no" title="A wide numeric category now refuses a fit that used to ignore it">improvise-e8no</a> observes that forcing a document category to <code>Forced::Categorical</code> (<a class="tid" href="../tickets.html#improvise-zv8t" title="fit_measure never tells preprocess which columns are categories, so a numeric- or date-valued category is unspellable">improvise-zv8t</a>) widens the encoding, so a high-cardinality numeric category now exceeds <code>MAX_FEATURES</code> where inference used to read it as one numeric column and the fit succeeded. The decline turns on what that success was worth: a tree that splits on a numeric column over a <em>category</em> column produces a formula <code>fitted_formula</code> <strong>refuses</strong>, because a bare reference to the category's name would resolve to a <code>_Measure</code> item of the same name (<a class="tid" href="../tickets.html#improvise-8xhy" title="[bug] A fit over a categorical feature emits an unevaluable formula">improvise-8xhy</a>). So the pre-zv8t success was either a fit that never touched the column — for which marking it a <code>Label</code> gives a fit over the same remaining features, within budget — or one that could not be stored. Note what this does <em>not</em> establish: an ordinal encoding is spellable as a weighted indicator sum, so it is declined on semantics and formula size rather than on spellability (<a href="../code/gp.html"><code>context/code/gp.md</code></a>).</p>
|
||
<p>The test asks <code>preprocess</code> with <code>Forced::Numeric</code>, which is the pre-zv8t reading reproduced deliberately rather than described, and then asks <code>Features::of</code> and <code>fitted_formula</code>. It does <strong>not</strong> use the <code>artefacts()</code> helper: that helper fabricates a <code>Spelling</code>, which is the shape gotcha 37(d) warns about — it would assert that the emitter refuses what the test told it to refuse. Here the document and the encoder decide.</p>
|
||
<p>Injected fault: change <code>spell()</code>'s <code>FeatureRole::Numeric { .. }</code> arm from <code>Spelling::Artefact(Artefact::NumericCategory)</code> to <code>Spelling::Measure(column.clone())</code> — the "fix" someone would reach for on reading <a class="tid" href="../tickets.html#improvise-e8no" title="A wide numeric category now refuses a fit that used to ignore it">improvise-e8no</a>, and the one that silently reopens <a class="tid" href="../tickets.html#improvise-8xhy" title="[bug] A fit over a categorical feature emits an unevaluable formula">improvise-8xhy</a>. Fails with</p>
|
||
<pre><code>assertion `left == right` failed: a numeric column over a category column is unspellable
|
||
left: Measure("Year")
|
||
right: Artefact(NumericCategory)</code></pre>
|
||
<h3 id="an-ordinal-split-on-a-category-is-spellable-as-an-indicator-sum"><a class="anchor" href="#an-ordinal-split-on-a-category-is-spellable-as-an-indicator-sum">#</a>an_ordinal_split_on_a_category_is_spellable_as_an_indicator_sum</h3>
|
||
<p>Not a bug fix: this pins a <strong>grammar fact a prose argument rests on</strong>, after that argument got it wrong. <a class="tid" href="../tickets.html#improvise-e8no" title="A wide numeric category now refuses a fit that used to ignore it">improvise-e8no</a>'s decline was first written claiming that one-hot is the only encoding of a category whose splits the formula language can spell, and therefore that ordinal encoding was ruled out by spellability. An adversarial review disproved it by running <code>parse_formula</code> instead of reading the rule that looked relevant: <code>is_expr</code> is a <code>primary</code>, so it is an <code>Expr</code> yielding 1/0, and <code>cmp_op</code>'s operands are full <code>expr</code>s — so <code>IF(2020*(Year IS |2020|) + 2021*(Year IS |2021|) < 2020.5, 1, 2)</code> parses.</p>
|
||
<p>The decline survives on other grounds (a category's items carry insertion order, not magnitude; and the emitted formula still spells one <code>IS</code> per item, so the width is not saved) — but the <em>stated reason</em> was false, and a decline is the one artefact nothing checks. This test is the check: if the grammar ever narrows so that an indicator sum stops parsing, the paragraph that says it does becomes discoverable rather than quietly wrong.</p>
|
||
<p>The negative arm is the incidental fact that explains the emitter: <code>Year IS 2020</code> does <strong>not</strong> parse, because <code>filter_value</code> is <code>string | pipe_quoted | bare_ident</code> and a bare number is none of them. That is why <code>formula_text</code> pipe-quotes a numeric item name through <code>quote_name</code>, and asserting it here keeps the positive arm from passing for the trivial reason that everything parses.</p>
|
||
<h3 id="the-preflight-counts-the-parts-a-date-measure-expands-into"><a class="anchor" href="#the-preflight-counts-the-parts-a-date-measure-expands-into">#</a>the_preflight_counts_the_parts_a_date_measure_expands_into</h3>
|
||
<p><a class="tid" href="../tickets.html#improvise-x1ns" title="encoded_width undercounts a forced category that has a missing coordinate">improvise-x1ns</a>, <strong>second instance</strong> — found by ChatGPT Codex on PR #97, live at <code>f128087</code>, i.e. in the very change that fixed the first one.</p>
|
||
<p><code>preprocess</code> infers <code>Numeric → Date → Categorical</code> (<code>data.rs:406</code>), and <code>encoded_width</code> had <strong>no date arm</strong>: numeric-or-categorical only. A date string does not parse as <code>f64</code>, so a measure column holding dates took the categorical arm and was counted by its distinct strings. <code>build_date</code> emits <code>epoch_day/year/month/day/dow</code> — seven parts once any cell has a time (<code>data.rs:326</code>) — so a column with <strong>one</strong> distinct timestamp promised 1 and materialised 5.</p>
|
||
<p>Fails before the fix, on the plain-date case:</p>
|
||
<pre><code>a plain date: the preflight under-reported: it promised 4 features and
|
||
`preprocess` materialised 8 — ["Region=East", "Region=North", "Region=South",
|
||
"When.epoch_day", "When.year", "When.month", "When.day", "When.dow"]</code></pre>
|
||
<p><strong>The fixture carries a time case because of the rival fix, not because of the bug.</strong> Two implementations repair the plain-date failure: "a date column is 5 features", and "5, or 7 when any present cell carries a time". They differ only on a column containing a time, so the second sub-case is what separates them. Injected, <code>date_parts</code> hard-coded to 5:</p>
|
||
<pre><code>a date with a time: the preflight under-reported: it promised 8 features and
|
||
`preprocess` materialised 10 — [… "When.hour", "When.minute"]</code></pre>
|
||
<p>Note the plain-date sub-case <strong>passes</strong> under that injection — so without the time fixture this test would have been satisfied by either reading, and would have pinned the bug without pinning the rule.</p>
|
||
<p>Both arms compare against <code>data.feature_names.len()</code> rather than against a width computed a second way, so the test cannot pass by agreeing with a stale constant or by reimplementing the thing under test. Two preconditions guard vacuity: every <code>When</code> cell really is the same string (so the categorical count really would be 1), and the fixture's timestamp really does or does not carry a time, asked of <code>parse_datetime</code> rather than of the literal's shape.</p>
|
||
<p>The column must be a <strong>measure</strong> — <code>as_categories</code> forces every document category to <code>Forced::Categorical</code>, so a category of dates never reaches the date branch at all. That is gotcha 37(d)'s "the date decline is narrower than it read": this is the genuine date <em>measure</em>.</p>
|
||
</main>
|
||
</div>
|
||
</body>
|
||
</html>
|