Fixed-Step vs CPI Escalation Formula Tradeoffs
The narrow decision this page resolves: when you model commercial rent growth, should the escalation engine treat a fixed-step (or fixed-percentage) clause and a CPI-indexed clause as two different code paths, or as one parameterized rule whose behaviour is entirely data? The clauses look similar on paper — both raise rent over the term — but they have opposite engineering profiles. A fixed step is a closed-form function of month number; a CPI-indexed reset is a function of an external index series that gets revised, printed late, and occasionally turns negative. This page sits under Escalation Formula Mapping, and it does not re-explain that engine — it argues which formula shape to prefer for which lease, and shows a EscalationRule model plus a single evaluator that runs either without branching in the caller.
The stakes are the same as anywhere money is computed from a contract: the number has to be reproducible years later, an auditor has to be able to reconstruct it, and a mis-modelled cap or a silently-revised index must never quietly restate net operating income across a portfolio.
Architectural context
Escalation shape is a modelling choice made at mapping time, downstream of labelling and normalization. By the time a clause reaches this decision it has been tagged rent_escalation and cleaned to typed decimals and ISO-8601 dates by the metadata normalization standards at the ingestion boundary. The validated rule this page builds is what feeds the lease data models for storage and, one hop further downstream, drives generating payment schedules from escalation rules. Operating-expense recoveries are deliberately out of scope here — those take a different taxonomy branch, covered in handling CAM charge variations — because a CAM pass-through and a base-rent escalation have different denominators, caps, and audit trails even when they share a lease. When the clause is ambiguous or a required parameter is missing, the record diverts to fallback routing logic rather than emitting a guessed rate.
Fixed-step vs CPI: the tradeoffs that matter
The two shapes are not “simple vs sophisticated” — they trade different engineering properties. A fixed step buys determinism at the cost of tracking real inflation; a CPI index tracks inflation at the cost of an external data dependency you now have to version, backfill, and reconcile. The dimensions below are the ones that actually change how you build and audit the engine.
| Dimension | Fixed-step / fixed-percentage | CPI-indexed |
|---|---|---|
| Predictability / determinism | Fully deterministic — rent for month n is a closed-form function of n | Non-deterministic until the index prints; two runs can disagree if the series changed |
| External data dependency | None — the rate lives in the clause | Hard dependency on a versioned CPI/RPI series with a base-year anchor |
| Replayability | Trivial — same input, same output, forever | Requires pinning the exact index vintage used, or replays silently drift |
| Cap / floor handling | Rarely present; step amounts are already the ceiling | Central — a collar (cap + floor) is what makes the clause billable and bounded |
| Dispute risk | Low — both parties can recompute from the lease alone | Higher — which index, which base month, and revisions are all contestable |
| Implementation complexity | Low — one compounding or lookup line | Higher — index store, base-year math, provisional prints, reconciliation |
| Audit reconstruction | Reconstructable from the clause text | Reconstructable only with the index vintage archived alongside the rent |
The practical read: prefer a fixed step when the lease gives you one, because everything downstream — payment schedules, forecasts, ASC 842 exports — gets cheaper and more auditable. Reach for CPI modelling only when the executed clause is genuinely index-linked, and when you do, treat the index series as a first-class, versioned input rather than a value you fetch inline.
One evaluator, both shapes as data
The design that keeps callers sane is to make the shape a field, not a class hierarchy. A single frozen EscalationRule carries the parameters for fixed_pct, step_up, or cpi_indexed, and one evaluate() function dispatches on that field. Money is Decimal throughout, and rounding happens exactly once, at the end.
For the CPI branch this page uses base-year indexing — rent scales by the ratio of the current index to the index at commencement, clamped by a cumulative collar. This differs from the year-over-year approach in the parent engine, and the difference is deliberate: a base-year ratio makes the cap and floor cumulative bounds over the whole term (the widening collar in the diagram), which is how many long commercial leases actually draft them, and it removes the need to carry a prior-period index. The clamped ratio is:
$$ R_n = R_0 \cdot \min!\Big( \max\big( \tfrac{I_n}{I_0},; 1 + f \big),; 1 + c \Big) $$
where ( R_0 ) is the base rent, ( I_0 ) the base-year index the lease was struck at, ( I_n ) the current index, and ( f ) and ( c ) the floor and cap as cumulative fractions. The clamp is applied to the ratio, before multiplying, so a deflationary print can never cut rent below the floor.
from __future__ import annotations
from decimal import Decimal, ROUND_HALF_UP
from enum import Enum
from typing import Optional
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
CENTS = Decimal("0.01")
class EscalationKind(str, Enum):
FIXED_PCT = "fixed_pct"
STEP_UP = "step_up"
CPI_INDEXED = "cpi_indexed"
class EscalationRule(BaseModel):
"""One escalation term, shape-as-data: the same rule feeds one evaluator."""
model_config = ConfigDict(frozen=True, extra="forbid")
kind: EscalationKind
base_rent: Decimal = Field(gt=0, decimal_places=2)
frequency_months: int = Field(default=12, gt=0, le=120)
# fixed_pct: the rate per completed period, as a percent (e.g. Decimal("3.0")).
rate_pct: Optional[Decimal] = Field(default=None, ge=0)
# step_up: {month_offset_from_start: absolute_rent_amount}.
step_schedule: Optional[dict[int, Decimal]] = None
# cpi_indexed: the index value at commencement (the base year).
base_index: Optional[Decimal] = Field(default=None, gt=0)
# Collar bounds, as cumulative fractions vs the base year (cpi only).
cap_pct: Optional[Decimal] = Field(default=None, ge=0)
floor_pct: Optional[Decimal] = Field(default=None, ge=0)
@field_validator("step_schedule")
@classmethod
def _steps_non_negative(cls, v: Optional[dict[int, Decimal]]):
if v and any(k < 0 or amt < 0 for k, amt in v.items()):
raise ValueError("step offsets and amounts must be non-negative")
return v
@model_validator(mode="after")
def _require_params(self) -> "EscalationRule":
if (
self.cap_pct is not None
and self.floor_pct is not None
and self.cap_pct < self.floor_pct
):
raise ValueError("cap_pct cannot be below floor_pct")
if self.kind is EscalationKind.FIXED_PCT and self.rate_pct is None:
raise ValueError("fixed_pct requires rate_pct")
if self.kind is EscalationKind.STEP_UP and not self.step_schedule:
raise ValueError("step_up requires a step_schedule")
if self.kind is EscalationKind.CPI_INDEXED and self.base_index is None:
raise ValueError("cpi_indexed requires base_index")
return self
def evaluate(
rule: EscalationRule,
months_elapsed: int,
current_index: Optional[Decimal] = None,
) -> Decimal:
"""Return the Decimal rent owed `months_elapsed` after commencement.
Pure and idempotent: fixed_pct and step_up ignore `current_index`;
cpi_indexed requires it. Rounding is applied once, at the end.
"""
if months_elapsed < 0:
raise ValueError("months_elapsed must be non-negative")
if rule.kind is EscalationKind.STEP_UP:
applicable = [k for k in rule.step_schedule if k <= months_elapsed]
amount = rule.step_schedule[max(applicable)] if applicable else rule.base_rent
return amount.quantize(CENTS, rounding=ROUND_HALF_UP)
if rule.kind is EscalationKind.FIXED_PCT:
rate = rule.rate_pct / Decimal("100")
periods = months_elapsed // rule.frequency_months
return (rule.base_rent * (Decimal("1") + rate) ** periods).quantize(
CENTS, rounding=ROUND_HALF_UP
)
# cpi_indexed — base-year ratio, clamped to the cumulative collar.
if current_index is None:
raise ValueError("cpi_indexed evaluation requires current_index")
ratio = current_index / rule.base_index
if rule.floor_pct is not None:
ratio = max(ratio, Decimal("1") + rule.floor_pct / Decimal("100"))
if rule.cap_pct is not None:
ratio = min(ratio, Decimal("1") + rule.cap_pct / Decimal("100"))
return (rule.base_rent * ratio).quantize(CENTS, rounding=ROUND_HALF_UP)
Because the shape is a field, a caller that generates a full rent roll never branches on the clause type — it hands each rule and a month to evaluate(), and for CPI leases it passes the archived index value for that month. The frozen model means the same rule can be cached, versioned per amendment, and replayed identically.
Edge cases specific to commercial leases
- The CPI series is revised retroactively. Statistical agencies restate prior prints. If you stored only “we used CPI for March”, a replay silently changes historical rent. Archive the exact index value used alongside the computed rent (or pin an index vintage), so a reconstruction reproduces the number that was actually billed, not the latest revision.
- A CPI print is missing at billing time. The reset date arrives before the official release. Do not block the run and do not guess with a fixed step — apply a provisional rate, flag the record, and reconcile when the print lands. Fixed-step clauses never hit this case, which is a real part of their appeal.
- Cap, floor, and collar. An unclamped CPI formula is a liability in both directions: no cap exposes the landlord’s tenant to unbounded increases the lease never permitted, and no floor lets a deflationary print cut rent, which almost no commercial lease allows. Model the collar explicitly; a
cap_pctbelowfloor_pctis an extraction error, not a valid term, and the constructor rejects it. - Negative CPI / deflation. When ( I_n < I_0 ), the base-year ratio drops below 1. The floor clamp is exactly what holds rent at or above the negotiated minimum. Test this path directly — it is the single most common production bug, because sunny-day test data never exercises it.
- Mid-year reset vs anniversary. Fixed steps usually bump on the lease anniversary; CPI resets often key off a calendar reference month that is not the anniversary. Encode the reset cadence in
frequency_monthsand the base month in the index lookup, and never assume the two align — a portfolio that mixes them will otherwise off-by-one an entire cohort of leases.
When to escalate
Not every clause resolves to a clean rule, and the engine must refuse to invent one. Divert to review rather than emit a number when:
- The escalation shape is ambiguous — prose that reads as “the greater of 3% or CPI” is a hybrid, not a single
kind. Route it through fallback routing logic for a human to disambiguate, or model it as an explicit compound rule; never silently pick one side. - A required parameter is missing — a
cpi_indexedclause with no base index, or astep_upwith an empty schedule, fails construction. Preserve the original payload on the dead-letter path so a corrected pattern can replay it, instead of dropping the lease. - A CPI clause carries no cap — unusual enough in most portfolios to warrant verification against the executed lease before it reaches billing.
- A cap/floor consistency check fails — a transposed collar (
cap_pct < floor_pct) is almost always an extraction slip; re-parse with positional anchors, and divert if the clause is genuinely contradictory.
Frequently asked questions
When should I prefer a fixed-step clause over a CPI-indexed one in the model? Prefer the fixed step whenever the executed lease gives you one. It is fully deterministic, needs no external index, replays identically forever, and makes every downstream schedule and audit cheaper. Only model CPI when the clause is genuinely index-linked, and then treat the index series as a versioned input.
How do I handle a CPI series that gets revised after I have already billed? Archive the exact index value you used for each period next to the computed rent, or pin an index vintage. A replay then reproduces what was actually billed rather than the latest restatement. Reconcile deliberately if you choose to adopt a revision, and post the delta rather than silently overwriting history.
Why clamp the base-year ratio instead of the year-over-year change? Base-year indexing makes the cap and floor cumulative bounds over the whole term, which is how many long commercial leases draft their collars, and it removes the need to carry a prior-period index. Clamping the ratio before multiplying guarantees a deflationary print can never push rent below the negotiated floor.
Can one evaluator really serve both shapes without branching in the caller?
Yes. Make the escalation shape a field on a frozen rule, validate that each shape carries its required parameters at construction, and dispatch inside a single evaluate() function. Rent-roll generation then passes a rule and a month for every lease and only supplies an index value for CPI terms.
Related
- Escalation Formula Mapping — the parent engine that parses a clause into a callable formula; this page is the shape-selection decision within it.
- Handling CAM Charge Variations in Lease Taxonomy Design — why operating-expense recoveries take a separate taxonomy branch from base-rent escalations.
- Generating Payment Schedules from Escalation Rules — the downstream consumer that expands one validated rule into billable rent rows.
- Lease Data Models — where versioned rules are stored and amendment precedence is resolved.