Capstone dossier · how 33,000 menu lines got addresses
Author
Dei Martinez Elurbe
Published
July 2, 2026
Dossier, not the essay. Backstage layer for technical readers; the capstone essay is the general-audience artifact. Corpus: Snapshot · June 2026, ~900 priced venues, roughly 4 in 10 of Amsterdam’s ~2,028 restaurants.
The verdict
Every dish in the corpus gets an address: role · protein · method · form, four axes that say what a dish intrinsically is. That coordinate system is the master instrument of this project. 200 cells cover 96% of 32,651 clean dishes, and every downstream number stands on it: the going-rate atlas prices cells, the variance decomposition compares within cells, the tourist tax controls with the concept families built alongside it.
The design lesson worth carrying: names lie, coordinates don’t. The word “carpaccio” names at least five different products in this city, at five different going rates. A €15 raw-beef starter, a €13 lunch sandwich, a €17.50 main-course salad, a €16 fish starter. Any analysis that string-matches dish names inherits that ambiguity silently. The coordinate system is what makes “the price of a carpaccio” a well-posed question: you price the cell, and “carpaccio” is just the label the data hangs on one coordinate.
Figure 1: All 267 dishes whose name contains “carpaccio”, grouped by the cell they actually live in (cells with at least 10 such dishes). One word, five products, five going rates. The exemplar of a cell is a label for a coordinate, not a search term.
Why the obvious approach lies
The obvious way to classify dishes is keywords: pick marker words, match them, count. It fails in both directions, and both failures were measured before the system was built.
Hand-picked markers get refuted by data. We would have bet on “burrata” as a marker of a recognizable venue type. The corpus says no: burrata appears at 20% of all venues, at garnish-level prominence (1.8 items per venue when present). It is a stopword, as common as “cheese”. So is “carpaccio” itself (18% of venues), and so is bare “steak”. The screening metric that caught this (document frequency × prominence-when-present) earned its keep on the first run, against our own intuitions.
Real concepts scatter across words. The opposite failure: a genuine concept fragments into synonyms and transliterations, so no single token clears any support floor. The kebab concept lives in {kebab, köfte, shish, gyros, shoarma, döner, adana, tavuk}; Mexican in {taco, burrito, quesadilla, enchilada}; the noodle-soup anchor “ramen” coexists with udon, soba, and pho. Token counting misses all of them; only deliberate enumeration of families with synonym sets catches them.
The system was therefore built from two instruments, both locked before any clustering result could bias them.
Instrument 1: the FORM × METHOD axes
Every dish first gets a form (the vessel or shape: plate, sandwich, pizza, sushi, curry, bowl, flatbread, … 17 levels) and a method (grilled, fried, baked, stew, raw, none). These came out of a pre-registered ablation with a committed pass bar. The form cleanup won its test. The method hypothesis failed its own bar (adding a method block to the clustering distance cost 0.07 silhouette and lost on interpretability), so method was demoted to a descriptive column and never entered a distance again. Losing a hypothesis to a pre-committed test is the system working; method still earns its keep here, as a cell axis, where raw-versus-fried is a real price distinction (a raw fish starter and a fried fish starter are different products).
Instrument 2: the 24 concept families
A concept family is a canonical concept plus its synonym set, measured at family level: document frequency (a generic ceiling excludes stopwords, a support floor excludes can’t-anchor families), prominence-when-present (anchor versus garnish), distinctiveness, and mutedness. Every keep/discard verdict also had to pass a recognition gate: would an Amsterdam restaurateur recognise this as a thing? The verdicts live in a ledger, each with its data and its reasoning. Three examples that show the method rather than the list:
burger kept, salad discarded, on the same test. Both are common words; the question was whether either anchors a specialist venue type. Burger has dominated specialists (one venue’s menu is 57% burger family); salad has none (its maximum share venue, 29%, is a Vietnamese restaurant). Same probe, opposite verdicts.
fish restaurant discarded, honestly. Deluxe seafood venues exist (everyone can name one), but their defining dishes are token-unreachable: oysters listed by varietal brand names, fish priced by weight. Rather than ship a family that can’t see its own members, the family was discarded and the blind spot documented as a coverage gap. The tight raw_bar family (oyster, ceviche, coquilles) was kept instead.
hot pot dropped by its own verification step, after an initial add: the candidate venues turned out invisible for the same by-parts-pricing reason. Adding a family is cheap; keeping a family that can’t detect its members is a silent error.
The vocabulary is locked but extensible: a later audit added korean and argentinian (families 23 and 24), re-stratifying 9 venues and leaving every downstream result unchanged.
The cell: what is an axis, and what is a layer
The cell key is role · protein · method · form and deliberately nothing else. The question that settled it: is cuisine an axis or a layer? Tested, not debated. Adding cuisine as a fifth key axis gained only 2 qualifying cells while costing about 10 percentage points of coverage, because form already encodes the cuisine-distinctive shapes (pizza, curry, sushi, noodle, dumpling, flatbread). So cuisine enters as a layered effect (a premium you can estimate, with uncertainty) and as a view filter, never as part of a dish’s address. The same logic keeps area and venue type out of the key: bolting context into the address explodes 200 cells into thousands of sparse ones and destroys the ability to decompose prices into “what the dish is” versus “where it is sold” (dossier D4 depends on exactly that separation).
One axis earned an exception. Desserts drop the protein axis (their cells are sweet · method · form): protein is not a meaningful distinction for sweets, and the audit found the protein filter had been silently excluding 985 desserts (34% of all sweets, skewing pricier, biasing every sweet cell low). The fix recovered them. That catch is documented in the data-layer audit record, and it is why the sweet cells look three-axis in the tables.
Show the code
grid <- dishes |>count(dish_role, form)ggplot(grid, aes(dish_role, reorder(form, n, sum), fill = n)) +geom_tile(colour ="white") +geom_text(aes(label = n), size =2.6) +scale_fill_gradient(low ="#f7ede8", high ="#b5623f", trans ="log10",guide ="none") +labs(x ="role", y ="form") +theme_minimal()
Figure 2: Two of the four axes: dish counts over role × form (log-scaled fill). The full coordinate space also splits by protein and method; this slice shows how the mass distributes over the most visible axes.
The last mile: price hygiene
An address is useless if the number attached to it is wrong, so the coordinate system ships with a frozen hygiene layer. The one non-obvious design choice: the guard against per-piece contamination is mechanism-matched, not magnitude-matched. Sushi at “€2” is not a cheap dish, it is a per-piece price; but a blanket “drop cheap mains” rule would have killed ~288 real café sandwich mains (a €3.90 broodje jonge kaas is a real main course). So a dish is dropped as a per-piece artifact only when it is cheap and carries per-piece evidence: a per-piece-prone form (sushi, dumpling, sharing), a raw-bar oyster context, an explicit count token (“8 stuks”, “x6”), or a venue known to price per piece. Proof it works: the main · fish · raw · sushi cell’s median moved from €4.40 (contaminated) to €16.50 (real). A €4 floor catches residual implausible mains, with sandwiches and bread exempt for the broodje reason above.
The stress-test battery
The threat
The test
The verdict
Hand-picked markers are wrong
document-frequency × prominence screen
burrata (20% of venues, garnish prominence), carpaccio, bare “steak” all rejected as stopwords before use
A common word ≠ a venue concept
specialist-tail probe (does any venue specialise in it?)
burger kept (57% specialist exists), salad discarded (none; max 29% is a Vietnamese place)
A family that can’t see its members
verification against known venues
fish-restaurant discarded (oysters by varietal, fish by weight: token-unreachable), hot pot dropped; both logged as coverage gaps, not silent misses
Cuisine deserves to be an axis
add it as a 5th key axis, measure
+2 qualifying cells, −10pp coverage: rejected, cuisine is a layer
mechanism-matched guard, before/after on the worst cells
main · fish · raw · sushi median €4.40 → €16.50; ~288 real cheap broodjes spared
What this is, and what it isn’t
Centers of gravity, not hard borders. Concept boundaries in a real city are fuzzy; venues pad menus with safe crowd-pleasers. The system characterises by distinctiveness and never claims anthropological validity. It is our own empirical taxonomy, and it is owned as such.
Concept detection fires on 27% of dishes, by design. The 73% “none” is not a failure; it is the generic-eclectic substrate of the city (the carbonara and burrata everyone serves). The cell scheme is what turned that from a problem into the comparable baseline: generic dishes are exactly the ones that price cleanly across venues.
Known recall limits, documented. Rare-cuisine tokens run thin (korean, thai, ethiopian are under-counted by Latin-token detection); non-Latin-script menus are invisible (a real Chinese menu in Chinese characters parses to nothing); by-weight seafood stays dark. About 7% of dishes sit outside priced cells, a granular tail rather than a hidden category.
The hygiene layer is frozen but not exhaustive. It catches the mechanisms we found; the audit reproduced it end to end, and residual oddities (a real broodje carpaccio as the modal name of a sandwich cell) are exemplar-labelling quirks, not price errors.
Lineage
Method + verdicts:docs/m2_gestalt.md (the four diagnoses + architecture), docs/m2_gestalt_ledger.md (every keep/discard with data; VOCAB-LOCK section), docs/m1_5_method_axis.md (FORM × METHOD ablation).
Data:data/processed/dish_atlas_dishes.csv (32,651 clean dishes with cells), dish_atlas.csv (200 priced cells), dish_atlas_excluded.csv (the four quarantined package coordinates, with reasons).