The Advanced Model page ends with a tornado chart: one bar per input, showing how much the mean cost differs between draws where that input is in its top 10% and draws where it is in its bottom 10%. It is easy to read and, as a first pass, useful. It is also easy to over-read. Several of the inputs move together by construction, the 10% cut is arbitrary, the mean is pulled around by the right tail, and the question the chart answers (“what do costs look like in the worlds where this input is high?”) is not the question people usually have in mind (“what happens to costs if this input changes?”). Neither is the question that matters for research prioritization (“would learning this change what we would do?”).
This page works through those problems with live numbers and then runs the alternatives on the same scenario. Everything below is computed from the same 30,000-draw simulation the main page uses, except the Shapley table, which is precomputed offline for the default scenario, and the intervention and structure checks (Sections 3.4 and 4.1), which re-run the engine with one setting changed. The methods and text were drafted with AI assistance and have not been independently reviewed; the code is in sensitivity-methods.mjs with tests, and we welcome corrections via Hypothes.is annotations.
Code
costModel =import(newURL("./cost-model.mjs",window.location.href).href)sm =import(newURL("./sensitivity-methods.mjs",window.location.href).href)urlState =window.__CM_URL_STATE__|| {}isCustom =Object.keys(urlState).length>0scenarioParams = sm.advancedUrlStateToParams(urlState, costModel.maturityForYear)results = costModel.simulate(30000,42, scenarioParams)uc = results.unit_cost// Probability thresholds use the hybrid product on the dry basis (wet cost on the wet basis).tc = results.threshold_costdryBasis = results.basis.cost_basis==="dry"costUnit = dryBasis ?"per kg dry cell mass":"per kg wet biomass"thresholdUnit = dryBasis ?"hybrid product":"wet"logUc = uc.map(Math.log)inputs = sm.displayedInputs(results, scenarioParams)quantile = costModel.quantilemean = costModel.meanshort = ({density:"Cell density",media_turnover:"Media-use multiplier",media_cost_L:"Media $/L",price_recf:"GF price",g_recf:"GF quantity",maturity:"Maturity",wacc:"WACC",asset_life:"Asset life",plant_kta:"Plant capacity",cycle_days:"Production time",uptime:"Utilization",L_per_kg:"Fresh media L/kg",media_override:"Media override",gf_override:"GF override"})label = i => short[i.key] || i.nameusd = v => (v <0?"−":"") +"$"+Math.abs(v).toFixed(v >=100|| v <=-100?0:1) +"/kg"pct = v => (100* v).toFixed(0) +"%"
Code
{// Keep a shared scenario in the address bar after load, as the Advanced page does.if (isCustom) setTimeout(() => {try { history.replaceState(null,"", location.pathname+"?"+newURLSearchParams(urlState).toString() + location.hash); } catch (e) {} },300);const cap = v => (100* tc.filter(c => c < v).length/ tc.length).toFixed(0);returnhtml`<div style="border:1px solid #bcc8cc; border-left:4px solid #1a5276; padding:0.6rem 0.9rem; margin:0.5rem 0 1rem; font-size:0.95em;"> <strong>Scenario analyzed:</strong> ${isCustom ?"custom settings carried over from the Advanced page link":"the Advanced page defaults (maturity 0.5 mapped to 2036, seed 42)"}. Unit cost ${costUnit}: p10 / p50 / p90 = ${usd(quantile(uc,0.1))} / ${usd(quantile(uc,0.5))} / ${usd(quantile(uc,0.9))}; mean ${usd(mean(uc))}; standard deviation ${usd(Math.sqrt(sm.variance(uc)))}; P(cost < $25/kg ${thresholdUnit}) = ${cap(25)}%.${dryBasis ?html`<br><em>Costs are per kg dry cell mass (since October 26, 2026). Thresholds such as $10 or $25/kg apply to a kg of hybrid product: ${(results.basis.cell_inclusion_rate*100).toFixed(0)}% cultivated cells at ${(results.basis.target_solids_fraction*100).toFixed(1)}% solids plus plant-based ingredients at $${results.basis.filler_cost}/kg (since October 29, 2026).</em>`:""}${inputs.length} inputs are active in the chart. To analyze a different scenario, set it up on the <a href="index.html">Advanced page</a> and use the “Analyze this scenario” link under its tornado chart.${results.warnings.length?html`<div style="margin-top:0.4rem;"><strong>Scenario notes:</strong> ${results.warnings.join(" ")}</div>`:""} </div>`;}
1. What a bar on the tornado chart is
For each input \(x\), the chart sorts the 30,000 draws by \(x\), takes the top 10% and the bottom 10% (3,000 draws each), and reports the difference in mean unit cost between the two groups:
That is all. There is no re-simulation, no holding anything else fixed, and no model of how \(x\) enters the cost equation. It is a summary of the joint distribution the simulation happened to produce.
Code
{const rows = inputs.map(i => ({i,d: sm.tailContrastDetail(i.data, uc)})).sort((a, b) =>Math.abs(b.d.contrast) -Math.abs(a.d.contrast));const top = rows[0];const x = top.i.data;returnhtml`<p><em>Worked example for the largest bar in this scenario.</em> For <strong>${top.i.name}</strong>, the 10% of draws with the highest values (above ${quantile(x,0.9).toPrecision(3)}) have a mean cost of <strong>${usd(top.d.hiStat)}</strong>; the 10% with the lowest values (below ${quantile(x,0.1).toPrecision(3)}) have a mean cost of <strong>${usd(top.d.loStat)}</strong>. The bar is the difference, ${usd(top.d.contrast)}, with a Monte Carlo standard error of about ${usd(top.d.se)}. The smallest bars in the chart are ${rows.slice(-3).map(r =>`${label(r.i)} (${usd(r.d.contrast)} ± ${usd(r.d.se)})`).join(", ")}: at this sample size they are not distinguishable from zero.</p>`;}
2. Why the bars are hard to interpret
2.1 The inputs move together by construction
The chart lists inputs side by side as if each were an independent dial. In the engine they are not:
Coupling in the engine
What it does to the bars
Process mode → density and media-use multiplier. Each draw first picks fed-batch, perfusion or continuous, then draws density and the multiplier from that mode’s range (fed-batch 1–6 g dry/L with multiplier 1–2; perfusion 6–30 g dry/L, 1–5; continuous 10–40 g dry/L, 0.5–3; i.e. 5–30, 30–150 and 50–200 g wet/L at 20% solids).
The top density decile is almost entirely continuous and perfusion draws; the bottom decile is fed-batch. The density bar is partly a process-mode bar, and it is entangled with the multiplier bar.
Maturity → adoption, financing and equipment. One latent maturity draw shifts the hydrolysate and cheap-GF adoption probabilities, the supplemental-protein regime, WACC and the custom-reactor share.
The maturity bar bundles all of those channels. It overlaps with the Media $/L, GF price, GF quantity and WACC bars, which already carry the regime effects it induces.
Regime switches inside the mixtures. “Media $/L” mixes a hydrolysate regime and a pharma-grade regime; “GF price” and “GF quantity” mix a cheap and an expensive regime, and both switch together.
The GF price and GF quantity bars share the same coin flip, so they overlap heavily. Each mixture bar is a regime effect plus a within-regime effect.
Deterministic identities. Fresh media L/kg = 1000 / density × multiplier; GF cost = quantity × price.
Removed from the chart for this reason, but the parents still co-determine cost in a multiplicative way.
The consequence is that a bar answers “what do costs look like in the worlds where this input is high?” and those worlds differ from the low-input worlds in other respects too. Bars cannot be added, and no bar isolates “the effect of” its input. The rank correlations among the displayed inputs, computed from the current draws, show the couplings directly:
Code
{const r = inputs.map(i => sm.ranks(i.data));const cells = [];for (let a =0; a < inputs.length; a++) for (let b =0; b < inputs.length; b++) cells.push({a:label(inputs[a]),b:label(inputs[b]),rho: a === b ?1: sm.pearson(r[a], r[b])});const names = inputs.map(label);return Plot.plot({width:Math.min(760,80+62* names.length),height:80+34* names.length,marginLeft:130,marginBottom:110,x: {domain: names,label:null,tickRotate:-40},y: {domain: names,label:null},color: {scheme:"RdBu",domain: [-1,1],reverse:true,label:"Spearman ρ",legend:true},marks: [ Plot.cell(cells, {x:"a",y:"b",fill:"rho",inset:0.5}), Plot.text(cells, {x:"a",y:"b",text: d => d.a=== d.b?"": d.rho.toFixed(2),fill: d =>Math.abs(d.rho) >0.5?"white":"#222",fontSize:10}) ] });}
Spearman rank correlations between the displayed inputs across the 30,000 draws. Red: move together; blue: move in opposite directions. Correlations near zero mean the engine samples the pair independently in this scenario; they say nothing about whether the pair is really independent.
2.2 Some uncertainties are probably correlated in ways the model does not encode
The couplings above are the ones the engine builds in. There are others it leaves out. Media cost per litre and achievable cell density are plausibly linked (denser cultures need richer, more expensive media, at least in fed-batch systems), but the engine samples them independently within a process mode, so the joint distribution the chart summarizes puts weight on combinations an expert might rule out — very cheap media with very high density, for example. The chart cannot detect this; it only summarizes the draws it is given. The beliefs form asks for media cost per kilogram of biomass, rather than $/L and density separately, partly so that respondents can integrate this interaction themselves. The Limits page discusses it further.
2.3 “Top 10% versus bottom 10%” and “mean cost” are choices
Nothing in the method fixes the 10% cut or the mean. Comparing the top and bottom 5% gives bigger bars (more extreme worlds); comparing the top and bottom halves gives smaller ones. The mean cost is dominated by the right tail (in the default scenario the mean is about $570 against a median of $289 per kg dry cell mass, engine 2026-11-09.1), so a bar mostly reports what happens in the expensive tail. If the question is whether cultivated chicken can get under $25/kg, the relevant statistic is a threshold probability, and that ranks the inputs differently. Try it:
{const maxAbs =Math.max(...contrastRows.map(r => r.abs+2* r.se),1);const unit = statIsShare ?"pp":"$/kg";return Plot.plot({width:860,height:60+34* contrastRows.length,marginLeft:290,marginRight:110,x: {label: statIsShare ?"Difference in probability (percentage points), top tail minus bottom tail":"Dollar swing, top tail minus bottom tail ($/kg)",domain: [0, maxAbs *1.15],grid:true},y: {label:null,tickSize:0,domain: contrastRows.map(r => r.name)},style: {fontSize:"13px"},marks: [// For probabilities of clearing a threshold, a positive contrast is good news. Plot.barX(contrastRows, {y:"name",x:"abs",fill: d => (statIsShare ? d.value<0: d.value>=0) ?"#c0392b":"#27ae60",fillOpacity:0.8}), Plot.ruleY(contrastRows, {y:"name",x1: d =>Math.max(0, d.abs-2* d.se),x2: d => d.abs+2* d.se,stroke:"#222",strokeWidth:1.5}), Plot.text(contrastRows, {y:"name",x: d => d.abs+2* d.se+ maxAbs *0.015,text: d => (d.value>=0?"+":"−") + d.abs.toFixed(1) +" "+ unit,textAnchor:"start",fontSize:12}) ] });}
Bar colour gives the sign (red: high input goes with higher cost or a lower chance of clearing the threshold; green: the opposite). Black whiskers are ±2 Monte Carlo standard errors. For the threshold statistics, green means a high value of the input goes with a higher probability of cheap cultivated meat.
Code
{const tails = [0.05,0.10,0.25,0.50];const table = inputs.map(i => ({name: i.name,values: tails.map(t => sm.tailContrast(i.data, uc, {tailFrac: t}))}));const rankAt = k => [...table].sort((a, b) =>Math.abs(b.values[k]) -Math.abs(a.values[k])).map(r => r.name);const ranks = tails.map((_, k) =>rankAt(k));const rows = [...table].sort((a, b) =>Math.abs(b.values[1]) -Math.abs(a.values[1]));returnhtml`<details><summary style="cursor:pointer;">Rank of each input under each tail width (mean cost)</summary> <table style="font-size:0.9em;"><thead><tr><th>Input</th>${tails.map(t =>html`<th>${Math.round(t *100)}% tails: $/kg (rank)</th>`)}</tr></thead> <tbody>${rows.map(r =>html`<tr><td>${r.name}</td>${r.values.map((v, k) =>html`<td>${usd(v)} (${ranks[k].indexOf(r.name) +1})</td>`)}</tr>`)}</tbody></table> <p style="font-size:0.85em; color:#555;">The largest bars usually keep their order; the middle of the ranking does not. An input whose bar shrinks sharply as the tail widens matters mainly through its extreme values.</p></details>`;}
2.4 Small bars are noise
Each bar is a difference between two sample means of 3,000 draws with a standard deviation around $1,090 per kg dry cell mass, so its standard error is roughly $25–50/kg. In the default scenario (engine 2026-11-09.1) the utilization, WACC and asset-life bars (about $20–30/kg, about one standard error) are noise, and the plant-capacity bar (about $53/kg, two standard errors) is at best marginally distinguishable from zero; the chart on the main page nonetheless draws and labels them. The whiskers above show the same problem for other statistics.
2.5 Two different questions: learning an input versus setting it
The chart, and every statistic in this section, conditions on the input: it compares worlds in which the input happens to be high with worlds in which it happens to be low. Under the model’s dependence structure, that is the right way to answer “if we learned this input’s value, what would we expect cost to be?”, because learning that density is high also tells you the process is probably continuous, and the conditional statistic carries that along.
It is the wrong way to answer “if this input were changed while everything else stayed as it is, what would happen to cost?”, which is what an engineer improving a process or a funder backing a technology usually means. That needs an intervention: re-run the simulation with the input pinned. When inputs are independent, the two answers coincide; here they do not (Section 3.5 shows the gap for the inputs the engine lets us pin). Neither answer is more correct in general; they are answers to different questions, and the chart only gives the first.
2.6 None of this is research value
The September review makes the point directly: a large bar can have low information value if every plausible value of the input supports the same decision, and a small bar can matter if the decision is on a knife edge. Deciding what to study requires specifying a choice (fund a medium improvement, obtain a scale-up measurement, support deployment) and asking whether plausible evidence would change it. None of the statistics on this page do that. They describe the model’s uncertainty; they do not price it.
3. Alternatives, run on this scenario
3.1 Conditional-mean profiles: where in the range the cost moves
Instead of two tails, look at all ten deciles. For each input, the red line is the mean cost within each decile of that input, the dashed blue line is the median, and the band is the 10th–90th percentile of cost within the decile. Steep, straight profiles are what the tornado bar assumes; curved profiles show that the swing comes from one end of the range; flat profiles with wide bands are inputs the bar ranks by noise.
Log scale on cost. Deciles are equal-count bins of the input’s own distribution (decile 1 is the lowest 10%, decile 10 the highest), so the horizontal axis is not linear in the input’s units.
3.2 Expected uncertainty after learning one input
This is the closest thing to the question “how much would our uncertainty shrink if we pinned this input down?” Split the draws into 30 equal-count bins of the input, measure the spread of cost within each bin, and average. The share of variance removed, \(\eta^2 = 1 - \mathbb{E}[\operatorname{Var}(\text{cost} \mid x)]/\operatorname{Var}(\text{cost})\), is the first-order Sobol index when inputs are independent; under dependence it also counts the information the input carries about correlated inputs, so the shares can add up to more than 100%. The 80% interval width is the more robust companion, because the variance of a heavy-tailed cost is driven by a few extreme draws.
{const total = learningRows.reduce((s, r) => s + r.eta2,0);const width = v => targetKey ==="log"?"×"+Math.exp(v).toFixed(1) :usd(v);const sd = v => targetKey ==="log"? v.toFixed(2) :usd(v);const base = learningRows[0];returnhtml`<div style="display:flex; gap:24px; flex-wrap:wrap; align-items:flex-start;"> <div>${Plot.plot({width:460,height:50+30* learningRows.length,marginLeft:260,marginRight:60,x: {label:"Share of variance removed by learning this input (η²)",domain: [0,Math.max(0.05,...learningRows.map(r => r.eta2)) *1.2],tickFormat: v => (100* v).toFixed(0) +"%",grid:true},y: {label:null,tickSize:0,domain: learningRows.map(r => r.name)},style: {fontSize:"12px"},marks: [Plot.barX(learningRows, {y:"name",x:"eta2",fill:"#1a5276",fillOpacity:0.8}), Plot.text(learningRows, {y:"name",x:"eta2",dx:4,text: d => (100* d.eta2).toFixed(1) +"%",textAnchor:"start",fontSize:11})] })}</div> <table style="font-size:0.88em; max-width:420px;"><thead><tr><th>Input</th><th>80% interval width now → expected after learning it</th><th>SD now → expected</th></tr></thead> <tbody>${learningRows.map(r =>html`<tr><td>${r.name}</td><td>${width(r.marginalWidth80)} → <strong>${width(r.expectedWidth80)}</strong></td><td>${sd(r.marginalSd)} → ${sd(r.expectedSd)}</td></tr>`)}</tbody></table> </div> <p style="font-size:0.85em; color:#555;">The shares sum to ${(100* total).toFixed(0)}%${total >1?", above 100% because the inputs overlap":""}. ${targetKey ==="log"?"For log cost the 80% interval width is shown as the ratio p90/p10.":"Widths are the p90 − p10 range of cost, now and averaged across what it would be after learning the input."} Thirty bins of 1,000 draws; the within-bin spread still includes variation of the input inside its bin, so the shares are slightly conservative.</p>`;}
3.3 Rank regression: partial adjustment for co-movement
Regress the rank of cost on the ranks of all displayed inputs at once. The standardized coefficients (SRRC) describe each input’s monotone association with cost holding the other listed inputs at their ranks, which is a partial correction for the overlap problem: an input whose association runs entirely through other listed inputs gets a coefficient near zero. The \(R^2\) says how much of the rank variation the listed inputs explain additively; the remainder is unlisted inputs (other variable costs, reactor and plant-factor draws, supplemental proteins, fixed costs) plus interactions and non-monotone effects. It is still a description of the joint sample, not an intervention.
Code
{const fit = sm.rankRegression(inputs.map(i => i.data), targetY);const rows = inputs.map((i, j) => ({name: i.name,rho: sm.spearman(i.data, targetY),srrc: fit.coefficients[j],eta2: sm.learningValue(i.data, targetY,30).eta2})).sort((a, b) =>Math.abs(b.srrc) -Math.abs(a.srrc));const f = v => (v >=0?"+":"−") +Math.abs(v).toFixed(2);returnhtml`<table style="font-size:0.9em;"><thead><tr><th>Input</th><th>Spearman ρ with ${targetKey ==="log"?"log cost":"cost"}</th><th>SRRC (partial, all inputs together)</th><th>η² (Section 3.2)</th></tr></thead> <tbody>${rows.map(r =>html`<tr><td>${r.name}</td><td>${f(r.rho)}</td><td><strong>${f(r.srrc)}</strong></td><td>${(100* r.eta2).toFixed(1)}%</td></tr>`)}</tbody></table> <p style="font-size:0.85em; color:#555;">Rank regression R² = ${fit.r2.toFixed(2)}: the listed inputs explain ${(100* fit.r2).toFixed(0)}% of the rank variation in ${targetKey ==="log"?"log cost":"cost"} through additive monotone effects. Compare ρ and SRRC for maturity: its raw association is largely accounted for by the media and GF inputs it drives.</p>`;}
3.4 Interventions: pin one input and re-run
For the inputs the engine exposes as ranges, we can set the input to a single value and re-simulate with the same seed, so every other draw is unchanged. The set-points are the mean of the input within the bottom and top 10% tails, which makes the intervention comparable with the chart’s conditional contrast. The difference between the two columns is the dependence structure at work.
{if (!interventions.length) returnhtml`<p><em>No input in this scenario can be pinned without changing the model structure.</em></p>`;returnhtml`<table style="font-size:0.9em;"><thead><tr><th>Input</th><th>Set-points (bottom-tail mean → top-tail mean)</th><th>Conditional contrast (chart)</th><th>Intervention contrast</th><th>Note</th></tr></thead> <tbody>${interventions.map(r =>html`<tr><td>${r.name}</td><td>${r.lo.toPrecision(3)} → ${r.hi.toPrecision(3)}</td><td>${usd(r.conditional)}</td><td><strong>${usd(r.intervention)}</strong></td><td style="font-size:0.85em; color:#555;">${r.note}${r.warnings.length?" "+ r.warnings.join(" ") :""}</td></tr>`)}</tbody></table> <p style="font-size:0.85em; color:#555;">Mean cost, 20,000 paired draws per run. Interventions are only available for inputs the engine parameterizes as a range (density via the custom-prior override, plant capacity, WACC, asset life, and fresh media when the direct model is selected). Pinning the media-use multiplier, media $/L or the GF inputs would require engine changes; pinning maturity would require a point-mass option for the latent factor.</p>`;}
In the default scenario the density intervention is larger than the conditional contrast. Setting density to the bottom-tail value for every draw, including perfusion draws with high media-use multipliers, produces media volumes the engine never generates on its own (low density with a fed-batch multiplier is what the conditional bottom tail contains). So the intervention breaks a coupling that is there for a physical reason, and its answer is not “the effect of density” either. When inputs are structurally linked, the honest statement is that the effect of one input is only defined relative to what is assumed about the others.
3.5 Shapley effects: a variance split that respects dependence
Sobol indices split the output variance into shares attributable to each input, but the classical decomposition requires independent inputs, which we do not have. Shapley effects (Owen 2014; Song, Nelson and Staum 2016; Iooss and Prieur 2019) are the standard answer to that problem. They borrow the Shapley value from cooperative game theory: treat each input as a player, define the “value” of any group of inputs as the variance they jointly explain, \(c(u) = \operatorname{Var}(Y) - \mathbb{E}[\operatorname{Var}(Y \mid X_u)]\), and give each input the average of its marginal contribution over all orders in which the inputs could be added. The result is a set of shares that sum to the explained variance even when inputs are dependent, with each input’s share lying between its first-order and total-effect Sobol index. So the answer to “is this relevant here?” is yes: it is precisely the method built for dependent inputs like ours.
Two practical limits. First, computing \(\mathbb{E}[\operatorname{Var}(Y \mid X_u)]\) for every subset \(u\) needs conditional distributions of the inputs, which the engine does not expose; we use the given-data estimator of Broto, Bachoc and Depecker (2020), which approximates the conditional variance by the variance of cost among each draw’s nearest neighbours in the subset’s coordinates. That estimator degrades quickly as the number of inputs grows, because neighbourhoods in many dimensions are wide: with all ten displayed inputs it returns negative shares and explains only about half the variance. It behaves well for the five inputs that carry nearly all the variance (six, including growth-factor quantity, before that quantity was tied to medium use on November 9, 2026), on log cost, with two independent subsamples agreeing closely. Second, the shares depend on how the inputs are carved up. “Media $/L including the hydrolysate regime” is one input here; split it into the regime switch and the within-regime price and the attribution changes.
{const runs = shapleyData.shapley_runs;const main = runs.filter(r => r.inputs_set==="reduced"&& r.target==="log_cost");const raw = runs.filter(r => r.inputs_set==="reduced"&& r.target==="cost");const all = runs.find(r => r.inputs_set==="all");const eta =Object.fromEntries(shapleyData.full_sample.log_cost.inputs.map(i => [i.key, i.eta2]));const keys = main[0].inputs.map(i => i.key);const f = v => (v <-0.005?"−":"") +Math.abs(100* v).toFixed(1) +"%";const rows = keys.map((k, j) => ({name: main[0].inputs[j].name,eta: eta[k],main: main.map(r => r.inputs[j].shapley),raw: raw.map(r => r.inputs[j].shapley),first: main[0].inputs[j].first_order,total: main[0].inputs[j].total_effect}));const order = [...rows].sort((a, b) => b.main[0] - a.main[0]);const unexplained =1- main[0].explained_share;returnhtml`<p><em>Default scenario only</em> (${isCustom ?"the scenario above is custom; this table does not update":"matches the scenario above"}). Engine ${shapleyData.model_version}, generated ${shapleyData.generated}.</p>${Plot.plot({width:620,height:50+30* order.length,marginLeft:260,marginRight:60,x: {label:"Shapley share of the variance of log cost",domain: [Math.min(0,...order.map(r => r.main[0])) *1.2,Math.max(...order.map(r => r.main[0])) *1.25],tickFormat: v => (100* v).toFixed(0) +"%",grid:true},y: {label:null,tickSize:0,domain: order.map(r => r.name)},style: {fontSize:"12px"},marks: [Plot.barX(order, {y:"name",x: d => d.main[0],fill:"#8e44ad",fillOpacity:0.8}), Plot.dot(order, {y:"name",x: d => d.main[1],fill:"#222",r:3,title:"second subsample"}), Plot.text(order, {y:"name",x: d =>Math.max(d.main[0],0),dx:6,text: d =>f(d.main[0]),textAnchor:"start",fontSize:11})] })} <table style="font-size:0.88em;"><thead><tr><th>Input</th><th>Shapley share, log cost (subsample 1 / 2)</th><th>First-order / total, same estimator</th><th>η² from Section 3.2 (log cost, full sample)</th><th>Shapley share, cost in $/kg (1 / 2)</th></tr></thead> <tbody>${order.map(r =>html`<tr><td>${r.name}</td><td><strong>${f(r.main[0])}</strong> / ${f(r.main[1])}</td><td>${f(r.first)} / ${f(r.total)}</td><td>${f(r.eta)}</td><td>${f(r.raw[0])} / ${f(r.raw[1])}</td></tr>`)}</tbody></table> <p style="font-size:0.85em; color:#555;">${main[0].inputs.length} inputs, log cost, ${main[0].subsample.toLocaleString()} draws subsampled from 30,000, k = ${main[0].k} nearest neighbours. Shares sum to ${(100* main[0].explained_share).toFixed(0)}% (subsample 2: ${(100* main[1].explained_share).toFixed(0)}%); the remaining ${(100* unexplained).toFixed(0)}% is inputs not in the chart plus estimator bias. For comparison, a rank regression on the same ${main[0].inputs.length} inputs explains ${(100* main[0].rank_r2_same_inputs).toFixed(0)}% of the rank variation in log cost. The small negative share for maturity is impossible in theory and is the estimator's signature: adding an input that carries almost no extra information widens the neighbourhoods and looks like added noise. The run with all ${all.inputs.length} inputs (${all.subsample.toLocaleString()} draws) explained ${(100* all.explained_share).toFixed(0)}% and produced ${all.inputs.filter(i => i.shapley<-0.005).length} negative shares, which is why it is not shown as a result. On cost in dollars rather than log cost the density share falls and the two subsamples disagree more, because the variance of a heavy-tailed quantity is driven by a few extreme draws.</p> <p style="font-size:0.85em; color:#555;">Compared with the η² column, the Shapley shares remove the overlap: media $/L and the multiplier are linked through process mode, and density also carries the growth-factor dose (which follows medium use since November 9, 2026), so first-order shares over-count and the Shapley shares are lower.</p>`;}
Reproduce with node scripts/sensitivity-shapley.mjs, which writes sensitivity-shapley-2026-09.json with the full-sample statistics for every input alongside the runs.
3.6 What is still not done
Sobol indices on independent primitive inputs. The engine draws about twenty-five independent primitives (the mode-selection uniform, the Beta noise on each adoption probability, the within-regime lognormals, the reactor, plant-factor and fixed-cost draws, and so on). A classical Sobol decomposition on those would be exact and cheap with the pick-freeze scheme, but the engine would need to accept the primitives as inputs, and most primitives are not quantities anyone would set out to research (“the uniform draw that picks the process mode” is not a research target). Attributing to the displayed, aggregated inputs is more meaningful and less clean; that trade-off is inherent, not an implementation gap.
Multi-factor dependence. All of the above takes the single latent maturity factor as given. The structural comparison on the main page shows what independent maturity channels do to the headline numbers; rerunning this page’s statistics under that setting is one click away via the Advanced page link.
Value of information. The decision-relevant version of this analysis (expected value of perfect or sample information for a specified choice) needs a decision and a loss function. We have neither on the site yet.
4. What the cheap draws look like
This section was drafted with AI assistance in October 2026 and has not yet been reviewed by a person.
The sections above ask which inputs move cost. This one turns the question around: take the draws that come in under a cost threshold and see how they differ from the draws as a whole. If nearly every cheap draw has high cell density, density is something that has to be true for cheap cultivated meat in this model. Like the tornado chart, this conditions rather than intervenes: it shows what the cheap draws have in common, not what would make a real plant cheap, and it inherits every assumption the engine makes.
What the thresholds mean here. The model’s headline output is the cost per kg of dry cell mass at the factory gate, in a mix of the source studies’ dollar years. Since October 29, 2026 the thresholds apply to a kg of hybrid product: 25% cultivated cells, counted at the water content of chicken meat (27.5% solids), and 75% plant-based ingredients at $3/kg. Products will not be made entirely of cells; 25% is a convention, not a consensus figure. Because the filler alone costs $2.25 per kg of product, the hybrid thresholds sit far up the dry-cost axis: $5/kg hybrid is about $40 per kg dry cell mass (close to the old $10/kg all-cell meat-equivalent line, $36 dry), $10/kg about $113 and $25/kg about $331. A draw under $5/kg is “very cheap biomass”, not parity with chicken. Since 5 November 2026 conventional chicken is compared at its US wholesale price, the same point in the supply chain as the model’s cost: about $3.50/kg for boneless breast in 2026 dollars (range $2.6–4.6), which a hybrid product reaches only if cells cost under about $18 per kg dry (comparison with conventional chicken). At the defaults (engine 2026-11-09.1) none of the 30,000 draws gets there; the cheapest costs about $3.84 per kg of hybrid product. The cautions under the main results set this out.
{const p = profile;const fmt = {L_per_kg: v => v.toFixed(0),density: v => v.toFixed(1),media_turnover: v => v.toFixed(2),media_cost_L: v =>"$"+ v.toFixed(2),g_recf: v => (1000* v).toPrecision(2),price_recf: v =>"$"+ (v >=1000?Math.round(v).toLocaleString() : v.toPrecision(3)),maturity: v => v.toFixed(2),plant_kta: v => v.toFixed(0),uptime: v =>pct(v),wacc: v => (100* v).toFixed(1) +"%" };const names = {g_recf:"Growth factor quantity (mg per kg)"};const cell = (s, f) => s ?html`<strong>${f(s.p50)}</strong> <span style="color:#666;">(${f(s.p10)}–${f(s.p90)})</span>`:"—";const usd2 = v => v ==null?"—":"$"+ v.toFixed(2);const comps = [["media","Medium"], ["recf","Recombinant growth factors"], ["capex","Capital (annualized)"], ["fixed","Fixed operating costs"], ["other","Everything else (other variable costs, supplemental proteins, any toll or downstream cost)"], ["unit_cost","Total (mean unit cost)"]];const c = p.components;returnhtml`<p><strong>${p.count.toLocaleString()} of ${p.n.toLocaleString()} draws (${(100* p.share).toFixed(1)}%) come in under $${p.threshold}/kg ${thresholdUnit}.</strong> ${dryBasis ?`Litres of medium, growth-factor quantity and cost components below are per kg dry cell mass.`:""}</p>${p.warning?html`<div style="border-left:4px solid #c0392b; background:#fdf2f2; padding:0.5rem 0.8rem; margin:0.5rem 0; font-size:0.92em;"><strong>Small group:</strong> ${p.warning} Medians and percentiles for fewer than ${p.minDraws} draws shift noticeably with the random seed.</div>`:""} <table style="font-size:0.9em;"><thead><tr><th>Input</th><th>All draws: median (10th–90th percentile)</th><th>Draws under $${p.threshold}/kg</th></tr></thead> <tbody>${p.continuous.map(r =>html`<tr><td>${names[r.key] || r.name}</td><td>${cell(r.all, fmt[r.key])}</td><td>${cell(r.under, fmt[r.key])}</td></tr>`)}${p.binary.map(r =>html`<tr><td>${r.name}: share of draws</td><td><strong>${pct(r.all)}</strong></td><td><strong>${r.under==null?"—":pct(r.under)}</strong></td></tr>`)} <tr><td colspan="3" style="background:#f4f6f7;"><em>Mean cost components ($/kg)</em></td></tr>${comps.map(([k, name]) =>html`<tr><td>${name}</td><td>${usd2(c.all[k])}</td><td>${c.under?usd2(c.under[k]) :"—"}</td></tr>`)} </tbody></table> <p style="font-size:0.85em; color:#555;">Fresh medium per kg is 1000 / density × media-use multiplier under the default medium rule (see below). The cost of capital (WACC, weighted average cost of capital) includes the maturity-linked shift. Component means need not match the medians above, and the group under the threshold is selected on total cost, so its components are pulled down together.</p>`;}
The figures in this paragraph and in Section 4.1 are for engine 2026-11-09.1 (seed 42, 30,000 draws), which includes the provisional nutrient floor, recovery losses, failed runs and seed-train capital added on November 7, 2026, pending engineering review (physical consistency), and the growth-factor dose linked to medium use since November 9 (correction).
In the default scenario only 0.14% of draws (42 of 30,000) come in under $5/kg of hybrid product, too few for reliable figures; 12.0% (3,614) come in under $10 and 55.6% under $25. Compared with all draws, the under-$10 draws use less than half as much fresh medium per kilogram of dry cell mass (median 94 against 206 litres), because they have higher cell density (21 against 13 g dry/L), a lower media-use multiplier (1.33 against 1.66) and cheaper medium ($0.36 against $0.61 per litre). Most use hydrolysates (92% against 73%) and cheap recombinant growth factors (76% against 48%). Plant capacity, utilization and the cost of capital barely differ between the two groups. The cheap draws are cheap because medium and growth factors become small, not because of scale or financing. Their mean cost is $86 per kg dry, of which medium is $42, and capital and fixed operating costs ($11 and $18) make up about a third. The 42 under-$5 draws show the same pattern more sharply: just over a quarter of the typical medium use (57 against 206 litres), nearly double the density, all on hydrolysates, and capital plus fixed costs about two fifths of a $35 mean. (Engine 2026-11-07.2, before the growth-factor correction: 41 draws under $5, 11.6% under $10 and 58.2% under $25.)
Before November 7 (engine 2026-10-29.1) 1.0% of draws were under $5, 23.6% under $10 and 67.2% under $25; the under-$5 draws then used about a fifth of the typical medium. The nutrient floor removed most of those very low-medium draws.
4.1 Structure check: how much depends on the medium rule
The model’s default derives fresh medium per kilogram of cells from density: litres per kg = 1000 / density × media-use multiplier. So any draw with high density automatically uses little medium (since November 7, 2026, no less than the nutrient floor allows), and since medium is the largest cost component, high density lowers cost twice, once through reactor size and once through medium. Two independent reviews dispute the medium half of that. The September scientific review and an outside audit (Report 5 in the review response) both argue that medium per kg is set by how much of each nutrient the cells consume per kg of growth (nutrient yield), which is roughly the same whatever density the culture reaches; concentrating a culture does not undo the medium it has already used. Under that view, density mainly sets reactor size.
The Advanced page has an alternative rule that draws fresh medium per kg directly (default range 40–300 litres per kg dry cell mass, 5th–95th percentile, i.e. 8–60 per wet kg at 20% solids; an illustrative stress range rather than a sourced estimate) and leaves density to set reactor volume and capital cost only. A second alternative (October 2026) derives medium per kg from nutrient yield, the grams of dry cell mass the nutrients in a litre of medium support: litres per kg dry = 1000 / yield (default yield range 3–30 g dry cells per litre, 5th–95th percentile, 3–25 before November 7, 2026; about 33–330 litres per kg dry). Since November 7 the default density rule also uses this yield as a floor (nutrient_floor), so row (a) is the density rule with the floor. Density then affects only reactor size. The table runs the current scenario under each rule with the same seed, so every other input is identical draw for draw. Row (c) also adds forming, $0.5–2.5 per kg of hybrid product since November 7, 2026 (an unsourced $2–15 per kg of cells before), which is outside the cell-mass estimate and which the default leaves out; it changes the hybrid-product thresholds but not the cost per kg dry.
Code
structureRuns = {const rules = [ {key:"a",label:"(a) Medium from density × multiplier (default)",params: {...scenarioParams,media_use_model:"density"}}, {key:"b",label:"(b) Fresh medium per kg drawn directly",params: {...scenarioParams,media_use_model:"direct"}}, {key:"c",label:"(c) Direct medium, plus forming and texturization",params: {...scenarioParams,media_use_model:"direct",include_downstream:true}}, {key:"d",label:"(d) Medium from nutrient yield (density-invariant)",params: {...scenarioParams,media_use_model:"yield"}} ];return rules.map(r => { const s = costModel.simulate(30000,42, r.params);return {...r,uc: s.unit_cost,tc: s.threshold_cost}; });}
Code
{const below = (u, t) => u.filter(v => v < t).length/ u.length;const pct1 = v => (100* v).toFixed(1) +"%";const [a, b] = structureRuns;const cheapA = [], keep = [];for (let i =0; i < a.tc.length; i++) if (a.tc[i] <5) { cheapA.push(i);if (b.tc[i] <5) keep.push(i); }const lost = cheapA.length?1- keep.length/ cheapA.length:0;const override = scenarioParams.ep_media_p10!=null;const current = scenarioParams.media_use_model==="yield"?"d": scenarioParams.media_use_model!=="direct"?"a": scenarioParams.include_downstream?"c":"b";returnhtml`<table style="font-size:0.9em;"><thead><tr><th>Medium rule</th><th>P(cost < $5/kg ${thresholdUnit})</th><th>P(cost < $10/kg ${thresholdUnit})</th><th>Median cost (${costUnit})</th></tr></thead> <tbody>${structureRuns.map(r =>html`<tr><td>${r.label}${r.key=== current ?html` <em style="color:#666;">— this scenario's setting</em>`:""}</td><td><strong>${pct1(below(r.tc,5))}</strong></td><td>${pct1(below(r.tc,10))}</td><td>${usd(quantile(r.uc,0.5))}</td></tr>`)}</tbody></table>${override ?html`<p style="font-size:0.9em;"><strong>Note:</strong> this scenario sets medium cost directly with a custom prior, which takes precedence over both rules, so rows (a) and (b) differ only through other channels.</p>`:html`<p>Of the <strong>${cheapA.length.toLocaleString()}</strong> draws under $5/kg with the default rule, <strong>${(cheapA.length- keep.length).toLocaleString()} (${pct(lost)})</strong> rise above $5/kg when only the medium rule changes to the direct rule (b). ${lost >0.5?html`<strong>Most of the under-$5 draws depend on the density-derived medium assumption.</strong>`:"Fewer than half of them depend on the density-derived medium assumption here."}${cheapA.length<300?"With so few draws under $5, this share is imprecise. ":""}${below(b.tc,5) >=below(a.tc,5) ?"The direct rule produces more cheap draws of its own (draws where the direct medium draw happens to be low) than it removes, so P(cost < $5/kg) rises under it.":"The direct rule produces some cheap draws of its own (draws where the direct medium draw happens to be low), which is why P(cost < $5/kg) falls by less than this."}</p>`}`;}
Engine 2026-11-09.1, default scenario, thresholds on the hybrid product. Since the nutrient floor, the default rule is the most expensive of the three: it can never use less medium than the yield rule, and it keeps the density rule’s heavy high-use tail (median 206 litres per kg dry and 90th percentile 647, against 109 and 239 under the direct rule and 106 and 261 under the yield rule). So the alternatives now give more cheap draws, not fewer: 0.35% under $5/kg and 23.3% under $10 for the direct rule, 0.48% and 25.0% for the yield rule, against 0.14% and 12.0% by default; their medians are about $184 and $182 per kg dry against $289. Holding every other input fixed, 28 of the 42 default under-$5 draws and 1,164 of the 3,614 under-$10 draws (32%) rise above the threshold under the direct rule, and none do under the yield rule. Adding forming ($0.5–2.5 per kg of hybrid product) cuts the under-$10 share to 15.2% with the direct rule and to 7.3% with the default rule, and leaves almost no draws under $5. Because the growth-factor dose now follows medium use, every rule’s medium figure also moves its growth-factor cost.
Before the floor (engine 2026-10-29.1), the picture was the reverse: the very cheap tail rested mainly on the assumption that dense cultures use proportionally less medium (247 of the 306 under-$5 draws rose above $5 under the direct rule). The floor removed most of that dependence. What remains is the question of how much medium low-density, high-multiplier processes need, which drives the default’s expensive tail. Anyone quoting a threshold share should still report it alongside the direct-rule or yield-rule figure. These figures should be rerun after engine changes.
Before October 26, 2026 these thresholds applied to wet biomass, where 2.7% of draws were under $10 per wet kg and 27.7% under $25; those figures are reproduced exactly by the wet cost basis. From October 26 to 29 they applied to an all-cell chicken-meat equivalent (dry × 27.5%), where 0.7% of draws were under $10 and 15.8% under $25.
5. Summary
Method
Question it answers
Handles the engine’s dependence?
Unit
Where
Tornado (tail contrast, mean)
In worlds where \(x\) is high, how much higher is mean cost than in worlds where it is low?
Describes the joint sample; bars overlap and cannot be added
$/kg
Main page; Section 2.3 with other tails and statistics
Threshold contrast
Same, for P(cost below a target)
Same
percentage points
Section 2.3
Conditional profiles
How does cost change across the whole range of \(x\)?
Same
$/kg by decile
Section 3.1
Expected uncertainty after learning \(x\) (η², interval width)
If we learned \(x\), how much narrower would the cost distribution be?
Yes, in the “learning” sense: counts information carried by correlated inputs
share; $/kg
Section 3.2
Rank regression (SRRC)
Monotone association with cost, adjusting for the other listed inputs
Partially, among listed inputs
SD units
Section 3.3
Intervention contrast
If \(x\) were set to a value, everything else unchanged, what would cost be?
Breaks the dependence by design; only for pinnable inputs
$/kg
Section 3.4
Shapley effects
How should the variance be split among inputs so the shares add up?
Yes, by construction; estimator is the weak point
share of variance
Section 3.5 (default scenario, offline)
Threshold profile
What do the draws under $10, $25 or $50/kg have in common?
Describes the joint sample, conditioned on the outcome
input medians, shares, $/kg
Section 4
Medium-rule structure check
How much of the cheap tail rests on density setting medium use?
Compares model structures with paired draws
probability, $/kg
Section 4.1
Sobol on primitives, value of information
Exact variance split; decision relevance
—
—
Not implemented
The practical reading for this model, in the default scenario (engine 2026-11-09.1): cell density, growth-factor price and media $/L dominate every measure; the media-use multiplier comes next (growth-factor quantity, a separate input until November 9, now follows medium use, so its effect is carried by the density and multiplier bars); and the financing, scale, timing and utilization inputs explain almost none of the variance (η² under 1%). With 30,000 draws, production time, and at the cost thresholds plant capacity and WACC, show small swings that are distinguishable from zero, but they are an order of magnitude below the leading inputs. The methods disagree about the ordering and size of the middle group, which is the honest result: the middle group’s ranking depends on which question you ask.
Estimators: sensitivity-methods.mjs; tests: tests/sensitivity-methods.test.mjs; offline run: scripts/sensitivity-shapley.mjs. References: Owen, A. B. (2014) “Sobol’ indices and Shapley value,” SIAM/ASA J. Uncertainty Quantification; Song, E., Nelson, B. L. and Staum, J. (2016) “Shapley effects for global sensitivity analysis,” SIAM/ASA JUQ; Iooss, B. and Prieur, C. (2019) “Shapley effects for sensitivity analysis with correlated inputs,” Int. J. Uncertainty Quantification; Broto, B., Bachoc, F. and Depecker, M. (2020) “Variance reduction for estimation of Shapley effects and adaptation to unknown input distribution,” SIAM/ASA JUQ; Saltelli et al. (2008) Global Sensitivity Analysis: The Primer.
Source Code
---title: "Sensitivity analysis: what the tornado chart can and cannot tell you"subtitle: "Why the dollar-swing bars are hard to interpret, and six other ways to ask which uncertainties matter"format: html: toc: true toc-depth: 3 css: styles.css include-in-header: text: | <script> // Same convention as the Advanced page: read ?key=val into a global and // strip the query so Hypothes.is anchors to the bare URL. (function () { try { if (!window.location.search) return; var usp = new URLSearchParams(window.location.search); window.__CM_URL_STATE__ = {}; usp.forEach(function (v, k) { window.__CM_URL_STATE__[k] = v; }); history.replaceState(null, "", window.location.pathname + window.location.hash); } catch (e) { console.warn("CM URL state extraction failed:", e); } })(); </script> include-after-body: text: | <script src="https://hypothes.is/embed.js" async></script>---```{=html}<div style="margin:6px 0 14px; display:flex; gap:8px; align-items:center; flex-wrap:wrap;"> <a href="index.html#sensitivity-analysis-tornado-chart" style="display:inline-block; padding:6px 14px; background:#f0f8ff; color:#1a5276; border:1px solid #3498db; border-radius:6px; text-decoration:none; font-size:13px;">← Back to the tornado chart</a> <a href="docs.html#sensitivity-analysis-dollar-swing-metric" style="display:inline-block; padding:6px 14px; background:#fff; color:#555; border:1px solid #bbb; border-radius:6px; text-decoration:none; font-size:13px;">Dollar-swing definition (docs)</a></div>```The Advanced Model page ends with a tornado chart: one bar per input, showing how much the mean cost differs between draws where that input is in its top 10% and draws where it is in its bottom 10%. It is easy to read and, as a first pass, useful. It is also easy to over-read. Several of the inputs move together by construction, the 10% cut is arbitrary, the mean is pulled around by the right tail, and the question the chart answers ("what do costs look like in the worlds where this input is high?") is not the question people usually have in mind ("what happens to costs if this input changes?"). Neither is the question that matters for research prioritization ("would learning this change what we would do?").This page works through those problems with live numbers and then runs the alternatives on the same scenario. Everything below is computed from the same 30,000-draw simulation the main page uses, except the Shapley table, which is precomputed offline for the default scenario, and the intervention and structure checks (Sections 3.4 and 4.1), which re-run the engine with one setting changed. The methods and text were drafted with AI assistance and have not been independently reviewed; the code is in [`sensitivity-methods.mjs`](sensitivity-methods.mjs) with tests, and we welcome corrections via Hypothes.is annotations.```{ojs}//| echo: falsecostModel = import(new URL("./cost-model.mjs", window.location.href).href)sm = import(new URL("./sensitivity-methods.mjs", window.location.href).href)urlState = window.__CM_URL_STATE__ || {}isCustom = Object.keys(urlState).length > 0scenarioParams = sm.advancedUrlStateToParams(urlState, costModel.maturityForYear)results = costModel.simulate(30000, 42, scenarioParams)uc = results.unit_cost// Probability thresholds use the hybrid product on the dry basis (wet cost on the wet basis).tc = results.threshold_costdryBasis = results.basis.cost_basis === "dry"costUnit = dryBasis ? "per kg dry cell mass" : "per kg wet biomass"thresholdUnit = dryBasis ? "hybrid product" : "wet"logUc = uc.map(Math.log)inputs = sm.displayedInputs(results, scenarioParams)quantile = costModel.quantilemean = costModel.meanshort = ({density: "Cell density", media_turnover: "Media-use multiplier", media_cost_L: "Media $/L", price_recf: "GF price", g_recf: "GF quantity", maturity: "Maturity", wacc: "WACC", asset_life: "Asset life", plant_kta: "Plant capacity", cycle_days: "Production time", uptime: "Utilization", L_per_kg: "Fresh media L/kg", media_override: "Media override", gf_override: "GF override"})label = i => short[i.key] || i.nameusd = v => (v < 0 ? "−" : "") + "$" + Math.abs(v).toFixed(v >= 100 || v <= -100 ? 0 : 1) + "/kg"pct = v => (100 * v).toFixed(0) + "%"``````{ojs}//| echo: false{ // Keep a shared scenario in the address bar after load, as the Advanced page does. if (isCustom) setTimeout(() => { try { history.replaceState(null, "", location.pathname + "?" + new URLSearchParams(urlState).toString() + location.hash); } catch (e) {} }, 300); const cap = v => (100 * tc.filter(c => c < v).length / tc.length).toFixed(0); return html`<div style="border:1px solid #bcc8cc; border-left:4px solid #1a5276; padding:0.6rem 0.9rem; margin:0.5rem 0 1rem; font-size:0.95em;"> <strong>Scenario analyzed:</strong> ${isCustom ? "custom settings carried over from the Advanced page link" : "the Advanced page defaults (maturity 0.5 mapped to 2036, seed 42)"}. Unit cost ${costUnit}: p10 / p50 / p90 = ${usd(quantile(uc, 0.1))} / ${usd(quantile(uc, 0.5))} / ${usd(quantile(uc, 0.9))}; mean ${usd(mean(uc))}; standard deviation ${usd(Math.sqrt(sm.variance(uc)))}; P(cost < $25/kg ${thresholdUnit}) = ${cap(25)}%. ${dryBasis ? html`<br><em>Costs are per kg dry cell mass (since October 26, 2026). Thresholds such as $10 or $25/kg apply to a kg of hybrid product: ${(results.basis.cell_inclusion_rate * 100).toFixed(0)}% cultivated cells at ${(results.basis.target_solids_fraction * 100).toFixed(1)}% solids plus plant-based ingredients at $${results.basis.filler_cost}/kg (since October 29, 2026).</em>` : ""} ${inputs.length} inputs are active in the chart. To analyze a different scenario, set it up on the <a href="index.html">Advanced page</a> and use the “Analyze this scenario” link under its tornado chart. ${results.warnings.length ? html`<div style="margin-top:0.4rem;"><strong>Scenario notes:</strong> ${results.warnings.join(" ")}</div>` : ""} </div>`;}```## 1. What a bar on the tornado chart is {#what-the-bar-is}For each input $x$, the chart sorts the 30,000 draws by $x$, takes the top 10% and the bottom 10% (3,000 draws each), and reports the difference in mean unit cost between the two groups:$$\text{Swing}(x) = \overline{\text{cost}}\,\big|\,x \in \text{top 10\%} \;-\; \overline{\text{cost}}\,\big|\,x \in \text{bottom 10\%}.$$That is all. There is no re-simulation, no holding anything else fixed, and no model of how $x$ enters the cost equation. It is a summary of the joint distribution the simulation happened to produce.```{ojs}//| echo: false{ const rows = inputs.map(i => ({i, d: sm.tailContrastDetail(i.data, uc)})).sort((a, b) => Math.abs(b.d.contrast) - Math.abs(a.d.contrast)); const top = rows[0]; const x = top.i.data; return html`<p><em>Worked example for the largest bar in this scenario.</em> For <strong>${top.i.name}</strong>, the 10% of draws with the highest values (above ${quantile(x, 0.9).toPrecision(3)}) have a mean cost of <strong>${usd(top.d.hiStat)}</strong>; the 10% with the lowest values (below ${quantile(x, 0.1).toPrecision(3)}) have a mean cost of <strong>${usd(top.d.loStat)}</strong>. The bar is the difference, ${usd(top.d.contrast)}, with a Monte Carlo standard error of about ${usd(top.d.se)}. The smallest bars in the chart are ${rows.slice(-3).map(r => `${label(r.i)} (${usd(r.d.contrast)} ± ${usd(r.d.se)})`).join(", ")}: at this sample size they are not distinguishable from zero.</p>`;}```## 2. Why the bars are hard to interpret {#why-hard}### 2.1 The inputs move together by construction {#coupled-inputs}The chart lists inputs side by side as if each were an independent dial. In the engine they are not:| Coupling in the engine | What it does to the bars ||---|---|| **Process mode → density and media-use multiplier.** Each draw first picks fed-batch, perfusion or continuous, then draws density and the multiplier from that mode's range (fed-batch 1–6 g dry/L with multiplier 1–2; perfusion 6–30 g dry/L, 1–5; continuous 10–40 g dry/L, 0.5–3; i.e. 5–30, 30–150 and 50–200 g wet/L at 20% solids). | The top density decile is almost entirely continuous and perfusion draws; the bottom decile is fed-batch. The density bar is partly a process-mode bar, and it is entangled with the multiplier bar. || **Maturity → adoption, financing and equipment.** One latent maturity draw shifts the hydrolysate and cheap-GF adoption probabilities, the supplemental-protein regime, WACC and the custom-reactor share. | The maturity bar bundles all of those channels. It overlaps with the Media $/L, GF price, GF quantity and WACC bars, which already carry the regime effects it induces. || **Regime switches inside the mixtures.** "Media $/L" mixes a hydrolysate regime and a pharma-grade regime; "GF price" and "GF quantity" mix a cheap and an expensive regime, and both switch together. | The GF price and GF quantity bars share the same coin flip, so they overlap heavily. Each mixture bar is a regime effect plus a within-regime effect. || **Deterministic identities.** Fresh media L/kg = 1000 / density × multiplier; GF cost = quantity × price. | Removed from the chart for this reason, but the parents still co-determine cost in a multiplicative way. |The consequence is that a bar answers "what do costs look like in the worlds where this input is high?" and those worlds differ from the low-input worlds in other respects too. Bars cannot be added, and no bar isolates "the effect of" its input. The rank correlations among the displayed inputs, computed from the current draws, show the couplings directly:```{ojs}//| echo: false{ const r = inputs.map(i => sm.ranks(i.data)); const cells = []; for (let a = 0; a < inputs.length; a++) for (let b = 0; b < inputs.length; b++) cells.push({a: label(inputs[a]), b: label(inputs[b]), rho: a === b ? 1 : sm.pearson(r[a], r[b])}); const names = inputs.map(label); return Plot.plot({ width: Math.min(760, 80 + 62 * names.length), height: 80 + 34 * names.length, marginLeft: 130, marginBottom: 110, x: {domain: names, label: null, tickRotate: -40}, y: {domain: names, label: null}, color: {scheme: "RdBu", domain: [-1, 1], reverse: true, label: "Spearman ρ", legend: true}, marks: [ Plot.cell(cells, {x: "a", y: "b", fill: "rho", inset: 0.5}), Plot.text(cells, {x: "a", y: "b", text: d => d.a === d.b ? "" : d.rho.toFixed(2), fill: d => Math.abs(d.rho) > 0.5 ? "white" : "#222", fontSize: 10}) ] });}```*Spearman rank correlations between the displayed inputs across the 30,000 draws. Red: move together; blue: move in opposite directions. Correlations near zero mean the engine samples the pair independently in this scenario; they say nothing about whether the pair is really independent.*### 2.2 Some uncertainties are probably correlated in ways the model does not encode {#correlated-beliefs}The couplings above are the ones the engine builds in. There are others it leaves out. Media cost per litre and achievable cell density are plausibly linked (denser cultures need richer, more expensive media, at least in fed-batch systems), but the engine samples them independently within a process mode, so the joint distribution the chart summarizes puts weight on combinations an expert might rule out — very cheap media with very high density, for example. The chart cannot detect this; it only summarizes the draws it is given. The [beliefs form](https://uj-cm-workshop.netlify.app/cm-cost-beliefs.html) asks for media cost per kilogram of biomass, rather than $/L and density separately, partly so that respondents can integrate this interaction themselves. The [Limits page](limits.qmd#parameter-grounding) discusses it further.### 2.3 "Top 10% versus bottom 10%" and "mean cost" are choices {#tail-and-statistic}Nothing in the method fixes the 10% cut or the mean. Comparing the top and bottom 5% gives bigger bars (more extreme worlds); comparing the top and bottom halves gives smaller ones. The mean cost is dominated by the right tail (in the default scenario the mean is about \$570 against a median of \$289 per kg dry cell mass, engine `2026-11-09.1`), so a bar mostly reports what happens in the expensive tail. If the question is whether cultivated chicken can get under $25/kg, the relevant statistic is a threshold probability, and that ranks the inputs differently. Try it:```{ojs}//| echo: falseviewof tailFrac = Inputs.radio(new Map([["5%", 0.05], ["10% (chart default)", 0.10], ["25%", 0.25], ["50% (top half vs bottom half)", 0.50]]), {value: 0.10, label: "Tail width"})viewof statKey = Inputs.radio(new Map([["Mean cost ($/kg)", "mean"], ["Median cost ($/kg)", "median"], ["P(cost < $10/kg, hybrid product on the dry basis), percentage points", "below10"], ["P(cost < $25/kg, hybrid product), percentage points", "below25"], ["P(cost < $50/kg, hybrid product), percentage points", "below50"]]), {value: "mean", label: "Output statistic"})``````{ojs}//| echo: falsestatSpec = ({mean: {type: "mean"}, median: {type: "median"}, below10: {type: "below", threshold: 10}, below25: {type: "below", threshold: 25}, below50: {type: "below", threshold: 50}})[statKey]statIsShare = statKey.startsWith("below")contrastRows = inputs.map(i => { const d = sm.tailContrastDetail(i.data, statIsShare ? tc : uc, {tailFrac, stat: statSpec}); const scale = statIsShare ? 100 : 1; return {name: i.name, value: d.contrast * scale, se: d.se * scale, abs: Math.abs(d.contrast * scale)};}).sort((a, b) => b.abs - a.abs)``````{ojs}//| echo: false{ const maxAbs = Math.max(...contrastRows.map(r => r.abs + 2 * r.se), 1); const unit = statIsShare ? "pp" : "$/kg"; return Plot.plot({ width: 860, height: 60 + 34 * contrastRows.length, marginLeft: 290, marginRight: 110, x: {label: statIsShare ? "Difference in probability (percentage points), top tail minus bottom tail" : "Dollar swing, top tail minus bottom tail ($/kg)", domain: [0, maxAbs * 1.15], grid: true}, y: {label: null, tickSize: 0, domain: contrastRows.map(r => r.name)}, style: {fontSize: "13px"}, marks: [ // For probabilities of clearing a threshold, a positive contrast is good news. Plot.barX(contrastRows, {y: "name", x: "abs", fill: d => (statIsShare ? d.value < 0 : d.value >= 0) ? "#c0392b" : "#27ae60", fillOpacity: 0.8}), Plot.ruleY(contrastRows, {y: "name", x1: d => Math.max(0, d.abs - 2 * d.se), x2: d => d.abs + 2 * d.se, stroke: "#222", strokeWidth: 1.5}), Plot.text(contrastRows, {y: "name", x: d => d.abs + 2 * d.se + maxAbs * 0.015, text: d => (d.value >= 0 ? "+" : "−") + d.abs.toFixed(1) + " " + unit, textAnchor: "start", fontSize: 12}) ] });}```*Bar colour gives the sign (red: high input goes with higher cost or a lower chance of clearing the threshold; green: the opposite). Black whiskers are ±2 Monte Carlo standard errors. For the threshold statistics, green means a high value of the input goes with a higher probability of cheap cultivated meat.*```{ojs}//| echo: false{ const tails = [0.05, 0.10, 0.25, 0.50]; const table = inputs.map(i => ({name: i.name, values: tails.map(t => sm.tailContrast(i.data, uc, {tailFrac: t}))})); const rankAt = k => [...table].sort((a, b) => Math.abs(b.values[k]) - Math.abs(a.values[k])).map(r => r.name); const ranks = tails.map((_, k) => rankAt(k)); const rows = [...table].sort((a, b) => Math.abs(b.values[1]) - Math.abs(a.values[1])); return html`<details><summary style="cursor:pointer;">Rank of each input under each tail width (mean cost)</summary> <table style="font-size:0.9em;"><thead><tr><th>Input</th>${tails.map(t => html`<th>${Math.round(t * 100)}% tails: $/kg (rank)</th>`)}</tr></thead> <tbody>${rows.map(r => html`<tr><td>${r.name}</td>${r.values.map((v, k) => html`<td>${usd(v)} (${ranks[k].indexOf(r.name) + 1})</td>`)}</tr>`)}</tbody></table> <p style="font-size:0.85em; color:#555;">The largest bars usually keep their order; the middle of the ranking does not. An input whose bar shrinks sharply as the tail widens matters mainly through its extreme values.</p></details>`;}```### 2.4 Small bars are noise {#noise}Each bar is a difference between two sample means of 3,000 draws with a standard deviation around \$1,090 per kg dry cell mass, so its standard error is roughly \$25–50/kg. In the default scenario (engine `2026-11-09.1`) the utilization, WACC and asset-life bars (about \$20–30/kg, about one standard error) are noise, and the plant-capacity bar (about \$53/kg, two standard errors) is at best marginally distinguishable from zero; the chart on the main page nonetheless draws and labels them. The whiskers above show the same problem for other statistics.### 2.5 Two different questions: learning an input versus setting it {#learn-vs-set}The chart, and every statistic in this section, conditions on the input: it compares worlds in which the input happens to be high with worlds in which it happens to be low. Under the model's dependence structure, that is the right way to answer *"if we learned this input's value, what would we expect cost to be?"*, because learning that density is high also tells you the process is probably continuous, and the conditional statistic carries that along.It is the wrong way to answer *"if this input were changed while everything else stayed as it is, what would happen to cost?"*, which is what an engineer improving a process or a funder backing a technology usually means. That needs an intervention: re-run the simulation with the input pinned. When inputs are independent, the two answers coincide; here they do not (Section 3.5 shows the gap for the inputs the engine lets us pin). Neither answer is more correct in general; they are answers to different questions, and the chart only gives the first.### 2.6 None of this is research value {#not-research-value}The [September review](review-2026-09.qmd) makes the point directly: a large bar can have low information value if every plausible value of the input supports the same decision, and a small bar can matter if the decision is on a knife edge. Deciding what to study requires specifying a choice (fund a medium improvement, obtain a scale-up measurement, support deployment) and asking whether plausible evidence would change it. None of the statistics on this page do that. They describe the model's uncertainty; they do not price it.## 3. Alternatives, run on this scenario {#alternatives}### 3.1 Conditional-mean profiles: where in the range the cost moves {#profiles}Instead of two tails, look at all ten deciles. For each input, the red line is the mean cost within each decile of that input, the dashed blue line is the median, and the band is the 10th–90th percentile of cost within the decile. Steep, straight profiles are what the tornado bar assumes; curved profiles show that the swing comes from one end of the range; flat profiles with wide bands are inputs the bar ranks by noise.```{ojs}//| echo: false{ const panels = inputs.map(i => { const bins = sm.conditionalProfile(i.data, uc, 10); const plot = Plot.plot({ width: 270, height: 180, marginLeft: 48, marginBottom: 34, x: {label: `decile of ${label(i)} →`, ticks: [1, 5, 10]}, y: {type: "log", label: "$/kg", grid: true}, marks: [ Plot.areaY(bins, {x: "bin", y1: "yP10", y2: "yP90", fill: "#3498db", fillOpacity: 0.15}), Plot.lineY(bins, {x: "bin", y: "yMedian", stroke: "#1a5276", strokeDasharray: "4,2"}), Plot.lineY(bins, {x: "bin", y: "yMean", stroke: "#c0392b", strokeWidth: 2}) ] }); return html`<div style="flex:0 0 auto;">${plot}</div>`; }); return html`<div style="display:flex; flex-wrap:wrap; gap:10px;">${panels}</div>`;}```*Log scale on cost. Deciles are equal-count bins of the input's own distribution (decile 1 is the lowest 10%, decile 10 the highest), so the horizontal axis is not linear in the input's units.*### 3.2 Expected uncertainty after learning one input {#learning-value}This is the closest thing to the question "how much would our uncertainty shrink if we pinned this input down?" Split the draws into 30 equal-count bins of the input, measure the spread of cost within each bin, and average. The share of variance removed, $\eta^2 = 1 - \mathbb{E}[\operatorname{Var}(\text{cost} \mid x)]/\operatorname{Var}(\text{cost})$, is the first-order Sobol index when inputs are independent; under dependence it also counts the information the input carries about correlated inputs, so the shares can add up to more than 100%. The 80% interval width is the more robust companion, because the variance of a heavy-tailed cost is driven by a few extreme draws.```{ojs}//| echo: falseviewof targetKey = Inputs.radio(new Map([["Cost ($/kg)", "cost"], ["Log cost (proportional uncertainty)", "log"]]), {value: "cost", label: "Attribute the uncertainty in"})``````{ojs}//| echo: falsetargetY = targetKey === "log" ? logUc : uclearningRows = inputs.map(i => ({name: i.name, ...sm.learningValue(i.data, targetY, 30)})).sort((a, b) => b.eta2 - a.eta2)``````{ojs}//| echo: false{ const total = learningRows.reduce((s, r) => s + r.eta2, 0); const width = v => targetKey === "log" ? "×" + Math.exp(v).toFixed(1) : usd(v); const sd = v => targetKey === "log" ? v.toFixed(2) : usd(v); const base = learningRows[0]; return html`<div style="display:flex; gap:24px; flex-wrap:wrap; align-items:flex-start;"> <div>${Plot.plot({ width: 460, height: 50 + 30 * learningRows.length, marginLeft: 260, marginRight: 60, x: {label: "Share of variance removed by learning this input (η²)", domain: [0, Math.max(0.05, ...learningRows.map(r => r.eta2)) * 1.2], tickFormat: v => (100 * v).toFixed(0) + "%", grid: true}, y: {label: null, tickSize: 0, domain: learningRows.map(r => r.name)}, style: {fontSize: "12px"}, marks: [Plot.barX(learningRows, {y: "name", x: "eta2", fill: "#1a5276", fillOpacity: 0.8}), Plot.text(learningRows, {y: "name", x: "eta2", dx: 4, text: d => (100 * d.eta2).toFixed(1) + "%", textAnchor: "start", fontSize: 11})] })}</div> <table style="font-size:0.88em; max-width:420px;"><thead><tr><th>Input</th><th>80% interval width now → expected after learning it</th><th>SD now → expected</th></tr></thead> <tbody>${learningRows.map(r => html`<tr><td>${r.name}</td><td>${width(r.marginalWidth80)} → <strong>${width(r.expectedWidth80)}</strong></td><td>${sd(r.marginalSd)} → ${sd(r.expectedSd)}</td></tr>`)}</tbody></table> </div> <p style="font-size:0.85em; color:#555;">The shares sum to ${(100 * total).toFixed(0)}%${total > 1 ? ", above 100% because the inputs overlap" : ""}. ${targetKey === "log" ? "For log cost the 80% interval width is shown as the ratio p90/p10." : "Widths are the p90 − p10 range of cost, now and averaged across what it would be after learning the input."} Thirty bins of 1,000 draws; the within-bin spread still includes variation of the input inside its bin, so the shares are slightly conservative.</p>`;}```### 3.3 Rank regression: partial adjustment for co-movement {#rank-regression}Regress the rank of cost on the ranks of all displayed inputs at once. The standardized coefficients (SRRC) describe each input's monotone association with cost *holding the other listed inputs at their ranks*, which is a partial correction for the overlap problem: an input whose association runs entirely through other listed inputs gets a coefficient near zero. The $R^2$ says how much of the rank variation the listed inputs explain additively; the remainder is unlisted inputs (other variable costs, reactor and plant-factor draws, supplemental proteins, fixed costs) plus interactions and non-monotone effects. It is still a description of the joint sample, not an intervention.```{ojs}//| echo: false{ const fit = sm.rankRegression(inputs.map(i => i.data), targetY); const rows = inputs.map((i, j) => ({name: i.name, rho: sm.spearman(i.data, targetY), srrc: fit.coefficients[j], eta2: sm.learningValue(i.data, targetY, 30).eta2})) .sort((a, b) => Math.abs(b.srrc) - Math.abs(a.srrc)); const f = v => (v >= 0 ? "+" : "−") + Math.abs(v).toFixed(2); return html`<table style="font-size:0.9em;"><thead><tr><th>Input</th><th>Spearman ρ with ${targetKey === "log" ? "log cost" : "cost"}</th><th>SRRC (partial, all inputs together)</th><th>η² (Section 3.2)</th></tr></thead> <tbody>${rows.map(r => html`<tr><td>${r.name}</td><td>${f(r.rho)}</td><td><strong>${f(r.srrc)}</strong></td><td>${(100 * r.eta2).toFixed(1)}%</td></tr>`)}</tbody></table> <p style="font-size:0.85em; color:#555;">Rank regression R² = ${fit.r2.toFixed(2)}: the listed inputs explain ${(100 * fit.r2).toFixed(0)}% of the rank variation in ${targetKey === "log" ? "log cost" : "cost"} through additive monotone effects. Compare ρ and SRRC for maturity: its raw association is largely accounted for by the media and GF inputs it drives.</p>`;}```### 3.4 Interventions: pin one input and re-run {#interventions}For the inputs the engine exposes as ranges, we can set the input to a single value and re-simulate with the same seed, so every other draw is unchanged. The set-points are the mean of the input within the bottom and top 10% tails, which makes the intervention comparable with the chart's conditional contrast. The difference between the two columns is the dependence structure at work.```{ojs}//| echo: falseinterventions = sm.interventionContrasts(results, scenarioParams, {n: 20000, seed: 42, tailFrac: 0.10})``````{ojs}//| echo: false{ if (!interventions.length) return html`<p><em>No input in this scenario can be pinned without changing the model structure.</em></p>`; return html`<table style="font-size:0.9em;"><thead><tr><th>Input</th><th>Set-points (bottom-tail mean → top-tail mean)</th><th>Conditional contrast (chart)</th><th>Intervention contrast</th><th>Note</th></tr></thead> <tbody>${interventions.map(r => html`<tr><td>${r.name}</td><td>${r.lo.toPrecision(3)} → ${r.hi.toPrecision(3)}</td><td>${usd(r.conditional)}</td><td><strong>${usd(r.intervention)}</strong></td><td style="font-size:0.85em; color:#555;">${r.note}${r.warnings.length ? " " + r.warnings.join(" ") : ""}</td></tr>`)}</tbody></table> <p style="font-size:0.85em; color:#555;">Mean cost, 20,000 paired draws per run. Interventions are only available for inputs the engine parameterizes as a range (density via the custom-prior override, plant capacity, WACC, asset life, and fresh media when the direct model is selected). Pinning the media-use multiplier, media $/L or the GF inputs would require engine changes; pinning maturity would require a point-mass option for the latent factor.</p>`;}```In the default scenario the density intervention is *larger* than the conditional contrast. Setting density to the bottom-tail value for every draw, including perfusion draws with high media-use multipliers, produces media volumes the engine never generates on its own (low density with a fed-batch multiplier is what the conditional bottom tail contains). So the intervention breaks a coupling that is there for a physical reason, and its answer is not "the effect of density" either. When inputs are structurally linked, the honest statement is that the effect of one input is only defined relative to what is assumed about the others.### 3.5 Shapley effects: a variance split that respects dependence {#shapley}Sobol indices split the output variance into shares attributable to each input, but the classical decomposition requires independent inputs, which we do not have. **Shapley effects** (Owen 2014; Song, Nelson and Staum 2016; Iooss and Prieur 2019) are the standard answer to that problem. They borrow the Shapley value from cooperative game theory: treat each input as a player, define the "value" of any group of inputs as the variance they jointly explain, $c(u) = \operatorname{Var}(Y) - \mathbb{E}[\operatorname{Var}(Y \mid X_u)]$, and give each input the average of its marginal contribution over all orders in which the inputs could be added. The result is a set of shares that sum to the explained variance even when inputs are dependent, with each input's share lying between its first-order and total-effect Sobol index. So the answer to "is this relevant here?" is yes: it is precisely the method built for dependent inputs like ours.Two practical limits. First, computing $\mathbb{E}[\operatorname{Var}(Y \mid X_u)]$ for every subset $u$ needs conditional distributions of the inputs, which the engine does not expose; we use the given-data estimator of Broto, Bachoc and Depecker (2020), which approximates the conditional variance by the variance of cost among each draw's nearest neighbours in the subset's coordinates. That estimator degrades quickly as the number of inputs grows, because neighbourhoods in many dimensions are wide: with all ten displayed inputs it returns negative shares and explains only about half the variance. It behaves well for the five inputs that carry nearly all the variance (six, including growth-factor quantity, before that quantity was tied to medium use on November 9, 2026), on log cost, with two independent subsamples agreeing closely. Second, the shares depend on how the inputs are carved up. "Media $/L including the hydrolysate regime" is one input here; split it into the regime switch and the within-regime price and the attribution changes.```{ojs}//| echo: falseshapleyData = fetch(new URL("./sensitivity-shapley-2026-09.json", window.location.href).href).then(r => r.json())``````{ojs}//| echo: false{ const runs = shapleyData.shapley_runs; const main = runs.filter(r => r.inputs_set === "reduced" && r.target === "log_cost"); const raw = runs.filter(r => r.inputs_set === "reduced" && r.target === "cost"); const all = runs.find(r => r.inputs_set === "all"); const eta = Object.fromEntries(shapleyData.full_sample.log_cost.inputs.map(i => [i.key, i.eta2])); const keys = main[0].inputs.map(i => i.key); const f = v => (v < -0.005 ? "−" : "") + Math.abs(100 * v).toFixed(1) + "%"; const rows = keys.map((k, j) => ({name: main[0].inputs[j].name, eta: eta[k], main: main.map(r => r.inputs[j].shapley), raw: raw.map(r => r.inputs[j].shapley), first: main[0].inputs[j].first_order, total: main[0].inputs[j].total_effect})); const order = [...rows].sort((a, b) => b.main[0] - a.main[0]); const unexplained = 1 - main[0].explained_share; return html`<p><em>Default scenario only</em> (${isCustom ? "the scenario above is custom; this table does not update" : "matches the scenario above"}). Engine ${shapleyData.model_version}, generated ${shapleyData.generated}.</p> ${Plot.plot({ width: 620, height: 50 + 30 * order.length, marginLeft: 260, marginRight: 60, x: {label: "Shapley share of the variance of log cost", domain: [Math.min(0, ...order.map(r => r.main[0])) * 1.2, Math.max(...order.map(r => r.main[0])) * 1.25], tickFormat: v => (100 * v).toFixed(0) + "%", grid: true}, y: {label: null, tickSize: 0, domain: order.map(r => r.name)}, style: {fontSize: "12px"}, marks: [Plot.barX(order, {y: "name", x: d => d.main[0], fill: "#8e44ad", fillOpacity: 0.8}), Plot.dot(order, {y: "name", x: d => d.main[1], fill: "#222", r: 3, title: "second subsample"}), Plot.text(order, {y: "name", x: d => Math.max(d.main[0], 0), dx: 6, text: d => f(d.main[0]), textAnchor: "start", fontSize: 11})] })} <table style="font-size:0.88em;"><thead><tr><th>Input</th><th>Shapley share, log cost (subsample 1 / 2)</th><th>First-order / total, same estimator</th><th>η² from Section 3.2 (log cost, full sample)</th><th>Shapley share, cost in $/kg (1 / 2)</th></tr></thead> <tbody>${order.map(r => html`<tr><td>${r.name}</td><td><strong>${f(r.main[0])}</strong> / ${f(r.main[1])}</td><td>${f(r.first)} / ${f(r.total)}</td><td>${f(r.eta)}</td><td>${f(r.raw[0])} / ${f(r.raw[1])}</td></tr>`)}</tbody></table> <p style="font-size:0.85em; color:#555;">${main[0].inputs.length} inputs, log cost, ${main[0].subsample.toLocaleString()} draws subsampled from 30,000, k = ${main[0].k} nearest neighbours. Shares sum to ${(100 * main[0].explained_share).toFixed(0)}% (subsample 2: ${(100 * main[1].explained_share).toFixed(0)}%); the remaining ${(100 * unexplained).toFixed(0)}% is inputs not in the chart plus estimator bias. For comparison, a rank regression on the same ${main[0].inputs.length} inputs explains ${(100 * main[0].rank_r2_same_inputs).toFixed(0)}% of the rank variation in log cost. The small negative share for maturity is impossible in theory and is the estimator's signature: adding an input that carries almost no extra information widens the neighbourhoods and looks like added noise. The run with all ${all.inputs.length} inputs (${all.subsample.toLocaleString()} draws) explained ${(100 * all.explained_share).toFixed(0)}% and produced ${all.inputs.filter(i => i.shapley < -0.005).length} negative shares, which is why it is not shown as a result. On cost in dollars rather than log cost the density share falls and the two subsamples disagree more, because the variance of a heavy-tailed quantity is driven by a few extreme draws.</p> <p style="font-size:0.85em; color:#555;">Compared with the η² column, the Shapley shares remove the overlap: media $/L and the multiplier are linked through process mode, and density also carries the growth-factor dose (which follows medium use since November 9, 2026), so first-order shares over-count and the Shapley shares are lower.</p>`;}```Reproduce with `node scripts/sensitivity-shapley.mjs`, which writes [`sensitivity-shapley-2026-09.json`](sensitivity-shapley-2026-09.json) with the full-sample statistics for every input alongside the runs.### 3.6 What is still not done {#not-done}- **Sobol indices on independent primitive inputs.** The engine draws about twenty-five independent primitives (the mode-selection uniform, the Beta noise on each adoption probability, the within-regime lognormals, the reactor, plant-factor and fixed-cost draws, and so on). A classical Sobol decomposition on those would be exact and cheap with the pick-freeze scheme, but the engine would need to accept the primitives as inputs, and most primitives are not quantities anyone would set out to research ("the uniform draw that picks the process mode" is not a research target). Attributing to the displayed, aggregated inputs is more meaningful and less clean; that trade-off is inherent, not an implementation gap.- **Multi-factor dependence.** All of the above takes the single latent maturity factor as given. The [structural comparison](index.qmd#structural-comparison) on the main page shows what independent maturity channels do to the headline numbers; rerunning this page's statistics under that setting is one click away via the Advanced page link.- **Value of information.** The decision-relevant version of this analysis (expected value of perfect or sample information for a specified choice) needs a decision and a loss function. We have neither on the site yet.## 4. What the cheap draws look like {#cheap-draws}*This section was drafted with AI assistance in October 2026 and has not yet been reviewed by a person.*The sections above ask which inputs move cost. This one turns the question around: take the draws that come in under a cost threshold and see how they differ from the draws as a whole. If nearly every cheap draw has high cell density, density is something that has to be true for cheap cultivated meat in this model. Like the tornado chart, this conditions rather than intervenes: it shows what the cheap draws have in common, not what would make a real plant cheap, and it inherits every assumption the engine makes.**What the thresholds mean here.** The model's headline output is the cost per kg of dry cell mass at the factory gate, in a mix of the source studies' dollar years. Since October 29, 2026 the thresholds apply to a kg of **hybrid product**: 25% cultivated cells, counted at the water content of chicken meat (27.5% solids), and 75% plant-based ingredients at $3/kg. Products will not be made entirely of cells; 25% is a convention, not a consensus figure. Because the filler alone costs $2.25 per kg of product, the hybrid thresholds sit far up the dry-cost axis: $5/kg hybrid is about $40 per kg dry cell mass (close to the old $10/kg all-cell meat-equivalent line, $36 dry), $10/kg about $113 and $25/kg about $331. A draw under $5/kg is "very cheap biomass", not parity with chicken. Since 5 November 2026 conventional chicken is compared at its US wholesale price, the same point in the supply chain as the model's cost: about $3.50/kg for boneless breast in 2026 dollars (range $2.6–4.6), which a hybrid product reaches only if cells cost under about $18 per kg dry ([comparison with conventional chicken](docs.qmd#chicken-comparison)). At the defaults (engine `2026-11-09.1`) none of the 30,000 draws gets there; the cheapest costs about \$3.84 per kg of hybrid product. The [cautions under the main results](index.qmd#output-cautions) set this out.```{ojs}//| echo: falseviewof profileThreshold = Inputs.radio(new Map([["$5/kg", 5], ["$10/kg", 10], ["$25/kg", 25]]), {value: 5, label: "Cost threshold (hybrid product, 25% cells)"})``````{ojs}//| echo: falseprofile = sm.thresholdProfile(results, profileThreshold)``````{ojs}//| echo: false{ const p = profile; const fmt = { L_per_kg: v => v.toFixed(0), density: v => v.toFixed(1), media_turnover: v => v.toFixed(2), media_cost_L: v => "$" + v.toFixed(2), g_recf: v => (1000 * v).toPrecision(2), price_recf: v => "$" + (v >= 1000 ? Math.round(v).toLocaleString() : v.toPrecision(3)), maturity: v => v.toFixed(2), plant_kta: v => v.toFixed(0), uptime: v => pct(v), wacc: v => (100 * v).toFixed(1) + "%" }; const names = {g_recf: "Growth factor quantity (mg per kg)"}; const cell = (s, f) => s ? html`<strong>${f(s.p50)}</strong> <span style="color:#666;">(${f(s.p10)}–${f(s.p90)})</span>` : "—"; const usd2 = v => v == null ? "—" : "$" + v.toFixed(2); const comps = [["media", "Medium"], ["recf", "Recombinant growth factors"], ["capex", "Capital (annualized)"], ["fixed", "Fixed operating costs"], ["other", "Everything else (other variable costs, supplemental proteins, any toll or downstream cost)"], ["unit_cost", "Total (mean unit cost)"]]; const c = p.components; return html`<p><strong>${p.count.toLocaleString()} of ${p.n.toLocaleString()} draws (${(100 * p.share).toFixed(1)}%) come in under $${p.threshold}/kg ${thresholdUnit}.</strong> ${dryBasis ? `Litres of medium, growth-factor quantity and cost components below are per kg dry cell mass.` : ""}</p> ${p.warning ? html`<div style="border-left:4px solid #c0392b; background:#fdf2f2; padding:0.5rem 0.8rem; margin:0.5rem 0; font-size:0.92em;"><strong>Small group:</strong> ${p.warning} Medians and percentiles for fewer than ${p.minDraws} draws shift noticeably with the random seed.</div>` : ""} <table style="font-size:0.9em;"><thead><tr><th>Input</th><th>All draws: median (10th–90th percentile)</th><th>Draws under $${p.threshold}/kg</th></tr></thead> <tbody> ${p.continuous.map(r => html`<tr><td>${names[r.key] || r.name}</td><td>${cell(r.all, fmt[r.key])}</td><td>${cell(r.under, fmt[r.key])}</td></tr>`)} ${p.binary.map(r => html`<tr><td>${r.name}: share of draws</td><td><strong>${pct(r.all)}</strong></td><td><strong>${r.under == null ? "—" : pct(r.under)}</strong></td></tr>`)} <tr><td colspan="3" style="background:#f4f6f7;"><em>Mean cost components ($/kg)</em></td></tr> ${comps.map(([k, name]) => html`<tr><td>${name}</td><td>${usd2(c.all[k])}</td><td>${c.under ? usd2(c.under[k]) : "—"}</td></tr>`)} </tbody></table> <p style="font-size:0.85em; color:#555;">Fresh medium per kg is 1000 / density × media-use multiplier under the default medium rule (see below). The cost of capital (WACC, weighted average cost of capital) includes the maturity-linked shift. Component means need not match the medians above, and the group under the threshold is selected on total cost, so its components are pulled down together.</p>`;}```*The figures in this paragraph and in Section 4.1 are for engine `2026-11-09.1` (seed 42, 30,000 draws), which includes the provisional nutrient floor, recovery losses, failed runs and seed-train capital added on November 7, 2026, pending engineering review ([physical consistency](docs.qmd#physical-consistency)), and the growth-factor dose linked to medium use since November 9 ([correction](docs.qmd#gf-dose-linked)).*In the default scenario only 0.14% of draws (42 of 30,000) come in under \$5/kg of hybrid product, too few for reliable figures; 12.0% (3,614) come in under \$10 and 55.6% under \$25. Compared with all draws, the under-\$10 draws use less than half as much fresh medium per kilogram of dry cell mass (median 94 against 206 litres), because they have higher cell density (21 against 13 g dry/L), a lower media-use multiplier (1.33 against 1.66) and cheaper medium (\$0.36 against \$0.61 per litre). Most use hydrolysates (92% against 73%) and cheap recombinant growth factors (76% against 48%). Plant capacity, utilization and the cost of capital barely differ between the two groups. The cheap draws are cheap because medium and growth factors become small, not because of scale or financing. Their mean cost is \$86 per kg dry, of which medium is \$42, and capital and fixed operating costs (\$11 and \$18) make up about a third. The 42 under-\$5 draws show the same pattern more sharply: just over a quarter of the typical medium use (57 against 206 litres), nearly double the density, all on hydrolysates, and capital plus fixed costs about two fifths of a \$35 mean. *(Engine `2026-11-07.2`, before the growth-factor correction: 41 draws under \$5, 11.6% under \$10 and 58.2% under \$25.)**Before November 7 (engine `2026-10-29.1`) 1.0% of draws were under \$5, 23.6% under \$10 and 67.2% under \$25; the under-\$5 draws then used about a fifth of the typical medium. The nutrient floor removed most of those very low-medium draws.*### 4.1 Structure check: how much depends on the medium rule {#structure-check}The model's default derives fresh medium per kilogram of cells from density: litres per kg = 1000 / density × media-use multiplier. So any draw with high density automatically uses little medium (since November 7, 2026, no less than the nutrient floor allows), and since medium is the largest cost component, high density lowers cost twice, once through reactor size and once through medium. Two independent reviews dispute the medium half of that. The [September scientific review](review-2026-09.qmd#make-a-small-physical-core-before-eliciting-more-priors) and an outside audit (Report 5 in the [review response](review-response-2026-09.qmd#structures)) both argue that medium per kg is set by how much of each nutrient the cells consume per kg of growth (nutrient yield), which is roughly the same whatever density the culture reaches; concentrating a culture does not undo the medium it has already used. Under that view, density mainly sets reactor size.The Advanced page has an alternative rule that draws fresh medium per kg directly (default range 40–300 litres per kg dry cell mass, 5th–95th percentile, i.e. 8–60 per wet kg at 20% solids; an illustrative stress range rather than a sourced estimate) and leaves density to set reactor volume and capital cost only. A second alternative (October 2026) derives medium per kg from **nutrient yield**, the grams of dry cell mass the nutrients in a litre of medium support: litres per kg dry = 1000 / yield (default yield range 3–30 g dry cells per litre, 5th–95th percentile, 3–25 before November 7, 2026; about 33–330 litres per kg dry). Since November 7 the default density rule also uses this yield as a floor (`nutrient_floor`), so row (a) is the density rule with the floor. Density then affects only reactor size. The table runs the current scenario under each rule with the same seed, so every other input is identical draw for draw. Row (c) also adds forming, \$0.5–2.5 per kg of hybrid product since November 7, 2026 (an unsourced \$2–15 per kg of cells before), which is outside the cell-mass estimate and which the default leaves out; it changes the hybrid-product thresholds but not the cost per kg dry.```{ojs}//| echo: falsestructureRuns = { const rules = [ {key: "a", label: "(a) Medium from density × multiplier (default)", params: {...scenarioParams, media_use_model: "density"}}, {key: "b", label: "(b) Fresh medium per kg drawn directly", params: {...scenarioParams, media_use_model: "direct"}}, {key: "c", label: "(c) Direct medium, plus forming and texturization", params: {...scenarioParams, media_use_model: "direct", include_downstream: true}}, {key: "d", label: "(d) Medium from nutrient yield (density-invariant)", params: {...scenarioParams, media_use_model: "yield"}} ]; return rules.map(r => { const s = costModel.simulate(30000, 42, r.params); return {...r, uc: s.unit_cost, tc: s.threshold_cost}; });}``````{ojs}//| echo: false{ const below = (u, t) => u.filter(v => v < t).length / u.length; const pct1 = v => (100 * v).toFixed(1) + "%"; const [a, b] = structureRuns; const cheapA = [], keep = []; for (let i = 0; i < a.tc.length; i++) if (a.tc[i] < 5) { cheapA.push(i); if (b.tc[i] < 5) keep.push(i); } const lost = cheapA.length ? 1 - keep.length / cheapA.length : 0; const override = scenarioParams.ep_media_p10 != null; const current = scenarioParams.media_use_model === "yield" ? "d" : scenarioParams.media_use_model !== "direct" ? "a" : scenarioParams.include_downstream ? "c" : "b"; return html`<table style="font-size:0.9em;"><thead><tr><th>Medium rule</th><th>P(cost < $5/kg ${thresholdUnit})</th><th>P(cost < $10/kg ${thresholdUnit})</th><th>Median cost (${costUnit})</th></tr></thead> <tbody>${structureRuns.map(r => html`<tr><td>${r.label}${r.key === current ? html` <em style="color:#666;">— this scenario's setting</em>` : ""}</td><td><strong>${pct1(below(r.tc, 5))}</strong></td><td>${pct1(below(r.tc, 10))}</td><td>${usd(quantile(r.uc, 0.5))}</td></tr>`)}</tbody></table> ${override ? html`<p style="font-size:0.9em;"><strong>Note:</strong> this scenario sets medium cost directly with a custom prior, which takes precedence over both rules, so rows (a) and (b) differ only through other channels.</p>` : html`<p>Of the <strong>${cheapA.length.toLocaleString()}</strong> draws under $5/kg with the default rule, <strong>${(cheapA.length - keep.length).toLocaleString()} (${pct(lost)})</strong> rise above $5/kg when only the medium rule changes to the direct rule (b). ${lost > 0.5 ? html`<strong>Most of the under-$5 draws depend on the density-derived medium assumption.</strong>` : "Fewer than half of them depend on the density-derived medium assumption here."} ${cheapA.length < 300 ? "With so few draws under $5, this share is imprecise. " : ""}${below(b.tc, 5) >= below(a.tc, 5) ? "The direct rule produces more cheap draws of its own (draws where the direct medium draw happens to be low) than it removes, so P(cost < $5/kg) rises under it." : "The direct rule produces some cheap draws of its own (draws where the direct medium draw happens to be low), which is why P(cost < $5/kg) falls by less than this."}</p>`}`;}```*Engine `2026-11-09.1`, default scenario, thresholds on the hybrid product.* Since the nutrient floor, the default rule is the most expensive of the three: it can never use less medium than the yield rule, and it keeps the density rule's heavy high-use tail (median 206 litres per kg dry and 90th percentile 647, against 109 and 239 under the direct rule and 106 and 261 under the yield rule). So the alternatives now give *more* cheap draws, not fewer: 0.35% under \$5/kg and 23.3% under \$10 for the direct rule, 0.48% and 25.0% for the yield rule, against 0.14% and 12.0% by default; their medians are about \$184 and \$182 per kg dry against \$289. Holding every other input fixed, 28 of the 42 default under-\$5 draws and 1,164 of the 3,614 under-\$10 draws (32%) rise above the threshold under the direct rule, and none do under the yield rule. Adding forming (\$0.5–2.5 per kg of hybrid product) cuts the under-\$10 share to 15.2% with the direct rule and to 7.3% with the default rule, and leaves almost no draws under \$5. Because the growth-factor dose now follows medium use, every rule's medium figure also moves its growth-factor cost.Before the floor (engine `2026-10-29.1`), the picture was the reverse: the very cheap tail rested mainly on the assumption that dense cultures use proportionally less medium (247 of the 306 under-\$5 draws rose above \$5 under the direct rule). The floor removed most of that dependence. What remains is the question of how much medium low-density, high-multiplier processes need, which drives the default's expensive tail. Anyone quoting a threshold share should still report it alongside the direct-rule or yield-rule figure. These figures should be rerun after engine changes.*Before October 26, 2026 these thresholds applied to wet biomass, where 2.7% of draws were under $10 per wet kg and 27.7% under $25; those figures are reproduced exactly by the wet cost basis. From October 26 to 29 they applied to an all-cell chicken-meat equivalent (dry × 27.5%), where 0.7% of draws were under $10 and 15.8% under $25.*## 5. Summary {#summary}| Method | Question it answers | Handles the engine's dependence? | Unit | Where ||---|---|---|---|---|| Tornado (tail contrast, mean) | In worlds where $x$ is high, how much higher is mean cost than in worlds where it is low? | Describes the joint sample; bars overlap and cannot be added | $/kg | Main page; Section 2.3 with other tails and statistics || Threshold contrast | Same, for P(cost below a target) | Same | percentage points | Section 2.3 || Conditional profiles | How does cost change across the whole range of $x$? | Same | $/kg by decile | Section 3.1 || Expected uncertainty after learning $x$ (η², interval width) | If we learned $x$, how much narrower would the cost distribution be? | Yes, in the "learning" sense: counts information carried by correlated inputs | share; $/kg | Section 3.2 || Rank regression (SRRC) | Monotone association with cost, adjusting for the other listed inputs | Partially, among listed inputs | SD units | Section 3.3 || Intervention contrast | If $x$ were set to a value, everything else unchanged, what would cost be? | Breaks the dependence by design; only for pinnable inputs | $/kg | Section 3.4 || Shapley effects | How should the variance be split among inputs so the shares add up? | Yes, by construction; estimator is the weak point | share of variance | Section 3.5 (default scenario, offline) || Threshold profile | What do the draws under $10, $25 or $50/kg have in common? | Describes the joint sample, conditioned on the outcome | input medians, shares, $/kg | Section 4 || Medium-rule structure check | How much of the cheap tail rests on density setting medium use? | Compares model structures with paired draws | probability, $/kg | Section 4.1 || Sobol on primitives, value of information | Exact variance split; decision relevance | — | — | Not implemented |The practical reading for this model, in the default scenario (engine `2026-11-09.1`): cell density, growth-factor price and media $/L dominate every measure; the media-use multiplier comes next (growth-factor quantity, a separate input until November 9, now follows medium use, so its effect is carried by the density and multiplier bars); and the financing, scale, timing and utilization inputs explain almost none of the variance (η² under 1%). With 30,000 draws, production time, and at the cost thresholds plant capacity and WACC, show small swings that are distinguishable from zero, but they are an order of magnitude below the leading inputs. The methods disagree about the ordering and size of the middle group, which is the honest result: the middle group's ranking depends on which question you ask.*Estimators: [`sensitivity-methods.mjs`](sensitivity-methods.mjs); tests: `tests/sensitivity-methods.test.mjs`; offline run: `scripts/sensitivity-shapley.mjs`. References: Owen, A. B. (2014) "Sobol' indices and Shapley value," SIAM/ASA J. Uncertainty Quantification; Song, E., Nelson, B. L. and Staum, J. (2016) "Shapley effects for global sensitivity analysis," SIAM/ASA JUQ; Iooss, B. and Prieur, C. (2019) "Shapley effects for sensitivity analysis with correlated inputs," Int. J. Uncertainty Quantification; Broto, B., Bachoc, F. and Depecker, M. (2020) "Variance reduction for estimation of Shapley effects and adaptation to unknown input distribution," SIAM/ASA JUQ; Saltelli et al. (2008) Global Sensitivity Analysis: The Primer.*