Under the hood
The core track can skip this chapter; nothing later depends on it. It exists for the reader who wants to know why the guarantees hold. Why can a compiled model not dangle a reference? Why are runs byte-reproducible? Why is a circular series read refused with its path named? Each promise is a property of one specific stage. Knowing which stage answers which question makes diagnosis faster.
The pipeline, stage by stage
A model goes from text to results through fixed stages. The compiler has nine, and the engine runs the result. Each stage has one job and one class of refusal:
- Load and normalize finds
model.cfdlin the model root and reads the source files. - Lexing turns bytes into tokens. Refusals here are lexical: an unterminated string, an unclosed comment.
- Parsing turns tokens into a syntax tree, recording where each node came from. Refusals are structural: a malformed schedule, a stream name with one segment, a keyword where an expression belongs.
- Import resolution builds the import graph and merges the files into one unit. Refusals: import cycles, escapes from the model root.
- Pack resolution loads the pack the model names with
use pack, and its type registry. - Name resolution builds the symbol table and resolves every name — entities, contracts, streams, phases, assumptions, options, events — to its declaration. Refusals: duplicates, references to nothing. That is what chapter 2's "reference to nobody" refusal was teaching.
- Validation checks model-level shape: exactly one time grid, schedules inside the horizon, required clauses present, types that fit. With a pack in play, the pack's own validations run here too. A contract missing a required term is refused in the pack's vocabulary.
- Lowering expands each contract into the streams its pack's rules emit, derives stable IDs, and records each node's provenance: the file and span it came from. Lowering creates stream names no statement wrote, so a check on a lowered stream runs here: an event that switches a stream nothing runs (
E1302) is refused after lowering, not before. - Emission writes the IR — the intermediate representation — as one JSON document against a published schema. Declared slices and statements are filed in their own part of the document,
views— presentation kept apart from computation, for a reason the hashes below make precise.
The engine then evaluates the IR under a run configuration and emits results — the other published schema, whose shape is stated by its results_version (0.17). Its sections are the ones chapter 3 read: deterministic, scenarios and monte_carlo for the three kinds of run, inputs for the review page, graph, statements, warnings and engine, with the two hashes beside them.
The division of labor explains a distinction you have already felt. Everything structural fails before any number exists, in the compiler's stages. Everything numerical surfaces at evaluation: a division by zero in some period, a prev read in period 0. When something goes wrong, first ask which stage owns the failure. The next chapter's diagnostic codes are organized along the same seam.
The IR: the model with nothing left implicit
The IR is the model after every convenience is expanded: names resolved, contract sugar lowered, defaults applied. What remains is a complete description of what will be computed. A schedule stays a specification rather than a list of dates: a date, a recurrence, a phase entry, a recurrence over a phase, or a state entry. A state-entry window cannot be listed in advance at all, since it opens whenever the machine enters the state, and it resolves during the walk. The IR also lists required_refs: every assumption the model needs from outside, by full name and type. Three consequences follow.
The IR is the audit artifact. When a regulator, a counterparty, or a future you asks what the model did, the IR is the answer. Two differently organized sources that mean the same model produce the same computation and the same cash. They do not produce the same document: every node records the file and span it came from, so a declaration moved between files changes its provenance, and with it the model hash.
The IR is the interface. The playground, the command line, the Python bindings, and any future surface all run IR. A tool that consumes or produces IR speaks the language without parsing it.
The IR is diffable at the semantic level. A source diff shows what you edited. An IR diff shows what changed in meaning. A reorganization touches only provenance and node IDs; an assumption edit touches exactly the affected expressions. For a model that moves money, review consequential changes at both levels.
The results also carry two fingerprints, and the split between them is a claim about what identity means. The model hash is taken over the IR without its views: a slice filters and a statement organizes, and neither produces cash, so two users who look at identical results differently are running the same model and share the hash. A declared metric is not excluded — it is a figure the model claims, so it belongs to the model and moves the hash. The ledger hash covers what came out: the series, the journal, and the transitions. The run configuration is hashed by neither — a discount rate is a question asked of the model, not part of it. Between them, the question "which model produced this number?" has a cryptographic answer. A committee memo can cite a model the way it cites a document version.
Evaluation: the period walk
The engine settles one period at a time. Inside each period the order is fixed: state first (rules, then the machine's edges, then events), then the period's streams in their waves, then whatever the schedules say distributes. This order is the period walk, and it is what makes chapter 11.5 possible. When a guard evaluates at period t, every period before t is a settled fact, so logic may read series and balances at time.t - 1 and earlier. Chapter 8's impossible-cycles argument is this order read as a proof: rules see the previous period's committed column, guards see settled history, streams read the state just committed, and every read points backward.
One construct depends on the finished projection, and the walk serves it by name rather than breaking the rule. A stream has no window: an amount folding time.t + 12 is refused. The forward-income exit capitalizes next year's NOI, and next year is a valuation's to fold — the pack's cre.exit on forward NOI publishes it as figures over the projection tail — so the reversion pays the figure, metric.cre.exit.gross_value. The walk runs twice. The first pass leaves the reversion out, and the figures fold over what it settles, the tail included. The second pass runs every stream in place, the reversion paying the figure, so a waterfall distributing at the sale period allocates proceeds that exist. Acyclicity keeps it sound, and the refusal names the path: logic that reads what the reversion pays — sale proceeds feeding state that feeds the NOI being capitalized — is refused like any other cycle. A forward window in a guard is refused outright. Whether a stream is active is a causal fact, and a fact cannot be read from the future.
Reproducibility falls out of two further choices you have already met. Arithmetic is decimal with defined rounding, so no platform float quirks enter. Every random draw is keyed, never sequenced: a trial's draw of an assumption by the run's seed, the trial and the assumption's full name, and a sample by those and the reader's subject entity and period. The keying is why each assumption owns an independent draw stream, and why adding one assumption, entity or read reshuffles nothing else: no shared random state exists to perturb. Determinism is not a test the engine passes; it is the absence of any mechanism for nondeterminism.
Waves: the order series reads impose
Chapter 9 promised the story behind series_sum's evaluation order. Here it is.
Every stream's reads are visible in its source: series_sum("collections", ...) names what the stream depends on — and the same is true of all six reductions, series_avg through series_count; the dependency machinery makes no distinction between folding a sum and folding a maximum. The engine therefore reads the dependency graph straight off the model and evaluates in waves. Streams that read no series are wave 0, and they evaluate first, because nothing they read can depend on their peers. A reader evaluates one wave past the deepest stream it reads: a fee on collections is wave 1, a kicker on that fee is wave 2, and so on to any depth. Each wave runs against a store in which everything the wave names is already finished. A series read never sees a half-computed number, because the whole series it reads was committed a wave earlier.
One thing survives as a refusal, because no ordering can satisfy it: a cycle. The fee reads the kicker and the kicker reads the fee; no wave can hold either. The engine names the loop ('fee' -> 'kicker' -> 'fee') rather than iterate toward a fixed point. That is the same circularity the language refuses everywhere else.
A cycle in a model is almost always a modeling error. When the economics genuinely loop — a fee on distributions that themselves net the fee — the fix is chapter 8's staging discipline: read the base quantity, or carry one side as a field that advances at period close. The wall is exactly the wall a spreadsheet gives you. A model that respects it is a model whose evaluation you can narrate, wave by wave.
Reading this back onto the promises
Every guarantee the course has leaned on now has an address:
- Nothing dangles — name resolution, and after lowering for the streams contracts generate.
- Nothing is silently discarded — parsing refuses what it cannot represent.
- What compiles, runs — validation and lowering exhaust the structural checks before the engine starts.
- Same inputs, same bytes — decimal arithmetic, keyed draws, ordered evaluation.
- Cycles are refused — rules-before-streams across constructs, and the wave sort within streams, which rejects a genuine loop with its path named.
None of these is a slogan; each is one stage doing one job. That is what a small language buys: the machinery is short enough to actually know.
On your own
- Compile any model from this course with the command-line tool and read the IR it emits, end to end, once. Find your schedule as the specification the engine resolves, and find your contract-free streams as they will be computed. The word "compile" stops being abstract in about twenty minutes.
- Reorganize a multi-file model (move a stream between files) and diff the two IRs. Then change one assumption and diff again. The first diff touches only where each node came from; the second touches exactly what the assumption feeds. Run both and compare the series: the first leaves them identical.
- Predict which wave each stream of your chapter-9 venue model evaluates in. Then explain to a colleague why the percentage-rent kicker can never race the revenue it reads.