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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user