Sizing and TCO

Appendix A · The DSL, in full

A model is a YAML file. It declares named quantities, each with a unit, and how they depend on each other. There is no evaluation order in the file, no cells, no hidden state, and no way to write a number in one place and have it mean something else in another.

The whole language is on this page. It is small on purpose: a model somebody has to learn a system to read is a model nobody reads.

The file

model: web_service
title: A web service and its data, on a fleet of Linux hosts
currency: USD

description: >-
  The book's running example: a service that takes requests and keeps records, run on some
  number of ordinary hosts, sized from a stated workload and costed over five years. Requests
  arrive and data accumulates; a host has cores, memory and disk, and each of those is a chain
  that ends in a count of hosts. One of the three binds.

  It is a sizing model. Its ceilings are the queueing knee, the point where more hosts stop
  helping, the working set outgrowing memory, disk filling, and a host dying at the busy hour;
  its one measured constant is how well the stored records compress. Its one rate constant —
  CPU time per request — belongs on a rig nobody has declared, and is held as a labelled claim
  until one is.

  Vendor-neutral by construction. Nothing below names a product; every figure is a workload
  assumption, a price you would get quoted, a spec sheet, or the measured constant.

Four keys before the nodes begin. model is the identifier, and it is the directory name. title is the name a reader sees, and it defaults to the identifier when a model does not give one. currency is declared rather than assumed, so that a model mixing two of them fails to typecheck instead of quietly adding them. description is prose, and it is where a model says what it is for — the one thing a reader cannot reconstruct from the graph.

The four node kinds

Node kindCountWhat it carries
input26a value or a distribution, a provenance kind and a source
derived23a formula, whose declared unit is checked against what it produces
measured5a stamped result, a standard error, and the implementation it belongs to
ceiling4a limit, a declared headroom, and a reason
classified as a sizing model — it has measured constants or ceilings in it, so sampling the inputs is not sufficient on its own

Source — observability-reference · every input on a slider · 2 constant(s) not yet measured

The last row is the distinction the book is built on, and the file decides it rather than its author’s opinion of it. A model containing a measured node or a ceiling node is a sizing model. It has an empirical constant that belongs to one stack at one version, or a limit past which its arithmetic stops describing anything. In either case sampling the inputs is not sufficient on its own. A model with neither is a cost model.

Here are the kinds, as the loader defines them:

class Node:
    name: str
    unit: str
    label: str | None = None
    note: str | None = None
    kind: ClassVar[str] = "node"

    def depends_on(self) -> set[str]:
        return set()

    @property
    def display(self) -> str:
        """What a figure calls this node. Falls back to the name, which is already readable."""
        return self.label or self.name.replace("_", " ")


#: Who settles an input. A model cannot work this out for itself: the nearest signal it has is
#: whether the input was given a shape, and ch02 is about how poor a proxy that is -- nothing in
#: that chapter's file has a shape, so the growth rate reads as a choice. Declaring it makes the
#: split a claim the model makes rather than an inference from how finished the file is.
#:
#: ``you`` is a choice somebody made and can change: the fleet, the horizon, a headroom margin.
#: ``world`` is an observation, whether or not it has been given a shape yet: the busy hour, the
#: records held, a price. ``definition`` is an identity nobody chooses and the world does not
#: vary -- a year in seconds, one host, one request.
DECIDED_BY = ("you", "world", "definition")


@dataclass(frozen=True)
class Input(Node):
    value: float | None = None
    distribution: dict | None = None
    provenance: Provenance | None = None
    slider: tuple[float, float] | None = None
    #: One of :data:`DECIDED_BY`. Empty only in a file that has not declared it, which
    #: ``verify-models.py`` refuses.
    decided: str = ""
    kind: ClassVar[str] = "input"

    @property
    def is_uncertain(self) -> bool:
        return self.distribution is not None

    @property
    def is_yours(self) -> bool:
        """Whether a reader could have chosen this differently."""
        return self.decided == "you"


@dataclass(frozen=True)
class Derived(Node):
    formula: dict = field(default_factory=dict)
    formula_text: str = ""
    kind: ClassVar[str] = "derived"

    def depends_on(self) -> set[str]:
        return expr.refs(self.formula)


@dataclass(frozen=True)
class Measured(Node):
    result: str = ""
    #: The stamped payload, or None when nobody has taken this measurement yet.
    measurement: dict | None = None
    kind: ClassVar[str] = "measured"

    @property
    def is_measured(self) -> bool:
        return self.measurement is not None

    @property
    def value(self) -> float | None:
        if self.measurement is None:
            return None
        return float(self.measurement["summary"]["value"])

    @property
    def sd(self) -> float:
        """The measurement's standard error, or zero if it was reported without one.

        Zero is a claim, and a loud one: it says this constant was measured exactly. Almost
        nothing is, so ``scripts/verify-models.py`` reports a measured node with no stated
        uncertainty rather than letting it pass as precision.
        """
        if self.measurement is None:
            return 0.0
        return float(self.measurement["summary"].get("sd", 0.0))

    @property
    def stack(self) -> str | None:
        """What was measured — the implementation and version this constant belongs to.

        The reason a measured constant is not a fact about the world. ch03: change the encoder,
        change the version, change the shape of your data, and this number is about something
        else.
        """
        if self.measurement is None:
            return None
        return self.measurement.get("produced_by", {}).get("stack")


@dataclass(frozen=True)
class Ceiling(Node):
    of: dict = field(default_factory=dict)
    of_text: str = ""
    limit: dict = field(default_factory=dict)
    limit_text: str = ""
    headroom: dict = field(default_factory=dict)
    headroom_text: str = ""
    because: str = ""
    kind: ClassVar[str] = "ceiling"

    def depends_on(self) -> set[str]:
        """Everything the ceiling reads — including its limit and its margin.

        ``limit`` and ``headroom`` are expressions rather than numbers so that a margin used by a
        sizing formula and the margin a ceiling checks against can be *the same node*. Writing
        0.25 in both places is how a model comes to be sized for one headroom and audited against
        another, six months after anybody remembers there were two.
        """
        return expr.refs(self.of) | expr.refs(self.limit) | expr.refs(self.headroom)

    @property
    def declares_headroom(self) -> bool:
        """Whether a margin was stated at all. A ceiling without one is not a sizing rule."""
        return bool(self.headroom_text.strip())

input — a number somebody chose

A value, or a distribution, or both. Plus a provenance kind, and a source the build will not let you leave empty:

  annual_growth:
    kind: input
    decided: world
    unit: dimensionless
    label: annual growth factor
    distribution: {lognormal: {p10: 1.12, p90: 1.60}}
    provenance:
      kind: assumption
      source: "ch04 — lognormal because growth compounds and cannot be negative. The p10/p90
        say: surprised below 12% a year, surprised above 60%. One factor for requests and for
        records, because users drive both."
    range: [1.0, 2.0]

range is what the interactive page turns into a slider. It is a plausible span for a reader to explore, not a claim about the distribution — the distribution is the claim about the distribution.

derived — arithmetic

A formula over other nodes, and a unit that has to agree with what the formula produces:

  peak_request_rate:
    kind: derived
    unit: request/second
    label: peak request rate at horizon
    formula: peak_request_rate_t0 * annual_growth ** horizon_periods

The formula language is deliberately not Python. It is parsed with Python’s own parser and then walked, and only these functions survive the walk:

FUNCTIONS: dict[str, Any] = {
    "min": min,
    "max": max,
    "ceil": math.ceil,
    "floor": math.floor,
    "sqrt": math.sqrt,
    "log": math.log,
    "exp": math.exp,
}

Arithmetic is + - * / ** and unary minus. There is no name lookup other than other nodes, no attribute access, no calls to anything not in that dictionary. A model file cannot do anything, so running a stranger’s model is reading their arithmetic rather than executing their code.

measured — a constant somebody measured

Not a value. A reference to a stamped result:

  record_compression:
    kind: measured
    unit: dimensionless
    label: record compression ratio
    result: records-compression

The number, its standard error and the implementation it belongs to all come from bench/results/. That is what separates a measured constant from an input that happens to have been measured once. If the result does not exist, the node has no value and neither does anything downstream of it — the state propagates by itself and the figures say not yet measured rather than showing an estimate (ch03).

ceiling — where the arithmetic stops working

A quantity, a limit, a margin, and a reason:

  queueing_headroom:
    kind: ceiling
    unit: dimensionless
    label: utilisation at the busy hour
    of: utilisation
    limit: 1
    headroom: queueing_margin
    because: >-
      Not a capacity limit: a fleet at ninety per cent has not run out of anything, it has a
      queue in front of it. The margin is large because the cost of crossing it is paid in
      latency by every request, not in a failure somebody gets paged for (ch06).

of, limit and headroom are all expressions rather than numbers, so that a margin a sizing formula uses and a margin a ceiling audits against can be the same node. Writing the same figure in both places is how a design comes to be sized for one headroom and checked against another, some months after anybody remembers there were two.

because is required and headroom is required. A ceiling with a limit and no margin is not a sizing rule but a comparison, and the build refuses it (ch11).

Provenance

#: How much a modeller is claiming when they write a number down.
#:
#: Three, and the gap between the first two is the one that costs money. A ``fact`` is something
#: this repository can point at: a stamped measurement, an invoice, a published specification. A
#: ``vendor_claim`` is a number somebody selling you something has stated — often true, never
#: checked here, and coloured differently in every figure so that it cannot quietly become the
#: first kind. An ``assumption`` is a decision, and naming it as one is what lets a reviewer
#: argue with it.
PROVENANCE_KINDS = ("fact", "vendor_claim", "assumption")

Three kinds, and a source string on every input. A vendor’s claim is not a fact, and that gap is the one that costs money. Every figure in the book colours the three differently, and none of them is ever silently promoted.

Distributions

An input may declare exactly one shape. These are the four, and adding a fifth is a percentile function and a line:

SHAPES: dict[str, Callable[..., np.ndarray]] = {
    "uniform": uniform_ppf,
    "triangular": triangular_ppf,
    "lognormal": lognormal_ppf,
    "normal": normal_ppf_scaled,
}

Appendix C is one page each on what they assume and how they lie.

Outputs

Which nodes are answers. Everything else in the graph is working:

outputs:
  # The sizing and the price, first: what a reader of ch12 and ch18 wants.
  - hosts_recommended
  - hosts
  - tco
  - cost_per_million_requests
  - cost_per_stored_tb_month
  - capex
  - annual_opex
  - annual_energy
  # The ceilings, and what each one is about.
  - queueing_headroom
  - failure_headroom
  - cache_fill
  - disk_fill
  - scaling_loss
  - coordination_headroom
  # Part II's own figures. Terminal in the finished model, and outputs because a quantity the
  # book quotes has to be one -- a node that feeds nothing and is reported nowhere is a leftover.
  - utilisation
  - residence_time
  - waiting_time
  - concurrency
  - in_flight_unqueued
  - optimism
  - headroom_to_peak

The list decides what the tables report and what the tornado is drawn against. It is not a restriction — every node keeps its value and its distribution, and the interactive page will show you any of them. It is an editorial judgement about which handful of numbers somebody is going to be asked about.

Correlations

A model may declare that two inputs move together, with a reason:

correlations:
  - a: peak_request_rate_t0
    b: service_demand
    rho: 0.4
    because: >-
      The unhappy one. A busier service is usually a slower one per request — caches miss more,
      locks are held longer, the garbage collector runs while somebody is waiting. Treating the
      two as independent understates the tail of every figure in this model, and it understates
      it exactly when the fleet is busiest.

  - a: host_price
    b: network_price_per_host
    rho: 0.5
    because: >-
      Both are quoted by the same supply chain in the same quarter. A year when hosts are scarce
      is usually a year when optics are, and treating them as independent narrows the interval
      on capital cost by pretending one can save you from the other.

The reason is not optional. A coefficient with no reason attached is a number somebody will copy into the next model without knowing what it was for (ch14).

Scenarios

A scenario is a small file beside the model. It overrides inputs, names why, and pins the sample count and the seed so the run can be reproduced exactly:

scenario: sized_for_growth
title: Sized for the growth we might get, not the growth we expect
because: >-
  The reference scenario buys what the point estimates recommend, and the model then puts a
  good share of its futures over the knee and past the working set's margin. This scenario buys
  the fleet the p90 growth case needs instead, and is here so the two can be read side by side:
  what the extra capital buys is a number, the fall in every P(over) is a number, and the
  decision between them is a judgement somebody has to make and defend (ch21).
samples: 100000
seed: 20260916
overrides:
  hosts: 131

Scenarios are how you compare two designs without editing either one into the other (ch21).

What the build checks

scripts/verify-models.py runs on every model on every push, and fails on any of:

The graph

A web service and its data, on a fleet of Linux hosts — What arrives, and what accumulates — dependency graphinputderivedmeasuredceilingyou decideannual growth factorhorizonhorizon periodsone yearpeak request rate athorizonpeak request rate, day onerecords held at horizonrecords held, day one

The running example as ch02 leaves it, the smallest graph in the book: five inputs, three derived nodes and nothing else. It has no ceiling and no measured constant, so it is a cost model, and ch06 is where it stops being one. Appendix F is the model with all four kinds in it. Reading right to left from any answer gives exactly the quantities it rests on; reading left to right shows how few inputs most of the graph is downstream of.

Running it

python3 -m bench.run_models                  # evaluate, sample and stamp every model
python3 -m bench.run_models --model NAME     # just one
python3 scripts/verify-models.py             # units, provenance, ceilings, classification
python3 scripts/build-viewers.py             # the interactive pages