A coffee shop is the smallest resource model we have that still has a real question in it: five drinks draw on shared stocks, and we want to know which stock runs out first. The net here is the five-drink core of NewInventoryNet in go-pflow's examples/coffeeshop/inventory.go. That file also carries an iced latte, a sugar condiment and a refill transition per ingredient; we leave those out, the same cut Chapter 5 of the book makes. Recipes are arc weights, each ingredient gets a conservation law from a paired tracking place, and the rates give the depletion order. The interactive café on sim.pflow.xyz is a different net — see below.
| Drink | Beans | Water | Milk | Syrup | Cups |
|---|---|---|---|---|---|
| Espresso | 18g | 30ml | — | — | 1 |
| Americano | 18g | 200ml | — | — | 1 |
| Latte | 18g | 30ml | 180ml | — | 1 |
| Cappuccino | 18g | 30ml | 120ml | — | 1 |
| Mocha | 18g | 30ml | 150ml | 2 pumps | 1 |
Starting inventory: 1,000g beans, 10,000ml water, 5,000ml milk, 500 pumps of syrup, 100 cups. Orders arrive continuously, and the question is which stock goes first and how that moves when demand changes.
The model has two categories of places. Ingredient places hold current stock:
| Place | Initial | Role |
|---|---|---|
coffee_beans |
1000g | Raw ingredient |
water |
10000ml | Raw ingredient |
milk |
5000ml | Raw ingredient |
cups |
100 | Consumable |
syrup |
500 pumps | Raw ingredient (mocha only) |
Consumption tracking places count what's been used (all start at 0): beans_used, water_used, milk_used, cups_used, syrup_used. This paired structure creates conservation laws — every token consumed from an ingredient place appears in the corresponding tracking place.
Each drink requires specific quantities. The make_espresso transition has:
coffee_beans (weight 18), water (weight 30), cups (weight 1)beans_used (weight 18), water_used (weight 30), cups_used (weight 1)The arc weights are the recipe, and the conservation law follows from them: coffee_beans + beans_used = 1000 for all time. In go-pflow's example they are not the only copy of the recipe, though. inventory.go also keeps a Recipes map that CanMakeDrink reads, and the two drifted: Recipes listed an iced latte that the net had no transition for, so the shop offered a drink the net could not make, until make_iced_latte was added in August 2026 (the comment above it in inventory.go says why). The net can only be the specification if it is the one place the recipe lives.
Each transition has a rate constant. InventoryRates gives them as drinks per minute at peak:
| Transition | Rate | Drinks/hr |
|---|---|---|
make_espresso |
0.5 | 30 |
make_americano |
0.3 | 18 |
make_latte |
0.8 | 48 (most popular) |
make_cappuccino |
0.4 | 24 |
make_mocha |
0.2 | 12 |
The continuous dynamics follow mass-action kinetics:
flux = rate × ∏[input places]
go-pflow's solver (solver/ode.go) uses the first power of each input's marking; the arc weight scales how much a firing consumes, not the exponent. As a stock depletes, every transition that reads it slows with it, with no scheduling logic. The product has a cost the drinks-per-minute column hides: a latte's flux carries a factor of milk (5,000) that an espresso's doesn't, so inside the ODE the rate constants are not drinks per minute at all, and the example multiplies every one of them by 0.0001 before solving (simulation.go). Scaling every constant by the same factor only changes the clock. The drinks-per-minute reading belongs to the linear estimate in the next section.
The order follows from how big a bite one drink takes out of each stock. A milk drink takes 120–180ml of 5,000ml milk (2.4–3.6%), 18g of 1,000g beans (1.8%), one of 100 cups (1%) and 30ml of 10,000ml water (0.3%). Reading the rate constants as drinks per minute — what PredictRunout in simulation.go does — gives a linear runout per stock, which can be checked by hand:
| Stock | Draw per minute | Linear runout |
|---|---|---|
| milk | 180(0.8) + 120(0.4) + 150(0.2) = 222 ml | ≈ 22 min |
| coffee_beans | 18 × 2.2 = 39.6 g | ≈ 25 min |
| cups | 2.2 | ≈ 45 min |
| water | 30(0.5 + 0.8 + 0.4 + 0.2) + 200(0.3) = 117 ml | ≈ 85 min |
| syrup | 2 × 0.2 = 0.4 pumps | ≈ 1,250 min |
Milk goes first, and once it runs low all three milk drinks stall, because milk is a factor in each of their fluxes. Beans go next, still drawn by espresso and americano. Cups are the smallest number in the shop and never come close: a latte takes 180 tokens of milk and one cup, and the weights decide the order, not the counts.
What we have not committed is an ODE run that shows this order with its own clock. Under product kinetics no stock reaches exactly zero — each decays, and the transitions that read it slow down with it — so "first" means "falls fastest", and the minutes above are the linear estimate's, not the ODE's. The conservation laws do hold along any ODE trajectory, because the solver adds 18v to beans_used for every 18v it takes from coffee_beans; checking coffee_beans + beans_used = 1000 in the solver output is a quick test of the wiring, though no test in the example asserts it yet.
By adjusting rates, we can model different days. The minutes are linear estimates from the table above:
| Scenario | Change | First Bottleneck |
|---|---|---|
| Slow day | Halve all rates | Milk still first, later (~45 min; beans ~50) — 5,000ml still doesn't last a shift |
| Normal | Baseline rates | Milk (~22 min) |
| Rush hour | Double all rates | Milk in half the time (~11 min) |
| Latte promotion | Triple latte rate | Milk still first and sooner (~10 min, 510ml/min); beans move up from ~25 to ~15 min |
Each scenario is the same net with different rate constants. The structure — places, arcs, weights — stays the same. Only the dynamics change.
The full Petri net has ingredient places, tracking places, and one transition per drink type:
Places:
coffee_beans, water, milk, — Ingredients
syrup, cups
beans_used, water_used, — Consumption tracking
milk_used, syrup_used, cups_used
Transitions:
make_espresso, make_americano, — Drink preparation
make_latte, make_cappuccino,
make_mocha
Every arc runs from an ingredient place into a drink transition or from a drink transition out to a tracking place, with the same weight on both sides, so each ingredient and its tracking place sum to a constant — one conservation law per ingredient. Add the refill transitions from inventory.go and that pair no longer balances; the law widens to three places (stock, used and supply), which Chapter 5 works through.
Changing the rates moves the timings: milk leads at baseline, and a latte promotion makes it lead sooner while pulling beans up behind it. None of the four scenarios moves the order of the first two, and we haven't searched for a rate mix that would. What the rates can't change is which recipe touches which stock; that is fixed by the arcs.
The café on sim.pflow.xyz has three drinks fused from three separately written nets — counter, pantry and staff (16 places, 18 transitions) — with two baristas, capped stocks with restock transitions, customers who walk out, and no water and no tracking places. Its recipes are its own: espresso takes 20g of beans, a latte 15g and 50ml of milk, a cappuccino 15g and 30ml, against 2000g of beans, 2000ml of milk and 200 cups. The two diagrams below are from an older three-drink net again — an order → make → serve workflow (8 places, 12 transitions, services/coffeeshop.json) with the same espresso and latte recipes against 500ml of milk — not from the one above. None of these numbers will match the tables here. The two models haven't been reconciled; Small Models > LLMs notes the same gap.
inventory.go, with links to it, simulation.go and the solver. Mocha's two pumps of syrup, missing from the recipe table, are back. The "slow day" row said nothing depletes during a shift; at half rates the linear estimate still runs milk out in about 45 minutes, which is what the row now says. The ODE figure "half the milk gone in roughly 21 minutes" came from a run that wasn't kept, and the example's ODE uses rescaled constants on a different clock, so it is gone; every minute figure is now labelled as the linear estimate. The rate constants are no longer called drinks per minute inside the ODE. "No separate configuration needed" is qualified: go-pflow's example kept a second copy of the recipes, and it drifted. The demo link and both diagrams are now labelled as the demo's three-drink net, and a hand-drawn depletion chart that showed beans going first (at 20g per espresso, from the demo net) was removed.Related: Declarative Differential Models · Skip the Spreadsheet: What-If Analysis · Small Models > LLMs · Enzyme Kinetics · Chapter 5: Resource Modeling of Petri Nets as a Universal Abstraction