D8 · The Protein Ladder

Capstone dossier · what the protein choice does to the price of a dish: the receipts of the protein program

Author

Dei M. Elurbe

Published

August 7, 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

In 12 of the 13 dish families where a kitchen offers both a plant and an animal version, the animal side costs more, and the typical plant dish undercuts the typical animal dish in every family but flatbread. Where one named dish comes with a protein choice, the menu runs a ladder: crossing from the plant side to the animal side costs about +2, each rung above adds another +1 to 2, and prawns take the top. And where the dish is a fixed format (a burger, a wrap, a pita), the ladder goes silent: across 140 plant burgers at 100 venues, the typical gap to the same kitchen’s meat burger is 0.00. It was never about the protein; it is about the dish.

The program and its provenance

This page is the public methods layer for one program of measurements about protein and price, the pillar behind the stream posts that cite it. Everything here comes from one corpus, Snapshot · June 2026: menus of ~900 priced Amsterdam venues, organised by the dish coordinate system of dossier D1 and the going-rate atlas of D3. The working frame for this program (called M below) is the set of main-course items in the 13 dish families where at least one kitchen prices both a plant and an animal version in confident atlas cells: 15,150 item rows.

Three units recur below, and they are not interchangeable. A family is one of the 13 atlas dish families (pizza, curry, sandwich, flatbread, and so on): the unit of the venue-paired gap and of the typicals law. A dish is a named dish inside a family (a biryani, a wrap): the unit of the same-dish ladder. A format dish is a dish whose menu price attaches to its form rather than its protein (a burger, a wrap, a pita). The levels can disagree, and that disagreement is a finding, not a contradiction: the sandwich family clears every bar at +0.55 while the burgers inside it price plant at 0.00. When a claim below says family, it never means a dish, and the reverse.

The program ran under a two-layer discipline. The measurement layer wrote every construction down before running it, in dated journal entries; the copy layer then ruled, number by number, what may print where. Every number below carries one of three status labels, and the label is part of the result:

  • Pre-registered: the construction, token lists, and wording bars were frozen in writing before the scan ran.
  • Seen-data: an exploratory scan that predates any freeze; its numbers are honest descriptions, not confirmatory tests.
  • Post-hoc: the analysis grew out of a check after results were already on the table.

Two independent findings in this program refuted their own registered predictions, and one of its most quotable numbers is post-hoc and printed here only with that origin attached. Both facts are kept visible on purpose: they are what the discipline looks like when it works.

Throughout: a median is the middle value; a 95% CI is a 95% confidence interval (here from a bootstrap over venues, a resampling procedure); a Wilson lower bound is the conservative low end of a 95% confidence interval for a share (roughly: shrink the observed share toward one half by adding about two successes and two failures, then take the low end; it stays honest at the small counts where the naive plus-minus-two-standard-errors interval does not). Elsewhere in this project the atlas’s price ranges are Bayesian, partially pooled, and quoted at 90%; this program’s claims are counts and paired medians, so it uses the plainest defensible instruments instead, a venue bootstrap and exact share bounds, both frozen in the protocol at the conventional 95% before anything was measured. Prices are in euros, as sold, per plate. No venue is named.

The venue-paired family gap

The obvious approach lies. Comparing the average plant dish against the average animal dish across the whole city confounds the protein with the venue: plant-heavy menus cluster in cheaper venue types, so a citywide gap partly measures where plant dishes are sold, not what the swap costs. The decisive measurement is venue-paired: within each kitchen that prices both sides of one family, take the kitchen’s animal-side median minus its plant-side median (call it d), then take the median of d across kitchens, with a venue-bootstrap CI.

Show the code
t1 <- rd(file.path(S1, "t1_venue_confound.csv")) %>%
  filter(construction == "venue-paired") %>%
  mutate(family = fam_short(family),
         holds = ci_excludes_0,
         label = sprintf("%s  (%d kitchens)", family, n_venues))

ggplot(t1, aes(x = median_d, y = reorder(label, median_d))) +
  geom_vline(xintercept = 0, linetype = "dashed", colour = "grey50") +
  geom_linerange(aes(xmin = ci_lo, xmax = ci_hi), linewidth = .6, colour = TERRA) +
  geom_point(aes(shape = holds), size = 2.6, colour = TERRA, fill = "white") +
  scale_shape_manual(values = c(`TRUE` = 16, `FALSE` = 21), guide = "none") +
  annotate("text", x = 1.45, y = 1, label = "does not clear the noise bar",
           hjust = 0, size = 3.1, colour = "grey30") +
  labs(x = "venue-paired gap d (euros, animal side minus plant side), 95% CI", y = NULL) +
  theme_minimal()
Dot and interval chart of 13 dish families. Plate shows the largest gap at 4 euros, most families sit between 1 and 3, sandwich near 0.55, and flatbread, drawn as an open dot, has an interval touching zero.
Figure 1: The venue-paired gap per family: median animal-minus-plant difference within the same kitchen, 95% bootstrap CI. Flatbread (open dot) is the one family whose interval touches zero: it does not clear the noise bar.

12 of the 13 families hold (d > 0 with the CI excluding 0); flatbread is the exception at +0.50 [0.00, 1.22]. The count is unchanged when pairing is tightened to venue x concept (same kitchen, same cuisine concept), unchanged when sides-included items are dropped, and unchanged when the marinara and every meat-analogue-named plant item are excluded. The full table, with both constructions and the caveat each family carries:

Show the code
t1full <- rd(file.path(S1, "t1_venue_confound.csv")) %>%
  mutate(family = fam_short(family))
notes <- c(sandwich = "analogue undercount; never a range endpoint",
           plate = "portion-uncontrolled; see the plate trio below",
           flatbread = "does not clear the noise bar",
           pizza = "vegan cell chain-influenced (see below)")
t1w <- t1full %>%
  filter(construction == "venue-paired") %>%
  transmute(family, kitchens = n_venues,
            `median d` = sprintf("%+.2f", median_d),
            `95% CI` = sprintf("[%.2f, %.2f]", ci_lo, ci_hi)) %>%
  left_join(t1full %>% filter(construction != "venue-paired") %>%
              transmute(family, `concept-matched d` = sprintf("%+.2f", median_d),
                        `concept CI` = sprintf("[%.2f, %.2f]", ci_lo, ci_hi)),
            by = "family") %>%
  mutate(caveat = ifelse(family %in% names(notes), notes[family], "")) %>%
  arrange(desc(as.numeric(`median d`)))
kable(t1w, align = "lrrrrrl")
Table 1
family kitchens median d 95% CI concept-matched d concept CI caveat
plate 248 +4.00 [3.25, 4.25] +4.00 [3.25, 4.25] portion-uncontrolled; see the plate trio below
curry 50 +2.76 [2.05, 3.77] +2.63 [2.02, 3.77]
noodle 47 +2.00 [1.00, 2.50] +1.50 [1.00, 2.12]
rice_dish 61 +2.00 [2.00, 2.98] +2.00 [1.75, 2.55]
bowl 82 +1.75 [1.28, 2.00] +1.75 [1.50, 2.00]
bread 27 +1.50 [0.75, 2.38] +1.75 [0.75, 2.38]
pasta 101 +1.50 [0.62, 2.00] +1.45 [1.00, 2.00]
brunch 63 +1.30 [1.00, 2.00] +1.14 [0.88, 1.78]
salad 131 +1.25 [1.00, 1.62] +1.20 [1.00, 1.50]
pizza 113 +1.00 [1.00, 1.50] +1.50 [1.00, 1.50] vegan cell chain-influenced (see below)
other 44 +0.97 [0.25, 1.00] +0.97 [0.45, 1.00]
sandwich 313 +0.55 [0.50, 1.00] +0.55 [0.50, 1.00] analogue undercount; never a range endpoint
flatbread 35 +0.50 [0.00, 1.22] +0.25 [0.00, 1.25] does not clear the noise bar

This table is the only surface where the family gaps appear as a range, and it appears with its caveats attached per family; quoting the endpoints without them misleads (the copy rule this program calls SL-22). The three caveats in full:

  • Plate (+4.00) travels as a trio. The plate family is portion-uncontrolled (menus almost never state weight, see the portion note below), its animal side lists served sides more often than its plant side (+14 percentage points, the largest such gap), and the label audit found its plant side carries the program’s one contamination pocket: 4 animal items (fish-sauce dishes) in its 200 audited plant-side samples, an error that sits on the plant side. So the printed form is +4.00 primary, +3.50 on the no-sides subset (175 kitchens), portion uncontrolled, stated together, always.
  • Sandwich (+0.55) is never a named range endpoint. The blind audit’s rubric tagged 25.7% of audited vegan-sandwich items as meat analogues (a “burger” or “kapsalon” with a plant qualifier), far more than the program’s frozen token list catches, so the plant side of sandwich is partly the analogue shelf. Its small gap is real but its composition is the least like-for-like of the 13.
  • Flatbread (+0.50 [0.00, 1.22]) does not clear the noise bar, and the typicals law below shows why: it is the one family where a third of kitchens price the format, not the protein.

The rungs inside the gap. The atlas prices four protein rungs per family (vegan, vegetarian, fish, meat). For the two headline families, every rung pair was measured venue-paired; the same table shows which orders clear the reference bar (CI excluding 0 and a median of at least one price tick, 0.50):

Show the code
t2 <- rd(file.path(S1, "t2_rung_pairs.csv")) %>%
  mutate(family = fam_short(family)) %>%
  transmute(family, pair = paste(rung_lo, "to", rung_hi), kitchens = n_venues,
            `median d` = sprintf("%+.2f", median_d),
            `95% CI` = sprintf("[%.2f, %.2f]", ci_lo, ci_hi),
            `clears the bar` = ifelse(ci_excludes_0 & ge_tick, "yes", "no"))
kable(t2, align = "llrrrl")
Table 2
family pair kitchens median d 95% CI clears the bar
pizza vegan to vegetarian 62 +4.00 [2.22, 5.50] yes
curry vegan to vegetarian 27 +0.50 [0.00, 1.00] no
pizza vegan to fish 60 +5.22 [5.00, 6.00] yes
curry vegan to fish 26 +4.34 [3.51, 5.00] yes
pizza vegan to meat 62 +5.75 [3.50, 7.00] yes
curry vegan to meat 31 +3.50 [2.78, 4.45] yes
pizza vegetarian to fish 100 +0.50 [0.25, 1.00] yes
curry vegetarian to fish 32 +3.64 [2.50, 4.00] yes
pizza vegetarian to meat 112 +1.00 [0.50, 1.28] yes
curry vegetarian to meat 42 +2.10 [1.50, 2.88] yes
pizza fish to meat 99 +0.75 [0.00, 1.00] no
curry fish to meat 43 -1.00 [-1.15, -0.50] yes

Two pairs do not clear it: the pizza fish-meat order and the curry vegan-vegetarian order. One order claim earns a neutral sentence: the fish-meat order clears the noise bar only in curry (fish above meat there). No mechanism is attached to it here.

The canonical rung typicals next to their venue-first counterparts (each venue counted once before the cell statistic, the chain-robustness recomputation), for the headline rungs:

Show the code
vf <- rd(file.path(S1, "t4_venue_first.csv")) %>%
  filter(family %in% c("main · baked · pizza", "main · stew · curry")) %>%
  mutate(family = fam_short(family),
         rung = factor(protein_class, levels = c("vegan","vegetarian","fish","meat"))) %>%
  arrange(family, rung) %>%
  transmute(family, rung, `cell typical` = sprintf("%.2f", typical_eur),
            `venue-first` = sprintf("%.2f", venue_first),
            delta = sprintf("%+.2f", delta_vf_vs_typical))
kable(vf, align = "llrrr")
Table 3
family rung cell typical venue-first delta
curry vegan 17.50 17.25 -0.25
curry vegetarian 18.00 18.00 +0.00
curry fish 21.80 21.00 -0.80
curry meat 20.80 20.00 -0.80
pizza vegan 13.50 13.50 +0.00
pizza vegetarian 16.00 16.50 +0.50
pizza fish 16.60 17.12 +0.52
pizza meat 17.50 17.75 +0.25

Across all 49 rung cells of the 13 families, 20 move by more than one tick under venue-first counting; the full 49-row table is in the release CSV. The one flagged cell is the vegan pizza rung: its largest brand holds 18.2% of the cell’s items (the program’s chain-influence bar is 15%), and the reassurance is in the same file: the cell’s venue-first typical moves 0.00 from the canonical one.

The vegan pizza rung under the microscope. The rung’s cheap look has a composition, not a discount. The marinara, a pizza that carries no cheese by construction, accounts for 57 of the 99 dedup rows of the cell (58%; dedup = chain repeats collapsed to one row). Split on a frozen token list, the cell decomposes into:

Show the code
t2bc <- rd(file.path(S1, "t2bc_branch_table.csv")) %>%
  transmute(branch = c("no cheese by construction", "vegan cheese named",
                       "negation context (\"zonder kaas\" and kin)"),
            items = n, kitchens = venues, `median (euros)` = sprintf("%.2f", median_eur))
kable(t2bc, align = "lrrr")
Table 4
branch items kitchens median (euros)
no cheese by construction 68 50 13.50
vegan cheese named 14 11 17.23
negation context (“zonder kaas” and kin) 17 12 9.90

The residual of the cell (neither marinara nor negation-context) is almost entirely named vegan versions of standard pizzas, and its median sits at the dairy vegetarian typical: the cell’s low typical is a blend of cheeseless and cheesed pizzas, not the price of any real pizza type. The branch labels are string-read and stay string-ambiguous at the margin: 8 of the 14 vegan-cheese items read as ordinary dairy-cheese pizzas from the item text alone, which is why no vegan-cheese price claim prints anywhere in post copy, in either direction. And the sensitivity of the one loud pizza pair to this composition is its own table: the vegan-to-meat venue-paired jump collapses when the marinara is excluded, which is exactly why that +5.75 never prints alone:

Show the code
sl04 <- rd(file.path(S2, "sl04_pizza_vm_exclusions.csv")) %>%
  transmute(run, kitchens = n_venues, `median d` = sprintf("%+.2f", median_d),
            `95% CI` = sprintf("[%.2f, %.2f]", ci_lo, ci_hi))
kable(sl04, align = "lrrr")
Table 5
run kitchens median d 95% CI
base (reproduce +5.75) 62 +5.75 [3.50, 7.00]
(a) marinara excluded from vegan side 30 +2.00 [1.00, 2.28]
(b) negation-context 17 excluded 53 +4.00 [3.50, 6.25]
(c) both excluded 29 +2.25 [1.00, 2.28]

The fish twist. In 8 of the 11 families with a fish rung, fish sits above meat venue-paired. The exceptions are pizza (meat above fish), sandwich and salad (parity), and the twist has a name: preserved fish. Almost 9 in 10 fish pizzas are tuna or anchovy (87% primary practice list; 89% under the wider sensitivity list), about 3 in 4 fish salads are tuna or smoked fish (76% / 81%). Split the fish rung into preserved and fresh legs and the order rights itself: fresh-fish pizzas top meat pizzas by +1.00 [0.15, 2.47] in the 20 kitchens carrying both. The boundary rows: sandwich (64% preserved, parity in both split legs), brunch (70% preserved, fresh leg +1.00). This is a co-occurrence fact about what the fish rung is in those families, not a mechanism claim.

Show the code
fm <- rd(file.path(S1, "t2_fish_meat_all_families.csv")) %>%
  mutate(family = fam_short(family)) %>%
  transmute(family, kitchens = n_venues,
            `fish minus meat` = sprintf("%+.2f", d_fish_minus_meat),
            `95% CI` = sprintf("[%.2f, %.2f]", -ci_hi, -ci_lo))
shares <- rd(file.path(S2, "a5_preserved_shares.csv")) %>%
  transmute(family, `fish-cell items` = n_fish,
            `preserved share` = sprintf("%.2f", share_primary),
            `sensitivity` = sprintf("%.2f", share_sensitivity))
legs <- rd(file.path(S2, "a5_paired_legs.csv")) %>%
  transmute(family, leg = fish_leg, kitchens = n_venues,
            `median d vs meat` = sprintf("%+.2f", median_d),
            `95% CI` = sprintf("[%.2f, %.2f]", ci_lo, ci_hi))
kable(fm, align = "lrrr", caption = "Fish vs meat, venue-paired, the 11 measurable families (d = fish minus meat).")
kable(shares, align = "lrrr", caption = "Preserved-fish share of the fish cell (primary practice list; wider list as sensitivity).")
kable(legs, align = "llrrr", caption = "The two fish legs, venue-paired against meat.")
Table 6: The two fish legs, venue-paired against meat.
Fish vs meat, venue-paired, the 11 measurable families (d = fish minus meat).
family kitchens fish minus meat 95% CI
pizza 99 -0.75 [-1.00, -0.00]
bowl 49 +0.78 [0.25, 1.23]
brunch 22 +1.00 [1.00, 2.00]
noodle 30 +2.62 [1.42, 3.38]
other 20 +0.50 [-0.00, 1.24]
pasta 72 +1.50 [1.00, 2.00]
plate 298 +1.00 [0.50, 1.25]
rice_dish 59 +2.00 [1.50, 2.50]
salad 75 -0.00 [-1.00, 0.50]
sandwich 221 -0.00 [-0.25, -0.00]
curry 43 +1.00 [0.50, 1.15]
Preserved-fish share of the fish cell (primary practice list; wider list as sensitivity).
family fish-cell items preserved share sensitivity
pizza 200 0.87 0.89
salad 162 0.76 0.81
brunch 37 0.70 0.84
sandwich 454 0.64 0.85
other 91 0.19 0.29
bowl 141 0.18 0.33
pasta 188 0.17 0.31
plate 966 0.08 0.20
rice_dish 102 0.01 0.04
curry 181 0.00 0.01
noodle 71 0.00 0.04
The two fish legs, venue-paired against meat.
family leg kitchens median d vs meat 95% CI
pizza preserved 95 -0.75 [-1.00, -0.50]
pizza fresh 20 +1.00 [0.15, 2.47]
sandwich preserved 174 +0.00 [-0.50, 0.00]
sandwich fresh 93 +0.50 [0.00, 1.00]
salad preserved 56 -0.06 [-1.22, 0.00]
salad fresh 26 +0.25 [0.00, 1.00]
plate preserved 51 +0.00 [-2.00, 1.25]
plate fresh 283 +1.00 [0.25, 1.38]
pasta preserved 22 +0.00 [-1.00, 0.75]
pasta fresh 71 +1.80 [1.25, 2.70]
brunch preserved 14 +1.00 [1.00, 1.50]
brunch fresh 8 +2.00 [1.00, 2.38]

Cheapest-and-priciest, two constructions. Ordering claims (“the cheapest X on the card is a plant one”) source to two tables. The merged-plant construction asks: in kitchens carrying both sides, is the strictly cheapest item of the family a plant one and the strictly priciest an animal one (tie-inclusive and strict variants); the licensing column is part of the table. The four-rung construction asks the same of venues carrying every rung. In curry, 44 of the 50 kitchens with both sides pass strictly; in pizza, 87 of 113 pass tie-inclusive.

Show the code
a6 <- rd(file.path(S2, "a6_merged_f1b.csv")) %>%
  transmute(family, kitchens = universe,
            `tie-inclusive` = sprintf("%d (%.2f)", k_tie, share_tie),
            strict = sprintf("%d (%.2f)", k_strict, share_strict),
            `strict 95% CI` = sprintf("[%.2f, %.2f]", strict_lo, strict_hi),
            `superlative licensed` = ifelse(superlative_licensed, "yes", "no"))
kable(a6, align = "lrrrrl", caption = "The merged-plant ordering table (A6): floor strictly plant AND ceiling strictly animal.")
r6 <- rd(file.path(S2, "sl11_f1b_wilson.csv")) %>%
  mutate(family = fam_short(family)) %>%
  transmute(family, `full-ladder venues` = full_ladder_venues,
            pass = sprintf("%d (%.2f)", k_pass, share_pass),
            strict = sprintf("%d (%.2f)", k_strict, share_pass_strict),
            `strict 95% CI` = sprintf("[%.2f, %.2f]", strict_lo, strict_hi))
kable(r6, align = "lrrrr", caption = "The four-rung ordering table (R6): venues carrying every confident rung of the family.")
Table 7: The four-rung ordering table (R6): venues carrying every confident rung of the family.
The merged-plant ordering table (A6): floor strictly plant AND ceiling strictly animal.
family kitchens tie-inclusive strict strict 95% CI superlative licensed
curry 50 47 (0.94) 44 (0.88) [0.76, 0.94] yes
rice_dish 61 56 (0.92) 45 (0.74) [0.62, 0.83] yes
brunch 63 54 (0.86) 46 (0.73) [0.61, 0.82] yes
bread 27 24 (0.89) 19 (0.70) [0.52, 0.84] yes
plate 248 196 (0.79) 172 (0.69) [0.63, 0.75] yes
bowl 82 69 (0.84) 52 (0.63) [0.53, 0.73] yes
noodle 47 41 (0.87) 29 (0.62) [0.47, 0.74] no
salad 131 110 (0.84) 78 (0.60) [0.51, 0.68] yes
pasta 101 72 (0.71) 53 (0.52) [0.43, 0.62] no
other 44 40 (0.91) 23 (0.52) [0.38, 0.66] no
pizza 113 87 (0.77) 52 (0.46) [0.37, 0.55] no
flatbread 35 28 (0.80) 13 (0.37) [0.23, 0.54] no
sandwich 313 200 (0.64) 99 (0.32) [0.27, 0.37] no
The four-rung ordering table (R6): venues carrying every confident rung of the family.
family full-ladder venues pass strict strict 95% CI
pizza 60 57 (0.95) 46 (0.77) [0.65, 0.86]
pasta 37 32 (0.86) 25 (0.68) [0.51, 0.80]
plate 47 41 (0.87) 33 (0.70) [0.56, 0.81]
sandwich 108 50 (0.46) 38 (0.35) [0.27, 0.45]
curry 21 20 (0.95) 13 (0.62) [0.41, 0.79]

The portion limitation, stated once for everything above. Menus almost never state weight: 269 of the 15,150 M rows (1.8%) carry a gram token. Every number in this program is a price per plate as sold, not per gram. Pizza is the portion-standardised family; plate is the portion-uncontrolled one.

One number, three true constructions. The same dish name yields different true numbers under different constructions (raw item median, dedup cell-restricted median, cell typical), and a published trail (the Misset Horeca fact-check) supplies an external anchor. The program’s rule is one construction per public surface; this table is where the constructions sit side by side:

Show the code
tc <- rd(file.path(S1, "tconsistency_table.csv")) %>%
  transmute(dish, cell = fam_short(cell), `cell typical` = cell_typical,
            `item median (dedup, in cell)` = med_name, n = n_name,
            `Misset published` = ifelse(is.na(misset_published), "", format(misset_published)))
kable(tc, align = "llrrrr")
Table 8
dish cell cell typical item median (dedup, in cell) n Misset published
marinara main · vegan · baked · pizza 13.5 11.00 57 13.50
margherita main · vegetarian · baked · pizza 16 13.50 158 13.95
quattro formaggi main · vegetarian · baked · pizza 16 17.95 64
diavola main · meat · baked · pizza 17.5 17.50 35 17.50
tonno main · fish · baked · pizza 16.6 16.00 74 16.60
napoletana main · fish · baked · pizza 16.6 16.50 32
chana masala main · vegan · stew · curry 17.5 17.50 25 17.50
paneer (veg+dairy_only) main · vegetarian · stew · curry + main · dairy_only · stew · curry 18 / NA 19.95 74
butter chicken main · meat · stew · curry 20.8 22.95 32
fish curry (cell) main · fish · stew · curry 21.8 21.95 181 21.80
carbonara main · meat · none · pasta 18.5 18.50 52 18.50

The typicals law

The family gap above compares kitchen medians. Addendum 7 froze the sharpest form of that question before running it: in what share of kitchens does the plant-side median strictly undercut the animal-side median, per family, with wording tiers set in advance (“in most kitchens” needs a Wilson lower bound of at least 0.50; “nearly always” at least 0.75)? Status: pre-registered. The universe is the merged frame minus children’s items and sweet mains (the catch list was human-reviewed before use).

Show the code
a7 <- rd(file.path(S2, "a7_median_comparison.csv")) %>%
  transmute(family, kitchens = universe,
            `plant median under animal` = sprintf("%d (%.2f)", k_pass, share_pass),
            `Wilson lower` = sprintf("%.3f", pass_lo),
            `exact ties` = k_tie,
            tier = case_when(licensed_nearly_always ~ "nearly always",
                             licensed_most ~ "in most kitchens",
                             TRUE ~ "not licensed"))
kable(a7, align = "lrrrrl")
Table 9
family kitchens plant median under animal Wilson lower exact ties tier
curry 50 48 (0.96) 0.865 1 nearly always
rice_dish 61 55 (0.90) 0.802 3 nearly always
plate 246 219 (0.89) 0.845 10 nearly always
bread 27 24 (0.89) 0.719 3 in most kitchens
noodle 47 41 (0.87) 0.748 3 in most kitchens
bowl 82 69 (0.84) 0.747 2 in most kitchens
brunch 59 49 (0.83) 0.715 5 in most kitchens
pizza 113 92 (0.81) 0.733 13 in most kitchens
other 37 27 (0.73) 0.570 7 in most kitchens
pasta 99 72 (0.73) 0.632 12 in most kitchens
salad 131 89 (0.68) 0.595 29 in most kitchens
sandwich 312 197 (0.63) 0.577 39 in most kitchens
flatbread 35 21 (0.60) 0.436 11 not licensed

Three families clear “nearly always” (curry 48 of 50, Wilson lower 0.865, with rice dishes and plate); every other family clears “in most kitchens” (pizza 92 of 113); the single exception is flatbread (0.600, Wilson lower 0.436), where 11 of the 35 kitchens price the two sides identically to the cent.

Extremes and typicals are different laws. Sandwich is the proof: it fails the floor law (only 0.351 of sandwich kitchens have a strictly plant floor, children’s items excluded; cheap animal broodjes own sandwich floors) yet passes the median law (0.631). The typical plant broodje undercuts the typical animal one even though the cheapest thing on the card is a kroket. This is why the program’s floor-derived superlatives were refused across the board at adjudication (floor extremes carry catalogued artifacts: promo-price capture, chain multiplication, name homonyms, children’s items) and the protein story is told at the typicals.

The same-dish ladder

The family comparisons above still mix dishes (the plant side of curry is not the meat side minus meat). The cleanest construction holds the dish itself constant: one named dish, offered at one venue in two or more protein variants, priced per variant. The menu writes the ladder out itself: “rendang: veg 21 / beef 22”.

The curry scan came first. Status: seen-data, an exploratory scan that predates any freeze, printed here with that label and replicated by the pre-registered extension below. In 146 venue x dish sets at 38 venues (saag, korma, madras, vindaloo leading): the plant-to-animal boundary is the big step (veg to chicken +2.62, paneer to chicken +2.22, veg to lamb +4.25), the rungs above it cost about a euro each (chicken to lamb +1.00, positive in 0.91 of sets), fish sits at the lamb rung (lamb to fish 0.00, a third exact ties), and prawn clears everything (fish to prawn +1.00, positive in 0.97 of sets; chicken to prawn +3.00). Within a set the full ladder is weakly monotone (each step up never cheaper) in 0.842 of sets, strictly in 0.664.

The corpus-wide extension was pre-registered (dish tokens, protein tokens, ladder order and wording bars frozen in a dated journal entry before the run; burgers excluded by design as different named dishes, not one dish with a protein choice). It found 90 new sets and replicated the grammar:

Show the code
pool <- rd(file.path(S2, "tierx_pooled_legs.csv")) %>%
  transmute(leg, sets = n_sets, venues = n_venues,
            `median step` = sprintf("%+.2f", median_delta),
            `share positive` = sprintf("%.3f", share_positive),
            `Wilson lower` = sprintf("%.3f", wilson_lo_pos))
kable(pool, align = "lrrrrr", caption = "Pooled legs of the extension ladder (pre-registered aggregates). The plant-internal leg is 3 sets: too thin to read.")
dish <- rd(file.path(S2, "tierx_boundary_by_dish.csv")) %>%
  filter(n_sets >= 5) %>%
  transmute(dish, sets = n_sets, venues = n_venues,
            `median boundary step` = sprintf("%+.2f", median_delta),
            `share positive` = sprintf("%.2f", share_positive))
kable(dish, align = "lrrrr", caption = "The plant-to-animal boundary step per dish (5 or more sets; full list in the release CSV).")
Table 10: The plant-to-animal boundary step per dish (5 or more sets; full list in the release CSV).
Pooled legs of the extension ladder (pre-registered aggregates). The plant-internal leg is 3 sets: too thin to read.
leg sets venues median step share positive Wilson lower
animal-internal 162 59 +1.50 0.827 0.762
boundary 139 51 +2.00 0.842 0.772
plant-internal 3 2 +3.00 1.000 0.439
The plant-to-animal boundary step per dish (5 or more sets; full list in the release CSV).
dish sets venues median boundary step share positive
biryani 56 19 +3.00 0.98
nasi 30 11 +2.00 0.77
bami 11 2 +2.75 0.82
wrap 9 5 +0.00 0.33
bibimbap 8 4 +0.95 1.00
pad thai 7 2 +4.00 1.00
burrito 5 2 +2.20 0.80
Show the code
dishp <- rd(file.path(S2, "tierx_boundary_by_dish.csv")) %>%
  filter(n_sets >= 5) %>%
  mutate(fmt = dish == "wrap",
         label = sprintf("%s (%d sets)", dish, n_sets))
ggplot(dishp, aes(x = median_delta, y = reorder(label, median_delta))) +
  geom_vline(xintercept = 0, linetype = "dashed", colour = "grey50") +
  geom_point(aes(shape = fmt), size = 2.8, colour = TERRA, fill = "white") +
  scale_shape_manual(values = c(`FALSE` = 16, `TRUE` = 21), guide = "none") +
  annotate("text", x = 0.28, y = 1, label = "prices the format",
           hjust = 0, size = 3.1, colour = "grey30") +
  labs(x = "median boundary step (euros, first animal rung minus plant version)", y = NULL) +
  theme_minimal()
Dot chart of median plant-to-animal boundary steps for eight dishes. Pad thai and biryani sit at 3 to 4 euros, nasi and burrito near 2, bibimbap and omelet under 1, and wrap, drawn as an open dot, sits at zero.
Figure 2: The boundary step per dish, extension scan. Ladder dishes charge for the crossing; the wrap (open dot) prices the format instead.

The pooled boundary step is +2.00 (139 pairs at 51 venues, 0.842 positive, pair-level Wilson lower 0.772). Pairs cluster within venues, so the licensing unit collapses to one median boundary step per venue first (a post-hoc robustness step, labeled as such in the script): 41 of 51 venues price the crossing upward (0.804, Wilson lower 0.675), which clears “in most kitchens” and does not clear “nearly always”. The licensed printed forms, used by the stream posts: “8 of 10 kitchens”, boundary “+2”, rung “+1 to 2” (animal-internal median +1.50, 162 pairs), prawn “+4” (veg to prawn +4.00, 24 sets, all positive).

The ladder’s discipline replicates: weakly monotone in 0.856 of extension sets (strict 0.622) against the curry scan’s 0.842 / 0.664. The prawn crown generalizes (chicken to prawn +2.12, 0.94 positive). Two textures for the record: chicken and pork share a rung (0.88 of their pairs are exact ties), and duck is the odd expensive bird (chicken to duck +5.50, 11 sets).

The format pocket and the burger parity

The ladder has a silent register. Where the menu prices the format, the protein choice costs nothing. Units matter here: flatbread below is a family (one of the 13), the wrap and the burger are dishes, and the two levels are telling one story: the flatbread family is the only family that fails the noise bar in large part because a third of its kitchens are format-pricing shops, while the wrap and the burger show the same silence at dish level inside families that clear their bars comfortably.

  • Wraps: the boundary step is 0.00 (9 sets, only 0.33 positive): the one dish token in the extension whose plant and animal versions price at parity.
  • Flatbread: 11 of the 35 flatbread kitchens tie their plant and animal medians exactly (the texture is pita, lahmacun and flammkuchen shops). Status: this count was registered as descriptive color only; it prints here under a recorded ruling because it names the mechanism behind the flatbread exception running through every table above: where the format is the product, the ladder goes silent.
  • The burger parity. Across 140 plant burgers at 100 venues, the median gap to the same kitchen’s meat-burger median is 0.00 (0.54 at or above). Branded imitation patties (the Beyond class): 11 pairs, median 0.00, 9 of 11 at or above the meat burger.

The burger numbers are post-hoc, and here is the origin, disclosed as part of the result. The registered gradient scan (next section) first produced a naive readout that branded imitation burgers price +4.88 above the chicken comparator. An artifact check caught the comparator mismatch: the venue-family chicken median mixes broodjes into the baseline, so the readout compared burgers against sandwiches. The like-for-like repair (branded burger vs the same venue’s meat burger) gave 0.00, and widening it to all plant burgers gave the 140-pair result above. That widening happened after the data had been seen; no freeze covers it. It prints here under an explicit, recorded ruling with this origin stated, and its construction is one script re-run away from checkable (R/74_burger_parity.R). The plant discount does not exist in burgers; the same parity shape appears in the vegan-cheese pizza branch above.

The synthesis the posts compress: two pricing grammars. Choose-your-protein dishes run a ladder (plant base, about +2 at the crossing, +1 to 2 per rung, prawn on top). Fixed-format dishes (burger, wrap, pita, the vegan-cheese pizza) charge for the format, plant at parity. An earlier apparent contradiction dissolves under this reading: falafel looks price-neutral because it lives in wraps; paneer looks upcharged because it lives in curry. The dish decides.

The named-protein gradient

Does naming the plant protein move the price? I set the taxonomy from hospitality experience, with three predictions registered before the run (status: pre-registered, freeze granted directional wording; the numeric table below prints under a recorded ruling). Categories, read from the item name only: no named protein / processed plant (tofu, falafel, tempeh) / dairy-based (paneer, halloumi) / imitation (a meat word or brand token on a plant-labelled dish). Comparator: the venue’s chicken median in the same family.

Show the code
npg <- rd(file.path(S2, "npg_gradient_venue.csv")) %>%
  transmute(category = sub("^\\d ", "", category), venues = n_venues,
            `median delta vs chicken` = sprintf("%+.2f", median_delta),
            `share at or above chicken` = sprintf("%.2f", share_at_or_above))
kable(npg, align = "lrrr", caption = "Venue-collapsed gradient (the robustness unit): median delta vs the venue's chicken median in the same family.")
Table 11: Venue-collapsed gradient (the robustness unit): median delta vs the venue’s chicken median in the same family.
Venue-collapsed gradient (the robustness unit): median delta vs the venue’s chicken median in the same family.
category venues median delta vs chicken share at or above chicken
no named protein 316 -1.01 0.27
processed plant 61 -1.00 0.34
dairy 33 -1.00 0.36
imitation 25 -0.50 0.44

The registered ordering (none < processed < dairy <= imitation) is refuted. Naming the protein does nothing: no-name, processed and dairy all sit about a euro under the chicken version (-1.01 / -1.00 / -1.00 across 316, 61 and 33 venues), and only imitation narrows (-0.50, 25 venues). The three registered predictions, scored:

  • P1 (tofu below chicken by a small step): half-confirmed. Tofu does sit below the chicken version, but by the same margin as the generic-veg gap, not a smaller one; the registered “swap premium” does not exist.
  • P2 (halloumi at chicken parity in bread formats): refuted and inverted at tiny n. Halloumi ran below chicken in the bread formats and above it elsewhere, on single-digit venue counts in both legs; logged as a curiosity, no claim.
  • P3 (imitation at or above the meat version): confirmed in like-for-like form. Against the correct comparator (the same venue’s meat burger, previous section), branded imitation sits at parity, 9 of 11 pairs at or above.

The gradient is this page’s second refutation kept on purpose: the plant side of the menu is priced by the dish it sits in, not by what the protein is called.

The stress-test battery

The threat The test The verdict
Venue confound (plant dishes sold in cheaper venues) every headline construction is venue-paired or within-venue the confound is removed by design; citywide raw gaps are never quoted
Concept mix inside a family venue x concept re-pairing of all 13 families 12-of-13 unchanged; no family’s count collapses
Wrong labels (a “chicken” curry filed plant) blind label audit, 6 coders + direction-blind adjudication, extended to n = min(100, census) per cell with venue-holistic re-adjudication all 20 sampled cells clear the frozen 10% error bar; worst judged value 7.0%; 14 directional errors in 1,994 audited rows
Chains multiply one menu brand scan of every headline cell + venue-first recomputation of all 49 rung cells one cell flagged (vegan pizza, largest brand 18.2%); its venue-first typical moves 0.00; 20 of 49 cells move more than a tick and the venue-first column ships with the canonical one
Sides ride the animal price sides-rate gap per family + no-sides re-run no family crosses the 15pp recompute bar; the 12-of-13 count is unchanged on the no-sides subset; plate’s no-sides value prints in its trio
Portions differ by side gram-token census menus state weight in 1.8% of rows; stated as a limitation on every surface, plate marked portion-uncontrolled
Multiplicity (13 families, some pass by luck) permutation null over side-label swaps within venue 12 observed; the null’s maximum over 1,000 permutations is 1 (zero in 994)
Fragile aggregation marinara + analogue exclusion, no-sides, concept matching the holding count is 12 in every run
Seen-data results dressed as confirmatory two-layer discipline: dated pre-registrations, status labels, per-number print rulings the labels are printed next to the numbers above; two registered predictions are reported refuted; the burger parity prints only with its post-hoc origin disclosed

What this is, and what it isn’t

  • A snapshot of menu prices, not costs or margins. Nothing here says what the plant or animal version costs the kitchen; the ladder is a description of menu arithmetic.
  • Venue-paired, so “in most kitchens” means kitchens that offer both sides. Kitchens that price only one side of a family are outside every paired number.
  • Co-occurrence, not mechanism. The preserved-fish twist, the format pocket and the reference-price readings are stated as patterns; the design does not identify why any kitchen prices as it does.
  • Portions are unobserved except for the 1.8% of rows that state grams; price per plate as sold is the unit everywhere.
  • The atlas frame, not the whole city. Families are confident atlas cells inside the ~900-venue snapshot; no venue is named, and per-venue values are never derivable from the tables here.

Lineage

  • Protocol + report: docs/stream_sposts_verification_protocol.md (the frozen test battery), docs/stream_sposts_verification_report.md (Stage 1 measurements; Stage 2 adjudication, ratified 2026-08-07; the licensing rulings this page prints under).
  • Code: R/53-R/57 (Stage-1 battery), R/58-R/60 (label-audit extension), R/61-R/63 (vegan-pizza decomposition, preserved fish, merged ordering), R/64-R/66 (floor forensics + the typicals law), R/68 (curry ladder), R/69 (ladder extension), R/71 (named-protein gradient), R/74 (burger parity), guard _number_guard_d8.R (every printed number asserts against the output files at render).
  • Data: output/sposts_stage1/ and output/sposts_stage2/ tables (osm_id-free summary tables in the public release), atop data/processed/dish_atlas*.csv.
  • Journal trail: 2026-07-30_001 (Stage 1), 2026-07-30_002/_003 + 2026-07-31_001 (Stage 2), 2026-07-31_002 (typicals law + floor forensics), 2026-08-06_001 (extension pre-registrations + scans), 2026-08-07_001 (ratification), 2026-08-07_003 (this page).
  • Sources: restaurant websites (menus), OpenStreetMap (venue universe). All open data. Snapshot · June 2026.