Skip to main content
CFDL

This is a specification page.

It defines CFDL normatively — complete, exact, and written for people implementing against it. If you are building a model, Reference covers the same ground at the altitude of the work.

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.3 is true. 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.3 yields ~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 arithmetic key 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, and eval is eval_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 moves model_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:

RootContents
modelmodel.id, model.base_currency
timetime.t (0-based period index), time.date, time.phase, time.ppy (periods per year for the model's calendar), time.days_in_period
entityfields of the stream's owning entity, and every entity's fields under its family — entity.asset.tlb.balance
asset, party, container, contract, referencean entity's fields, spelled bare: asset.tlb.balance is the same read as entity.asset.tlb.balance
inputsassumption values (assume statements), including ones derived from other assumptions (§2.1)
preva 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
availablethis period's netted stream cash of the waterfall's entity — waterfall from and step expressions
remainingwhat is left in the pot — present in waterfall step expressions only (§3.2)
paidpaid.<step>, what an earlier waterfall step actually paid — steps only
owedowed.<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:

formresolves topresent in
<family>.<entity>.<field>that field at the current periodstreams, waterfall steps, event guards
prevthis field at the previous periodnext expressions
prev.<family>.<entity>.<field>another field at the previous periodnext 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.
  • init is 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 when has 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.
  • next has no series access in v0.1. It sees prev, prev.<family>.<entity>.<field>, time.*, inputs.* and curves. Reading a stream's history from a recurrence is not expressible; series_sum remains 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)" }