The duplicate problem
A model is an inconsistent speller. Over a month of plates, Pippa might write "Gambas al ajillo", "gambas al ajillo!" and "Gambas-al-Ajillo" for the same dish. If every spelling made a new class, one dish's plates would scatter across several rows. The badge would not be the problem, since one plate cooked at home is enough for a class to read as cooked. Where the plates land would be. Each row's page would list only its share of the plates, and a sibling Pippa proposed earlier could stay "Pippa's inference" after Dad had eaten the dish, because his plate went to a twin row. The tree would lie by fragmentation.
Sizzle has two defenses, and they run in order.
First: hand the model the ids
Every reading's input includes Dad's tree as data, built by store.catalog(): each principle's id, name, summary and status, and each dish or variant's id, kind, name, gloss, cuisine and status. The rubric tells Pippa to put a matching id in ref or dish_ref and keep the existing name.
The engine does not take that id on faith. _resolve_principle accepts a ref only if it names an existing class whose kind is principle. _resolve_dish accepts a dish_ref only for a dish or a variant. An id Pippa misremembers, or a dish id put where a principle id belongs, is ignored, and matching falls back to names.
Second: fold the name
When there is no usable id, the engine compares names through norm, a six-line function in backend/store.py. Its steps, in order:
- NFKD splits each accented letter into a base letter plus a combining mark.
- Every combining mark is dropped, which removes the accents.
- NFKC recomposes what is left and maps compatibility forms, such as full-width letters, to their plain equivalents.
casefold()lowercases more aggressively thanlower(), so "Straße" and "strasse" agree.- Every run of characters that are not letters or digits, underscores included, becomes one space, and the ends are trimmed.
I ran it on origin/main against a handful of names. What folds together:
| Name as written | Matching key |
|---|---|
Gambas-al-Ajillo | gambas al ajillo |
gambas_al_ajillo | gambas al ajillo |
Crème brûlée | creme brulee |
paella (valenciana) | paella valenciana |
KIMCHI (full-width) | kimchi |
김치-찌개 | 김치 찌개 |
And what stays apart. 김치찌개 and 김치 찌개 do not match, because norm turns any separator into a single space but never deletes one. Spaghetti aglio e olio and Aglio e olio differ too, because there is no fuzzy or partial matching at all. A name made only of punctuation folds to an empty key, and find_class returns nothing for an empty key rather than matching every unnamed thing.
Hangul passes through intact. NFKD does split a syllable into its jamo, but jamo are not combining marks, so nothing is dropped and NFKC puts the syllable back together.
Two names, two chances
A dish has two names, and the lookup uses both. find_dish tries the reading's original name first and its Korean name second. For each candidate, find_class compares the key against both the name and the gloss column of every dish and variant. So a reading that writes the Korean name where the original belongs still finds the class whose gloss it matches. Variants are included, which means a plate of Dad's own version links to the variant itself when the reading names it the way he did. Principles are matched by name only, through find_principle.
The engine test for this lesson is short. Two plates of the same dish, both read, and the dish's page lists exactly those two plates on one class.
Three keys, three jobs
There are three name keys in Sizzle, and they do not agree on everything:
normin the engine, for matching the tree. It folds accents and punctuation._keyinsideintegrate, for matching a sibling's principle name to a principle named earlier in the same reading. It folds only spacing and case.nameKeyon the web andTreeIndex.keyon the phone, for turning names inside a reading into links to class pages. They apply NFKC, lowercase and collapse spaces, and they keep accents.
That leaves one seam you can see. If the tree holds "Crème brûlée" and a reading writes "creme brulee", the engine links the plate to the right class, and the web plate's "In the tree" list shows it. The dish heading inside the reading stays plain text unless its Korean name happens to match. The record is right. Only a convenience link is missing.