The Code Layer

The procedure you run yourself

This appendix is for the reader who would rather write the code than drive a form or supervise a model. It performs the same arithmetic as the Profit Analytics app and the same procedure as the method layer, in R and in Python, with the reasoning kept where a person can read it.

Why code rather than a prompt. An earlier version of this material was written as prompts: a paragraph describing what to build, handed to an AI. It kept collapsing into the same thing. Asked in words for a transformation that counts every respondent and keeps the zeros, a model would return something conventional instead — fit a regression, drop the outliers, put the prices on an even grid. The only prompt that reliably produced the right code was one containing the code, at which point the prompt was doing nothing. So here is the code. Lift a block, change the variable names, run it.

Every block below runs on muscle-cola.csv, the practice dataset used throughout the book: 46 respondents, a protein cola sold through gyms. The printed numbers are what you should see. If yours differ, the difference is in your setup and it is worth finding before you trust the method on data whose answer you do not know.


What you need

Three columns, one row per respondent.

Column Meaning Used by
wtp the most this respondent would ever pay for one unit both elicitations
quantity how many they would take per period at that maximum price how-many only
quantity_at_P0 how many they would take per period if it were free how-many only

Every screened-in respondent stays in. Someone whose maximum is zero is not a broken row; they are a member of your market telling you they will not buy, which is one of the more useful things anyone will tell you. Someone who would take none even when it is free is the same. The most common way to get a wrong demand curve is to quietly drop them, and nothing in the output will tell you that you did.

The line that matters is not buyer versus non-buyer. It is inside the target population versus outside it. A non-buyer inside the population is evidence: the market contains people who decline at every price, and a curve that hides them overstates demand. A respondent outside the population is not evidence at all, whichever way they answered — an enthusiastic yes from someone who would never face this purchase corrupts the estimate exactly as much as their no would. That distinction is the screener’s job, not the analysis’s, and it is why a zero from someone who should not have passed the screen is a sampling problem rather than a demand observation.

A blank is not a zero. A typed zero is an answer. A blank is an unanswered question, and imputing zero for it manufactures a non-buyer who never existed — pushing the curve down and making the offering look worse than the evidence says.

The exception is a blank your instrument created. If the form branched — a respondent said they would pay nothing and was routed past the quantity questions — the blank is a zero, because the answer is implied by the path they took. They were not asked; they were answered for, correctly.

So the rule is: a blank means whatever your form made it mean.

Where the blank came from Read it as Then
The form branched past the question zero keep the respondent
The question was optional and they skipped it missing drop the respondent from that analysis, and report how many
You do not know which missing drop, report, and fix the instrument before the next fielding

And the design rule that makes this nearly moot: make the valuation questions required, with zero explicitly permitted. Then blanks should not occur at all, and any that do came from a branch.

muscle-cola.csv has no blanks in these three columns. All 11 respondents who would pay nothing typed a zero, so nothing in this appendix depends on the rule above — but your own data will.

import csv, statistics

def num(x):
    """A typed zero is an answer. A blank is not -- return None and decide later."""
    x = (x or "").strip()
    return float(x) if x else None

rows = list(csv.DictReader(open("muscle-cola.csv")))
cols = ("wtp", "quantity", "quantity_at_P0")
blanks = {c: sum(1 for r in rows if num(r[c]) is None) for c in cols}
blanks                                     # {'wtp': 0, 'quantity': 0, 'quantity_at_P0': 0}

wtp = [num(r["wtp"])            for r in rows]
q   = [num(r["quantity"])       for r in rows]
q0  = [num(r["quantity_at_P0"]) for r in rows]

len(rows)                                  # 46
sum(1 for w in wtp if w <= 0)              # 11  typed zeros, and they stay
sum(1 for x in q0  if x <= 0)              # 12
max(wtp)                                   # 3.51
statistics.median([w for w in wtp if w > 0])   # 1.99
library(dplyr); library(readr); library(tidyr); library(purrr)

d <- read_csv("muscle-cola.csv", show_col_types = FALSE) |>
  select(wtp, quantity, q0 = quantity_at_P0) |>
  mutate(across(everything(), as.numeric))

# Count the blanks before deciding anything about them.
summarise(d, across(everything(), ~ sum(is.na(.x))))   # 0, 0, 0 in this file

nrow(d)                  # 46
sum(d$wtp <= 0)          # 11  typed zeros, and they stay
sum(d$q0  <= 0)          # 12
max(d$wtp)               # 3.51
median(d$wtp[d$wtp > 0]) # 1.99

Clean what came back

The practice file is tidy. Your export will not be. It arrives with the screener questions still in it, the open-text answers, prices typed as $12, about 10, 12-15 and free, and respondents who should never have passed the screen. This section is the one that decides whether everything after it means anything, and it is mechanical rather than clever.

Parse, do not coerce. A value you cannot read is not a zero and not a one; it is a value you cannot read, and it should announce itself.

import re

def parse_money(x):
    """Return a number, or None if this cell cannot be read as one.
       Never guess: an unreadable answer is reported, not repaired."""
    if x is None: return None
    s = str(x).strip().lower()
    if s in ("", "na", "n/a", "-"):              return None
    if s in ("free", "nothing", "zero", "none"): return 0.0
    s = s.replace("$", "").replace(",", "")
    m = re.findall(r"\d+(?:\.\d+)?", s)
    if not m:  return None
    if len(m) > 1:                                # "12-15" -- a range, not an answer
        return sum(float(v) for v in m) / len(m)  # midpoint, and report that you did
    return float(m[0])
parse_money <- function(x) {
  s <- tolower(trimws(as.character(x)))
  out <- rep(NA_real_, length(s))
  out[s %in% c("free", "nothing", "zero", "none")] <- 0
  nums <- regmatches(s, gregexpr("[0-9]+(\\.[0-9]+)?", s))
  vals <- vapply(nums, \(v) if (length(v) == 0) NA_real_ else mean(as.numeric(v)), numeric(1))
  ifelse(is.na(out), vals, out)   # "12-15" becomes 13.5 -- report that you did this
}

Then, in this order:

  1. Drop respondents who failed the screen. Not because their answers are wrong, but because they are answers about a different population. Record how many.
  2. Decide the blanks by the rule above: a branch implies zero, an optional skip is missing.
  3. Flag, do not silently remove. Quantity at the maximum price exceeding quantity at zero. Willingness to pay above any plausible bound. Straight-lining — the same number in every box. Duplicate submissions.
  4. Reconcile units and period. Someone who answered per year when your period is monthly is a units error, not an outlier, and the fix is division rather than deletion.

Walk the flagged responses one at a time and say what each would do to the estimate if kept. The judgment is yours; the arithmetic below cannot make it for you, and a rule applied blindly at this step is how a clean-looking curve gets built on a contaminated sample.

Then report three numbers, always: screened in, usable, and flagged. If more than a fifth of your responses are unusable, the honest move is to re-field rather than to proceed.


Yes/no demand: counting

When a buyer takes one unit per period or none, the transformation is a count. At each price, how many respondents said they would pay at least that much? No regression, no interpolation, no assumption about anyone’s behaviour beyond the answer they gave.

Two details decide whether your curve is right.

Use the prices your respondents named. The distinct willingness-to-pay values are where the evidence is. An evenly spaced grid you invent puts as much weight on the sparse tail as on the dense middle, and it changes the answer.

Add a price of zero to that set. Respondents whose maximum is zero take one when it is free and nothing above it, which is exactly what w >= p produces. They belong at that point and nowhere else.

def demand_yes_no(wtp):
    """One unit per buyer. At price p, everyone whose maximum is >= p buys."""
    prices = sorted(set(wtp) | {0.0})
    return [(p, sum(1 for w in wtp if w >= p)) for p in prices]

yn = dict(demand_yes_no(wtp))
yn[0.0], yn[1.0], yn[2.0]                  # 46, 32, 15
demand_yes_no <- function(wtp) {
  prices <- sort(unique(c(0, wtp)))          # the respondents' own prices, plus zero
  tibble(price    = prices,
         quantity = map_dbl(prices, \(p) sum(wtp >= p)))
}

yn <- demand_yes_no(d$wtp)
yn |> filter(price %in% c(0, 1, 2))
#> price quantity
#>     0       46
#>     1       32
#>     2       15

That table is the demand curve: a staircase that steps down as the price rises. It is not an estimate yet and nothing has been fitted. Everything in the next section is the same operation with quantities that can exceed one.


How-many demand: horizontal summation

When a buyer takes several per period, each respondent’s three anchors describe their own small demand line:

  • q0 units at a price of zero
  • quantity units at their own maximum, wtp
  • nothing above it — a price beyond what they will pay does not make them buy fewer, it makes them stop

Join the first two points with a straight line and you have that person’s demand. Then add the quantities at each price. That sum is the market curve, and the word horizontal refers to the direction you are adding in: quantity runs along the horizontal axis, so you are summing sideways at a fixed height.

Respondents whose maximum is zero carry no slope. Dividing by wtp would divide by zero, so they contribute their free quantity at a price of zero and nothing above it. That is a coherent answer — I would take three if you gave them away and will not pay for them — not bad data.

def respondent_line(q_free, max_wtp, q_at_max):
    """That respondent's own demand: q_free when free, q_at_max at their
       maximum, nothing above it."""
    if max_wtp <= 0:
        return lambda p: (q_free if p <= 0 else 0.0)
    slope = (q_at_max - q_free) / max_wtp
    return lambda p: max(0.0, q_free + slope * p) if p <= max_wtp else 0.0

def demand_how_many(rows, prices):
    """Horizontal summation: add the respondents' quantities at each price."""
    lines = [respondent_line(*r) for r in rows]      # r = (q_free, max_wtp, q_at_max)
    return [(p, sum(f(p) for f in lines)) for p in prices]

rows   = list(zip(q0, wtp, q))
prices = sorted(set(wtp) | {0.0})
hm     = dict(demand_how_many(rows, prices))
[round(hm[p], 1) for p in (0.0, 1.0, 2.0)]           # [1402.0, 997.9, 419.0]
respondent_q <- function(p, q_free, max_wtp, q_at_max) {
  if (max_wtp <= 0) return(if (p <= 0) q_free else 0)   # no slope exists
  if (p > max_wtp)  return(0)                           # above their ceiling, they stop
  max(0, q_free + ((q_at_max - q_free) / max_wtp) * p)  # linear between the anchors
}

demand_how_many <- function(d, prices) {
  tibble(price    = prices,
         quantity = map_dbl(prices, \(p)
           sum(pmap_dbl(list(d$q0, d$wtp, d$quantity),
                        \(f, m, a) respondent_q(p, f, m, a)))))
}

prices <- sort(unique(c(0, d$wtp)))      # again: their prices, plus zero
hm <- demand_how_many(d, prices)
demand_how_many(d, c(0, 1, 2))
#> price quantity
#>     0   1402.0
#>     1    997.9
#>     2    419.0

Check these three numbers before going further. 1402.0, 997.9 and 419.0 are what the practice data produces. If your figures are close but not equal, the usual causes are blanks read as missing instead of zero, respondents dropped for having a maximum of zero, or a price grid of your own invention rather than the respondents’ own prices.


The two are one method

Yes/no demand is how-many demand where nobody buys more than one.

Set every respondent’s q0 and quantity to 1 and run the how-many code: each line becomes flat at one unit up to their maximum and zero above it, the sum becomes a head count, and the head count is the staircase from the previous section. One transformation, two special cases, which is why the app handles both without switching technique.

It also means a reader who has the three anchors can always produce the yes/no curve, and a reader who has only wtp cannot produce the how-many one. Collect the three anchors whenever the product is plausibly bought more than once per period; you can always discard information you do not need.


Fitting a curve

The staircase is evidence. A fitted curve is an assumption about the prices nobody was asked about, and it is what you need in order to compute profit at a price you are considering. Keep the two separate in your head: counting is nearly assumption-free; fitting invents behaviour.

Fit at the prices the respondents named, the same set you built the curve on, plus zero. An evenly spaced grid of your own invention weights the sparse tail — where one or two respondents sit — as heavily as the dense middle, and it moves the answer.

Fit all three forms and report all three. Not to crown a winner on fit, but to see how much your conclusion depends on the shape you assumed. When the three agree, your decision is robust; when they diverge, the divergence is the finding.

import numpy as np
from scipy.optimize import curve_fit

P = np.array([p for p, _ in hm_points])
Q = np.array([q for _, q in hm_points])

coef = np.polyfit(P, Q, 1)                       # Q = a + bP
pred_lin = lambda p: coef[0] * p + coef[1]

m = Q > 0                                        # log of zero is undefined
k, lnA = np.polyfit(P[m], np.log(Q[m]), 1)       # ln Q = ln A + kP
pred_exp = lambda p: np.exp(lnA) * np.exp(k * p)

f = lambda p, L, P0, s: L / (1 + np.exp((p - P0) / s))
par, _ = curve_fit(f, P, Q, p0=[max(Q), np.median(P), (max(P) - min(P)) / 4])
pred_sig = lambda p: f(p, *par)
lin_model <- lm(quantity ~ price, hm)

# Exponential: regress on log quantity. Drop zero quantities first -- log(0) is
# undefined -- and say how many you dropped.
exp_model <- lm(log(quantity) ~ price, filter(hm, quantity > 0))

# Sigmoid: a real non-linear optimizer, not a grid search.
sig_model <- nls(quantity ~ SSlogis(price, Asym, xmid, scal), data = hm)

pred_lin <- function(p) predict(lin_model, newdata = tibble(price = p))
pred_exp <- function(p) exp(predict(exp_model, newdata = tibble(price = p)))
pred_sig <- function(p) predict(sig_model, newdata = tibble(price = p))

SSlogis is written for curves that rise, so on demand data scal comes back negative (here −0.628). That is the curve bending the right way, not an error.

On the practice data, all three fit the same 22 points:

shape q at $1 q at $2
linear slope −447.6, crosses zero at $3.12 949.1 501.5
exponential A = 2209.9, k = −0.913 887.1 356.1
sigmoid L = 1520.7, midpoint $1.43 1008.5 434.9

At $1 they differ by 121 units — 12% — and at $2 by 145, which is 29%. Nothing downstream is more consequential than this choice, and no fit statistic makes it for you.

Goodness of fit, on one scale

Compute fit for all three by predicting quantity at each observed price and scoring there. This is easy to get wrong and the error is invisible.

The exponential was fitted by regressing on ln Q, so the R² that falls out of that regression describes how well the logs line up. It runs far higher than the same model’s fit to actual quantities and it is not comparable to the linear model’s R². On this dataset the log-scale figure is 0.913 and the quantity-scale figure is 0.607 — second of three by one measure, last by a wide margin on the other. The sigmoid, fitted by non-linear least squares, has no variance decomposition at all, so an R² reported for it is descriptive only.

def fit_quality(P, Q, predict):
    resid  = Q - np.array([predict(p) for p in P])
    ss_res = float((resid ** 2).sum())
    ss_tot = float(((Q - Q.mean()) ** 2).sum())
    return {"r2":   1 - ss_res / ss_tot if ss_tot else float("nan"),
            "rmse": float(np.sqrt((resid ** 2).mean()))}

fit_quality(P, Q, pred_lin)   # r2 0.970  rmse  73.0
fit_quality(P, Q, pred_exp)   # r2 0.607  rmse 262.5
fit_quality(P, Q, pred_sig)   # r2 0.993  rmse  34.5
fit_quality <- function(predict_fn, tb) {
  resid <- tb$quantity - predict_fn(tb$price)      # always on the quantity scale
  c(r2   = 1 - sum(resid^2) / sum((tb$quantity - mean(tb$quantity))^2),
    rmse = sqrt(mean(resid^2)))
}

fit_quality(pred_lin, hm)   # r2 0.970  rmse  73.0
fit_quality(pred_exp, hm)   # r2 0.607  rmse 262.5   <- not summary(exp_model)$r.squared
fit_quality(pred_sig, hm)   # r2 0.993  rmse  34.5

Report RMSE beside R², in units you recognise. “Off by 73 units a month on average” is a sentence you can argue with; 0.970 is not.

Neither statistic chooses the curve. Choose on behaviour: does quantity fall as price rises everywhere you care about, does the curve stay non-negative, does it say something possible at a price you have intuition about, and what does it assume beyond the range of your evidence? A model that fits slightly worse while implying something sensible is the better guide to an irreversible commitment.

What to expect on yes/no data

The same three fits run on a yes/no staircase, with one difference worth knowing in advance.

A logistic needs a flat shoulder at low prices before it bends, so that its ceiling and its bend can be told apart. Horizontal summation produces that shoulder. A yes/no staircase does not — it falls fastest immediately — so the sigmoid is poorly identified there.

On this dataset’s yes/no curve it converges and looks respectable, R² 0.957, and still gets the end that matters wrong: its ceiling lands at 39.6 when 46 people were observed at a price of zero, understating buyers at the low end by eight. The exponential does worse still, R² 0.165, because a head count does not decay exponentially. The linear fit, R² 0.941, is the honest description of a staircase.

So on yes/no data: fit all three, and expect the plain line to be the one you defend.

Look at it

Plot the points and the fitted curve together before you use either. This is the check that catches what statistics hide: a curve sitting consistently above or below the evidence, one that fits the middle and misses both ends, or one dragged by two extreme answers.

import matplotlib.pyplot as plt
grid = np.linspace(0, P.max(), 200)
plt.plot(P, Q, "o", label="respondents")
for name, fn in (("linear", pred_lin), ("exponential", pred_exp), ("sigmoid", pred_sig)):
    plt.plot(grid, [fn(p) for p in grid], label=name)
plt.xlabel("Price"); plt.ylabel("Quantity per period"); plt.legend()
plt.title("Demand: respondents' points and three fitted curves")
library(ggplot2)
grid <- tibble(price = seq(0, max(hm$price), length.out = 200)) |>
  mutate(linear = pred_lin(price), exponential = pred_exp(price), sigmoid = pred_sig(price)) |>
  tidyr::pivot_longer(-price, names_to = "model", values_to = "quantity")

ggplot(hm, aes(price, quantity)) +
  geom_point(size = 2) +
  geom_line(data = grid, aes(colour = model)) +
  labs(x = "Price", y = "Quantity per period",
       title = "Demand: respondents' points and three fitted curves")

Cover the curves and look only at the points. Would you have drawn that line yourself? If not, the difference between what you would have drawn and what was fitted is the finding, and it is usually telling you something about the evidence rather than about the model.


From sample to population

Your curve describes the people who answered. The commitment you are weighing is a population-scale number. They cannot be subtracted from one another until they are on the same plane, and moving them there is one multiplication and one large assumption.

Define the reachable population, N, before you compute anything. Not everyone with the problem. The people you could find, contact, earn trust from, and serve again next period at a cost you can carry. Write the reductions down — everyone with the problem, then those you can reach at all, then those reachable at a cost you can carry, then those you could serve again — and where a step is a range, use the low end and say you did.

n is everyone who passed the screen, including the people who would buy nothing. They are evidence about the population, not missing rows. On this dataset 11 of 46 would pay nothing and 12 would take none even when it is free; rescaling on 35 instead of 46 would overstate demand by 31% and nothing downstream would reveal it.

n = len(rows)          # 46
N = 5000               # reachable population
k = N / n              # 108.7

q_pop = lambda p: max(0.0, pred_sig(p)) * k
q_pop(1.0)             # 109,621
n <- nrow(d)          # 46 -- everyone screened in, zeros included
N <- 5000             # reachable population, argued down and written out
k <- N / n            # 108.7

q_pop <- function(p) pred_sig(p) * k      # or pred_lin / pred_exp
q_pop(1)                                   # 109,621 units per month

Rescale the fitted curve, not the raw points, and report k out loud. A curve built on 46 people is being multiplied by 109. That number should make you uncomfortable, and the discomfort is the correct response.

Say the assumption every time: the sample behaves like the population. It is the strongest assumption in the method and the arithmetic never tests it. A sample skewed toward enthusiasts does not produce a slightly optimistic curve — it produces one that is too high and too flat, which reads as raising the price is cheap. That is the most expensive thing to be wrong about. If you recruited through your own network, say so here rather than in a footnote at the end.

Break-even, as a check on the rescaling

required_sales  <- function(p, c, f) ceiling(f / (p - c))   # a fraction of a sale covers nothing
required_buyers <- function(p, c, f, units_per_buyer) required_sales(p, c, f) / units_per_buyer

Under yes/no these two are the same number. Under how-many they are not, and reporting sales as people overstates your position by exactly the units-per-buyer factor.

Then stop and divide by hand. Required buyers over N, said aloud as a fraction. This is the one calculation worth leaving undone in code: the number only lands when the person facing the commitment produces it themselves.


Cost

Two numbers, and both are about triggers rather than categories.

Variable cost, c — what one more sale triggers. A salaried baker who works the same hours whether you sell ten or thirty is not a variable cost; the flour is.

Fixed commitment, f — what going ahead commits: signed, hired, leased, ordered. This method runs before the commitment, so the number is prospective. It is the size of the bet, not a record of spending, and it is defined by irreversibility rather than by recurrence.

Both must be in the same period as your demand curve. This is the error that silently destroys a profit estimate: quantity per month against a commitment per year understates the required scale by twelve. Check it before you go further, every time.

c_var <- 0.90      # per unit, same period basis as q()
f_fix <- 8000      # per month, because quantity is per month

Profit

Assemble: profit(p) = (p − c) · q(p) − f, with q the population curve.

Do it on a grid, not with calculus. The fitted curve is clipped at zero, so the profit function has a kink, and a derivative set to zero can return a peak sitting in the region where quantity is already zero. A grid cannot make that mistake.

def profit_curve(q_of_p, c, f, lo=0.0, hi=4.0, step=0.01):
    out, p = [], lo
    while p <= hi:
        out.append((p, (p - c) * max(0.0, q_of_p(p)) - f))
        p += step
    return out

def read_curve(curve, lo, hi):
    pos = [p for p, pi in curve if pi > 0]
    peak_p, peak = max(curve, key=lambda t: t[1])
    if not pos:
        return {"reading": "impossible", "peak_price": peak_p, "peak": peak}
    width = (max(pos) - min(pos)) / (hi - lo)
    return {"reading": "fragile" if width < 0.28 else "robust",
            "band": (min(pos), max(pos)), "width": width,
            "peak_price": peak_p, "peak": peak}
profit_curve <- function(q_of_p, c, f, lo = 0, hi = 4, n = 401) {
  tibble(price = seq(lo, hi, length.out = n)) |>
    mutate(profit = (price - c) * pmax(0, map_dbl(price, q_of_p)) - f)
}

read_curve <- function(curve, lo, hi) {
  pos <- curve$price[curve$profit > 0]
  i   <- which.max(curve$profit)
  if (!length(pos)) return(list(reading = "impossible",
                                peak_price = curve$price[i], peak = curve$profit[i]))
  width <- (max(pos) - min(pos)) / (hi - lo)
  list(reading    = if (width < 0.28) "fragile" else "robust",
       band       = c(min(pos), max(pos)), width = width,
       peak_price = curve$price[i], peak = curve$profit[i])
}

curve <- profit_curve(q_pop, c_var, f_fix, 0, 4)
read_curve(curve, 0, 4)

Report four things, never only the first:

  1. peak profit, and the price where it occurs
  2. the band of prices where profit is positive
  3. that band as a share of the price range — the reading
  4. what falls away on either side, and how steeply

The reading: impossible if the curve never crosses zero, fragile if the positive band is under 28% of the price range, robust above it.

The shape you chose is now worth real money

Run the practice data both ways — same respondents, same N of 5,000, same c of $0.90 and f of $8,000 a month, price range $0 to $4 — and change nothing but the fitted curve:

fitted with peak profit at price positive band reading
linear $51,969 $2.01 $0.98 – $3.04 robust
sigmoid $44,941 $1.85 $0.98 – $4.00 robust

Same reading, $7,028 of monthly profit apart, at prices 16 cents apart. And look at the band’s upper edge: under the line it closes at $3.04, where fitted quantity reaches zero; under the sigmoid it runs to $4.00, the edge of the range, because a logistic tail still sells 5,859 units a month at $3.50 where the line says nobody buys.

Two lessons, and the second is easy to miss.

When the shapes disagree, the disagreement is the finding. Report it rather than choosing the flattering one.

A band that reaches the edge of your price range is not a band. It means the range was too narrow to contain the answer. Widen it and recompute, and say in your write-up what range you used — the width, and therefore the reading, is a fraction of a range you chose.


Feasibility, fragility, sensitivity — and optimization last

The order matters, and it is the opposite of what the arithmetic invites.

Feasibility first: is profit positive anywhere? If the curve never crosses zero, no price rescues it, and everything after is decoration.

Then fragility: how wide is the band? A robust reading says you can be wrong about the price and survive. A fragile one says the venture depends on finding a narrow window, and the honest response is to ask what would have to be true for the window to be wider.

Then sensitivity: which assumption, if wrong, changes the decision? Vary one input at a time — variable cost, fixed commitment, reachable population, units per buyer, demand slope, maximum willingness to pay — and record the value at which the reading changes. That threshold is the output. The ranking by size of effect is not.

threshold <- function(vals, build_q, c, f, lo = 0, hi = 4) {
  map_dfr(vals, function(v) {
    r <- read_curve(profit_curve(build_q(v), c, f, lo, hi), lo, hi)
    tibble(value = v, reading = r$reading, peak = r$peak)
  })
}

# example: how small can the reachable population get before this stops working?
threshold(seq(5000, 500, by = -500), \(N) \(p) pred_sig(p) * (N / n), c_var, f_fix)

Then sort what you found by actionability, not by size of effect:

  • testable — could be checked with more evidence, cheaply
  • designable — could be changed by changing the offering
  • deferrable — the commitment could wait until it is clearer
  • fixed — outside your control

Optimization comes last, and it is the least interesting number in this appendix. You already have it: peak_price from read_curve. It is reported after feasibility, fragility and sensitivity precisely so that it arrives as one fact among several rather than as the answer. A peak computed from a curve fitted to 46 people and multiplied by 109 is not a price to take to the bank. The band is what you can defend; the peak is where the band is highest.


The competition case — two products, a rival’s price, and equilibrium — is a different method throughout, and has its own appendix.