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()
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):
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:
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:
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.
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()
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)
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.