Stochastic modeling
A CFDL model gives you the deterministic number and the distribution around it from the same file. Draws are seeded, so a run reproduces byte for byte.
Declaring uncertainty
Any assumption can be a distribution instead of a constant:
assume discount_rate = 0.10
assume rent_growth ~ Normal(mean=0.03, stdev=0.01, clip=[-0.02, 0.08])Supported distributions: Normal(mean, stdev, clip?),
LogNormal(mu, sigma, clip?), Uniform(min, max),
Triangular(min, mode, max). Expressions reference stochastic values the
same way as constants, via inputs.<name>.
A distribution takes a type and a range the same way a constant does:
assume occupancy : fraction ~ Normal(mean=0.92, stdev=0.03, clip=[0.7, 1.0]) within [0.5, 1.0]clip truncates the draws; within states the range the deal admits and is
checked on every value the run supplies, a refused value being E5041. A
deterministic run reads the distribution's clipped central value, which
equals the mean only for a symmetric distribution.
inputs.<name> is one draw per trial, the same everywhere in that trial.
sample(inputs.<name>) is a draw of the same distribution for the reader's
entity and period, for risk that is independent per entity and per period,
such as each loan in a pool defaulting on its own. It is read in a field
rule, a stream, a lifecycle guard or an arrival action.
Running Monte Carlo
run monte_carlo trials 20000 seed 42Every Monte Carlo run declares an explicit seed. Each assumption gets its own deterministic draw stream, so adding a new assumption never reshuffles another assumption's draws — results are reproducible byte-for-byte across machines and runs. The run configuration can override or add distributions without touching the model.
Scenario-consistent branching
Because draws are ordinary values, expressions can branch on them — producing coherent, binary outcomes per trial rather than expected-value blends:
// Per trial: either the tenant renews (renewal rent, no downtime)
// or the space rolls (market rent after downtime and re-lease costs).
amount = if(inputs.renewal_draw < 0.70, inputs.renewal_rent, inputs.market_rent)An expected-value blend hides the bimodal shape of outcomes like lease
rollover; per-trial branching preserves it. Every assumption is read through
inputs.: a bare name resolves to nothing, and the run is refused (E5031).
Dispersion inside one period: quantiles
Distributions spread a value across trials. Some economics depend on the spread within a single period — a battery earns the gap between a month's most and least expensive hours; overage rent is an option on sales above a breakpoint. A quantile-shaped assumption declares that within-period distribution as a value per cumulative share:
assume prices = quantile linear {
0.00: 11.0
0.50: 28.0
0.98: 340.0
1.00: 512.0
}Three functions read it: quantile_mean(inputs.prices, 0.98, 1.0) averages a
slice (the top 2% of hours), quantile_at reads one point, and
quantile_of inverts it — the share of hours below a threshold. A nonlinear
payoff evaluated at a point estimate is wrong even when the point estimate
is right; when the payoff bends, feed it the distribution it bends over. The
two compose: the quantile carries the within-period shape, and a distributed
assumption multiplying the read carries the across-trials uncertainty about
its level.
What results carry
Each trial's summary carries the full metric map — the engine's, the pack's,
and the model's declared metric.* figures alike — so a declared metric gets
a distribution, not only the built-ins. Monte Carlo results summarize each
metric with mean, stdev, min/max, percentiles (p01 through p99,
including p05/p25/p50/p75/p95), and trials — the count of trials
that published that name. A trial that cannot evaluate a declared metric
lists it under the trial's omitted with the reason. The NPV also gets an
aggregate, monte_carlo.aggregates.npv, with its mean, median, standard
deviation and p_negative, the share of trials with a negative NPV — see the
Results schema. The
Python SDK exposes them via results.monte_carlo().