Assumptions and inputs
By now your amounts read well but your numbers are scattered: a growth rate inside one expression, a fee inside another, a rate that appears twice. This chapter is about where numbers live. The language has one channel for them: an assume declares a named number, every expression reads it as inputs.<name>, and the run can replace it by that same full name. A number the world supplies after the model is written is an assume with no value. Using that channel well is what makes one model serve a base case, a downside, a committee question, and a simulation without ever being edited.
Assumptions: the model's named claims
An assume statement declares a named input, readable anywhere as inputs.<name>:
version 0.1
model "assumptions-cafe"
time calendar monthly from 2026-01 for 36
entity asset cafe : Asset.Real
assume monthly_covers = 5200
assume avg_ticket = 14.50
assume food_cost_pct = 0.31
assume rent = 6800
stream cafe.revenue on entity asset.cafe inflow currency USD {
schedule every month from 2026-01 to 2028-12
amount = inputs.monthly_covers * inputs.avg_ticket
}
stream cafe.food_cost on entity asset.cafe outflow currency USD {
schedule every month from 2026-01 to 2028-12
amount = inputs.monthly_covers * inputs.avg_ticket * inputs.food_cost_pct
}
stream cafe.rent on entity asset.cafe outflow currency USD {
schedule every month from 2026-01 to 2028-12
amount = inputs.rent
}Look at what the block of assume lines has become: the model's assumption page. It is the first thing a reviewer reads, the only place a committee edit lands, and the complete list of what this model believes. Every number a person might question belongs here; the expressions below it hold structure, not opinions. An assumption can be an expression itself (assume annual_revenue = inputs.monthly_covers * inputs.avg_ticket * 12), so derived assumptions stay derived rather than pre-computed.
This is the discipline every good spreadsheet strives for with its "Inputs" tab in blue type — except here it is not a convention someone can drift from. A number buried in an expression is visible in review precisely because it is not in the assumption block, and the block is where your eye goes first.
An assumption is evaluated once, when the run starts, before any period exists. It may read other assumptions, but not the clock: an assume that reads time.t or a prior period is refused where it is written (E2313). A quantity that moves with the period is a calculation, and belongs in a stream or a field. An assumption no expression reads draws a warning (W5046), because a number on the page that moves nothing is a claim the model does not make.
Saying more about a number
An assume can say what kind of number it is, what range the deal admits, and where it came from:
version 0.1
model "assumptions-stated"
time calendar monthly from 2026-01 for 36
entity asset cafe : Asset.Real
// A type, and the range this deal admits.
assume food_cost_pct : fraction = 0.31 within [0.25, 0.40]
source { publisher "Cafe management accounts" as_of 2025-12-31 }
// A unit, recorded and shown on the review page.
assume rent = 6800 "USD/month"
// A set: named assumptions stated together and read by path.
assume menu = {
covers = 5200
avg_ticket = 14.50
}
stream cafe.revenue on entity asset.cafe inflow currency USD {
schedule every month from 2026-01 to 2028-12
amount = inputs.menu.covers * inputs.menu.avg_ticket
}
stream cafe.food_cost on entity asset.cafe outflow currency USD {
schedule every month from 2026-01 to 2028-12
amount = inputs.menu.covers * inputs.menu.avg_ticket * inputs.food_cost_pct
}
stream cafe.rent on entity asset.cafe outflow currency USD {
schedule every month from 2026-01 to 2028-12
amount = inputs.rent
}- Type.
: fraction,: rate,: decimal,: intor: durationgives the value its domain: afractionis 0 to 1. Untyped, an assumption is a decimal. within [lo, hi]. The range this deal admits, on top of the type's domain. It is checked, never clamped, wherever the value arrives: here, in an override, a scenario or a draw.- Unit. A unit literal,
6800 "USD/month", is an assertion, not a conversion. On an untyped assumption it is recorded and shown; on a field a pack has typed, it is checked against the pack's unit. source. Who published the number, the publisher's series, the date it describes, when it was retrieved, a URL and a note. Every field is optional.- A set.
assume menu = { … }states several assumptions together, read by path,inputs.menu.covers. Each field is an assumption of its own, overridden by its full name,inputs.menu.covers.
The results carry all of it on the review page, inputs.assumptions: one row per assumption, a set's fields each on their own row, with its shape, type, unit, value, whether the value is the model's or the run's, and the source it cited. That page is what a reviewer signs against.
An assumption can also hold a date-indexed series (the next chapter) or a quantile function (chapter 12). Both are read through the same inputs.<name>.
Distributions: the same claim, with a width
An assumption can carry a distribution instead of a point:
assume growth ~ Normal(mean=0.03, stdev=0.01, clip=[0.0, 0.08])Read it as a widened claim: "growth is about 3%, give or take 1%, and never outside 0–8%." Four families exist — Normal, LogNormal, Uniform, and Triangular — and each accepts an optional clip. A distribution takes the same type and within as a point value. Chapter 12 is where they earn their keep in Monte Carlo. What matters now is the deterministic behavior: in an ordinary run, a distributed assumption resolves to its central value. A Normal resolves to its mean, a Uniform to its midpoint, a Triangular to (min + mode + max) / 3. So you can state the uncertainty on day one, and your base case simply runs through the middle of it — one model, both readings, no edits between them.
What the run supplies: the run's side of the line
Chapter 3 drew the line: the model owns the claims, the run configuration owns the question. Everything crosses that line through one channel, inputs.*, addressed by full name.
A knob the question turns. State it as an assumption and let the run move it. assume stress_factor : fraction = 1.0 is the base claim; a scenario replaces it by name:
{
"deterministic": {
"annual_discount_rate": 0.08,
"parameters": { "inputs.stress_factor": 1.0 }
},
"scenarios": {
"base": { "parameters": { "inputs.stress_factor": 1.0 } },
"downside": { "parameters": { "inputs.stress_factor": 0.85 } }
}
}A parameter names an assumption by its full name. A key that matches nothing the model declares is refused (E5033), naming the nearest name it could have meant, so a misspelled override cannot pass silently. One model, and the scenario table — base and downside, side by side in one run's output — comes entirely from configuration. The committee's "what if revenue is 15% softer?" is answered without touching the file the committee reviewed, and the review page shows which rows the run moved.
A fact from outside. The index fixing, the appraised value, last month's actuals: things the world supplies after the model is written. State the assumption with a type and no value — assume sofr_fixing : rate — and the run must supply it, by name, or the run is refused before any period exists. A bodiless assumption is never read as zero. The type is required, since it is all the model says about the number (E2314). In a model with a pack, the type may name one of the pack's observables — assume sofr : cre.base_rate — which gives the value its unit. A bodiless assumption, and any assumption typed by a pack observable, is listed in the compiled model's required_refs: the list of what the model needs from outside. The run supplies a value in parameters, or a value, a series or a quantile through an inputs file the run configuration names, each entry carrying its own source so the review page says where the snapshot came from.
Where does a number belong?
The complete decision, four tests long — apply them in order:
- If a reviewer would question it → an
assume, on the assumption page. (Growth, margins, pricing, costs.) - If the answer varies per run, per scenario, per user → an
assumewith a base value, moved by the run configuration by name. (Stress factors, toggles; the discount rate belongs to the run outright.) - If the world supplies it after the model is written → a bodiless
assumewith a type and no value, filled by the run or its inputs file. (Fixings, actuals, appraisals.) - If it is structure, not opinion → leave it literal. (Twelve months in a year, a contractual
net 45, a stated loan principal in a signed document.) Signed structure can itself be a formula, such as a rent escalating at "CPI plus 50 basis points". The term then states that formula:escalation = inputs.cpi + 0.005. What was agreed stays in the contract either way; only opinions move to the assumption page.
The failure mode this prevents has a familiar smell: the spreadsheet whose "inputs" tab is half the story, with the other half hard-coded in formulas nobody has opened since the analyst who wrote them left. Here, the forms are syntactically distinct — an assume with a value, an assume without one, and a literal — so how a number is written states what kind of number it is, and the model documents its own epistemology.
What can go wrong
An input nobody supplied. Declare assume sofr_fixing : rate and run without it in the configuration or the inputs file, and the run refuses with the missing name (E5045) — not a default, not a silent zero. The claim/question split is enforced from both sides.
A number the run moves and the model also derives from. An assumption derived from an overridden one reads the override, because the run's values land before any assumption is evaluated — so a derived assumption never silently disagrees with the scenario that moved its input.
An assumption page that lies by omission. The gate you cannot automate: a beautiful assume block plus one hard-coded * 1.15 "temporary stress" someone left in an expression. The discipline is cultural — numbers with opinions live on the assumption page, and review enforces it — but the language makes the violation visible, which is the most a language can do.
Exercises
Build the assumption page
A refactor with an invariant: the totals must not move.
- Run the starter. Note the NPV.
- Hoist the four buried numbers into
assumestatements. - Read each one back through
inputs.*. - Run again. Confirm the NPV matches to the penny.
A refactor that changes no numbers is the only kind you can make to a reviewed model without a re-review. The next change will be one visible line on the assumption page.
Then, on your own:
- Take the café model and add a downside scenario in the run configuration that cuts
monthly_covers20% via a parameter override — without editing the model. Compare the two NPVs in the scenario table. - From a spreadsheet you know well, list five numbers and assign each a form — an
assumewith a value, one the run moves, a bodilessassume, or a literal — using the four tests. The two you hesitate on are the interesting ones: they are usually claims masquerading as structure.