fix: persist a category's kind, so a label no longer unloads a document

improvise-dy3j (P1). CategoryKind::Label had no spelling in `.improv` at
all: format_md wrote every category as a bare `## Category: Name` and
build_category read every section back through add_category. The kind was
dropped on the way out AND on the way in, so a round-trip equality test
could not see it.

A label is outside MAX_CATEGORIES = 12 by design and the import wizard
scores it as zero, so twelve categories plus a label is legally
constructible. On load every label came back Regular, the thirteenth was
refused, and parse_md failed: any such document was permanently
unloadable. Below the limit the loss was quieter — a label silently
became a pivot category.

Grammar first. The ticket proposed `## Category: Note [label]`, and
improvise-pczv forbids it: a section header takes its name as the WHOLE
line, so the suffix is indistinguishable from a category named
`Note [label]`. The kind is therefore an entry inside the section,
`kind: label`, spelled with a real token rather than a lookahead — the
grammar-walking generator skips predicates. Absence means `regular`, so
every existing file parses unchanged and a pivot category's section is
byte-identical to before.

Tests assert the parsed Category::kind, never the emitted line. Both
rival faults were injected against the fixed tree and both fail two
tests: a writer that omits the line, and a reader that discards the kind
it parsed (whose file text is correct, so any text-comparing test passes
it). Reasoning in context/code/formula-and-io.md; evidence in
context/test-evidence/formula-and-io.md.

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-22 00:57:43 -07:00
parent d46ec59984
commit d9f732c529
6 changed files with 235 additions and 4 deletions
+53
View File
@@ -154,6 +154,59 @@ non-atomic rules produce the whitespace-separated tokens pest's implicit
with three or more alternatives (nested `Choice`s) pick deeper branches less
often — good enough for random generation, not a uniform distribution.
### The category kind is a line, not a header suffix (improvise-dy3j)
`CategoryKind::Label` had no spelling in the format at all. `format_md` wrote
every category as a bare `## Category: Name` and `build_category` read every
section back through `add_category`, so the kind was dropped on the way out
**and** on the way in. A round-trip equality test cannot see a loss both sides
share, which is why the existing suite was silent — the same shape as the
`Formula::raw` traps above, and the reason the new tests assert the *parsed*
`Category::kind` rather than the emitted text.
The consequence was a lost document, not a lost flag. `Label` sits outside
`MAX_CATEGORIES = 12` on purpose, and the import wizard's own precondition
scores a label as zero, so it will happily build twelve categories plus a
label. On load every label came back `Regular`, the thirteenth was refused, and
`parse_md` failed: **any document where regular + label exceeded twelve was
permanently unloadable.** Below the limit the failure was quieter — a label
silently became a pivot category, entering `regular_category_names()` pickers
and gaining axis auto-assignment.
**The ticket proposed `## Category: Note [label]`, and the grammar forbids
it.** A section header takes its name as the **whole line**, totally
(improvise-pczv) — `header_name` is `pipe_quoted ~ &NEWLINE | line_name` — so a
suffix is not a suffix, it is part of a category literally named `Note
[label]`. The two readings are indistinguishable, and adopting the suffix would
have made a legal name unspellable while quietly re-opening the ambiguity
`header_name` exists to close.
So the kind is an **entry inside the section**, beside the item lines:
```
## Category: Note
kind: label
- see the record view
```
Three properties earned it. It uses a **real token** (`"kind: "`), not a
lookahead, because the grammar-walking generator reads the grammar through
`pest_meta` and *skips predicates* — a rule guarded only by a lookahead is a
rule the generator can violate, which is why `WHERE` carries `sp+`. It cannot
collide with `item_list`, `grouped_item` or `group_hierarchy`, each of which
needs a `- ` or `> ` lead. And **absence is the compatible reading**: `kind_kw`
spells only `label`, the writer emits the line only for a label, so every file
written before this commit parses unchanged and every pivot category's section
is byte-identical to what it was. That is also why `parse_kind_kw` is an
exhaustive match returning `Result` rather than a `bool` read off the rule's
mere presence — a second keyword must fail loudly at the walker, not be read as
`label` because it arrived on a `kind_line`.
One case the format already allowed and this had to answer: two
`## Category:` sections naming one category are one category (improvise-349m),
merged in pass 1. Absence is not a claim, so the merge keeps a stated kind
rather than letting a later kindless section erase it.
### The tree walkers are infallible by construction
Every `build_*` in `parser.rs` operates on a `Pair` pest has already