fix(gp): a numeric- or date-valued category is spellable (improvise-zv8t)

`fit_measure` handed `preprocess` an `Overrides::default()`, so it never said
which header columns are document categories — although the very next line told
`Features::of`. `preprocess` then inferred Numeric -> Date -> Categorical from
the values, so a `Year` category with items 2024/2025 became a numeric column
and a category of ISO dates became a date column, and both were refused as
encoding artefacts with `Year IS 2024` spellable all along.

`fit_measure` now builds its `Overrides` from the same
`header[..category_columns]` slice it already hands `Features::of`, forcing each
to `Forced::Categorical`, so the one place the document's own answer was known
is now the only place either caller reads it from. `Forced::Categorical` already
existed; no formula-language change was needed.

Three things follow, and the last is a cost.

The date decline in gp.md §11 now means what it says. A `DatePart` could arrive
over a *category* column by exactly this accident, so that decline was narrower
than it read; it now covers a genuine date measure and nothing else.

The `Artefact` arms stay and stay correct. `crate::dataset` and `fitted_formula`
take the caller's `Overrides`, so a caller that does not force can still produce
a `Numeric` role over a category column. The arm is no longer reachable from
`fit_measure`, which is the only production entry point — it is not dead code,
and deleting it would derive the classification from one caller's habits.

Forcing widens the encoding, deliberately: a two-item `Year` is now two one-hots
rather than one numeric feature, and a two-hundred-item one is refused by
`MAX_FEATURES` where before it was refused one stage later as an artefact. Both
refuse, so nothing that fitted before stops fitting; what changes is which guard
speaks, and the width guard's message names the repair. `encoded_width` had to
learn the same slice for that to hold, or it counted such a column as one
feature and handed the refusal to the exact check *after* materialising the
matrix — the side of the cost the preflight exists to stay on (§14).

Evidence. The two spelling tests needed no fault injected: on the unfixed tree
`fit_measure` refuses with the two real messages the ticket named. Each fixture
gives the model one regular category, no other feature column, and a target that
category alone determines — because the refusal is conditioned on
`used_features`, so a fit whose champion happened to be a bare leaf would be
accepted and prove nothing. Asserting `formula.raw` contains `Year IS` rather
than merely `is_ok()` closes that in both directions. The preflight test did
need one: dropping the `col >= category_columns` clause makes it fail by name,
with the *exact* check speaking where the preflight should.

The prose this corrects was itself the ticket's subject. improvise-wd6n's §11
called `Artefact::NumericCategory` a permanent refusal that "no vocabulary
closes", which is false and is what filed improvise-zv8t. §11 and the gp
test-evidence entry are both updated to say it is closed rather than open.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
This commit is contained in:
Edward Langley
2026-09-21 03:52:42 -07:00
parent 6ee8ff389d
commit 99cfb7fb41
3 changed files with 257 additions and 25 deletions
+41 -14
View File
@@ -373,20 +373,47 @@ Two of the other three are declined permanently: a one-hot over a measure and
the `(missing)` pseudo-item name no coordinate *by construction*, so no
vocabulary closes them. They are refusals rather than gaps.
**`NumericCategory` is not, and this paragraph said it was** — which is the
more useful half of improvise-wd6n, because prose that calls something a
permanent refusal is what stops anyone filing the ticket. `fit_measure` hands
`preprocess` an `Overrides::default()`, so it never says which header columns
are document categories, although the very next line tells `Features::of`.
`preprocess` then infers `Numeric` → `Date` → `Categorical` from the cell
values, so a `Year` category with items `2024`/`2025` becomes a *numeric column*
and a category of ISO dates becomes a *date column* — and both are refused,
`Year IS 2024` being exactly spellable all along. That needs no
formula-language change whatever: `Forced::Categorical` already exists and is
the knob. Filed as **improvise-zv8t**. The same accident is why a `DatePart`
can arrive over a category column, so the date family's decline above is
narrower than it looks: it covers a genuine date *measure*, not a category
misread as one.
**`NumericCategory` was not, and improvise-zv8t has closed it.** That it was
*called* one is the more useful half of improvise-wd6n: prose declaring
something a permanent refusal is what stops anyone filing the ticket, and
nothing but a reader could have caught it — the suite was green either way.
The accident was one argument. `fit_measure` handed `preprocess` an
`Overrides::default()`, so it never said which header columns are document
categories, **although the very next line told `Features::of`**. `preprocess`
then inferred `Numeric` → `Date` → `Categorical` from the cell values, so a
`Year` category with items `2024`/`2025` became a *numeric column* and a
category of ISO dates became a *date column* — and both were then refused as
encoding artefacts with `Year IS 2024` spellable all along. `fit_measure` now
builds its `Overrides` from the same `header[..category_columns]` slice it
already hands `Features::of`, forcing each to `Forced::Categorical`, so the one
place the document's own answer was known is now the only place either caller
reads it from.
Three things follow, and the last is a cost rather than a win.
**The date decline above now means what it says.** A `DatePart` could arrive
over a *category* column by exactly this accident, so §11's date family was
narrower than it read. It now covers a genuine date **measure** and nothing
else, which is the scope the decline was always argued for.
**The `Artefact` arms stay, and stay correct.** `crate::dataset` and
`fitted_formula` take the caller's `Overrides`, so a caller that does not force
can still produce a `Numeric` role over a category column. The arm is no longer
reachable from `fit_measure`, which is the only production entry point; it is
not unreachable code, and deleting it would be deriving the classification from
one caller's habits instead of from the roles.
**Forcing widens the encoding, deliberately.** A `Year` category with two items
was one numeric feature and is now two one-hots; with two hundred items it is
two hundred, and `MAX_FEATURES` refuses it — where before it encoded as a single
feature and was refused one stage later as an artefact. Both refuse, so no
document that fitted before stops fitting; what changes is *which* guard speaks,
and the width guard's message names the repair (mark the category a Label). The
preflight `encoded_width` had to learn the same slice for that to be true, or it
counted such a column as one feature and handed the refusal to the exact check
after materialising the matrix — which is the side of the cost the preflight
exists to stay on (§14).
**And the refusal's own words must not name a column kind the arm does not
know.** `FeatureRole::Missing` is *not* conditioned on `is_category` — unlike
+63 -5
View File
@@ -606,10 +606,68 @@ The fixture reaches three families from one `preprocess` 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.
`AbsentCoordinate` is a permanent refusal; `NumericCategory` is **not**, and
`AbsentCoordinate` is a permanent refusal; `NumericCategory` was **not**, and
the first draft of this entry and of `gp.md` §11 both called it one — review
found that `fit_measure` simply never tells `preprocess` which columns are
categories, so it is a filed gap (improvise-zv8t) rather than a refusal. Worth
recording as the failure mode: the branch that *invented* a classification also
wrote the prose declaring one of its arms unfixable, and nothing but a reader
could have caught that.
categories. improvise-zv8t has since closed it (below). Worth recording as the
failure mode: the branch that *invented* 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.
### fit::tests::a_category_whose_items_are_numbers_is_still_spelled_as_a_coordinate
### fit::tests::a_category_whose_items_are_dates_is_still_spelled_as_a_coordinate
improvise-zv8t. `fit_measure` handed `preprocess` an `Overrides::default()`, so
it never said which header columns are document categories — although the very
next line told `Features::of`. `preprocess` infers `Numeric` → `Date` →
`Categorical` from the values, so a **`Year` category with items `2024`/`2025`**
became a numeric column and a **category of ISO dates** became a date column,
and both were refused as encoding artefacts with `Year IS 2024` spellable all
along. User-visible: a fit over such a document could not be stored at all.
No fault had to be injected, because the bug was live. On the unfixed tree both
tests fail inside `fit_measure` with the real refusals, which are the two
arms the ticket named and are worth quoting:
- `["Year"] names a category whose items all parse as numbers, so a reference
to it would resolve to a `_Measure` item of the same name`
- `["Year.epoch_day", "Year.year"] is a part of a date, and date arithmetic is
something improvise does not have anywhere`
The second is why `gp.md` §11's date decline was narrower than it read.
**Why each fixture is discriminating.** `year_document` gives the model **one**
regular category and **no other feature column**, and makes the target a
function of that category alone — so a tree that does not split on `Year` is a
poor fit and the GA does not settle for one. That matters because the refusal is
conditioned on `used_features`: a fit whose champion happened to be a bare leaf
would be *accepted* on the unfixed tree and prove nothing. Asserting
`formula.raw.contains("Year IS")` rather than merely `is_ok()` closes that hole
in both directions — on the unfixed tree the text could only have been
`Expr::Ref("Year")`, never `Year IS`. The numeric test then adds the property
the refusal exists to protect: the formula goes in through `add_formula` and
every record evaluates to a `Number`, not `Error("ref:…")` (improvise-8xhy).
### fit::tests::the_preflight_counts_a_numeric_category_at_its_cardinality
The other half of improvise-zv8t, and the one that is a *cost* rather than a
fix. Forcing a category categorical widens it from one numeric feature to one
per distinct value, so `encoded_width` — the cheap preflight that exists to
refuse a too-wide table **before** `preprocess` materialises it (§14) — had to
learn the same `header[..category_columns]` slice, or it would under-count
exactly the column this ticket is about.
Fault injected: the `col >= category_columns` clause dropped from `encoded_width`,
leaving the pre-ticket inference. The test fails by name, and the failure message
is the evidence — it shows the **exact** check speaking where the preflight
should:
> the PREFLIGHT must refuse it, before anything is materialised: fitting "Score"
> encoded 70 features, over the 64 this host will search.
A test asserting only `expect_err` would have passed under that injection, since
both guards refuse; the discriminator is *which message*, because the two guards
differ in what they have already paid for. The fixture also asserts its own
precondition — that `Year` really is the header's first column — so a
`feature_table` change that reorders the header fails here rather than silently
slicing the wrong names.
+153 -6
View File
@@ -1,7 +1,7 @@
//! One fit, end to end: a [`View`]'s records in, an improvise [`Formula`] and the
//! evidence for believing it out. It never prints — `context/code/gp.md` §14.
use gp_tree::data::{Overrides, Target, preprocess};
use gp_tree::data::{Forced, Overrides, Target, preprocess};
use gp_tree::fit::{
FitConfig, RegressionQuality, cv_regress, denormalize, fit, importance_ranking, mse,
permutation_importance, regression_quality, standardize,
@@ -15,7 +15,7 @@ use crate::dataset::{FeatureTable, feature_table};
/// A cheap upper bound on how wide `preprocess` would expand this table, computed
/// without expanding it. **Advisory, not authoritative** — `context/code/gp.md` §14.
fn encoded_width(table: &FeatureTable) -> usize {
fn encoded_width(table: &FeatureTable, category_columns: usize) -> usize {
(0..table.header.len().saturating_sub(1))
.map(|col| {
let cells: Vec<&str> = table
@@ -30,7 +30,12 @@ fn encoded_width(table: &FeatureTable) -> usize {
.filter(|c| !gp_tree::data::is_missing(c))
.collect();
let has_missing = present.len() < cells.len();
match !present.is_empty() && present.iter().all(|c| c.parse::<f64>().is_ok()) {
// A document category is forced categorical below, so the preflight must
// count it the same way or it under-counts exactly the improvise-zv8t shape.
let numeric = col >= category_columns
&& !present.is_empty()
&& present.iter().all(|c| c.parse::<f64>().is_ok());
match numeric {
// A numeric column is one feature, plus the `.missing` flag.
true => 1 + usize::from(has_missing),
// Everything else: one column per distinct value.
@@ -44,6 +49,18 @@ fn encoded_width(table: &FeatureTable) -> usize {
.sum()
}
/// Force every document category to be encoded as one, rather than inferred from its
/// values. The argument for asking the document is `context/code/gp.md` §11.
fn as_categories(header: &[String], category_columns: usize) -> Overrides {
Overrides {
force: header[..category_columns]
.iter()
.map(|name| (name.clone(), Forced::Categorical))
.collect(),
..Overrides::default()
}
}
/// The same text with every CR and LF turned into a space, so a name a document may
/// legally hold cannot split a one-line status bar. Public: the refusals need it too.
pub fn one_line(text: String) -> String {
@@ -216,7 +233,8 @@ pub fn fit_measure(
table.rows.len(),
request.max_records,
);
let width = encoded_width(&table);
let category_columns = model.regular_category_names().len();
let width = encoded_width(&table, category_columns);
anyhow::ensure!(
width <= MAX_FEATURES,
"fitting {target:?} would encode {width} features from {} records, which is too wide \
@@ -225,9 +243,12 @@ pub fn fit_measure(
table.rows.len(),
);
let category_columns = model.regular_category_names().len();
let header = table.header.clone();
let mut data = preprocess(&table.header, &table.rows, &Overrides::default());
let mut data = preprocess(
&table.header,
&table.rows,
&as_categories(&header, category_columns),
);
data.warnings.splice(0..0, table.warnings);
no_ambiguous_column_names(&header)?;
let features = crate::Features::of(&data, &header[..category_columns]);
@@ -501,6 +522,132 @@ mod tests {
}
}
/// Bug improvise-zv8t: `fit_measure` handed `preprocess` an `Overrides::default()`,
/// so a numeric-valued category was inferred numeric. Evidence: `context/test-evidence/gp.md`.
#[test]
fn a_category_whose_items_are_numbers_is_still_spelled_as_a_coordinate() {
let (mut model, view) = numeric_category_document();
let outcome =
fit_measure(&model, &view, "Score", FIT_INTO, &request()).expect("the fit lands");
assert!(
outcome.formula.raw.contains("Year IS"),
"the only predictor is a category; the fit spelled it {:?}",
outcome.formula.raw
);
// …and the spelling really resolves: every cell a number, not `Error("ref:…")`.
model.add_formula(outcome.formula.clone());
model.recompute_formulas(&[]);
for i in 0..12u32 {
match model.evaluate(&year_cell(&format!("r{i}"), &outcome.formula.target)) {
Some(CellValue::Number(_)) => {}
other => panic!("record r{i} came back as {other:?}"),
}
}
}
/// The same accident let a category of ISO dates arrive as a `DatePart`, so
/// `gp.md` §11’s date decline covers a date *measure*, not a misread category.
#[test]
fn a_category_whose_items_are_dates_is_still_spelled_as_a_coordinate() {
let (model, view) = date_category_document();
let outcome =
fit_measure(&model, &view, "Score", FIT_INTO, &request()).expect("the fit lands");
assert!(
outcome.formula.raw.contains("Year IS"),
"a category of ISO dates must stay a coordinate: {:?}",
outcome.formula.raw
);
}
/// The preflight must count a category the way `preprocess` will now be told to
/// encode it, or it under-counts exactly the column improvise-zv8t is about.
#[test]
fn the_preflight_counts_a_numeric_category_at_its_cardinality() {
let (model, view) = year_document(&WIDE_YEARS);
let table = feature_table(&model, &view, "Score").expect("the table builds");
assert_eq!(
table.header.first().map(String::as_str),
Some("Year"),
"the fixture must really put the category first, or the slice is wrong"
);
let err = fit_measure(&model, &view, "Score", FIT_INTO, &request())
.expect_err("a category with a value per record is too wide");
let msg = err.to_string();
assert!(
msg.contains("would encode"),
"the PREFLIGHT must refuse it, before anything is materialised: {msg}"
);
}
/// One distinct numeric `Year` per record, well past `MAX_FEATURES`.
const WIDE_YEARS: [&str; 70] = [
"1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15", "16",
"17", "18", "19", "20", "21", "22", "23", "24", "25", "26", "27", "28", "29", "30", "31",
"32", "33", "34", "35", "36", "37", "38", "39", "40", "41", "42", "43", "44", "45", "46",
"47", "48", "49", "50", "51", "52", "53", "54", "55", "56", "57", "58", "59", "60", "61",
"62", "63", "64", "65", "66", "67", "68", "69", "70",
];
/// One **Regular** category `Year`, no other feature column, and a target the
/// category alone determines. `items` are the coordinate values it carries.
fn numeric_category_document() -> (Model, View) {
year_document(&["2024", "2025"])
}
fn date_category_document() -> (Model, View) {
year_document(&["2024-01-01", "2025-01-01"])
}
fn year_document(items: &[&str]) -> (Model, View) {
let mut model = Model::new("sales");
model.add_category("Year").expect("under the limit");
model.add_category(TAG).expect("under the limit");
for item in items {
model
.category_mut("Year")
.expect("just added")
.add_item(*item);
}
model.category_mut(TAG).expect("just added").kind = CategoryKind::Label;
let records = items.len().max(12) as u32;
for i in 0..records {
model
.category_mut(TAG)
.expect("just added")
.add_item(format!("r{i}"));
}
model
.category_mut(MEASURE)
.expect("_Measure always exists")
.add_item("Score");
for i in 0..records {
let year = items[(i as usize) % items.len()];
model.set_cell(
CellKey::new(vec![
("Year".to_string(), year.to_string()),
(TAG.to_string(), format!("r{i}")),
(MEASURE.to_string(), "Score".to_string()),
]),
CellValue::Number(if i % 2 == 0 { 1000.0 } else { 4000.0 }),
);
}
let mut view = View::new("Default");
for cat in model.category_names() {
view.on_category_added(cat);
}
view.set_axis("Year", Axis::Row);
view.set_axis(MEASURE, Axis::Page);
(model, view)
}
fn year_cell(tag: &str, measure: &str) -> CellKey {
CellKey::new(vec![
("Year".to_string(), "2024".to_string()),
(TAG.to_string(), tag.to_string()),
(MEASURE.to_string(), measure.to_string()),
])
}
/// gotcha 37b: the seed alone does not reproduce a fit, so the outcome
/// carries the config beside it — and the pair really is sufficient.
#[test]