Status: Normative for the CFDL expression language (implemented by cfdl-calc,
exposed through cfdl-expr).
CFDL expressions are bare, Excel-familiar formulas written directly in model source:
amount = base_rent * (1 + escalation) ^ (time.t / 12)
active when time.t >= 6
They are deterministic and terminating by construction: no loops, no recursion, no I/O, no user-defined functions. Every expression, on the same inputs, always produces the same value.
1. Numeric semantics
Two evaluation modes exist; models always run in decimal mode.
-
Decimal mode (default). All arithmetic is exact 128-bit decimal (
rust_decimal, 28 significant digits).0.1 + 0.2 == 0.3istrue. Float64 is used ONLY as a documented escape for transcendental operations: fractional exponents (x ^ 0.5), the transcendental functions (ln,exp,normal_cdf), and iterative solvers (rate,cpr_to_smm). Integer exponents are decimal-exact. -
excel_compat mode. All arithmetic runs in IEEE-754 float64, reproducing Excel's representation artifacts (
0.1 + 0.2 - 0.3yields ~5.55e-17, exactly as Excel does), for proving parity against Excel reference models and explaining decimal-vs-float differences.A run selects it with the
arithmetickey in its run configuration —"decimal"(the default) or"excel_compat"— so a model CAN be run in it without touching Rust. The engine carries the choice into every expression environment, andevaliseval_with_mode(compiled, env, env.mode), so the mode is honoured on every evaluation rather than being an unused escape.Whether that matters is measured rather than assumed: the credit pack's arithmetic is evaluated both ways and the divergence is pinned below 1e-12 — about ten orders of magnitude inside the tolerance of the benchmark it feeds. Decimal mode already routes fractional exponents through the f64 escape, so the two modes differ only where a model accumulates long sums or compares for equality.
Rounding: round() follows Excel semantics (half away from zero), not
banker's rounding. round_down/round_up truncate toward/away from zero.
2. Syntax
- Operators, by precedence (loosest to tightest):
or<and<not< comparisons (==,!=,<,<=,>,>=) <+ -<* / %< unary-<^(right-associative). =and<>are accepted as Excel-style aliases for==and!=inside expressions. The compiler writes every expression into the IR in one canonical spelling,==and!=among it (docs/04§6.2), so how an expression is spelled never movesmodel_hash.- Literals: decimal numbers (
1200,0.05,1_000_000),true/false, double-quoted strings. - Variables are dotted paths resolved from the host environment (see §3).
- Function calls are lowercase snake_case:
pmt(0.005, 360, 100000).
3. Namespaces
The host (compiler or engine) provides values under these roots:
| Root | Contents |
|---|---|
model | model.id, model.base_currency |
time | time.t (0-based period index), time.date, time.phase, time.ppy (periods per year for the model's calendar), time.days_in_period |
entity | fields of the stream's owning entity, and every entity's fields under its family — entity.asset.tlb.balance |
asset, party, container, contract, reference | an entity's fields, spelled bare: asset.tlb.balance is the same read as entity.asset.tlb.balance |
inputs | assumption values (assume statements), including ones derived from other assumptions (§2.1) |
prev | a field's own previous value, bare — present inside that field's next only |
prev.<entity>.<field> | a field one period back — prev.asset.tlb.balance, inside a rule |
prev.<account> | an account's OPENING balance — the previous period's close, every allocation and movement through it included, or the account's init in the first period. Readable in rules, guards, step expressions and stream amounts; a stream never reads a same-period close |
available | this period's netted stream cash of the waterfall's entity — waterfall from and step expressions |
remaining | what is left in the pot — present in waterfall step expressions only (§3.2) |
paid | paid.<step>, what an earlier waterfall step actually paid — steps only |
owed | owed.<step>, what an earlier step would have paid, unbounded — steps only |
Unknown variables are hard errors (EXPR_EVAL), not nulls.
2.1 Derived assumptions
An assume may read another through inputs.<name>:
assume gross_sf = 10000.0
assume efficiency = 0.85
assume net_sf = inputs.gross_sf * inputs.efficiency
Assumptions resolve in dependency order, so each one is evaluated after
everything it reads — declaration order and name order are both irrelevant.
Random assumptions (assume ... ~ <dist>) resolve first: a distribution's
central value reads nothing, so a derived assumption may be built on one.
A circular derivation is refused with the cycle named, the same way a circular series read is (§3.1): no order satisfies it, and the engine does not iterate toward a fixed point. A name that is not an assumption is not a dependency — it comes from the run configuration, or from nowhere, and an unresolved name is a hard error by the rule above.
time.ppy is how many periods of the model's calendar make a year — 365, 12,
4 or 1 — so a model can spread an annual figure without hardcoding a divisor
and without being rewritten when the calendar changes:
amount = inputs.rent_year / time.ppy
Domain packs do not use it. A lowering rule resolves its own
periods-per-year at compile time ({{model.periods_per_year}}, see
Pack Interface), because a rule may pay on its own interval: a
monthly-paying loan carried on a daily book divides by 12, not 365, and only
the compiler can see that. time.ppy reads the calendar and would say 365.
time.days_in_period is the actual calendar days the current period spans —
31 in January, 28 in a non-leap February, 1 on a daily grid. It is a property
of the GRID, and it answers for the period the model is standing in, not for
the period a stream fires over. So rate * time.days_in_period / 360 is an
Actual/360 accrual only for a stream that fires every period; a stream on its
own longer cadence (schedule every quarter on a monthly grid) must measure
its own span, which year_frac does:
rate * year_frac(time.date, edate(time.date, 3), "act/360"). time.date is
the period's START, so edate from it names the end. Packs reach the same
expression through {{model.accrual_divisor}} rather than either form
directly.
3.1 Fields that move: <family>.<entity>.<field> and prev
A field with a rule is a named number per period defined by a recurrence — the
one shape pow(1 + r, t) cannot express, since that applies a single period's
rate as though it had held from the start. It belongs to the entity it describes:
entity asset firm : Asset.Financial {
revenue_index init 1.0
next prev * (1 + inputs.growth)
}
stream firm.revenue on entity asset.firm inflow currency USD {
schedule every year from 2026-01 to 2035-01
amount = 21765.4 * asset.firm.revenue_index
}init is the value at period 0 and is mandatory — an unstated base case
would otherwise evaluate as a silent zero for every period, since an unmatched
lookup returns 0. next is the value at every later period.
Inside next, bare prev is this field's own previous value and
prev.<family>.<entity>.<field> is another field's. A rule may not read any
field at the current period, which is what keeps a cycle unexpressible:
| form | resolves to | present in |
|---|---|---|
<family>.<entity>.<field> | that field at the current period | streams, waterfall steps, event guards |
prev | this field at the previous period | next expressions |
prev.<family>.<entity>.<field> | another field at the previous period | next expressions, streams |
prev accepts the entity-root spelling too — prev.entity.asset.tlb.balance
is the same read as prev.asset.tlb.balance, exactly as the two current-period
spellings are one read. It is the form a pack lowering rule produces, since
field.<name> resolves through the entity root (docs/07).
A stream environment carries no bare prev, so a stream cannot ask for "the
previous value" of something it does not own — the entry is not there to be
found. The same mechanism as series being empty when a wave-0 stream
evaluates.
Because everything a rule can read is already finished, no reference can close a cycle. Fields may therefore reference each other freely, including mutually, and declaration order carries no meaning:
entity asset pair : Asset.Financial {
a init 1.0 next prev + prev.asset.pair.b
b init 1.0 next prev + prev.asset.pair.a
}A field steps on the clock of whatever brought it
A field has no schedule clause of its own. An entity is not a temporal thing:
it does not start, stop or recur, so there is no cadence for it to carry. A
field declared directly on an entity therefore steps every model period.
A field a CONTRACT brings inherits that contract's schedule, because the contract is the thing with a term and a payment frequency. The recurrence steps on that cadence and holds between ticks, which is what lets a pool carried on a daily calendar but paying monthly compound its hazard twelve times a year rather than three hundred and sixty-five.
Two details that are off-by-one traps:
- The recurrence steps on accrual periods, not settlement periods. A quarterly schedule accrues at periods 0, 3, 6 and settles at 2, 5, 8; a stream's amount is evaluated at the accrual, so that is where the field must align.
initis the value at the first tick, not at model period 0. Otherwise the first payment would read the second value of the recurrence.
Three further properties, each the opposite of a defensible alternative:
- Holding is not being inactive. Between ticks a field keeps its value. An
inactive stream yields 0; a field does not, which is why
active whenhas no meaning here — a cadence says when the recurrence advances, not whether the quantity exists. - A field is not cash. It has no direction or currency. It is published in
results under its own path as bare numbers, and never enters
model.total,model.npv, the annual rollup or any domain metric. nexthas no series access in v0.1. It seesprev,prev.<family>.<entity>.<field>,time.*,inputs.*and curves. Reading a stream's history from a recurrence is not expressible;series_sumremains the route to a stream's window, from a stream.
3.2 Waterfall steps: remaining, paid and owed
A waterfall step is an expression like any other, with four extra names.
available is the cash the waterfall's entity produced this period: its
streams' signed values, netted, with its children rolled up by part of.
Streams only — no distribution feeds it, so a waterfall can never read its own
output through it. The engine supplies it before the waterfall runs; no model
declares a field for it. from available is therefore the ordinary spelling of
a pot, and the from expression remains free for the deals that draw on
something narrower.
remaining is what survives the steps above it. A step pays
min(max(0, expr), remaining), so = remaining means exactly what it says,
a step asking for more than is left takes what is left, and a negative
expression pays nothing rather than clawing cash back.
paid.<step> and owed.<step> read a step declared earlier in the same
waterfall — what it actually paid, and what it would have paid had the pot
been deep enough. They differ exactly when a step could not be paid in full,
so their difference is that step's shortfall:
amount = owed.trustee_fee - paid.trustee_fee
That is how a capped fee gets its overflow paid at a later priority, and how a step measures a balance "after giving effect to" the payments above it. Reading a step declared later is a compile error.
A step also sees everything a stream sees, including entity fields at the current period — a waterfall runs after fields are evaluated, so the balances it tests are period-close values.
Steps publish as series <waterfall>.<step>, so series_sum reaches an earlier
waterfall's output from a later one's from expression. That is how one
waterfall's payment becomes another's pot:
from series_sum("senior.residual", time.t, time.t)
Results publish the same series one namespace down, as
stream.<waterfall>.<step>, because in results every stream carries that
prefix. The results name is not the name an expression reads. This section
gave the results name until August 2026, and a model that followed it got an
empty pot rather than a diagnostic — a name nothing matches is not an error,
because a selector that matches nothing must be able to sum to zero. That
asymmetry is now the difference between a pattern (a * segment or |
alternatives, docs/01 §16.2) and a literal name; see
E5022_UNKNOWN_SERIES_REFERENCE.
A step's series is visible to a later waterfall's from and to nothing else.
Neither a stream nor a field's next can read it.
4. Builtin functions
Conditionals & aggregates: if(cond, a, b) (lazy — only the taken branch is
evaluated), min, max, sum, avg, abs.
Rounding: round(x, [digits]), round_down(x, [digits]),
round_up(x, [digits]), and round_to(x, step), which rounds x to the
nearest multiple of step, halves away from zero: round_to(28.05, 0.1) is
28.1 and round_to(1013, 25) is 1025. A step that is not a decimal power
is a tick a schedule publishes, such as a credit set to the nearest 0.1 cent
per kWh. A step of zero or below fails the period it is evaluated in, and
the run refuses with E5043; a rule that allows no rounding guards the
call, if(step > 0, round_to(x, step), x).
Math: pow(base, exp) (function form of ^), clamp(x, lo, hi).
Time value of money (Excel sign conventions, decimal-exact for whole-period
terms). Excel sign conventions mean pmt returns a negative number for a
positive pv — a loan payment is money leaving. On a stream already declared
outflow, negate it (amount = -pmt(...)), or the two negatives cancel and the
payment registers as income: pmt(rate, nper, pv, [fv], [due]), pv(rate, nper, pmt, [fv], [due]),
fv(rate, nper, pmt, [pv], [due]), nper(rate, pmt, pv, [fv], [due]),
rate(nper, pmt, pv, [fv], [due], [guess]) (Newton solver, f64, tolerance
1e-12), ipmt(rate, per, nper, pv, [fv]) / ppmt(rate, per, nper, pv, [fv])
(interest/principal split of payment per, 1-based; ordinary annuities).
Depreciation: macrs_rate(year, life) — IRS Pub 946 GDS half-year convention
percentages for 5/7/15/20-year property (year is 0-based; 0 beyond the
recovery period).
Credit: cpr_to_smm(cpr), cpr_to_periodic(cpr, ppy).
cpr_to_smm(x) is 1 - (1-x)^(1/12) and always means monthly.
cpr_to_periodic(x, ppy) is the same conversion on a grid of ppy periods per
year, and cpr_to_periodic(x, 12) == cpr_to_smm(x) exactly. Note this is a
root, not a division: CPR and CDR are effective annual rates, so they
convert by taking a root, while note rates are nominal and convert by dividing.
Using one convention for the other is a silent factor-level error.
Series: a series-shaped assumption (assume sofr = curve { … }) is read at
the reader's period as inputs.sofr, and curve_value(inputs.sofr, date)
reads it at another date; the first argument names the assumption, never a
string. step series (the default) are flat-forward: the last point at or
before the query date (the first value before the first point). linear
series interpolate linearly in calendar days between bracketing points and
clamp flat outside the declared range. Referencing an undeclared
curve is an evaluation error.
Sampling: sample(inputs.<name>) is a draw from a distribution-shaped
assumption for the reader's subject entity and period (docs/01 §12.2.1),
where inputs.<name> alone is one draw per trial. The argument is a
REFERENCE, like irr's: the assumption is named, not evaluated, and the
compiler checks it is a distribution (E2312). A draw is keyed by the
assumption's full name, the trial, the subject and the period, so the same
key always gives the same value and adding an entity or an assumption moves
no other draw. The subject is the entity whose field the rule computes, the
stream's owner, or the entity a lifecycle guard or arrival action moves; an
expression with no subject or no period, an event's when, an option, a
waterfall, an account or a metric, refuses it (E2315), and an assumption
refuses it by the timeless rule (E2313). A deterministic run reads the
clipped central value, which for a uniform is 0.5, so a draw compared to a
probability takes the likelier branch.
State entries: state_enter(<entity>, <state>) is the period at which the
entity most recently entered that lifecycle state, on the run's grid, so
elapsed-since-entry is time.t - state_enter(...) and needs no counter field.
The same words anchor a schedule (docs/01 §11.4); this is the value that
anchor resolves to (§11.5). Both arguments are REFERENCES, written bare and
resolved by the compiler: an entity the model declares and a state its machine
declares. Inside a lifecycle guard or arrival action the subject is written
entity, the entity-relative root a guard already uses for entity.<field>.
An entity that has not entered the state by the period being evaluated has no
answer, and the read is refused by name rather than resolved to zero or to a
period in the future. Only entries the state stage has settled are visible.
Parts: part_sum(<entity>, <summand>[, <state>][, <field> == <value>]) is the
sum over every entity part of that entity, at any depth, of a summand read
on each part, and
part_count(<entity>[, <state>][, <field> == <value>]) is how many there are.
The two filters are optional and positional: a state name keeps only the parts
in that lifecycle state, and a <field> == <value> test keeps only the parts
whose field has that value. part_sum(asset.north, rentable_area, leased) is
the leased area of the building, and dividing it by
part_sum(asset.north, rentable_area) gives its occupancy. The entity, the
field and the state are REFERENCES, written bare. The summand is a field
name or an expression on the part: inside it a bare name is the part's field
and prev.<name> the part's account, and every other root (inputs.,
time., a function) means what it means anywhere. A weighted average is a
ratio of two sums: a pool's coupon weighted by each loan's current balance is
part_sum(asset.pool, coupon * prev.balance) / part_sum(asset.pool, prev.balance), and a zero total weight is a division the run refuses rather
than a value it invents. The compiler expands each call into an ordinary sum
over the declared parts, so it reads each part's field at the reader's period
as any field read does. A state filter reads each part's lifecycle state at
the reader's moment: a stream, a waterfall step and pot, and an account's
inflow read the period's state; a field's rule and a guard read the state as
the period opened, so a move a machine makes is visible to the next period's
rule; a metric reads the state as the horizon closes; an assumption reads each
part as declared, its state or its machine's initial. Every name the summand reads must be declared on every
part the call reaches (E1131, naming the part). A parent with no parts
gives 0, and a parent the model does not declare is refused (E1387). A field
does not fold through part of the way an account does (docs/01 §10.6):
an area is additive and an occupancy or a coupon is not, so a model sums a
field by saying so.
Participant returns: irr(party.<name>) and moic(party.<name>) fold what one
party's ACCOUNT came to — a contribution is a negative inflow, a receipt is an
allocation in, so the sign change an IRR needs is recorded rather than
inferred. The argument is a REFERENCE, as curve_value's and
quantile_at's are assumptions named by path: a party is an entity, and
the reference is what the compiler resolves, type-checks and reports on. Both
are available in a metric declaration and nowhere else: they read the finished projection, so a
stream amount that called one would be asking for a return on cash that stream
has not produced yet, which the compiler refuses (E1355). A party that owns
no account, or whose flows never change sign, refuses the run naming the party
rather than publishing nothing.
Quantiles: a quantile-shaped assumption (assume prices = quantile { … }) is
indexed by cumulative share, not by date, and three functions read one, each
taking the assumption as inputs.<name> first, never a string.
quantile_at(inputs.x, share) is the value at a share — the direct analogue
of curve_value. quantile_mean(inputs.x, from, to)
is the mean over a share slice: a PARTIAL EXPECTATION, computed as the exact
integral of the interpolated function (rectangles under step, trapezoids
under linear) rather than by sampling it. quantile_of(inputs.x, value) is the
inverse of quantile_at — the share at or below which a value sits — and is
what turns a stated threshold, a lease breakpoint or a tranche attachment
point, into a share a slice can be taken against. Referencing an undeclared
quantile is an evaluation error, and so is passing a series to these or a
quantile to curve_value: the two are on different axes and neither resolves
against the other.
An entity's LITERAL fields are constants of the model and are readable in
every expression as <family>.<name>.<field> — asset.north.units, or a
string such as asset.north.delivery handed to parse_date. A field
carrying a rule is a series and is read as one (docs/01 §7).
Cross-stream series: series_sum, series_avg, series_max, series_min,
series_prod and series_count, each (name, from_t, to_t), reduce another
stream's signed per-period amounts over an inclusive period window (prefix.*
wildcards supported).
A subtotal for one entity. In a metric or a valuation figure, a money
subtotal may be read for one entity as entity.<symbol>.<subtotal> —
series_sum("entity.asset.north.domain.cre.noi", 0, time.t): the subtotal
folded over the streams that entity owns and every descendant's, following
part_of as entity.<symbol>.net_cash_flow does. A building's NOI is its
own in a model that holds several, and a portfolio's is the sum of its
buildings'. It is computed where an expression names it and is not
published. Causal logic — a stream, a field, a guard, an event, an option —
reads it, and the model's domain.*, once its cash has settled: a window
ending at time.t - 1 reads settled cash from anywhere; a STREAM may end the
window at time.t, read after every stream the subtotal folds, unless it is
one of them (E2113) or reaches one through what it reads (E5035); a
field, a guard, an event and an option reaching the period are refused
(E2113); and a ratio subtotal is a metric's to read (docs/01 §9).
wal(name[, from_t, to_t]) is the seventh, and not a reduction of the same
kind: it answers in YEARS, weighting each period's amount by its position on
the axis every time-weighted metric shares and dividing by the
total, so a bullet's life is its term and an ordinary annuity's first
collection falls at one period rather than zero. It is null when nothing was
paid — a life of nothing is not zero years — and each matched series is
weighted at its own placement, so it is the one fold that does not collapse a
selector to a single aggregate first.
Two valuation folds follow it, in a metric declaration and nowhere else
(docs/01 §15.3; E1355 elsewhere). npv(name, rate[, from_t]) is the
present value, at an annual rate compounded to the calendar's period as
model.npv is, of what the matched series pay from the open of from_t
(period 0 when absent) to the cash horizon, each matched series discounted
at its own placement in the period as wal weights it. yield(name, outlay[, from_t]) is the annual rate at which that present value equals
outlay, the full price paid at the open of from_t: the outlay is placed
as the first flow and the same bracketed bisection model.irr runs finds
the rate, annualized as model.irr is. A bond-equivalent
yield is arithmetic on the result: 2 * ((1 + y) ^ 0.5 - 1). Both publish
null when the selection matched nothing or no rate solves; an outlay that is
not positive, or a rate at or below -100%, is an evaluation error. A ratio
subtotal is refused as it is for wal: a ratio is not cash, and a present
value prices what was paid.
spread(name, outlay, inputs.<index>[, from]) is the third, for a
floater: the annual spread s over the named index path at which what the
matched series pay from the outlay's date is worth outlay. The index is
a series-shaped assumption, passed as a reference as curve_value takes
one, and read at each period's open, the reset date; between one
settlement date and the next the discount accrues at (index + s) × Δt,
SIMPLE over the stretch, with Δt the years between the two dates on the
series' own measure: a security's day count, or the period. That is the basis a floating coupon
accrues on and the definition of a discount margin, and it is what makes a
floater bought at par solve to its own margin under any index path and a
semiannual payer on a monthly grid accrue over the half-year rather than
six compounded months; compounding the index plus the spread, as npv and
yield compound a rate, would give a figure no quote states. The same
bracketed bisection finds it. Null when nothing matched or no spread
solves; a period the index has no value at, or an index this model does
not declare, is an evaluation error naming the index.
The outlay's date is the optional last argument of all three: a period,
placing it at that period's open, or a date, parse_date("2019-01-30"),
placing it on the day, which may fall before the first period. Absent, the
model start. Each matched series is measured from it on its own measure, a
security's day count or the period, and a cell paid before it is not
bought.
Every one of them folds the PER-PERIOD AGGREGATE. When a selector matches
several streams they are added together within each period first, and the fold
runs over the resulting single series. For a sum the order never mattered —
addition is associative — but for a maximum it decides the answer: the peak of
the combined position and the largest single cell are different numbers, and
the first is what "peak outstanding" means. series_count counts the periods
whose aggregate is non-zero.
A selection that matches nothing sums to 0, multiplies to 1 and counts 0 —
each fold's own identity — and averages to 0, its aggregate being zero in
every period of the window. series_max and series_min have none, and publish
null: nothing has no maximum, and returning 0 would state a value no period
reached.
Null rather than an evaluation error, because null is already the language's
word for absent — an entity state no event has set is one, and a ratio's
undefined period publishes as one — and it carries the guard rails this needs:
null == null compares, while ordering and arithmetic on a null are errors, so
an absence can never quietly become a number. An error would be equally safe
and strictly less expressive: it leaves the model no way to SAY that a selector
may legitimately be empty. if(series_count("x.*", 0, t) == 0, 0, series_max("x.*", 0, t)) is that sentence. (There is no null LITERAL in the
dialect, so the emptiness is tested through series_count rather than by
comparing to null directly.)
Asking for a series where no series exist at all — a plain expression context — remains an evaluation error. "You cannot ask that here" and "nothing matched" are different answers.
The window and the data. series_avg divides by the REQUESTED window
length, so a window extending past the data averages the available amounts over
the full window. The other folds have no such knob and simply ignore the absent
cells, so the two treat the tail differently by design.
Undefined is not zero. Two things look like "missing" and are not the same
thing. Past the end of the data, the CELL DOES NOT EXIST, and the paragraph
above says what each fold does there. Genuinely undefined, the period exists
and the quantity does not: a ratio subtotal in a period with no denominator
publishes null there (docs/06), and a metric may fold it. Every fold
skips the undefined periods — they are not observations. series_max and
series_min answer over the defined ones, series_sum adds them,
series_prod multiplies them, and series_count counts the defined non-zero
ones. series_avg's divisor is the requested window less the undefined
cells in it, which is the count of periods it actually folded. That rule
follows the SERIES, not the function: a cash series has no undefined cells, so
its divisor is the requested window exactly as the paragraph above states, and
a window past the data is still averaged over the full window. An
all-undefined window gives null for series_max, series_min and
series_avg — a mean of nothing is not zero — and the identity for
series_sum, series_prod and series_count. When a selector matches
several series, a period is defined for the aggregate if any matched series
defines it, and the defined cells are summed.
What is undefined is what was never computed: a ratio with no denominator, a
field read before it exists, a return on a party that never contributed. A
stream that has not started, a selection that matched nothing, a covenant
period with no shortfall — these are zero, or the fold's own identity, because
zero is a value they had. wal does not fold a ratio: it measures the life of
cash paid, and a ratio is not cash (E1365).
A lender's trailing-twelve-month coverage test is a RATIO OF SUMS, not a mean
of ratios: write it as series_sum over the two money subtotals the ratio is
built from. series_avg over the ratio answers a different question: the mean
level of coverage across the periods that had any.
A peak balance is a fold over the balance, not over the flows.
series_max("dbt.*", 0, 11) is the largest per-period NET FLOW. Peak
outstanding debt is series_max over the series that carries the balance —
an entity field, which a metric reads under the key results publishes it under
(docs/01 §15.3). A running total synthesised from flows alone is a scan
rather than a reduction, and there is no scan.
A transformed series is a field. "How many periods was coverage below
1.20", "when did it first fail", "what was the running peak and when" each fold
a series no stream carries, and the language declares that series as a
rule-bearing field: a per-period expression that reads settled cash strictly
backward, is published under its own key, and is foldable in a
metric by the reductions above. An indicator is a field that is 1 or 0; a first
crossing is a field that latches the period; a running total is a field that
accumulates, and its peak is series_max over it. The one-period lag is the
covenant's own convention — a test is struck on the prior period's cash — and
the position a latch records is a period, whose date the results' series index
gives. There is no predicate argument, no lambda and no scan construct, because
the line to fold is already a declaration.
version 0.1
model "coverage-breaches"
time calendar monthly from 2026-01 for 12
entity asset plant : Asset.Financial {
breach init 0.0 next if(series_sum("ops.noi", time.t - 1, time.t - 1) < 1.20 * (0.0 - series_sum("debt.service", time.t - 1, time.t - 1)), 1.0, 0.0)
first_breach init 0.0 next if(prev > 0.0, prev, if(prev.asset.plant.breach == 1.0, time.t - 1, 0.0))
}
stream ops.noi on entity asset.plant inflow currency USD {
schedule every month from 2026-01 to 2026-12
amount = if(time.t >= 4 and time.t <= 6, 12000, 20000)
}
stream debt.service on entity asset.plant outflow currency USD {
schedule every month from 2026-01 to 2026-12
amount = 15000
}
metric breach_periods = series_count("asset.plant.breach", 0, 11)
metric first_breach = series_max("asset.plant.first_breach", 0, 11)Streams evaluate in dependency order — waves. A stream that reads no series is
wave 0; a reader evaluates one wave past the deepest stream it reads, against
a store in which everything it names is already finished, to any depth. A
circular read is the one thing no order can satisfy, and the engine refuses it
with the named cycle rather than iterating toward a fixed point. A read whose series name is computed at runtime evaluates after every
literally-named stream and cannot itself be read. Windows may extend into the
projection tail (time ... project <n>) in a metric or a pack valuation,
which is what the tail is computed for; never in a stream, whose amount
has no window (docs/01 §9.5) — a sale pays the valuation's figure. The
tail is excluded from cash results and NPV.
Logs: ln(x) (natural logarithm, x > 0) and exp(x). These exist to turn a
cumulative product into a cumulative sum: a survival factor or growth
path under a varying rate is PROD(1 + r_i), which has no closed form and is
not pow(1 + r, t) — that applies one period's rate as though it had held
throughout. Since series_sum aggregates a stream over a window,
exp(series_sum(helper, 0, t)) recovers the product from a stream carrying
ln(1 + r_t). Both escape to float64, as pow already does for fractional
exponents, so they are not decimal-exact; prefer a closed form where one
exists.
series_prod is the direct route, and the better one. It multiplies the
per-period aggregate over the window with no helper stream — which matters
beyond convenience, because a helper carrying ln(1 + r_t) must be declared
inflow or outflow, so it IS cash and lands in model.total. The ln/exp
identity remains correct and remains documented; reach for it when the factors
live in a stream you already have.
The normal curve: normal_cdf(x) is the standard normal cumulative
distribution at x, the share of a unit normal at or below it, so
normal_cdf(0) is 0.5 and normal_cdf(1.96) is 0.975. It exists for the
shapes an industry states as a bell curve. A construction budget drawn on an
S-curve with a stated mean and standard deviation pays, in the period from
t to t + 1, the difference of two values:
amount = inputs.budget
* (normal_cdf((time.t + 1 - inputs.mean) / inputs.sd)
- normal_cdf((time.t - inputs.mean) / inputs.sd))
/ (normal_cdf((inputs.months - inputs.mean) / inputs.sd)
- normal_cdf((0 - inputs.mean) / inputs.sd))
where the divisor is the mass inside the window, so the draws sum to the
budget. A resource or availability P-value reads the same function. A
distribution (assume x ~ Normal(...)) is a sampler for Monte Carlo and a
quantile summarizes trials; neither gives the curve's value at a point, which
is what this is for. It escapes to float64 as ln and exp do, within 1e-15
of the true value, and is likewise not decimal-exact.
Dates: date(y, m, d), parse_date(text) (ISO YYYY-MM-DD or YYYY-MM),
edate(d, months), eomonth(d, months), months_between(d1, d2),
days_between(d1, d2), year_frac(d1, d2, basis). Date arithmetic: d2 - d1 yields days;
d + n / d - n shift by days.
Day-count bases for year_frac: "30/360" (aliases "30/360 us", "bond"),
"30e/360" (alias "eurobond"), "act/360", "act/365", "act/act" (ISDA;
aliases "actual/actual", "act/act isda"), per the standard market conventions
definitions.
act/act splits the span at calendar-year boundaries and measures each part
against its own year's length, so a period crossing a leap year is not charged
365 days for a 366-day year: 2024-07-01 to 2025-07-01 is 184/366 + 181/365,
not 365/365.
Business days: is_business_day(d, calendar), roll(d, convention, calendar),
add_business_days(d, n, calendar).
- Calendars:
"weekend"/"none"(weekends only),"us"/"us_federal"/"sifma","target"/"target2"/"eur","uk"/"uk_bank"/"london". - Roll conventions:
"none","following","modified_following","preceding","modified_preceding".
roll(parse_date("2027-01-01"), "following", "us") -- next US business day
add_business_days(time.date, 2, "london") -- T+2 on the UK calendar
5. Errors and diagnostics
Every parse and evaluation error carries a byte-offset span into the
expression source. The compiler surfaces them as diagnostics with code
E3001_EXPR_PARSE_ERROR. A run-time failure is FATAL: a field's rule refuses
under E5032, and every other reader — a stream's amount or active when, an
event's when, an option's exercise when or payoff, an account's inflow or
init, a waterfall's pot or step, an action's value — refuses under E5043,
naming the reader and the period. A value that was never computed is not a
number and is never substituted with one. An unknown name is
EXPR_UNKNOWN_NAME and fails the run (E5031); a curve read outside its dates
is E5040. The evaluator's own codes are registered in docs/08 §7.7.
One reading is not a failure. A guard reads series strictly backward, so at
the first period a window ending at the previous period lies entirely before
the grid. Nothing has happened yet, and the guard is FALSE — the condition did
not hold — with no warning, because that is the shape of every backward guard. The same read in an amount refuses the run: guard it with a
lazy if(time.t == 0, 0, …).
6. IR representation
Expressions are stored in IR as their raw source text with
"lang": "cfdl":
{ "lang": "cfdl", "src": "50000 * pow(1.15, time.t / 12.0)" }