Merge pull request #97 from fiddlerwoaroof/wave/format-and-fit

Wave M: number-format precision (711n), preflight under-count (x1ns), e8no declined
This commit is contained in:
Ed Langley
2026-09-21 10:55:57 -07:00
committed by GitHub
9 changed files with 774 additions and 36 deletions
+53
View File
@@ -641,3 +641,56 @@ targets, which is what the column list shows anyway.
And the list is **not** filtered to visible items. Hidden items are still cycled,
because that is what the TUI has always done, and making the browser differ would
be the divergence rather than the fix. Changing it is a change to both, together.
## The number format — `format.rs` (improvise-711n)
A view's `number_format` is a **printf-style picture, and only that**. A `,`
anywhere turns on thousands grouping; a `.` introduces the precision, which is
the **leading digit run after the last `.`**; anything following those digits —
a conversion letter — is ignored. So `,.0`, `,.2`, `.2`, `,.2f` and `%.2f` all
name what they look like they name.
**Why the trailing `f` is accepted rather than refused.** It round-trips into
documents today and always has. `improv.pest` spells the line
`format_line = { "format: " ~ rest_of_line ~ NEWLINE ~ blank_lines }` — the
grammar takes the format string as an **opaque line**, so `format: ,.2f` is a
document the grammar accepts; `:set-number-format` stores whatever the user
typed verbatim (`cmd/effect_cmds.rs`), and `format_md` writes `view.number_format`
back out unchanged. Refusing the spelling at load would therefore reject
documents this repo's own grammar admits and its own `.improv` example spells,
to no benefit: by gotcha 8 the format is display-only, so misreading it costs a
wrong pixel and can never cost a wrong number. A refusal here buys nothing and
costs a document.
**Why printf and not Excel.** The two conventions disagree on exactly the
spelling that distinguishes them: `.00` is *precision zero* to printf and *two
places* to Excel, so a parser that tried to honour both would have to guess. One
is picked, and it is the one every spelling in this repository already uses.
Consequences worth stating rather than discovering: `0.00` reads as
`(false, 0)`, and an Excel picture such as `#,##0.00` reads as
`(true, 0)` — grouped, no decimals.
**Nothing is validated, deliberately.** A spelling the rule cannot read names no
precision and formats with none; there is no error channel and no `Result`,
because the reader is display-only and four call sites would have to thread one.
**What the bug was, and why six tests sat on top of it.** The precision was read
as `fmt[i+1..].parse::<u8>()` over the *whole* tail, so `"2f"` failed to parse
and fell through to `unwrap_or(0)` — `,.2f` displayed **zero** decimals, in the
TUI, in the browser projection and in CSV export. `",.2f"` and its `,.3f`/`,.4f`
siblings are what this workspace's own fixtures reach for, in about ten tests,
and none could see it: they assert the stored *string* round-trips or use it as
an opaque value to dirty a widget, or — in `export_csv_quotes_commas_per_rfc4180`
— they assert the rendered value `starts_with("1,200")`, which `1,200` and
`1,200.00` both satisfy. A prefix check over a value whose *suffix* is the thing
in question answers a neighbouring question, which is why that one is now an
exact equality.
**Only two of the three consumers are guarded.** Reverting the fix fails
`format.rs`'s own unit tests and `export_csv_quotes_commas_per_rfc4180`, and
nothing else in the workspace: `ui/project.rs::grid_of` (the browser projection)
and `ui/app.rs` both call `parse_number_format` and neither has a test that
renders a cell and asserts its digits. That gap is named rather than closed —
those files are `improvise-command`, which was another wave's ground when this
landed — and it is the more useful half of this entry, because it says where the
next regression would go unseen.
+167 -7
View File
@@ -561,14 +561,79 @@ the fix (`Label`), because that is what the user actually wants.
side of the cost: a category with a distinct value per record allocates a
rows × rows one-hot *during* preprocessing, so the refusal arrives after the
memory and the O(rows²) work have been spent. It approximates `preprocess`'s
type inference rather than sharing it — a column whose present cells all parse
as a number is one feature (two with a missing indicator), anything else is
counted at its distinct-value count. Copying the predicate is the lesser evil;
type inference rather than sharing it. Copying the predicate is the lesser evil;
the alternative is exporting the inference and calling this a second
implementation of it. **The cost of the copy drifting is bounded in both
directions by the exact check** on `Dataset::feature_names.len()`:
over-counting costs a refusal the exact check would not have made,
under-counting only lets the exact check do its job.
implementation of it.
**The debt that copy carries is that the preflight owes EVERY branch
`preprocess` can take, and it paid two of three.** `preprocess` infers
`Numeric → Date → Categorical`, in that order, and the preflight had no date arm
at all: a column whose cells parse as neither numbers nor a forced category fell
through to the categorical count. So a **measure column holding dates** — one
distinct timestamp in every row — promised **1** and materialised **5**, because
`build_date` emits `epoch_day/year/month/day/dow`, and **7** once any cell
carries a time (improvise-x1ns, second instance, found by ChatGPT Codex on #97).
That is an under-count of four or six on a single column, against a budget of 64.
The two instances are the same defect one inference-branch apart, which is why
the shape is now structural rather than remembered. `inferred_kind` reproduces
`preprocess`'s match arm for arm and returns a `Forced`, and `encoded_width`
matches that **with no wildcard** — so a fourth `Forced` variant is a compile
error here instead of silently taking the categorical arm, which is exactly how
`Date` was missed. The `.missing` flag is added once outside the match, because
every branch owes it on the same terms.
The count each arm owes:
| `preprocess` builds | features |
|---|---|
| `build_numeric` | 1 |
| `build_date` | 5, or 7 if any present cell has a time |
| `build_categorical` | distinct present values, **+1** for the reserved `(missing)` |
plus `col.missing` wherever any cell is missing, and clamped to at least 1.
**The two directions of a drift do not cost the same, and this paragraph said
they did** (improvise-x1ns). It read: *the cost of the copy drifting is bounded
in both directions by the exact check* — over-counting costs a needless refusal,
under-counting "only lets the exact check do its job". That is wrong, and it is
wrong against the argument **two paragraphs above it**: the exact check runs on
`Dataset::feature_names.len()`, which exists only *after* `preprocess` has
materialised the matrix. So it bounds the **error message**, never the **cost** —
which is precisely the thing the preflight was introduced to bound. Over-counting
is cheap; under-counting spends the memory and the O(rows²) work the preflight
exists to prevent, and then reports it politely.
The live instance was the `(missing)` column: the categorical arm counted
`present` values only, so a forced category with N distinct present values and
one record lacking that coordinate promised N and materialised N+1. `has_missing`
was already computed and used in the numeric arm alone — **two arms of one match
disagreeing about what a column costs**, which is improvise-zv8t's forcing-by-name
/ budgeting-by-index defect one level in. That one was repaired by making the
ordering a data dependency and so a compile error; this one has no such shape
available, so the guard is a test that asks `preprocess` what it actually built
rather than asserting a number
(`the_preflight_counts_the_missing_category_a_forced_column_materialises`).
The previous wave's review saw a version of this and reasoned it away as *"read,
not injected"* and *"bounded by the exact check"*. Both halves of that are the
failure mode this file exists to catch: the first declined to run the experiment,
and the second repeated the sentence above instead of checking it. **An argument
that a cost is bounded is checkable, and this one was checkable the day it was
written**: `encoded_width` landed 2026-09-19 and the sentence 2026-09-20, so the
code it described was one commit old and one file away.
`encoded_width` remains **advisory** in the direction that is safe: it may
over-count, and a column whose cells are *entirely* missing is counted as one
where `preprocess` skips it altogether. It must never under-count.
**That sentence was written as a proposed `kb.pl` invariant while the tree held a
counter-example**, in the same change that fixed the categorical one — the date
branch was missing the whole time and nothing in the hand-back noticed. An
invariant proposed from the defect you just fixed generalises the *fix*, not the
code; the thing that would have caught it is the question "what else does
`preprocess` do that I have not mirrored", which is a question about the other
function and not about the bug.
`MAX_RECORDS = 20_000` bounds the *work*, which the width bound does not: a
one-feature table with a million records still runs cross-validation, the final
@@ -586,6 +651,101 @@ sequence, five-fold CV included. There is deliberately **no timing test**: a
wall-clock assertion is flaky on shared CI and would fail for a reason that is
not this code's. Re-measure after changing the numbers.
### Forcing a numeric category is a *representability* choice, not a budget one
improvise-zv8t made `fit_measure` force every document category to
`Forced::Categorical`, and improvise-e8no records the cost: a hundred-item
numeric category one-hots to a hundred columns, past `MAX_FEATURES`, and is
refused where inference used to read it as one numeric column and the fit
succeeded. **improvise-e8no is declined, and the refusal is correct.**
The ticket rules out making the forcing conditional on cardinality, and rightly:
that is inference by another name, and it makes the *encoding* depend on a budget
rather than on what the document says. But the stronger reason to decline is that
the alternatives are not merely distasteful — they are **unstorable**, and the
code already says so.
A coordinate becomes an `Expr` **only by passing through `IS`**: `is_expr` is
`identifier ~ sp+ ~ ^"IS" ~ sp+ ~ filter_value`, a single-value equality, and
there is no `IN` and no ordering over a category's items. (`where_clause` and
`inline_where` are coordinate predicates too, and also single-value equalities —
but they *gate* a formula or an aggregate rather than being an `Expr`, which is
gotcha 37(d)'s distinction and is why `IS` is the one that matters here.) So
whatever a tree says about a category, the emitted formula names its items **one
at a time**.
**A first draft of this paragraph said that made one-hot the only spellable
encoding. That is false**, and an adversarial review disproved it by running
`parse_formula` rather than reading the grammar: `is_expr` is a `primary`, so it
is an `Expr` yielding 1/0, and `cmp_op`'s operands are full `expr`s — so an
ordinal split *is* spellable, as a weighted indicator sum
(`IF(2020*(Year IS |2020|) + 2021*(Year IS |2021|) < 2020.5, …)`). The claim is
recorded here rather than quietly deleted because it is the shape this file keeps
catching: a grammar argument made by reading the rule that looked relevant
instead of running the parser.
So **ordinal is not ruled out by spellability, and is ruled out anyway**, for two
reasons that survive. It invents a **metric the document does not assert** — a
category's items carry insertion order, not magnitude, so `Year < 3.5` means "the
first four items as they happen to be ordered" and stops meaning that when
someone reorders them. And the width it saves does not stay saved: the *encoding*
would be one feature, but the *formula* still spells one `IS` per item, so it
trades a `MAX_FEATURES` problem for a formula-size problem while adding a false
order. What the current emitter does with it is narrower and is not the argument:
`spell()` maps `FeatureRole::Numeric` over a *category* column to
`Artefact::NumericCategory`, because a bare reference to the category's name
would resolve to a `_Measure` item of the same name (improvise-8xhy) — a fact
about the bare-ref spelling, not about ordinals in general. **Hashing** is ruled
out outright: a bucket has no name in the document, so it cannot even be refused
informatively.
And this is what makes the decline clean rather than resigned. The "fit that
previously succeeded" was one of two things, never a third. Either the tree
**never split** on the numeric category — in which case the column contributed
nothing to the rule, and marking it a `Label` gives a fit over the same remaining
features, inside the budget, which is exactly what the refusal already advises.
Or the tree **did** split on it, and `fitted_formula` refused the result as
`Artefact::NumericCategory` at storage time. So **no fit is lost that was both
storable and used the column**.
Two things that claim less than a first draft of this paragraph did, both worth
keeping. Marking the category a `Label` does **not** yield *the same fit*: the
feature count changes, so the RNG draw sequence in `random_tree`/`sample_thresh`
changes, so the champion for a given seed changes — gotcha 37(b)'s own rule,
applied to this paragraph. "The same fit" is a checkable cost claim and it is
false; what is true is a fit over the same remaining features. And the dichotomy
above is **argued**, from `spell()`'s exhaustive match over `FeatureRole`, not
asserted: what
`a_split_on_a_numeric_category_read_as_a_number_cannot_be_stored` asserts is one
instance of its second branch, over one three-row fixture. That is the most a
test can do here, and saying so is the difference between evidence and a
flourish.
The test still had no predecessor. `NumericCategory` had no coverage before it —
**and it was one of three, not the only one**: grepping each `Artefact` variant
and each variant's `reason()` text, `MissingFlag` and `DatePart` are covered by
`the_missing_flag_and_the_date_sub_features_are_refused_too`, and
**`ValueOfAMeasure` and `AbsentCoordinate` have no test at all**, before this
change or after it. Two families remain uncovered; that is filed, not fixed
here.
What the decline does cost, stated rather than waved at: a user whose `Year`
category really is a quantity they want fitted **as a number** has no way to say
so through `fit_measure`. The document says `Year` is a category of items, and
that is what is honoured. The remedy is a document change — `Year` belongs in
`_Measure` if it is a measurement — and `crate::dataset` is `pub` and takes the
caller's `Overrides`, so a host that wants to offer the choice can (gotcha 37d).
Offering it *inside* `fit_measure` would be a new user-facing control, and
nobody has asked for one.
The refusal's wording was narrower than the rule and is now aligned in both
places that carry it: it said *a category with a distinct value per record
becomes one column per record*, which diagnoses the runaway shape and not
improvise-e8no's — a category of modest cardinality that is simply wider than
the budget. Both now say a category becomes one column per distinct item,
**whether its items read as numbers or not**, since that clause is the whole of
what changed under zv8t.
### `Quality` is an enum, not an `Option`
`cv_regress` pools no pairs below two folds, and scoring an empty pool gives an
+41
View File
@@ -611,3 +611,44 @@ narrow as it claims.
Fault injected by deleting either emission: the corresponding test fails with
`Some(("Month", "Q1"))` / `Some(("Recipient", "Family"))` against `None`, and
`the_two_front_ends_draw_the_same_column_header` fails with it.
### a_trailing_conversion_letter_does_not_erase_the_precision
improvise-711n. `parse_number_format` read the precision as
`fmt[i+1..].parse::<u8>().ok().unwrap_or(0)` over everything after the last `.`,
so any trailing conversion letter made the whole suffix unparseable and the
format fell back to **zero decimals, silently**. `format: ,.2f` — the spelling
`context/repo-map.md`'s own `.improv` example carries, and the printf/Python
convention a user reaches for first — displayed integers everywhere.
Fails before the fix with `left: (true, 0)`, `right: (true, 2)`.
The test also pins the reading against its nearest *wrong* neighbour. Two
implementations both fix `,.2f`: take the leading digit run, or trim the
trailing non-digits off the tail. They differ only on a tail with digits on both
sides of a letter, so `".2f3"` is in the assertion. Injecting
`s.trim_end_matches(|c: char| !c.is_ascii_digit())` in place of `leading_digits`
fails the test at `left: (false, 0)`, `right: (false, 2)` — so the test sees the
rule, not merely the bug.
### a_zero_padded_picture_string_is_read_as_printf_not_as_excel
The companion to the above, and the reason the fix is not "be generous". A
forgiving reader would have to answer `.00`, where printf says precision zero
and an Excel picture says two places. This pins the printf reading — `"0.00"` is
`(false, 0)` — so that "accept the trailing `f`" cannot quietly grow into
"accept anything and guess". It passes before the fix as well as after: it is
there to stop the *next* change, not to catch this one.
### a_precision_too_large_to_name_reads_as_none
`".300"` does not fit `u8`, so it reads as no precision rather than as 300
decimal places. Unchanged by improvise-711n and pinned so the fallback is a
stated rule rather than an artefact of the integer type.
### a_format_naming_no_precision_at_all_reads_as_zero
`",f"` has no `.`, and `".f"` has a `.` with no digits after it. Both are zero
decimals. Pinned because `leading_digits` returns `""` for the second, and an
empty string reaching `parse::<u8>()` is the one path in the function with no
digits to carry the answer.
+9
View File
@@ -455,6 +455,15 @@ into two fields, **corrupting every row**. The test reads the file back with a
real CSV reader and asserts the field *count*, which is the thing a string
`contains` check cannot see.
**Strengthened by improvise-711n.** The assertion on the value was
`records[1][1].starts_with("1,200")`, and the fixture's format is `,.2f` — so
the check could not tell `1,200` from `1,200.00` and sat green while
`parse_number_format` threw the `.2` away. It is now an exact equality. Reverting
the `format.rs` fix fails it with `left: "1,200"`, `right: "1,200.00"`, which is
the cross-crate demonstration that the format bug reached a user's exported file
and not only the screen.
---
## `improvise-io/src/import/analyzer.rs`
+162
View File
@@ -749,3 +749,165 @@ fixture really names one column twice, that the preflight's 41 really disagrees
with what forcing would build, and that `as_categories` 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.
### the_preflight_counts_the_missing_category_a_forced_column_materialises
improvise-x1ns, found by ChatGPT Codex reviewing PR #90 and live on `main` at
`ce895ff`. `encoded_width` computed `has_missing` and used it in the **numeric**
arm only:
```
true => 1 + usize::from(has_missing),
false => present.iter().collect::<HashSet<_>>().len().max(1),
```
`build_categorical` maps every missing cell to `MISSING_CAT` and includes it in
`distinct(&mapped)`, so a categorical column with N distinct *present* values and
at least one absent cell materialises **N+1** one-hots. The preflight promised N.
It under-reports by exactly one per such column, and it under-reports in the
direction that costs: the preflight runs *before* `preprocess`, the exact check
on `feature_names.len()` runs *after*, 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
`context/code/gp.md` §14.
**Fails before the fix** with
```
the preflight under-reported: it promised 5 features and `preprocess`
materialised 6 — ["Year=(missing)", "Year=2020", "Year=2021", "Year=2022",
"Year=2023", "Cost"]
```
The fixture is Codex's suggested case, and the reason it is the interesting one:
a **forced numeric category with a missing coordinate**. Forcing is what sends a
numeric column down the categorical branch, so only a *forced* column reaches the
arm that was wrong, and only a missing cell makes the arms disagree.
The test does not assert a number. It asks `preprocess` what it actually built
and requires `encoded_width >= data.feature_names.len()`, 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 `Year=(missing)` one-hot is really
among the materialised features.
The `.max(1)` is deliberately outside the addition. A column whose cells are
*entirely* missing has no present values, and `preprocess` 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.
### a_split_on_a_numeric_category_read_as_a_number_cannot_be_stored
The evidence improvise-e8no is declined on, and the only coverage
`Artefact::NumericCategory` has ever had. It was **one of three** families with
no test, not the only one: `MissingFlag` and `DatePart` are covered by
`the_missing_flag_and_the_date_sub_features_are_refused_too`, and
`ValueOfAMeasure` and `AbsentCoordinate` still have none — filed, not fixed
here.
improvise-e8no observes that forcing a document category to `Forced::Categorical`
(improvise-zv8t) widens the encoding, so a high-cardinality numeric category now
exceeds `MAX_FEATURES` 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 *category* column produces a formula
`fitted_formula` **refuses**, because a bare reference to the category's name
would resolve to a `_Measure` item of the same name (improvise-8xhy). So the
pre-zv8t success was either a fit that never touched the column — for which
marking it a `Label` gives a fit over the same remaining features, within budget
— or one that could not be stored. Note what this does *not* establish: an
ordinal encoding is spellable as a weighted indicator sum, so it is declined on
semantics and formula size rather than on spellability (`context/code/gp.md`).
The test asks `preprocess` with `Forced::Numeric`, which is the pre-zv8t reading
reproduced deliberately rather than described, and then asks `Features::of` and
`fitted_formula`. It does **not** use the `artefacts()` helper: that helper
fabricates a `Spelling`, 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.
Injected fault: change `spell()`'s `FeatureRole::Numeric { .. }` arm from
`Spelling::Artefact(Artefact::NumericCategory)` to `Spelling::Measure(column.clone())`
— the "fix" someone would reach for on reading improvise-e8no, and the one that
silently reopens improvise-8xhy. Fails with
```
assertion `left == right` failed: a numeric column over a category column is unspellable
left: Measure("Year")
right: Artefact(NumericCategory)
```
### an_ordinal_split_on_a_category_is_spellable_as_an_indicator_sum
Not a bug fix: this pins a **grammar fact a prose argument rests on**, after that
argument got it wrong. improvise-e8no'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 `parse_formula` instead of reading the
rule that looked relevant: `is_expr` is a `primary`, so it is an `Expr` yielding
1/0, and `cmp_op`'s operands are full `expr`s — so
`IF(2020*(Year IS |2020|) + 2021*(Year IS |2021|) < 2020.5, 1, 2)` parses.
The decline survives on other grounds (a category's items carry insertion order,
not magnitude; and the emitted formula still spells one `IS` per item, so the
width is not saved) — but the *stated reason* 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.
The negative arm is the incidental fact that explains the emitter: `Year IS 2020`
does **not** parse, because `filter_value` is `string | pipe_quoted | bare_ident`
and a bare number is none of them. That is why `formula_text` pipe-quotes a
numeric item name through `quote_name`, and asserting it here keeps the positive
arm from passing for the trivial reason that everything parses.
### the_preflight_counts_the_parts_a_date_measure_expands_into
improvise-x1ns, **second instance** — found by ChatGPT Codex on PR #97, live at
`f128087`, i.e. in the very change that fixed the first one.
`preprocess` infers `Numeric → Date → Categorical` (`data.rs:406`), and
`encoded_width` had **no date arm**: numeric-or-categorical only. A date string
does not parse as `f64`, so a measure column holding dates took the categorical
arm and was counted by its distinct strings. `build_date` emits
`epoch_day/year/month/day/dow` — seven parts once any cell has a time
(`data.rs:326`) — so a column with **one** distinct timestamp promised 1 and
materialised 5.
Fails before the fix, on the plain-date case:
```
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"]
```
**The fixture carries a time case because of the rival fix, not because of the
bug.** 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, `date_parts` hard-coded to 5:
```
a date with a time: the preflight under-reported: it promised 8 features and
`preprocess` materialised 10 — [… "When.hour", "When.minute"]
```
Note the plain-date sub-case **passes** 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.
Both arms compare against `data.feature_names.len()` 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 `When` 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 `parse_datetime` rather than of the literal's shape.
The column must be a **measure** — `as_categories` forces every document
category to `Forced::Categorical`, 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 *measure*.
+52 -6
View File
@@ -10,14 +10,19 @@ pub fn format_value(v: Option<&CellValue>, comma: bool, decimals: u8) -> String
}
}
/// Parse a number format string like ",.0" into (use_commas, decimal_places).
/// The digits a precision is named by: everything up to the first non-digit.
fn leading_digits(s: &str) -> &str {
&s[..s.find(|c: char| !c.is_ascii_digit()).unwrap_or(s.len())]
}
/// Parse a printf-style number format like ",.2f" into (use_commas, decimals).
/// Which spellings are legal: `context/code/core.md`, "The number format".
pub fn parse_number_format(fmt: &str) -> (bool, u8) {
let comma = fmt.contains(',');
let decimals = fmt
.rfind('.')
.and_then(|i| fmt[i + 1..].parse::<u8>().ok())
.unwrap_or(0);
(comma, decimals)
let precision = fmt
.rsplit_once('.')
.map_or("", |(_, tail)| leading_digits(tail));
(comma, precision.parse::<u8>().unwrap_or(0))
}
/// Round half away from zero (the "normal" rounding people expect).
@@ -100,6 +105,47 @@ mod tests {
assert_eq!(parse_number_format(",.1.3"), (true, 3));
}
/// Bug improvise-711n: a trailing conversion letter made the whole suffix
/// unparseable. Evidence: `context/test-evidence/core.md`.
#[test]
fn a_trailing_conversion_letter_does_not_erase_the_precision() {
assert_eq!(parse_number_format(",.2f"), (true, 2));
assert_eq!(parse_number_format(".2f"), (false, 2));
assert_eq!(parse_number_format("%.2f"), (false, 2));
// The precision is the LEADING digit run, not the tail with its
// non-digits trimmed off — these two readings differ here.
assert_eq!(parse_number_format(".2f3"), (false, 2));
}
/// One convention, not two: `.00` is printf's "precision zero", never
/// Excel's "two places". Evidence: `context/test-evidence/core.md`.
#[test]
fn a_zero_padded_picture_string_is_read_as_printf_not_as_excel() {
assert_eq!(parse_number_format("0.00"), (false, 0));
assert_eq!(parse_number_format(",.0f"), (true, 0));
}
#[test]
fn a_precision_too_large_to_name_reads_as_none() {
assert_eq!(parse_number_format(".300"), (false, 0));
assert_eq!(parse_number_format(",.999f"), (true, 0));
}
/// `format:` is `rest_of_line` in the grammar, so the format string can hold
/// any text at all — including a multibyte char right after the dot.
#[test]
fn a_multibyte_character_after_the_dot_does_not_split_a_char_boundary() {
assert_eq!(parse_number_format(",.2\u{20ac}"), (true, 2));
assert_eq!(parse_number_format(".\u{20ac}"), (false, 0));
assert_eq!(parse_number_format(".\u{0662}"), (false, 0));
}
#[test]
fn a_format_naming_no_precision_at_all_reads_as_zero() {
assert_eq!(parse_number_format(",f"), (true, 0));
assert_eq!(parse_number_format(".f"), (false, 0));
}
// ── format_f64 basic ───────────────────────────────────────────────
#[test]
+223 -18
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::{Forced, Overrides, Target, preprocess};
use gp_tree::data::{Forced, Overrides, Target, parse_datetime, preprocess};
use gp_tree::fit::{
FitConfig, RegressionQuality, cv_regress, denormalize, fit, importance_ranking, mse,
permutation_importance, regression_quality, standardize,
@@ -13,6 +13,31 @@ use improvise_formula::Formula;
use crate::dataset::{FeatureTable, feature_table};
/// Which branch `preprocess` would take for this column. It mirrors that function's
/// own `match`, arm for arm — the preflight owes every branch, `context/code/gp.md` §14.
fn inferred_kind(present: &[&str], is_document_category: bool) -> Forced {
match present.is_empty() {
// `preprocess` SKIPS an entirely-missing column. Counting it as one
// categorical over-counts by one, which is the safe direction.
true => Forced::Categorical,
false if is_document_category => Forced::Categorical,
false if present.iter().all(|c| c.parse::<f64>().is_ok()) => Forced::Numeric,
false if present.iter().all(|c| parse_datetime(c).is_some()) => Forced::Date,
false => Forced::Categorical,
}
}
/// `build_date` emits five parts, or seven once any cell carries a time.
fn date_parts(present: &[&str]) -> usize {
match present
.iter()
.any(|c| parse_datetime(c).is_some_and(|d| d.has_time))
{
true => 7,
false => 5,
}
}
/// 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, category_columns: usize) -> usize {
@@ -29,20 +54,21 @@ fn encoded_width(table: &FeatureTable, category_columns: usize) -> usize {
.copied()
.filter(|c| !gp_tree::data::is_missing(c))
.collect();
let has_missing = present.len() < cells.len();
// A document category is forced categorical below, so the preflight
// counts it by cardinality too. Where the two still differ: gp.md §14.
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.
false => present
// Every branch adds the `.missing` flag on the same terms, so it is
// added once here rather than in each arm.
let missing = usize::from(present.len() < cells.len());
// No wildcard: a new `Forced` variant must be counted here rather
// than silently falling into the categorical arm.
match inferred_kind(&present, col < category_columns) {
Forced::Numeric => 1 + missing,
Forced::Date => date_parts(&present) + missing,
// One column per distinct value, plus the one `build_categorical`
// reserves for `(missing)` — gp.md §14.
Forced::Categorical => (present
.iter()
.collect::<std::collections::HashSet<_>>()
.len()
+ missing)
.max(1),
}
})
@@ -239,8 +265,9 @@ pub fn fit_measure(
anyhow::ensure!(
width <= MAX_FEATURES,
"fitting {target:?} would encode {width} features from {} records, which is too wide \
to search on the UI thread. A category with a distinct value per record becomes one \
column per record — mark such a category as a Label so it is not a feature",
to search on the UI thread. A category becomes one column per distinct item, whether \
its items read as numbers or not — mark such a category as a Label so it is not a \
feature",
table.rows.len(),
);
@@ -264,13 +291,13 @@ pub fn fit_measure(
cannot spell and a search cannot minimise. Correct or clear those cells first"
);
// The exact width, now that it is known. The preflight above is advisory, and
// this is the check that enforces the bound in both directions of a drift.
// The exact width, now that it is known. It bounds the error MESSAGE, not the
// cost: `preprocess` has already run — the preflight is the cost bound (§14).
anyhow::ensure!(
data.feature_names.len() <= MAX_FEATURES,
"fitting {target:?} encoded {} features, over the {MAX_FEATURES} this host will \
search. A category with many distinct values becomes one column per value — \
mark such a category as a Label so it is not a feature",
search. A category becomes one column per distinct item, whether its items read as \
numbers or not — mark such a category as a Label so it is not a feature",
data.feature_names.len(),
);
// Empty is a **panic**, not a poor fit: zero features makes `random_tree` draw
@@ -714,6 +741,184 @@ mod tests {
(model, view)
}
/// Bug improvise-x1ns (2nd): `preprocess` infers Date BEFORE Categorical and
/// `build_date` emits 5 or 7 parts. Evidence: `context/test-evidence/gp.md`.
#[test]
fn the_preflight_counts_the_parts_a_date_measure_expands_into() {
for (label, stamp, parts) in [
("a plain date", "2024-01-15", 5),
("a date with a time", "2024-01-15T09:30:00", 7),
] {
let (model, view) = a_date_valued_measure(stamp);
let table = feature_table(&model, &view, "Score").expect("the table builds");
let category_columns = model.regular_category_names().len();
// The fixture must really be the shape the bug needs: ONE distinct
// date, which the categorical arm would count as a single column.
let when: Vec<&str> = table
.rows
.iter()
.map(|r| r[category_columns].as_str())
.collect();
assert!(
when.iter().all(|c| *c == stamp),
"{label}: every When cell must be the same date: {when:?}"
);
assert!(
gp_tree::data::parse_datetime(stamp).is_some_and(|d| d.has_time == (parts == 7)),
"{label}: the fixture must really carry a time iff {parts} parts are expected"
);
let width = super::encoded_width(&table, category_columns);
let overrides =
super::as_categories(&table.header, category_columns).expect("the header is clean");
let data = preprocess(&table.header, &table.rows, &overrides);
assert_eq!(
data.feature_names
.iter()
.filter(|n| n.starts_with("When."))
.count(),
parts,
"{label}: the fixture must really expand into {parts} date parts — {:?}",
data.feature_names
);
assert!(
width >= data.feature_names.len(),
"{label}: the preflight under-reported: it promised {width} features \
and `preprocess` materialised {} — {:?}",
data.feature_names.len(),
data.feature_names,
);
}
}
/// One regular category and a `When` MEASURE whose every cell is the same
/// timestamp — the low-cardinality date column a distinct-count misses.
fn a_date_valued_measure(stamp: &str) -> (Model, View) {
let mut model = Model::new("sales");
model.add_category(REGION).expect("under the limit");
let measure = model.category_mut(MEASURE).expect("_Measure always exists");
for item in ["When", "Score"] {
measure.add_item(item);
}
for (i, region) in ["North", "South", "East"].iter().enumerate() {
model
.category_mut(REGION)
.expect("just added")
.add_item(*region);
model.set_cell(
CellKey::new(vec![
(REGION.to_string(), (*region).to_string()),
(MEASURE.to_string(), "When".to_string()),
]),
CellValue::Text(stamp.to_string()),
);
model.set_cell(
CellKey::new(vec![
(REGION.to_string(), (*region).to_string()),
(MEASURE.to_string(), "Score".to_string()),
]),
CellValue::Number(i as f64),
);
}
let mut view = View::new("Default");
for cat in model.category_names() {
view.on_category_added(cat);
}
view.set_axis(REGION, Axis::Row);
view.set_axis(MEASURE, Axis::Page);
(model, view)
}
/// Bug improvise-x1ns: the preflight counted a forced categorical by its
/// PRESENT values only. Evidence: `context/test-evidence/gp.md`.
#[test]
fn the_preflight_counts_the_missing_category_a_forced_column_materialises() {
let (model, view) = a_numeric_category_with_a_missing_coordinate();
let table = feature_table(&model, &view, "Score").expect("the table builds");
let category_columns = model.regular_category_names().len();
// The fixture must really carry the shape the bug needs: a forced
// category whose cells are numeric, one of them absent.
let year: Vec<&str> = table.rows.iter().map(|r| r[0].as_str()).collect();
assert_eq!(
year.iter().filter(|c| gp_tree::data::is_missing(c)).count(),
1,
"exactly one record must lack the Year coordinate: {year:?}"
);
assert!(
year.iter()
.filter(|c| !gp_tree::data::is_missing(c))
.all(|c| c.parse::<f64>().is_ok()),
"the present Year cells must all be numeric: {year:?}"
);
let width = super::encoded_width(&table, category_columns);
let overrides =
super::as_categories(&table.header, category_columns).expect("the header is clean");
let data = preprocess(&table.header, &table.rows, &overrides);
// `build_categorical` maps the absent cell to the reserved `(missing)`
// category, so it is one more one-hot column than the present values.
assert!(
data.feature_names
.iter()
.any(|n| n == &format!("Year={}", gp_tree::data::MISSING_CAT)),
"the forced column must really emit a (missing) one-hot: {:?}",
data.feature_names
);
assert!(
width >= data.feature_names.len(),
"the preflight under-reported: it promised {width} features and \
`preprocess` materialised {} — {:?}",
data.feature_names.len(),
data.feature_names,
);
}
/// A numeric `Year` category with one record that has no `Year` coordinate, so
/// forcing sends a numeric column down the categorical branch with a gap in it.
fn a_numeric_category_with_a_missing_coordinate() -> (Model, View) {
let mut model = Model::new("sales");
model.add_category("Year").expect("under the limit");
let measure = model.category_mut(MEASURE).expect("_Measure always exists");
for item in ["Cost", "Score"] {
measure.add_item(item);
}
for i in 0..4u32 {
let year = format!("{}", 2020 + i);
model
.category_mut("Year")
.expect("just added")
.add_item(year.clone());
for (m, v) in [("Cost", f64::from(i) * 10.0), ("Score", f64::from(i))] {
model.set_cell(
CellKey::new(vec![
("Year".to_string(), year.clone()),
(MEASURE.to_string(), m.to_string()),
]),
CellValue::Number(v),
);
}
}
// The record with no `Year` coordinate at all — an empty cell, which
// `is_missing` reads as absent rather than as a value.
for (m, v) in [("Cost", 99.0), ("Score", 9.0)] {
model.set_cell(
CellKey::new(vec![(MEASURE.to_string(), m.to_string())]),
CellValue::Number(v),
);
}
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)
}
/// The review finding on improvise-zv8t: forcing is by NAME and the preflight
/// counts by INDEX. Evidence: `context/test-evidence/gp.md`.
#[test]
+64
View File
@@ -812,6 +812,70 @@ mod tests {
}
}
/// improvise-e8no declines ordinal encoding on semantics, NOT on
/// spellability — a first draft said otherwise. `context/code/gp.md`.
#[test]
fn an_ordinal_split_on_a_category_is_spellable_as_an_indicator_sum() {
// `is_expr` is a `primary`, so it is an `Expr` yielding 1/0, and
// `cmp_op`'s operands are full `expr`s.
for text in [
"Score = (Year IS |2020|) + (Year IS |2021|)",
"Score = IF(2020*(Year IS |2020|) + 2021*(Year IS |2021|) < 2020.5, 1, 2)",
] {
assert!(
parse_formula(text, MEASURE).is_ok(),
"the grammar the decline reasons about must really accept {text:?}"
);
}
// And the reason a numeric item name must be pipe-quoted: a bare
// number is not a `filter_value`.
assert!(
parse_formula("Score = (Year IS 2020)", MEASURE).is_err(),
"a bare number as a filter_value must stay a parse error"
);
}
/// improvise-e8no's decline rests on this: the pre-zv8t encoding of a
/// numeric category yields fits no document can store. Evidence: `gp.md`.
#[test]
fn a_split_on_a_numeric_category_read_as_a_number_cannot_be_stored() {
use gp_tree::data::{Forced, Overrides, preprocess};
// The pre-improvise-zv8t reading: a category of numbers inferred as a
// numeric column. Asked of `preprocess`, never a fabricated spelling.
let header = vec!["Year".to_string(), TARGET.to_string()];
let rows: Vec<Vec<String>> = [("2020", "1"), ("2021", "2"), ("2022", "3")]
.iter()
.map(|(y, t)| vec![(*y).to_string(), (*t).to_string()])
.collect();
let overrides = Overrides {
force: [("Year".to_string(), Forced::Numeric)]
.into_iter()
.collect(),
..Overrides::default()
};
let data = preprocess(&header, &rows, &overrides);
let features = crate::Features::of(&data, &["Year".to_string()]);
assert_eq!(
data.feature_names,
vec!["Year".to_string()],
"the fixture must really encode Year as ONE numeric column"
);
assert_eq!(
features.0[0].spelling,
crate::Spelling::Artefact(Artefact::NumericCategory),
"a numeric column over a category column is unspellable"
);
let tree = split(0, 2020.5, leaf(1.0), leaf(2.0));
let err = fitted_formula(&tree, &features, TARGET, MEASURE, &document())
.expect_err("a split on a numeric category is not storable")
.to_string();
assert!(err.contains("Year"), "{err}");
assert!(err.contains("cannot spell"), "{err}");
}
/// The property the *lying* test could not state: if `fitted_formula` says
/// `Ok`, no cell of the target is a `ref:` error. See the evidence file.
#[test]
@@ -139,11 +139,9 @@ mod tests {
assert_eq!(r.len(), 2, "comma split a field: {r:?}");
}
assert_eq!(&records[1][0], "Smith, Jr.");
assert!(
records[1][1].starts_with("1,200"),
"expected comma-grouped value, got: {:?}",
&records[1][1]
);
// Exact, not `starts_with`: the fixture asks for `.2f`, and a prefix
// check cannot tell two decimals from none (improvise-711n).
assert_eq!(&records[1][1], "1,200.00");
}
/// Bug improvise-iot6: `render_csv` built its layout with `GridLayout::new`,