Skip to main content
CFDLAcademy
All chapters

Part 1 · Thinking in cash flows · Chapter 3 of 28

Reading results

You now know what a model declares. This chapter is about the other side of the pipeline: what a run hands back, and how to read it like a practitioner. Reading it that way means more than knowing where the NPV is. It means knowing how to challenge any number until it confesses where it came from.

A model to read results against — a small consulting practice, chosen because its numbers are easy to hold in your head:

version 0.1
model "reading-results"
time calendar monthly from 2026-01 for 24

entity asset practice : Asset.Intangible

// The purchase: the practice is bought at the start for 150,000.
stream practice.acquisition on entity asset.practice outflow currency USD {
  schedule every month start from 2026-01 to 2026-01
  amount = 150000
}

// Two retainers, invoiced monthly.
stream practice.retainers on entity asset.practice inflow currency USD {
  schedule every month from 2026-01 to 2027-12
  amount = 30000
}

// A project engagement in the second year only.
stream practice.project_fees on entity asset.practice inflow currency USD {
  schedule every month from 2027-01 to 2027-12
  amount = 12000
}

// Salaries and rent.
stream practice.overhead on entity asset.practice outflow currency USD {
  schedule every month from 2026-01 to 2027-12
  amount = 24000
}

Run it (the play button carries an 8% discount rate with it) and keep the output open as you read.

The shape of results

A run produces one document with a consistent anatomy, whether the model is this three-stream practice or a forty-stream financing. In reading order:

Per-stream series. Every stream appears under the name you gave it, as dated cash per period — practice.retainers is 24 entries of 30,000; practice.project_fees is twelve zeros, then twelve entries of 12,000; practice.acquisition is one entry and 23 zeros. This level is the ground truth: everything else in the document is arithmetic over these series, and when any higher number surprises you, you descend to here.

Signed convention. In results, direction becomes sign: inflows positive, outflows negative. Period 1 of this model nets 30,000 − 24,000 − the 150,000 purchase = −144,000; period 2 nets 6,000; period 13 nets 30,000 + 12,000 − 24,000 = 18,000. The signs come from the direction each stream declares, not from the numbers, so a figure's sign is always a claim you can find in the model.

Net cash flow and totals. The period-by-period net across all streams, plus per-stream and model totals. Do the practice's arithmetic once by hand: year one nets 6,000 × 12 − 150,000 = −78,000; year two nets 18,000 × 12 = 216,000; lifetime 138,000. Checking one such number by hand, every model, is a habit that outlives any tooling.

Annual rollup. The same cash regrouped by calendar year — the view a committee actually reads, and the bridge to how results are compared against a source document that reports annually.

Metrics. A flat map of named figures. The engine's own are model.total, model.irr, model.payback_periods, model.payback_years and model.wal_years, plus model.npv when the run states a discount rate; a run with no rate publishes no NPV rather than one at a rate nobody chose. Beside them sit stream.<name>.total for each stream and entity.<symbol>.total for each entity. The run.* entries record what the run was asked — run.annual_discount_rate, run.periods_per_year, and run.as_of when one was stated — so a metric is never a bare number you have to reconstruct context for. The prefix says who minted the number: model.* is the engine's, domain.<pack>.* the active pack's, and metric.<name> one the model declared — a figure the deal solved for, evaluated once at the horizon over the finished projection (Part III introduces declaring them).

The graph, and who owns what. A top-level graph lists every entity — its symbol, family, type, part of parent, (when the model carries one) its stable id, and the fields it states, as the run resolved them. A model with contracts also publishes graph.contracts: each contract's type, subject, parties, the terms it states, and the streams lowered from it. Each stream series states its owning entity and its category beside the values, and a series lowered from a contract also names its contract and its line. Together these mean the document stands alone: you can attribute any stream's cash to the thing that owns it, and to the agreement that made it, without the model open in another window.

Slices. A model that declares slices publishes each one: the selection it stated, the streams it matched, a net series, and total/NPV/IRR over the match. A slice may also state a window — a pair of dates bounding the periods, where every other clause bounds the streams — and the window is published as part of the lineage, because it is the one part of a selection that removes cash a reader can still see in the series beside it. A slice never carries a reconciliation block — it is partial by design, and the document shows it as partial.

Statements. Every results document with an entity in it carries at least one statement — rows with labels, depths, and a display sign, ending in a bottom line that reconciles against the model's cash. Where they come from is a three-way precedence: the model's own declared statements when it has them, the active pack's otherwise, and — when neither declares one — a default rendering of the entity hierarchy, marked default, so a reader holding results sees the model's shape rather than a flat list of series. A statement filtered by a slice reconciles against the slice's total, not the model's.

Inputs. A model that declares assumptions publishes inputs.assumptions, the review page: one row per assumption, with its shape, type, unit, value, where the value came from (the model's statement, the run's override, or an inputs file) and the source it cited — the model's, or the inputs file's; a value the run overrode cites none, since the model's source no longer describes it. It is the top of the audit chain — what went in, above the line items. Beside it, run is the run configuration as the run used it, with each inputs file's content in place of its path: what the run was asked.

Scenarios. When the configuration names scenarios, each one publishes a summary under scenarios.summaries, and a summary is a results document in small: its name, its metrics, its series, its annual rollup, its inputs as that scenario resolved them, its statements, and, where the model has them, its slices, its pack's figures, its transitions, its journal and its trace, and its own ledger_hash. A downside case therefore shows not only a lower NPV but the rows and the events that produced it, and can be read with the same habits as the deterministic run. A declared metric a scenario could not evaluate is listed under omitted with the reason, not published as a number.

Why each period is what it is. The journal records what the run did: every act, in order, with an event's or a transition's actions nested under it. The trace records what was decided in every period, including the periods in which nothing happened, as runs of one reason. For an entity with a lifecycle, each period's state, every guard tested and what each read, and whether it moved, held or had its state set; for a stream, ran or the gate that stopped it — before_schedule, after_schedule, inactive in a named state. A zero has a reason you can read, and a reason an act caused names that act's position in the journal.

Warnings and the engine. warnings lists what the run noticed and did not refuse; read it before the figures. engine names the engine and its version, and results_version the shape of the document itself.

Two hashes. The document states a model_hash — identifying the compiled model, taken without its views (slices and statements organize; they produce no cash) and without where anything is written (a comment, a blank line or spacing inside an expression leaves it as it was) — and a ledger_hash over what came out: the series, the journal, the transitions and the trace. Every result carries one: each scenario its own, and the Monte Carlo trials theirs. The run configuration is in neither hash; it is published as run. The machinery chapter in Part II tells the full story; what matters here is that "same model?" and "same result?" are separately answerable questions, and that a recipient can check both: cfdl package writes a manifest of a run's files and hashes, cfdl verify checks it without running anything, and cfdl reproduce runs it and requires every ledger hash to match.

Discounting: the two knobs that change everything

NPV in this output is not "the" NPV of the model; it is the NPV at the run's discount rate, from the run's as-of date. Both live in the run configuration, not the model — the same file valued at 8% and at 12% is the same set of claims under two costs of capital.

Two conventions to fix in your head now, because every discrepancy-hunt you ever run will check them first:

Rate conversion. The configured rate is annual; the grid here is monthly. The engine converts by compounding, (1 + r)^(1/12) − 1, not by dividing by twelve. At 8%, that is 0.643% per month, not 0.667%. A source that divides by twelve will disagree with your NPV in the third digit, and neither of you is wrong. You are using different claims about compounding. The fix is to match the source's method deliberately, as an exercise below does.

Timing. Cash is dated by its period on the grid; discounting measures from the as-of date. A model whose rent lands on the 1st versus the 15th versus month-end can carry the same totals and different NPVs. The schedule chapter in Part II gives you precise control over placement; what matters now is knowing that placement is a claim, and the results reflect it.

IRR is reported alongside NPV: the rate at which this cash's NPV crosses zero. For well-behaved shapes — money out, then money in, like this practice's purchase followed by its profits — it is the familiar number. Part III discusses the shapes where IRR misleads (sign changes mid-life, multiple roots) and what to report instead.

Interrogating a number

The discipline for challenging any figure in the output, in the order that finds the problem fastest:

  1. Identify the streams that feed it. Names answer the question — a total over practice.* is legible because the taxonomy was designed in the model.
  2. Find the periods where the cash lands. Open the series and look. Half of all surprises are timing, not magnitude.
  3. Check the run configuration that shaped it. Rate, as-of date, scenario overrides — all recorded in the output itself. A results document answers "how was this run?" without needing the person who ran it.
  4. Read the model, last. Only now open the source — you arrive already knowing which stream, which periods, and which claim to read.

This four-step descent works because every layer of the output is derived from the layer below it, down to dated per-stream cash, which is derived from lines someone wrote. There is nowhere for a number to hide.

Matching a method, not an answer

Sooner or later — often on day one — you rebuild a model that already exists: an offering memo, a bank case, someone's workbook. Your total is 288,000 and theirs is 287,946, and someone asks you to "reconcile."

The professional rule: match the source's method, then match its answer. The 54-dollar gap is never noise. It is a method difference: they divide the rate by twelve, they round each period to the dollar before summing, or they place cash at month-end where you place it at month-start. Find the method difference, decide on purpose whether to adopt it, and record the decision in the model where the next reader will meet it.

The language gives you the tools to adopt a source's method exactly. When a source rounds each period before aggregating, round_to in the amount expression reproduces their pennies instead of averaging the difference away. When an escalation compounds on an already-rounded prior value — spreadsheet-native behavior that a clean formula cannot reproduce — an entity field's rule (Part II) carries the rounded value forward the way the workbook did. A model that matches a source to the penny is a model whose every deviation from that source is now visible and chosen — which is the entire point of reconciliation.

Deep-dive aside for the technically inclined: the engine's arithmetic is decimal, not binary floating point — money math without the femto-cent residue that haunts float-based tools. There is also a compatibility mode that reproduces common spreadsheet function semantics for benchmark comparison. The core track never needs more than knowing this; the machinery chapter in Part II covers it.

One model, many runs

The run configuration is a small JSON document that travels beside the model. The one the play button used above:

{
  "deterministic": {
    "annual_discount_rate": 0.08
  }
}

A rate that changes over the horizon is a series-shaped assumption the model declares — assume wacc : rate = curve { 2026-01: 0.10 2027-01: 0.08 } — and the configuration names by its bare name instead of a rate — "annual_discount_curve": "wacc" — so a cost of capital that converges as a firm matures discounts each year at that year's rate. Configurations can also name scenarios: base and downside, each with its own rate and parameter overrides, run in one pass and reported side by side. They carry simulation settings too, which Part II's uncertainty chapter takes up properly. What to internalize now is the division of labor: the model owns the claims, the configuration owns the question being asked of them. When you find yourself editing the model to ask a different question, stop — you are usually holding the wrong file.

What can go wrong

The metric moved and you do not know why. Wrong instinct: stare at the metric. Right instinct: run the four-step descent. The metric is the last derivation in a chain; the cause is always in a series, a setting, or a claim.

Two runs of "the same model" disagree. They differ in model bytes or configuration — there is no third possibility, because runs are deterministic. Compare the two configurations first; they are smaller.

Your total matches, your NPV does not. Same cash, different placement or different rate handling. Check as-of date, then rate conversion, then schedule placement — in that order.

Exercises

The prediction-before-run habit, practiced in place:

Exercise

Predict, then run

The practice hires an assistant from July 2026 at 5,200 a month through the end of the model.

  1. Predict the new lifetime net total before you touch the model. The starter's total is 288,000, and the assistant works 18 months. Write your number down.
  2. Add the stream.
  3. Run. Compare the total to your prediction.

Prediction before run is the habit: a wrong model is cheapest to catch while your own expectation is still fresh. If the run surprises you, use the four-step descent from the chapter: streams, periods, run configuration, then the model.

Loading exercise…

Then, on your own:

  1. By hand, compute the original model's undiscounted total (shown above) and verify it against a run. Then lower the discount rate until NPV approaches the undiscounted total and explain, in one sentence, why they converge.
  2. Reproduce a "reconciliation gap" on purpose: compute year-one NPV at 8% annual with the compounding conversion, then with rate-divided-by-twelve, in any tool you like. The difference you find is the same third-digit gap you will someday be asked to explain in a meeting.
  3. Change the retainer amount to round_to(30000 / 7, 0.01) * 7 (a contrived per-diem-style calculation). Look at what one period actually pays, and explain where the pennies went — you have just performed, in miniature, every rounding reconciliation you will ever do.