Skip to content
Engine Source

The actual code that produces every number in the core model.

This page shows the files that produce every return, coverage ratio and verdict in the core model. What you see below is what runs. If a figure on the desk disagrees with this code, the correction is in the code on this page.

6 files, 2,209 lines: the pro forma, debt, sources and uses, waterfall, PMT and IRR, and the verdict rule. Those are the files the decision depends on. The memo's other sections are not shown here. This page shows these files as they run.

finance.ts

PMT and IRR

The two core financial primitives. pmt() is the standard Excel/financial-calc PMT formula (annuity payment given rate, periods, and present value). irr() runs Newton-Raphson on a cash-flow series with clamping so a single iteration can't overshoot the rate > -1 boundary. These two functions feed every levered cash flow and equity IRR the engine emits.

lib/engine/finance.tslive read from disk at render time
export function pmt(rate: number, nper: number, pv: number, fv: number = 0, type: 0 | 1 = 0): number {
  if (rate === 0) return -(pv + fv) / nper;
  const pvif = Math.pow(1 + rate, nper);
  return (-rate * (pv * pvif + fv)) / ((1 + rate * type) * (pvif - 1));
}

export function ipmt(rate: number, per: number, nper: number, pv: number, fv: number = 0, type: 0 | 1 = 0): number {
  if (rate === 0) return 0;
  if (per < 1 || per > nper) return 0;
  const payment = pmt(rate, nper, pv, fv, type);
  let interest: number;
  if (per === 1) {
    interest = type === 1 ? 0 : -pv * rate;
  } else {
    const fvBefore = fvAfter(rate, per - 1, payment, pv, type);
    interest = type === 1 ? fvBefore * rate / (1 + rate) : fvBefore * rate;
  }
  return interest;
}

function fvAfter(rate: number, periods: number, payment: number, pv: number, type: 0 | 1): number {
  const pvif = Math.pow(1 + rate, periods);
  return -(pv * pvif + payment * (1 + rate * type) * (pvif - 1) / rate);
}

export function cumipmt(rate: number, nper: number, pv: number, startPeriod: number, endPeriod: number, type: 0 | 1 = 0): number {
  let total = 0;
  for (let i = startPeriod; i <= endPeriod; i++) {
    total += ipmt(rate, i, nper, pv, 0, type);
  }
  return total;
}

/** Net present value of a series at a rate, Year 0 undiscounted. */
function npvAt(cashflows: number[], rate: number): number {
  let npv = 0;
  for (let t = 0; t < cashflows.length; t++) npv += cashflows[t] / Math.pow(1 + rate, t);
  return npv;
}

/** Changes of sign along a series, zeros skipped. */
function signChanges(cashflows: number[]): number {
  let last = 0;
  let n = 0;
  for (const c of cashflows) {
    if (c === 0) continue;
    const s = Math.sign(c);
    if (last !== 0 && s !== last) n++;
    last = s;
  }
  return n;
}

// A rate is handed back only when it is a root: the net present value there
// is zero to within a millionth of the flows' total size. A Newton walk that
// stalls on a series with no root at all used to return its last step, which
// read as a large IRR on a deal that loses money.
const ROOT_TOLERANCE = 1e-6;
// The highest IRR the desk reports; beyond it there is no usable rate.
const IRR_CAP = 5;
// The lowest rates the bracketing search tries on a deep loss.
const BRACKET_LOWS = [-0.9, -0.99, -0.999, -0.9999, -0.99999, -0.999999];

export function irr(cashflows: number[], guess: number = 0.1): number {
  if (cashflows.length < 2) return Number.NaN;
  if (!cashflows.every((c) => Number.isFinite(c))) return Number.NaN;
  const hasNeg = cashflows.some((c) => c < 0);
  const hasPos = cashflows.some((c) => c > 0);
  if (!hasNeg || !hasPos) return Number.NaN;

  const size = cashflows.reduce((s, c) => s + Math.abs(c), 0);
  const isRoot = (r: number) => {
    if (!Number.isFinite(r) || r <= -1 || Math.abs(r) > IRR_CAP) return false;
    const v = npvAt(cashflows, r);
    return Number.isFinite(v) && Math.abs(v) <= ROOT_TOLERANCE * size;
  };

  let rate = guess;
  for (let iter = 0; iter < 200; iter++) {
    let npv = 0;
    let dnpv = 0;
    for (let t = 0; t < cashflows.length; t++) {
      const factor = Math.pow(1 + rate, t);
      npv += cashflows[t] / factor;
      if (t > 0) dnpv -= (t * cashflows[t]) / (factor * (1 + rate));
    }
    if (!Number.isFinite(npv) || !Number.isFinite(dnpv) || Math.abs(dnpv) < 1e-15) break;
    let newRate = rate - npv / dnpv;
    if (!Number.isFinite(newRate)) break;
    // Clamp Newton steps so a single iteration can't overshoot past the
    // "rate must be > -1" boundary (where (1+rate)^t would explode) or
    // jump beyond the plausible IRR range. Without this clamp, even a
    // simple "you lost half your money" case (irr([-100, 50])) overshoots
    // to rate=-1.22 in the first iteration and bails out as divergent.
    if (newRate <= -0.99) newRate = (rate + -0.99) / 2;
    if (newRate >= 50) newRate = (rate + 50) / 2;
    if (Math.abs(newRate - rate) < 1e-12) {
      if (isRoot(newRate)) return newRate;
      break;
    }
    rate = newRate;
  }
  if (isRoot(rate)) return rate;

  // Newton did not land on a root. A series with one change of sign has
  // exactly one, so it is bracketed and bisected; a series with several
  // changes of sign may have none or several, and no rate is reported.
  if (signChanges(cashflows) !== 1) return Number.NaN;
  const hi = IRR_CAP;
  const fHi = npvAt(cashflows, hi);
  let lo = Number.NaN;
  for (const candidate of BRACKET_LOWS) {
    const f = npvAt(cashflows, candidate);
    if (Number.isFinite(f) && Number.isFinite(fHi) && Math.sign(f) !== Math.sign(fHi) && f !== 0) {
      lo = candidate;
      break;
    }
  }
  if (!Number.isFinite(lo)) return Number.NaN;
  let a = lo;
  let b = hi;
  let fa = npvAt(cashflows, a);
  for (let iter = 0; iter < 200 && b - a > 1e-15; iter++) {
    const mid = (a + b) / 2;
    const fm = npvAt(cashflows, mid);
    if (fm === 0) {
      a = mid;
      b = mid;
      break;
    }
    if (Math.sign(fm) === Math.sign(fa)) {
      a = mid;
      fa = fm;
    } else {
      b = mid;
    }
  }
  const root = (a + b) / 2;
  return isRoot(root) ? root : Number.NaN;
}

// Modified IRR (MIRR). Where standard IRR implicitly assumes that interim
// positive cash flows are reinvested at the IRR itself, MIRR explicitly
// separates the reinvestment rate (typically a conservative safe-rate or
// hurdle-rate proxy) from the financing rate applied to the initial and
// any subsequent negative cash flows. Closer to how real LPs think about
// returns: distributions aren't magically compounding at 18%.
//
// Formula:
//   n             = number of periods (cashflows.length - 1)
//   FV(positives) = sum of positives compounded forward to year n at reinvestRate
//   PV(negatives) = sum of negatives discounted to year 0 at financeRate
//   MIRR          = (FV(positives) / -PV(negatives))^(1/n) - 1
//
// Matches Excel's MIRR() function. When reinvestRate equals the implied
// IRR, MIRR exactly equals IRR; for any reinvestRate < IRR, MIRR < IRR
// (the "IRR overstates returns when distributions can't compound at the
// IRR rate" effect that institutional readers want to see).
export function mirr(
  cashflows: number[],
  financeRate: number,
  reinvestRate: number,
): number {
  if (cashflows.length < 2) return Number.NaN;
  const n = cashflows.length - 1;
  let fvPositives = 0;
  let pvNegatives = 0;
  for (let t = 0; t < cashflows.length; t++) {
    const cf = cashflows[t];
    if (cf > 0) {
      fvPositives += cf * Math.pow(1 + reinvestRate, n - t);
    } else if (cf < 0) {
      pvNegatives += cf / Math.pow(1 + financeRate, t);
    }
  }
  if (pvNegatives === 0 || fvPositives === 0) return Number.NaN;
  // pvNegatives is negative (sum of negatives); ratio must be positive.
  const ratio = fvPositives / -pvNegatives;
  if (ratio <= 0) return Number.NaN;
  return Math.pow(ratio, 1 / n) - 1;
}

What to look at: Read pmt() first: it's the rate x pv x (1+r)^n compounding formula, four lines of arithmetic. irr() is harder because Newton-Raphson on cash flows can be unstable near -1; the inline comment explains the clamping.

proforma.ts

15-year revenue and NOI build

Year-by-year build of EGI and NOI. Revenue is residential GPR + retail GPR + other income, less vacancy. Expenses are taxes / insurance / repairs / utilities / marketing scaled by expense growth, plus property management and reserves scaled as % of EGI. NOI is the sum. Horizon is 15 years so the waterfall can look up forward-NOI when hold equals the last modeled year.

lib/engine/proforma.tslive read from disk at render time
import type { DealInputs } from "./inputs";
import {
  commercialExpenseShare,
  commercialAreaSF,
  commercialPool,
  grossUpPool,
  recoveryGrossUpOn,
  reconcileRecoveries,
  auditRecoveries,
  recoveryAuditOn,
  recoveryOccupancy,
  recoveryReconciliationOn,
  recoveriesForYear,
  recoveriesActive,
  recoveryPayers,
  type ExpenseLines,
  type RecoveryYear,
} from "./recoveries";
import { leasingOverlay } from "./leasingAssumptions";
import { rolloverOverlay } from "./rollover";
import { hotelDepartmentalExpenses, hotelRevenueForYear, hotelRevenueOn, type HotelRevenueYear } from "./hotelRevenue";

export type ProFormaYear = {
  hotelRevenue?: HotelRevenueYear;
  year: number;
  residentialGPR: number;
  residentialVacancy: number;
  retailGPR: number;
  retailVacancy: number;
  // Free-rent abatement against retail GPR. Negative number (a reduction
  // in collected rent) when tenants have months of free rent at lease
  // start, spread proportionally. Zero when no tenant rent roll, or when
  // no tenant has free rent in this year.
  freeRentAbatement: number;
  otherIncome: number;
  // Expense recoveries credited to EGI (positive). Present ONLY when
  // inputs.recoveries is on: a deal with recoveries off returns pro forma
  // years with exactly the keys and values it returned before recoveries
  // existed. When present, egi includes it and noi moves by exactly it.
  expenseRecoveries?: number;
  egi: number;
  realEstateTaxes: number;
  insurance: number;
  propertyMgmt: number;
  repairsMaintenance: number;
  utilities: number;
  marketingGA: number;
  reserves: number;
  // Hotel-specific OpEx overlay (negative dollar amounts). All three are
  // 0 unless the deal is a hotel AND inputs.hotelOps is set. When active,
  // they're deducted from NOI alongside the generic OpEx lines.
  hotelBrandFee: number;
  hotelMgmtFee: number;
  hotelFFEReserve: number;
  // Opt-in hotel departmental costs (negative), inside totalOpEx. Present
  // only when hotelRevenue.departmentalExpenses is on.
  hotelDepartmentalExpenses?: number;
  // Leasing capex (TI/LC) on lease commencement years. Negative number
  // deducted from NOI in the year incurred. Zero when no tenant rent
  // roll or when no tenant starts a new lease in this year. Phase 1
  // simplification: real institutional treatment amortizes TIs over the
  // lease term; the engine takes the one-time hit. /beyond-phase-1.
  leasingCapex: number;
  totalOpEx: number;
  noi: number;
  opExPctOfEGI: number;
};

// 15-year horizon supports any realistic CRE hold (typical range 3-10 years)
// and gives the waterfall a forward-year NOI lookup when hold equals the last
// modeled year. Was 10; raising it does not affect existing canonical math
// because consumers slice to the actual hold period.
const YEARS = 15;
// A lease that ended more than this many years before the hold cannot touch
// any pro forma year (see tenantSegments); segments before it are skipped
// arithmetically rather than walked.
const LOOKBACK_YEARS = 100;
// Hard ceiling on segments walked per tenant. With the progress floor below
// this binds only for inputs no lease could describe.
const MAX_SEGMENTS = 10_000;

export type ProForma = ProFormaYear[];

export function buildProForma(inputs: DealInputs): ProForma {
  return buildProFormaDetail(inputs).proforma;
}

/** The pro forma and, when recoveries are on, the per-year recovery build
 *  behind its Expense recoveries line (by payer). One code path: the trace
 *  and the tests read the same numbers the pro forma credited. */
export function buildProFormaDetail(inputs: DealInputs): { proforma: ProForma; recoveries: RecoveryYear[] | null } {
  const { revenue, growth, operatingExpenses: oe } = inputs;

  const grow = (base: number, rate: number, year: number) =>
    base * Math.pow(1 + rate, year - 1);

  // Tenant-level retail GPR builder. When inputs.revenue.tenants is present
  // and non-empty, this REPLACES the aggregate retailSF × retailRentPerSFYear
  // math with a per-tenant build:
  //
  //   For each tenant active in year y (leaseStartYear <= y <= leaseEnd):
  //     in-term rent  = sf × baseRentPerSFYear × (1 + escalator)^(y - start)
  //   For each tenant whose lease has expired (re-lease at market rate):
  //     market rate at year y = baseRentPerSFYear × (1 + rentGrowth)^(y - 1)
  //     re-leased rent = sf × marketRate
  //
  // No downtime, TI/LC, free rent, or co-tenancy modeling in this Phase 1
  // version (see /methodology/beyond-phase-1). When tenants is absent or
  // empty, returns null and the aggregate calc runs unchanged.
  const tenants = revenue.tenants;
  const hasTenants = Array.isArray(tenants) && tenants.length > 0;
  // Opt-in leasing assumptions (lib/engine/leasingAssumptions.ts). Null
  // unless the deal turned one on, and then nothing below reads it.
  const hotelOn = hotelRevenueOn(inputs);
  const leasing = hotelOn ? null : leasingOverlay(inputs);
  // Null unless market leasing or monthly downtime is on. The base walk
  // below is then the only walk, and every existing figure stays put.
  const rollover = hotelOn ? null : rolloverOverlay(inputs);

  // Per-tenant lease segment iterator. Given a tenant, yields a sequence
  // of (commencementYear, leaseEnd, isReLease) tuples that walks from the
  // original lease through any number of mid-hold rollovers, each shifted
  // by the tenant's downtimeMonths converted to whole years (ceil).
  // Returns all segments whose start year falls within [1, YEARS]; callers
  // that need to know "is year y covered by a lease, and which one?"
  // iterate the segments to find a match.
  const tenantSegments = (
    t: NonNullable<typeof tenants>[number],
  ): Array<{ start: number; end: number; isReLease: boolean }> => {
    const segs: Array<{ start: number; end: number; isReLease: boolean }> = [];
    const startInit = Number.isFinite(t.leaseStartYear) ? t.leaseStartYear : 1;
    // typeof guards the optional field; Number.isFinite is not a type
    // guard, and an absent term was already falling to 5 here before the
    // field became optional. Unchanged for every term the model can use.
    const term = typeof t.leaseTermYears === "number" && Number.isFinite(t.leaseTermYears) ? t.leaseTermYears : 5;
    const downMonths = Number.isFinite(t.downtimeMonths) ? Math.max(0, t.downtimeMonths ?? 0) : 0;
    // Convert downtime months → whole-year offset on re-lease (ceil so
    // 6 months still pushes the next lease by a full year, matching how
    // institutional underwriting tends to round downtime conservatively).
    const downYears = Math.ceil(downMonths / 12);
    // V13 Part 28. Two values a stranger can put in a share link or an OG
    // request made this walk run forever: a term of zero or less left the
    // cursor where it was, and a start year far below one walked it
    // forward one lease at a time from wherever it began. Neither is a
    // lease. For a term of at least one year nothing below changes a
    // result: each segment already advances the cursor by a full step, so
    // the progress floor never binds, and the fast-forward skips only
    // leases that ended more than a century before the hold (a hundred
    // years of free rent is the only way such a lease could reach year 1),
    // landing on exactly the segment the walk would have reached.
    const step = Math.max(1, term + downYears);
    let cursor = startInit;
    let isFirst = true;
    const firstEnd = startInit + term - 1;
    if (firstEnd < 1 - LOOKBACK_YEARS) {
      const skipped = Math.ceil((1 - LOOKBACK_YEARS - firstEnd) / step);
      cursor = startInit + skipped * step;
      isFirst = false;
    }
    while (cursor <= YEARS && segs.length < MAX_SEGMENTS) {
      const end = Math.min(cursor + term - 1, YEARS);
      segs.push({ start: cursor, end, isReLease: !isFirst });
      isFirst = false;
      // Past expiration and downtime, and never in place.
      cursor = Math.max(cursor + 1, end + 1 + downYears);
    }
    return segs;
  };

  const retailGPRForYear = (y: number): number => {
    if (!hasTenants) {
      return (
        revenue.retailSF * revenue.retailRentPerSFYear *
        Math.pow(1 + growth.rentGrowthAnnual, y - 1)
      );
    }
    let sum = 0;
    for (const t of tenants) {
      if (!Number.isFinite(t.sf) || t.sf <= 0) continue;
      if (leasing?.owns(t)) {
        sum += leasing.tenantYear(t, y).gpr;
        continue;
      }
      if (rollover?.owns(t)) {
        sum += rollover.tenantYear(t, y).gpr;
        continue;
      }
      const segs = tenantSegments(t);
      // Find the segment that covers year y, if any. Years between
      // segments (downtime gap) contribute zero, because the space is empty.
      const seg = segs.find((s) => y >= s.start && y <= s.end);
      if (!seg) continue;
      if (!seg.isReLease) {
        // In-term original lease: contracted rent with escalator
        const yearsInLease = y - seg.start;
        sum += t.sf * t.baseRentPerSFYear * Math.pow(1 + t.escalatorAnnual, yearsInLease);
      } else {
        // Re-lease at market rate (rent growth applied from year 1 of the
        // proforma, which represents what space rents for at re-lease time).
        const marketRate = t.baseRentPerSFYear * Math.pow(1 + growth.rentGrowthAnnual, y - 1);
        sum += t.sf * marketRate;
      }
    }
    return sum;
  };

  // Free-rent abatement: negative dollar amount applied against retail GPR
  // when tenants have months of free rent at lease start (or at re-lease
  // commencement on a roll). Spread proportionally across the affected
  // years: 18 free-rent months starting in year 1 means year 1 is fully
  // abated (12 months) and year 2 is half-abated (6 months).
  //
  // Helper takes a list of (commencementYear, months) pairs for each
  // tenant (initial start + any mid-hold re-lease) and returns the dollar
  // abatement against year y. When a tenant has 0 free rent (or doesn't
  // start in/before y), contributes 0.
  const freeRentForYear = (y: number): number => {
    if (!hasTenants) return 0;
    let abate = 0;
    for (const t of tenants) {
      if (leasing?.owns(t)) {
        abate += leasing.tenantYear(t, y).free;
        continue;
      }
      if (rollover?.owns(t)) {
        abate += rollover.tenantYear(t, y).free;
        continue;
      }
      const fr = Number.isFinite(t.freeRentMonths) ? (t.freeRentMonths ?? 0) : 0;
      if (fr <= 0) continue;
      if (!Number.isFinite(t.sf) || t.sf <= 0) continue;
      // Use the same segment iterator as retail GPR so downtime-shifted
      // commencement years stay consistent across rent, free rent, and TIs.
      const segs = tenantSegments(t);
      for (const seg of segs) {
        const cYear = seg.start;
        // Months of free rent that land in year y, given that free rent
        // starts at the beginning of cYear and continues for `fr` months.
        const offset = y - cYear;
        if (offset < 0) continue;        // before commencement
        if (offset >= Math.ceil(fr / 12)) continue; // past the free-rent window
        const monthsStart = 12 * offset;
        const monthsEnd = Math.min(fr, 12 * offset + 12);
        const monthsInYear = Math.max(0, monthsEnd - monthsStart);
        if (monthsInYear <= 0) continue;
        // Rent for this tenant in year y. For the initial commencement
        // we use the contracted base × escalator; for a re-lease we use
        // the deal's market rate (rent growth).
        const yearsIn = y - cYear;
        const rentPerSF = seg.isReLease
          ? t.baseRentPerSFYear * Math.pow(1 + growth.rentGrowthAnnual, y - 1)
          : t.baseRentPerSFYear * Math.pow(1 + t.escalatorAnnual, yearsIn);
        abate -= t.sf * rentPerSF * (monthsInYear / 12);
      }
    }
    return abate;
  };

  // Leasing capex (TI/LC) for year y. Negative dollar amount fired in
  // each lease-commencement year (initial start + each re-lease). When
  // tenants is absent or tenant has no tiLcPerSF, contributes 0.
  const leasingCapexForYear = (y: number): number => {
    if (!hasTenants) return 0;
    let capex = 0;
    for (const t of tenants) {
      if (leasing?.owns(t)) {
        capex += leasing.tenantYear(t, y).capex;
        continue;
      }
      if (rollover?.owns(t)) {
        capex += rollover.tenantYear(t, y).capex;
        continue;
      }
      const ti = Number.isFinite(t.tiLcPerSF) ? (t.tiLcPerSF ?? 0) : 0;
      if (ti <= 0) continue;
      if (!Number.isFinite(t.sf) || t.sf <= 0) continue;
      // Use the same segment iterator: TI/LC fires in each commencement
      // year (initial + each downtime-shifted re-lease).
      const segs = tenantSegments(t);
      for (const seg of segs) {
        if (y === seg.start) capex -= t.sf * ti;
      }
    }
    return capex;
  };

  // Lease-up curve. When inputs.acquisition.leaseUpYears > 0, the engine
  // linearly interpolates vacancy from a higher "lease-up start" rate to
  // the stabilized rate across the lease-up years. After lease-up, vacancy
  // is the stabilized rate. When leaseUpYears is 0 / undefined, every year
  // uses the stabilized rate, matching the pre-lease-up behavior exactly.
  const leaseUpYears =
    Number.isFinite(inputs.acquisition.leaseUpYears)
      ? Math.max(0, Math.floor(inputs.acquisition.leaseUpYears ?? 0))
      : 0;
  const stabResVac = revenue.residentialVacancyPct;
  const stabComVac = revenue.retailVacancyPct;
  const startResVac =
    Number.isFinite(inputs.acquisition.leaseUpStartResidentialVacancyPct)
      ? inputs.acquisition.leaseUpStartResidentialVacancyPct ?? 0.4
      : 0.4;
  const startComVac =
    Number.isFinite(inputs.acquisition.leaseUpStartCommercialVacancyPct)
      ? inputs.acquisition.leaseUpStartCommercialVacancyPct ?? 0.5
      : 0.5;
  // Linear interp from start to stabilized vacancy over leaseUpYears years.
  // Year 1 = startVac, Year leaseUpYears+1 = stabVac.
  const vacancyForYear = (y: number, startVac: number, stabVac: number): number => {
    if (leaseUpYears <= 0 || y > leaseUpYears) return stabVac;
    // y is 1-indexed; at y=1 we want startVac, at y=leaseUpYears+1 we want stabVac.
    const t = (y - 1) / leaseUpYears;
    return startVac + (stabVac - startVac) * t;
  };

  // Expense recoveries (lib/engine/recoveries.ts). Null when off, and then
  // nothing below reads it.
  const recovery = !hotelOn && recoveriesActive(inputs)
    ? {
        share: commercialExpenseShare(inputs),
        payers: recoveryPayers(inputs, (t) => leasing?.segmentsFor(t) ?? rollover?.segmentsFor(t) ?? tenantSegments(t)),
        pools: [] as ExpenseLines[],
        billingPools: recoveryGrossUpOn(inputs) ? [] as ExpenseLines[] : undefined,
        years: [] as RecoveryYear[],
      }
    : null;

  const years: ProFormaYear[] = [];
  for (let y = 1; y <= YEARS; y++) {
    const hotel = hotelRevenueForYear(inputs, y);
    const residentialGPR =
      hotel ? hotel.availableRoomNights * hotel.averageDailyRate :
      revenue.residentialUnits * revenue.avgResidentialRentMonthly * 12 *
      Math.pow(1 + growth.rentGrowthAnnual, y - 1);
    const effectiveResVac = vacancyForYear(y, startResVac, stabResVac);
    const residentialVacancy = -residentialGPR * (hotel ? 1 - hotel.occupancyPct : effectiveResVac);

    const retailGPR = hotel ? 0 : retailGPRForYear(y);
    const effectiveComVac = vacancyForYear(y, startComVac, stabComVac);
    const extra = leasing ? leasing.extra(y) : null;
    const freeRentAbatement = hotel ? 0 : extra ? freeRentForYear(y) + extra.free : freeRentForYear(y);
    // Vacancy applies to rent that is still billed. A contractual free-rent
    // abatement is not billed, so charging the vacancy rate on the gross
    // potential as well produced a negative year when the abatement was the
    // whole year (potential $400,000, free rent -$400,000, vacancy -$20,000).
    // Rent the leasing assumptions say is not collected (co-tenancy, reserve
    // downtime) is charged here in full, and the vacancy rate runs on what
    // is still billed after it.
    const uncollected = extra ? Math.min(Math.max(0, extra.loss), Math.max(0, retailGPR + freeRentAbatement)) : 0;
    const retailVacancy = extra && uncollected > 0
      ? -(Math.max(0, retailGPR + freeRentAbatement) - uncollected) * effectiveComVac - uncollected
      : -Math.max(0, retailGPR + freeRentAbatement) * effectiveComVac;

    // Termination fees (opt-in tenant options) are other income in the year
    // the lease ends.
    const otherIncome = hotel
      ? grow(revenue.otherIncomeAnnual, growth.otherIncomeGrowthAnnual, y) + hotel.ancillaryRevenue
      : extra && extra.fees !== 0
      ? grow(revenue.otherIncomeAnnual, growth.otherIncomeGrowthAnnual, y) + extra.fees
      : grow(revenue.otherIncomeAnnual, growth.otherIncomeGrowthAnnual, y);

    const egi =
      residentialGPR + residentialVacancy + retailGPR + retailVacancy + freeRentAbatement + otherIncome;

    const realEstateTaxes = -grow(oe.realEstateTaxes, growth.expenseGrowthAnnual, y);
    const insurance = -grow(oe.insurance, growth.expenseGrowthAnnual, y);
    const propertyMgmt = -egi * oe.propertyMgmtPctEGI;
    const repairsMaintenance = -grow(oe.repairsMaintenance, growth.expenseGrowthAnnual, y);
    const utilities = -grow(oe.utilities, growth.expenseGrowthAnnual, y);
    const marketingGA = -grow(oe.marketingGA, growth.expenseGrowthAnnual, y);
    const reserves = -egi * oe.reservesPctEGI;

    const leasingCapex = hotel ? 0 : extra ? leasingCapexForYear(y) + extra.capex : leasingCapexForYear(y);

    // Hotel-specific OpEx overlay. Only fires when propertyType === "Hotel"
    // AND inputs.hotelOps is configured. Each fee is a % of EGI; deducted
    // from NOI alongside generic OpEx. Backwards-compatible: when absent,
    // all three lines are 0 and Cherry Street / non-hotel deals stay
    // byte-identical.
    const isHotel = inputs.meta.propertyType === "Hotel";
    const hotelOps = isHotel ? inputs.hotelOps : undefined;
    const hotelBrandFee = hotelOps && Number.isFinite(hotelOps.brandFeePctOfRevenue)
      ? -egi * Math.max(0, hotelOps.brandFeePctOfRevenue) : 0;
    const hotelMgmtFee = hotelOps && Number.isFinite(hotelOps.mgmtFeePctOfRevenue)
      ? -egi * Math.max(0, hotelOps.mgmtFeePctOfRevenue) : 0;
    const hotelFFEReserve = hotelOps && Number.isFinite(hotelOps.ffeReservePctOfRevenue)
      ? -egi * Math.max(0, hotelOps.ffeReservePctOfRevenue) : 0;

    const departmental = hotelDepartmentalExpenses(inputs, hotel);
    const baseOpEx =
      realEstateTaxes + insurance + propertyMgmt + repairsMaintenance +
      utilities + marketingGA + reserves +
      hotelBrandFee + hotelMgmtFee + hotelFFEReserve;
    const totalOpEx = departmental === null ? baseOpEx : baseOpEx + departmental;

    // Recoveries are computed from the lines above, which are all built on
    // EGI before recoveries (the management fee, reserves and hotel fees
    // are not charged on recovery income), so crediting them moves EGI and
    // NOI by exactly the recovered amount and nothing else.
    let expenseRecoveries = 0;
    if (recovery) {
      recovery.pools.push(
        commercialPool(recovery.share, { realEstateTaxes, insurance, repairsMaintenance, utilities, marketingGA, propertyMgmt }),
      );
      if (recovery.billingPools) {
        const occupancy = recoveryOccupancy(y, effectiveComVac, recovery.payers, commercialAreaSF(inputs));
        recovery.billingPools.push(grossUpPool(recovery.pools[y - 1], occupancy, inputs.recoveries!.grossUp!));
      }
      const r = recoveriesForYear(y, recovery.pools, effectiveComVac, recovery.payers, recovery.billingPools);
      recovery.years.push(r);
      expenseRecoveries = r.total;
      if (recoveryReconciliationOn(inputs)) {
        // Opt-in year-end reconciliation: credit this year's estimate plus
        // the true-ups that settle in it. A year depends only on actuals up
        // to itself, so the timing is read from the years built so far.
        const rawHold = inputs.acquisition.holdPeriodYears;
        const hold = Math.max(1, Math.min(Number.isFinite(rawHold) ? Math.floor(rawHold) : 1, YEARS));
        const timing = reconcileRecoveries(recovery.years.map((ry) => ry.total), hold, inputs.recoveries!.reconciliation!);
        r.reconciliation = timing[y - 1];
        expenseRecoveries = r.reconciliation.credited;
      }
      if (recoveryAuditOn(inputs)) {
        // Opt-in tenant audits: refund the entered share of billed
        // recoveries, a year later, from the years built so far.
        const rawHold = inputs.acquisition.holdPeriodYears;
        const hold = Math.max(1, Math.min(Number.isFinite(rawHold) ? Math.floor(rawHold) : 1, YEARS));
        const billed = recovery.years.map((ry) => ry.reconciliation?.credited ?? ry.total);
        r.audit = auditRecoveries(billed, hold, inputs.recoveries!.audit!)[y - 1];
        expenseRecoveries = r.audit.credited;
      }
    }
    const egiOut = recovery ? egi + expenseRecoveries : egi;

    // NOI is the institutional-standard "Net Operating Income" line:
    // EGI minus ongoing operating expenses. Leasing capex (TI/LC) sits
    // BELOW NOI as a separate capex item, which is the institutional
    // credit-committee treatment, where DSCR is tested on NOI proper. The waterfall and after-tax engines pull operating
    // cash flow from NOI - leasingCapex (the "Cash Flow After Capex"
    // line); only DSCR uses raw NOI.
    //
    // Earlier engine versions folded leasing capex into NOI, which
    // understated DSCR on rent-roll deals with TI/LC. Cherry Street
    // (no rent roll → leasingCapex always 0) is byte-identical either
    // way; deals with TIs see correct, higher DSCR with this fix.
    const noi = egiOut + totalOpEx;
    // Guard against zero/negative EGI (degenerate deal with no revenue inputs).
    const opExPctOfEGI = egiOut === 0 ? 0 : -totalOpEx / egiOut;

    years.push({
      ...(hotel ? { hotelRevenue: hotel } : {}),
      year: y,
      residentialGPR,
      residentialVacancy,
      retailGPR,
      retailVacancy,
      freeRentAbatement,
      otherIncome,
      ...(recovery ? { expenseRecoveries } : {}),
      egi: egiOut,
      realEstateTaxes,
      insurance,
      propertyMgmt,
      repairsMaintenance,
      utilities,
      marketingGA,
      reserves,
      hotelBrandFee,
      hotelMgmtFee,
      hotelFFEReserve,
      ...(departmental === null ? {} : { hotelDepartmentalExpenses: departmental }),
      leasingCapex,
      totalOpEx,
      noi,
      opExPctOfEGI,
    });
  }

  return { proforma: years, recoveries: recovery ? recovery.years : null };
}

What to look at: The grow() helper compounds a base value by an annual rate. Vacancy is stored as a negative number (vacancy is a loss against GPR). propertyMgmt and reserves are computed AFTER EGI so they scale correctly when other income grows at a different rate from rent.

debt.ts

fixed-rate amortizing mortgage with balloon

Fixed-rate fully-amortizing mortgage schedule. PMT comes from finance.ts. Each year decomposes annual debt service into principal + interest, tracks beginning and ending balance, and reports DSCR = NOI / annualDebtService. At the loan term the remaining balance is the balloon, paid off at sale. What this does NOT model: prepayment, defeasance, IO periods, and floating rate. Those are listed on /methodology/beyond-phase-1.

lib/engine/debt.tslive read from disk at render time
import type { DealInputs } from "./inputs";
import { pmt, cumipmt } from "./finance";
import type { ProForma } from "./proforma";
import {
  simulateSeniorYears, structureActive, interestOnlyMonths, activeFloating, rateCapCost,
  activeExtensions, extensionsExercised, seniorMaturityYears, type ExtensionExercise,
} from "./loanStructure";
import { prepaymentCost, type ExitDebtCost } from "./prepayment";

export type DebtYear = {
  year: number;
  begBalance: number;
  annualPI: number;
  interest: number;
  principal: number;
  endBalance: number;
  dscr: number;
  // Present only when the loan carries an interest-only period or a floating
  // rate (lib/engine/loanStructure.ts); a deal without either has rows with
  // exactly the keys above.
  /** Rate in force for the year. */
  rate?: number;
  /** Interest-only months inside the year, 0 to 12. */
  interestOnlyMonths?: number;
  /** NOI over the balance at the start of the year. */
  debtYield?: number;
  /** Present only in a year whose end exercises a loan extension
   *  (inputs.debt.extensions). The fee is included in annualPI, so the
   *  year's cash flow and coverage carry it. */
  extensionFee?: number;
};

export type DebtSchedule = {
  loanAmount: number;
  interestRate: number;
  amortizationMonths: number;
  termMonths: number;
  monthlyPayment: number;
  years: DebtYear[];
  // Refinance event metadata. Present only when inputs.refinance.enabled is
  // true AND hold > term, so the engine actually splices in a refi schedule.
  // Surfaces the cash event for the waterfall and lets the IC memo render
  // the refinance line item explicitly. Null otherwise.
  refinance?: {
    eventYear: number;          // year at which the original loan matures
    terminalAssetValue: number; // forward NOI(eventYear+1) / entryCapRate
    payoffBalance: number;      // remaining principal on the original loan
    newLoanAmount: number;      // refinance.ltv × terminalAssetValue
    cashOut: number;            // newLoanAmount - payoffBalance
    newInterestRate: number;    // refi rate
    newAmortizationMonths: number;
    /** Prepayment cost of retiring the acquisition loan at the refinance.
     *  Present only when a prepayment structure is entered. The refinance
     *  falls at maturity, so the charge is zero there. */
    prepaymentCost?: ExitDebtCost;
  } | null;
  /** Present only when the loan carries an interest-only period or a
   *  floating rate. */
  structure?: {
    interestOnlyMonths: number;
    floating: boolean;
    /** Upfront rate cap cost, a use of funds at closing. */
    rateCapCost: number;
  };
  /** Present only when extension options are on and the hold exercises at
   *  least one. */
  extensions?: {
    /** Extensions exercised, at most the count offered. */
    exercised: number;
    /** Senior maturity in loan years after them. */
    maturityYears: number;
    exercises: ExtensionExercise[];
  };
};

export function buildDebtSchedule(inputs: DealInputs, proforma: ProForma): DebtSchedule {
  const loanAmount = inputs.acquisition.purchasePrice * inputs.debt.ltv;
  const interestRate = inputs.debt.interestRate;
  const amortizationMonths = inputs.debt.amortizationYears * 12;
  const termMonths = inputs.debt.termYears * 12;
  const monthlyPayment = pmt(interestRate / 12, amortizationMonths, -loanAmount);
  // IO period or floating rate: the acquisition loan's years come from the
  // monthly simulation instead of cumipmt. Null on every deal without them,
  // which takes none of the branches below that read it.
  const structured = structureActive(inputs) ? simulateSeniorYears(inputs, loanAmount) : null;

  const years: DebtYear[] = [];
  let begBalance = loanAmount;

  // Match proforma horizon. The waterfall uses debt.years[hold-1] for end-of-
  // hold loan balance, so debt must cover the same year range as proforma.
  // If the loan amortizes within the schedule horizon (amort < 15 years), the
  // begBalance hits zero in some year and subsequent years should report zero
  // debt service rather than continuing to "pay" against a paid-off loan and
  // pushing the balance negative.
  //
  // Refinance event: when enabled AND hold > term, at year=termYears the loan
  // matures. We pay off the remaining balance and originate a new loan sized
  // against terminal asset value × refinance LTV. The new loan amortizes
  // through the rest of the proforma horizon.
  //
  // Extension options: when on and the hold runs past the term, the loan is
  // extended only as far as the hold needs, up to the count offered. The
  // maturity, and with it any refinance, moves out by the extensions
  // exercised, and each fee is paid at the maturity it moves, on the balance
  // outstanding then. With the option off, termYears is the base term and no
  // year carries a fee.
  const termYears = seniorMaturityYears(inputs);
  const extensionTerms = activeExtensions(inputs);
  const exercised = extensionsExercised(inputs);
  const exerciseYears = Array.from({ length: exercised }, (_, k) => inputs.debt.termYears + k * extensionTerms!.termYears);
  const exercises: ExtensionExercise[] = [];
  const holdYears = inputs.acquisition.holdPeriodYears;
  const refiEnabled =
    inputs.refinance?.enabled === true &&
    Number.isFinite(holdYears) &&
    Number.isFinite(termYears) &&
    holdYears > termYears &&
    Number.isFinite(inputs.refinance.ltv) &&
    inputs.refinance.ltv > 0;

  let refiMeta: NonNullable<DebtSchedule["refinance"]> | null = null;

  for (let y = 1; y <= 15; y++) {
    const noi = proforma[y - 1].noi;

    // Trigger the refinance at the start of year termYears + 1, that is, the
    // original loan amortized THROUGH year termYears, hit the balloon at
    // year-end, and the new loan covers year termYears + 1 onward.
    if (refiEnabled && y === termYears + 1 && refiMeta === null) {
      const refi = inputs.refinance!;
      const entryCapRate = proforma[0].noi / inputs.acquisition.purchasePrice;
      // Terminal asset value at the refi event = forward NOI / entry cap rate.
      // Use entry cap not exit cap: cap compression / expansion is an exit-
      // year thesis, not a mid-hold mark.
      const forwardNOI =
        proforma[y - 1]?.noi ?? proforma[proforma.length - 1].noi;
      const terminalAssetValue = forwardNOI / entryCapRate;
      const newLoanAmount = terminalAssetValue * refi.ltv;
      const payoffBalance = begBalance;
      // Prepayment cost of retiring the acquisition loan, when a structure is
      // entered. Null otherwise, and the cash-out is what it always was.
      const refiPrepay = prepaymentCost(inputs, payoffBalance, termYears, "refinance");
      const cashOut = refiPrepay && refiPrepay.amount > 0
        ? newLoanAmount - payoffBalance - refiPrepay.amount
        : newLoanAmount - payoffBalance;
      const newInterestRate = refi.interestRate ?? inputs.debt.interestRate;
      const newAmortYears = refi.amortizationYears ?? inputs.debt.amortizationYears;
      const newAmortMonths = newAmortYears * 12;

      refiMeta = {
        eventYear: termYears,
        terminalAssetValue,
        payoffBalance,
        newLoanAmount,
        cashOut,
        newInterestRate,
        newAmortizationMonths: newAmortMonths,
        ...(refiPrepay ? { prepaymentCost: refiPrepay } : {}),
      };

      // Switch the working loan to the refi loan. begBalance becomes
      // newLoanAmount; subsequent years amortize on (newInterestRate,
      // newAmortMonths). To keep cumipmt's year-anchored math correct we
      // need to track a separate "monthsIntoRefi" counter rather than the
      // global y.
      // Use a local index that resets at refi event: year y corresponds to
      // the first year of the refi loan (so periods 1..12 of the refi).
      begBalance = newLoanAmount;
    }

    if (begBalance <= 0 || !Number.isFinite(begBalance)) {
      years.push({
        year: y,
        begBalance: 0,
        annualPI: 0,
        interest: 0,
        principal: 0,
        endBalance: 0,
        dscr: noi === 0 ? Number.NaN : noi > 0 ? Number.POSITIVE_INFINITY : Number.NEGATIVE_INFINITY,
      });
      continue;
    }

    // Choose which amortization context to compute against. Pre-refi years
    // use the original (rate, amort, loan amount). Post-refi years use the
    // refi loan with a yearsIntoRefi index for cumipmt.
    let activeRate: number;
    let activeAmortMonths: number;
    let activePI: number;
    let startPeriod: number;
    let endPeriod: number;
    let activeLoanAmount: number;

    if (refiMeta && y > termYears) {
      activeRate = refiMeta.newInterestRate;
      activeAmortMonths = refiMeta.newAmortizationMonths;
      const yearsIntoRefi = y - termYears;
      startPeriod = (yearsIntoRefi - 1) * 12 + 1;
      endPeriod = yearsIntoRefi * 12;
      activeLoanAmount = refiMeta.newLoanAmount;
      activePI = pmt(activeRate / 12, activeAmortMonths, -activeLoanAmount) * 12;
    } else {
      activeRate = interestRate;
      activeAmortMonths = amortizationMonths;
      startPeriod = (y - 1) * 12 + 1;
      endPeriod = y * 12;
      activeLoanAmount = loanAmount;
      activePI = monthlyPayment * 12;
    }

    const onRefiLoan = refiMeta !== null && y > termYears;
    const simYear = structured && !onRefiLoan ? structured[y - 1] : null;
    const rawInterest = simYear
      ? simYear.interest
      : -cumipmt(activeRate / 12, activeAmortMonths, activeLoanAmount, startPeriod, endPeriod);
    const rawPrincipal = simYear ? simYear.principal : activePI - rawInterest;
    const principal = Math.min(rawPrincipal, begBalance);
    const interest = rawInterest;
    const endBalance = begBalance - principal;
    const exerciseAt = exerciseYears.indexOf(y);
    const extensionFee = exerciseAt >= 0 ? extensionTerms!.feePct * endBalance : null;
    if (extensionFee !== null) {
      exercises.push({ extension: exerciseAt + 1, year: y, balance: endBalance, fee: extensionFee });
    }
    const annualPI = extensionFee !== null ? principal + interest + extensionFee : principal + interest;
    const dscr = annualPI === 0
      ? noi > 0 ? Number.POSITIVE_INFINITY : noi === 0 ? Number.NaN : Number.NEGATIVE_INFINITY
      : noi / annualPI;
    years.push({
      year: y,
      begBalance,
      annualPI,
      interest,
      principal,
      endBalance,
      dscr,
      ...(structured
        ? {
            rate: simYear ? simYear.rate : activeRate,
            interestOnlyMonths: simYear ? simYear.interestOnlyMonths : 0,
            debtYield: begBalance > 0 ? noi / begBalance : Number.NaN,
          }
        : {}),
      ...(extensionFee !== null ? { extensionFee } : {}),
    });
    begBalance = endBalance;
  }

  const extensionMeta = exercised > 0
    ? { extensions: { exercised, maturityYears: termYears, exercises } }
    : {};

  if (structured) {
    // The rate and payment the loan opens on, read from the simulation.
    return {
      loanAmount,
      interestRate: structured[0].rate,
      amortizationMonths,
      termMonths,
      monthlyPayment: structured[0].monthlyPayment,
      years,
      refinance: refiMeta,
      structure: {
        interestOnlyMonths: interestOnlyMonths(inputs),
        floating: activeFloating(inputs) !== null,
        rateCapCost: rateCapCost(inputs),
      },
      ...extensionMeta,
    };
  }

  return {
    loanAmount,
    interestRate,
    amortizationMonths,
    termMonths,
    monthlyPayment,
    years,
    refinance: refiMeta,
    ...extensionMeta,
  };
}

// Build the mezzanine debt schedule when inputs.mezzDebt is present. Returns
// null otherwise. Call sites use the null to skip every mezz-aware code
// path so the no-mezz case stays byte-identical to the pre-mezz engine.
//
// Mezz is sized as additional LTV above senior. If senior is 65% LTV and
// mezz LTV is 10pp, mezz loan amount = purchasePrice × 0.10. The combined
// debt yield is what the mezz lender prices off, but for sizing we just
// use the explicit pp input.
//
// Mezz schedule mirrors senior structure (PMT amortization, balloon at
// term). Mezz years can be marked interest-only by passing a long amort
// (e.g. amortizationYears = 100); the math still works (principal pays
// down minimally each year).
//
// DSCR field on each year is MEZZ ONLY (NOI / mezz P&I), which is not useful
// for credit-committee purposes, but matches the senior schedule's shape
// for consistency. Most consumers will compute blended DSCR by summing
// senior + mezz P&I.
export function buildMezzSchedule(inputs: DealInputs, proforma: ProForma): DebtSchedule | null {
  const mezz = inputs.mezzDebt;
  if (!mezz || !Number.isFinite(mezz.ltv) || mezz.ltv <= 0) return null;

  const loanAmount = inputs.acquisition.purchasePrice * mezz.ltv;
  if (!Number.isFinite(loanAmount) || loanAmount <= 0) return null;

  const interestRate = mezz.interestRate;
  const amortizationMonths = mezz.amortizationYears * 12;
  const termMonths = mezz.termYears * 12;
  const monthlyPayment = pmt(interestRate / 12, amortizationMonths, -loanAmount);

  const years: DebtYear[] = [];
  let begBalance = loanAmount;

  for (let y = 1; y <= 15; y++) {
    const noi = proforma[y - 1].noi;
    if (begBalance <= 0 || !Number.isFinite(begBalance)) {
      years.push({
        year: y,
        begBalance: 0,
        annualPI: 0,
        interest: 0,
        principal: 0,
        endBalance: 0,
        dscr: noi === 0 ? Number.NaN : noi > 0 ? Number.POSITIVE_INFINITY : Number.NEGATIVE_INFINITY,
      });
      continue;
    }
    const rawAnnualPI = monthlyPayment * 12;
    const startPeriod = (y - 1) * 12 + 1;
    const endPeriod = y * 12;
    const rawInterest = -cumipmt(interestRate / 12, amortizationMonths, loanAmount, startPeriod, endPeriod);
    const rawPrincipal = rawAnnualPI - rawInterest;
    const principal = Math.min(rawPrincipal, begBalance);
    const interest = rawInterest;
    const annualPI = principal + interest;
    const endBalance = begBalance - principal;
    const dscr = annualPI === 0
      ? noi > 0 ? Number.POSITIVE_INFINITY : noi === 0 ? Number.NaN : Number.NEGATIVE_INFINITY
      : noi / annualPI;
    years.push({ year: y, begBalance, annualPI, interest, principal, endBalance, dscr });
    begBalance = endBalance;
  }

  return {
    loanAmount,
    interestRate,
    amortizationMonths,
    termMonths,
    monthlyPayment,
    years,
  };
}

What to look at: The monthly rate is annual / 12 because PMT compounds monthly. annualPI is monthly payment x 12. The dscr calc uses the proforma's NOI for that year. For all-equity deals (ltv = 0) the function short-circuits with an empty schedule.

sourcesUses.ts

capital stack at close

Sources and uses at closing. Uses = purchase price + closing costs + loan origination fee. Sources = senior debt + equity. LTV sizes off purchase price (not total uses), which is the conventional CRE convention; the borrower funds closing + origination out of equity. balanceCheck is sources minus uses and should always be exactly 0. Drift here means the engine has a math bug.

lib/engine/sourcesUses.tslive read from disk at render time
import type { DealInputs } from "./inputs";
import type { DebtSchedule } from "./debt";
import type { ExitSourcesUses } from "./prepayment";

export type SourcesUses = {
  uses: {
    purchasePrice: number;
    closingCosts: number;
    loanOriginationFee: number;
    // Total origination fees across all debt tranches (senior + mezz). When
    // there's no mezz, this equals the senior-only fee unchanged. Pre-mezz
    // callers that read `loanOriginationFee` continue to see senior-only;
    // the new field is additive.
    mezzOriginationFee: number;
    /** Upfront rate cap cost on a floating senior loan. Present only when one
     *  is entered (lib/engine/loanStructure.ts). */
    rateCapCost?: number;
    totalUses: number;
  };
  sources: {
    seniorDebt: number;
    // Mezz tranche loan amount. 0 when no mezz; preserves byte-identical
    // numbers for Cherry Street and every pre-mezz deal.
    mezzDebt: number;
    equity: number;
    totalSources: number;
  };
  balanceCheck: number;
  /** Sources and uses of the sale, with the exit-debt cost as its own line.
   *  Present only when a prepayment structure is entered; see
   *  buildExitSourcesUses in lib/engine/prepayment.ts. */
  atExit?: ExitSourcesUses;
};

export function buildSourcesUses(
  inputs: DealInputs,
  debt: DebtSchedule,
  mezz: DebtSchedule | null = null,
): SourcesUses {
  const purchasePrice = inputs.acquisition.purchasePrice;
  const closingCosts = purchasePrice * inputs.acquisition.closingCostPct;
  const loanOriginationFee = debt.loanAmount * inputs.debt.originationFeePct;
  const mezzOriginationFee =
    mezz && inputs.mezzDebt
      ? mezz.loanAmount * inputs.mezzDebt.originationFeePct
      : 0;
  const capCost = debt.structure?.rateCapCost ?? 0;
  const totalUses = capCost > 0
    ? purchasePrice + closingCosts + loanOriginationFee + mezzOriginationFee + capCost
    : purchasePrice + closingCosts + loanOriginationFee + mezzOriginationFee;
  const seniorDebt = debt.loanAmount;
  const mezzDebt = mezz?.loanAmount ?? 0;
  const equity = totalUses - seniorDebt - mezzDebt;
  const totalSources = seniorDebt + mezzDebt + equity;

  return {
    uses: capCost > 0
      ? { purchasePrice, closingCosts, loanOriginationFee, mezzOriginationFee, rateCapCost: capCost, totalUses }
      : { purchasePrice, closingCosts, loanOriginationFee, mezzOriginationFee, totalUses },
    sources: { seniorDebt, mezzDebt, equity, totalSources },
    balanceCheck: totalSources - totalUses,
  };
}

What to look at: Simplicity is the feature. Senior debt plus an optional mezzanine tranche; no pref equity, no construction draws. Listed on /methodology/beyond-phase-1.

waterfall.ts

4-tier equity promote

The four-tier waterfall that splits sale proceeds + accumulated operating cash flow between LP and GP. Tier 1 returns capital to LP. Tier 2 distributes the LP's preferred return. Tiers 3 and 4 escalate to higher IRR hurdles with progressively richer GP promote splits (90/10 to 80/20 to 70/30 to 60/40 residual). The critical invariant: LP + GP = max(0, totalProfit) for EVERY deal, including losing ones. No phantom pref on a deal that lost money.

lib/engine/waterfall.tslive read from disk at render time
import type { DealInputs, HurdleMode } from "./inputs";
import type { ProForma } from "./proforma";
import type { DebtSchedule } from "./debt";
import type { SourcesUses } from "./sourcesUses";
import { irr, mirr } from "./finance";
import { buildPeriodicWaterfall, periodicWaterfallOn, type PeriodicWaterfall } from "./periodicWaterfall";
import { additionalSalePrepayment, prepaymentCost, totalSaleDebtCost, type ExitDebtCost } from "./prepayment";

/** Which hurdle set a tier's size. "same" means the IRR amount and the MoIC amount matched. */
export type HurdleBound = "irr" | "moic" | "same";

export type WaterfallTier = {
  label: string;
  hurdleAmount: number;
  lpShare: number;
  gpShare: number;
  lpSplitPct: number;
  gpSplitPct: number;
  /** Set when any tier carries a MoIC hurdle. Absent when every tier is IRR only,
   *  so those results keep the shape they had before MoIC hurdles existed. */
  bound?: HurdleBound;
};

const HURDLE_KEYS = ["tier1", "tier2", "tier3"] as const;
type HurdleKey = (typeof HURDLE_KEYS)[number];

function finiteMoic(v: unknown): number | null {
  return typeof v === "number" && Number.isFinite(v) && v > 0 ? v : null;
}

function moicOf(inputs: DealInputs, key: HurdleKey): number | null {
  return finiteMoic(inputs.waterfall.moicHurdles?.[key]);
}

function modeOf(inputs: DealInputs, key: HurdleKey): HurdleMode {
  return inputs.waterfall.hurdleMode?.[key] === "either" ? "either" : "both";
}

/** The note under a tier. Tiers 1 to 3 are IRR hurdles when no MoIC hurdle is set. */
export function tierBoundLabel(tier: WaterfallTier, index: number): string | null {
  if (tier.bound === "moic") return "MoIC hurdle";
  if (tier.bound === "same") return "IRR and MoIC";
  if (tier.bound === "irr" || index < 3) return "IRR hurdle";
  return null;
}

export type Waterfall = {
  equityCashFlows: number[]; // Year 0 through Year hold
  projectIRR: number;
  // Modified IRR. Standard IRR implicitly assumes interim positive cash
  // flows compound at the IRR rate; MIRR uses a separate reinvestment
  // rate (set to the preferred return, a conservative LP-side proxy)
  // and a finance rate (also preferred return). When projectMIRR is
  // materially below projectIRR, the IRR is overstating the return that
  // could realistically be earned on reinvested distributions.
  projectMIRR: number;
  projectMoIC: number;
  totalDistribution: number;
  totalContribution: number;
  totalProfit: number;
  tiers: WaterfallTier[];
  totalLP: number;
  totalGP: number;
  // GP co-invest economics. Present only when inputs.waterfall.gpCoinvestPct
  // is set and > 0; null otherwise. When present, breaks out LP and GP
  // capital contributions, per-year distributions, and standalone IRRs.
  // GP IRR is typically materially higher than LP IRR thanks to promote
  // amplifying the GP's coinvest.
  gpCoinvest: {
    gpCoinvestPct: number;
    lpContribution: number;
    gpContribution: number;
    lpDistribution: number;
    gpDistribution: number;
    lpMoIC: number;
    gpMoIC: number;
    lpCashFlows: number[];      // Year 0 through Year hold (LP-only)
    gpCashFlows: number[];      // Year 0 through Year hold (GP-only)
    lpIRR: number;
    gpIRR: number;
  } | null;
  /** The annual waterfall view. Present only when
   *  inputs.waterfall.periodicDistributions is on. */
  periodic?: PeriodicWaterfall;
  /** The GP catch-up inside tier 2. Present only when
   *  inputs.waterfall.catchUp is on. */
  catchUp?: WaterfallCatchUp;
};

export type WaterfallCatchUp = {
  gpSharePct: number;
  targetGpPct: number;
  /** Profit paid through the catch-up band, out of tier 2. */
  amount: number;
  lpShare: number;
  gpShare: number;
};

const TIER1_SPLIT: [number, number] = [0.9, 0.1];
const TIER2_SPLIT: [number, number] = [0.8, 0.2];
const TIER3_SPLIT: [number, number] = [0.7, 0.3];
const TIER4_SPLIT: [number, number] = [0.6, 0.4];

/** The year the waterfall sells in. Clamp the hold period to the available
 *  proforma/debt horizon so we can never run off the end of those arrays.
 *  Validation surfaces a danger warning if the user-entered hold is out of
 *  the [1, proforma.length] range, but the engine itself stays robust. */
export function saleYear(inputs: DealInputs, proforma: ProForma): number {
  const requestedHold = inputs.acquisition.holdPeriodYears;
  return Math.max(
    1,
    Math.min(Number.isFinite(requestedHold) ? Math.floor(requestedHold) : 1, proforma.length),
  );
}

/** The sale at the end of year `y`: the price net of selling costs, and the
 *  senior and mezzanine balances it pays off. The waterfall adds the net
 *  price less both balances to the sale year's cash flow. The returns
 *  summary reads the same three figures, so the verdict plate can say
 *  whether the sale covers the loan from the figure the waterfall used
 *  rather than from a copy of this arithmetic. */
export type SaleAtExit = {
  netSale: number;
  seniorBalance: number;
  mezzBalance: number;
  /** Prepayment cost of paying off the senior loan at this sale. Present
   *  only when a prepayment structure is entered (lib/engine/prepayment.ts),
   *  so a sale without one has exactly the three figures above. */
  exitDebtCost?: ExitDebtCost;
  additionalExitDebtCosts?: ExitDebtCost[];
};

export function saleAtExit(
  inputs: DealInputs,
  proforma: ProForma,
  debt: DebtSchedule,
  mezz: DebtSchedule | null,
  y: number,
): SaleAtExit {
  // Forward NOI = year-hold+1 NOI. If the proforma horizon does not extend
  // that far (would happen only for unusually long holds), fall back to a
  // rent-growth-adjusted estimate so we never index past the array.
  const forward = proforma[y];
  const currentNOI = proforma[y - 1].noi;
  const forwardNOI = forward
    ? forward.noi
    : currentNOI * (1 + inputs.growth.rentGrowthAnnual);
  const grossSale = forwardNOI / inputs.growth.exitCapRate;
  const netSale = grossSale * (1 - inputs.growth.sellingCostPct);
  // Same safety on the debt schedule. When the refinance falls in the
  // exit year (a fractional hold one year past the term: the hold is
  // floored to the term year, while the refi triggers on hold > term),
  // the new loan was originated at the end of this year and its cash-out
  // added to the year's flow, so the sale repays the new loan, not the old
  // balance.
  const refiYear = debt.refinance?.eventYear ?? null;
  const seniorBalance =
    refiYear !== null && y === refiYear
      ? (debt.refinance?.newLoanAmount ?? 0)
      : (debt.years[y - 1]?.endBalance ?? debt.years[debt.years.length - 1].endBalance);
  // Mezz balloon at exit, paid off out of sale proceeds after senior,
  // before equity sees a dime. Zero when no mezz.
  const mezzBalance =
    mezz?.years[y - 1]?.endBalance ?? mezz?.years[mezz.years.length - 1]?.endBalance ?? 0;
  // The acquisition loan's prepayment cost. A sale after the refinance pays
  // off the refinance loan, which carries no prepayment terms here.
  const refiBefore = refiYear !== null && y > refiYear;
  const prepay = refiBefore ? null : prepaymentCost(inputs, seniorBalance, y, "sale");
  const additional = additionalSalePrepayment(inputs, debt, mezz, y, seniorBalance, mezzBalance);
  if (additional.length > 0) return {
    netSale, seniorBalance, mezzBalance,
    ...(prepay ? { exitDebtCost: prepay } : {}),
    additionalExitDebtCosts: additional,
  };
  if (prepay) return { netSale, seniorBalance, mezzBalance, exitDebtCost: prepay };
  return { netSale, seniorBalance, mezzBalance };
}

export function buildWaterfall(
  inputs: DealInputs,
  proforma: ProForma,
  debt: DebtSchedule,
  sourcesUses: SourcesUses,
  mezz: DebtSchedule | null = null,
): Waterfall {
  const hold = saleYear(inputs, proforma);
  const equity = sourcesUses.sources.equity;

  const cashFlows: number[] = [];
  cashFlows.push(-equity); // Year 0

  // Refinance event: when the senior schedule includes a refi, the cash-out
  // (or cash-in) happens at year=eventYear. Add it to that year's CF before
  // we look at exit-year mechanics. Net-zero in pre-refi engine because
  // debt.refinance is null when refi isn't triggered.
  const refiYear = debt.refinance?.eventYear ?? null;
  const refiCashOut = debt.refinance?.cashOut ?? 0;

  for (let y = 1; y <= hold; y++) {
    const noi = proforma[y - 1].noi;
    // Leasing capex (TI/LC) is a below-NOI capex line on rent-roll deals.
    // NOI itself is unburdened (the lender DSCR convention);
    // operating cash flow to equity subtracts it here. Zero on deals
    // without a tenant rent roll, so backwards-compatible.
    const leasingCapex = proforma[y - 1].leasingCapex ?? 0;
    const seniorPI = debt.years[y - 1].annualPI;
    // Mezz P&I subtracts from operating CF the same way senior does, so equity
    // sees what's left after BOTH debt tranches are served. When mezz is null,
    // this is 0 and the math is byte-identical to the pre-mezz engine.
    const mezzPI = mezz?.years[y - 1]?.annualPI ?? 0;
    let cf = noi + leasingCapex - seniorPI - mezzPI;

    // Refinance cash-out (or cash-in) at term-year maturity. Positive when
    // terminal asset value × refi LTV exceeds the maturing balance (sponsor
    // pulls cash out); negative when the gap goes the other way. When refi
    // is not triggered, refiYear is null and this branch is skipped.
    if (refiYear !== null && y === refiYear) {
      cf += refiCashOut;
    }

    if (y === hold) {
      // The same expression, in the same order, as before saleAtExit was
      // lifted out of this loop, so every sale year is bit-identical.
      const sale = saleAtExit(inputs, proforma, debt, mezz, y);
      const { netSale, seniorBalance, mezzBalance } = sale;
      cf += netSale - seniorBalance - mezzBalance;
      // Exit-debt cost, only when a prepayment structure is entered.
      const exitDebtCost = totalSaleDebtCost(sale);
      if (exitDebtCost > 0) cf -= exitDebtCost;
    }

    cashFlows.push(cf);
  }

  const projectIRR = irr(cashFlows, 0.15);
  // MIRR uses the preferred return as both finance and reinvestment rate:
  // an LP can realistically reinvest distributions at their pref-rate
  // ledger, not at the deal's headline IRR. When the deal's IRR is well
  // above pref, MIRR will be materially below IRR, surfacing the
  // "IRR overstatement" effect for the IC committee.
  const projectMIRR = mirr(cashFlows, inputs.waterfall.preferredReturn, inputs.waterfall.preferredReturn);
  // Equity multiple is every dollar distributed over every dollar contributed,
  // including a capital call in a later year. Counting only Year 0 as equity
  // in, and dropping later negative years, reported 2.59x on a deal that
  // returned 1.30x. On a deal whose operating years are all positive this is
  // the same pair of sums as before.
  const totalDistribution = cashFlows.filter((v) => v > 0).reduce((a, b) => a + b, 0);
  const totalContribution = cashFlows.filter((v) => v < 0).reduce((a, b) => a - b, 0);
  const totalProfit = totalDistribution - totalContribution;
  const projectMoIC = totalContribution === 0 ? Number.NaN : totalDistribution / totalContribution;

  // 4-tier waterfall, one distribution at exit. Each tier's IRR amount is
  // the profit that carries contributed equity to that tier's IRR over the
  // hold. A MoIC hurdle, when set, is contributed equity times the multiple,
  // less the equity. The tier pays at its split until the LP's share of the
  // tier reaches the larger of those two amounts (both, the default) or the
  // smaller (either). With no MoIC hurdle the amount is the IRR amount, the
  // same figure as before. Each tier is capped by remaining profit so the
  // four tiers sum to max(0, totalProfit), and a losing deal distributes zero.
  const anyMoic = HURDLE_KEYS.some((key) => moicOf(inputs, key) !== null);
  const cumulative = (rate: number, key: HurdleKey): { profit: number; bound: HurdleBound | null } => {
    const irrProfit = totalContribution * (Math.pow(1 + rate, hold) - 1);
    const moic = moicOf(inputs, key);
    if (moic === null) return { profit: irrProfit, bound: anyMoic ? "irr" : null };
    const moicProfit = totalContribution * (moic - 1);
    const either = modeOf(inputs, key) === "either";
    const chosen = either ? Math.min(irrProfit, moicProfit) : Math.max(irrProfit, moicProfit);
    // A multiple of 1x or less needs no profit. Clamping only the dual
    // target keeps a negative IRR hurdle, which is rare, on the old path.
    const profit = Math.max(0, chosen);
    const scale = Math.max(1, Math.abs(irrProfit), Math.abs(moicProfit));
    if (Math.abs(irrProfit - moicProfit) <= 1e-8 * scale) return { profit, bound: "same" };
    const moicWins = either ? moicProfit < irrProfit : moicProfit > irrProfit;
    return { profit, bound: moicWins ? "moic" : "irr" };
  };
  const c1 = cumulative(inputs.waterfall.preferredReturn, "tier1");
  const c2 = cumulative(inputs.waterfall.tier2Hurdle, "tier2");
  const c3 = cumulative(inputs.waterfall.tier3Hurdle, "tier3");
  // Each tier is the step up from the highest hurdle already cleared. With
  // IRR hurdles alone those steps rise, and the amounts match the prior
  // engine. A MoIC hurdle can clear a later tier early, and that later tier
  // then has nothing left to pay.
  const tier1Theoretical = c1.profit;
  const tier2Theoretical = c2.profit - c1.profit;
  const clearedThrough2 = Math.max(c1.profit, c2.profit);
  const tier3Theoretical = c3.profit - clearedThrough2;

  let remaining = Math.max(0, totalProfit);
  const tier1Amount = Math.min(remaining, Math.max(0, tier1Theoretical));
  remaining -= tier1Amount;
  const tier2Amount = Math.min(remaining, Math.max(0, tier2Theoretical));
  remaining -= tier2Amount;
  const tier3Amount = Math.min(remaining, Math.max(0, tier3Theoretical));
  remaining -= tier3Amount;
  const tier4Amount = Math.max(0, remaining);

  const tier1 = makeTier(`Tier 1: ${pct(inputs.waterfall.preferredReturn)} Preferred Return`, tier1Amount, TIER1_SPLIT, c1.bound);
  const catchUp = catchUpBand(inputs, tier1Amount, tier2Amount);
  const tier2 = catchUp
    ? catchUpTier(`Tier 2: GP catch-up, then pref to ${pct(inputs.waterfall.tier2Hurdle)} IRR`, tier2Amount, catchUp, c2.bound)
    : makeTier(`Tier 2: Pref to ${pct(inputs.waterfall.tier2Hurdle)} IRR`, tier2Amount, TIER2_SPLIT, c2.bound);
  const tier3 = makeTier(`Tier 3: ${pct(inputs.waterfall.tier2Hurdle)} to ${pct(inputs.waterfall.tier3Hurdle)} IRR`, tier3Amount, TIER3_SPLIT, c3.bound);
  const tier4 = makeTier(`Tier 4: > ${pct(inputs.waterfall.tier3Hurdle)} IRR (residual)`, tier4Amount, TIER4_SPLIT);

  const tiers = [tier1, tier2, tier3, tier4];
  const totalLP = tiers.reduce((acc, t) => acc + t.lpShare, 0);
  const totalGP = tiers.reduce((acc, t) => acc + t.gpShare, 0);

  // GP co-invest split. When the user sets a non-zero gpCoinvestPct, both
  // LP and GP contribute to the equity check in proportion to that pct,
  // and each receives their pari-passu share of distributions PLUS the
  // GP receives the carry (the "GP split" portion of each tier).
  //
  // For per-year cash flows, we apply the same split to operating CF
  // (treat operating CF as a pro-rata distribution between LP and GP
  // weighted by their capital contribution, with no promote on operating
  // CF in the simplified model). The tier-based promote applies only
  // to the exit distribution; we allocate it to the LP/GP based on the
  // tier mechanics above. Sum-check: lpCF + gpCF == equityCashFlows.
  const coinvestPctRaw = inputs.waterfall.gpCoinvestPct;
  const coinvestPct =
    Number.isFinite(coinvestPctRaw) && (coinvestPctRaw ?? 0) > 0
      ? Math.min(0.5, Math.max(0, coinvestPctRaw ?? 0))
      : 0;
  let gpCoinvest: Waterfall["gpCoinvest"] = null;
  if (coinvestPct > 0) {
    const lpContribution = equity * (1 - coinvestPct);
    const gpContribution = equity * coinvestPct;
    // Pro-rata operating split: each year's CF (years 1..hold-1) flows
    // pari-passu between LP and GP based on their capital share. At
    // exit (year=hold), the residual equity CF is allocated via the
    // tier mechanics: LP receives the LP-split portion (× 1 - coinvest),
    // GP receives the GP-split portion + coinvest × LP-split portion.
    const lpFlows: number[] = [-lpContribution];
    const gpFlows: number[] = [-gpContribution];
    for (let y = 1; y < hold; y++) {
      const yCF = cashFlows[y] ?? 0;
      lpFlows.push(yCF * (1 - coinvestPct));
      gpFlows.push(yCF * coinvestPct);
    }
    // Exit year. The tier mechanics above split the whole hold's profit:
    //   LP receives: lpContribution back + (1 - coinvestPct) × totalLP
    //   GP receives: gpContribution back + coinvestPct × totalLP + totalGP
    // The operating years before the exit have already been paid pro rata
    // to capital, so the exit delivers what each party has not yet received
    // of that entitlement. Sum-check: lpCF + gpCF == equityCashFlows.
    if (hold > 0) {
      const exitYearCF = cashFlows[hold] ?? 0;
      let interimCF = 0;
      for (let y = 1; y < hold; y++) interimCF += cashFlows[y] ?? 0;
      // Profit the deal actually paid over the hold, from these cash flows.
      // totalProfit is the same sum (distributions minus every contribution,
      // capital calls included). The exit still has to be split so that a
      // cash-out before the sale is not paid twice, and no party is asked
      // to write a check at the exit. Only the profit shares move.
      const paidProfit = exitYearCF + interimCF - equity;
      const promoteLP = (1 - coinvestPct) * totalLP;
      const promoteGP = coinvestPct * totalLP + totalGP;
      const promoteSum = promoteLP + promoteGP;
      let exitLP: number;
      let exitGP: number;
      if (promoteSum > 0 && paidProfit > 0 && exitYearCF > 0) {
        const lpEntitlement = lpContribution + paidProfit * (promoteLP / promoteSum);
        const lpAlreadyPaid = interimCF * (1 - coinvestPct);
        // A cash-out before the exit (a refinance) can pay the LP more pro
        // rata than its whole-hold share; the GP's promote then comes out of
        // the exit, and no party is asked to pay money back at the sale.
        exitLP = Math.min(exitYearCF, Math.max(0, lpEntitlement - lpAlreadyPaid));
        exitGP = exitYearCF - exitLP;
      } else {
        // Losing or break-even deal: just split pro-rata, no promote.
        exitLP = exitYearCF * (1 - coinvestPct);
        exitGP = exitYearCF * coinvestPct;
      }
      lpFlows.push(exitLP);
      gpFlows.push(exitGP);
    }
    const lpIRR = irr(lpFlows, 0.15);
    const gpIRR = irr(gpFlows, 0.20);
    const lpDistribution = lpFlows.filter((v) => v > 0).reduce((a, b) => a + b, 0);
    const gpDistribution = gpFlows.filter((v) => v > 0).reduce((a, b) => a + b, 0);
    const lpContributed = lpFlows.filter((v) => v < 0).reduce((a, b) => a - b, 0);
    const gpContributed = gpFlows.filter((v) => v < 0).reduce((a, b) => a - b, 0);
    const lpMoIC = lpContributed > 0 ? lpDistribution / lpContributed : Number.NaN;
    const gpMoIC = gpContributed > 0 ? gpDistribution / gpContributed : Number.NaN;
    gpCoinvest = {
      gpCoinvestPct: coinvestPct,
      lpContribution,
      gpContribution,
      lpDistribution,
      gpDistribution,
      lpMoIC,
      gpMoIC,
      lpCashFlows: lpFlows,
      gpCashFlows: gpFlows,
      lpIRR,
      gpIRR,
    };
  }

  return {
    equityCashFlows: cashFlows,
    projectIRR,
    projectMIRR,
    projectMoIC,
    totalDistribution,
    totalContribution,
    totalProfit,
    tiers,
    totalLP,
    totalGP,
    gpCoinvest,
    ...(periodicWaterfallOn(inputs) ? { periodic: buildPeriodicWaterfall(inputs, cashFlows, totalGP) } : {}),
    ...(catchUp ? { catchUp } : {}),
  };
}

// Opt-in GP catch-up (inputs.waterfall.catchUp). Tier 2 opens with a band
// in which the sponsor takes gpSharePct of each dollar until it holds
// targetGpPct of the profit paid through tiers 1 and 2. With tier 1 at its
// 10% split, the band that closes the gap is
//   C = tier1 x (target - 0.10) / (gpShare - target),
// capped at tier 2. The rest of tier 2 is split 80/20 as before. Null when
// the option is off, so tier 2 is built exactly as it always was.
function catchUpBand(inputs: DealInputs, tier1Amount: number, tier2Amount: number): WaterfallCatchUp | null {
  const c = inputs.waterfall.catchUp;
  if (c?.enabled !== true) return null;
  const num = (v: number | undefined, fallback: number) => (typeof v === "number" && Number.isFinite(v) ? v : fallback);
  const targetGpPct = Math.min(1, Math.max(TIER1_SPLIT[1], num(c.targetGpPct, 0.2)));
  const gpSharePct = Math.min(1, Math.max(targetGpPct, num(c.gpSharePct, 1)));
  const full = gpSharePct > targetGpPct ? (tier1Amount * (targetGpPct - TIER1_SPLIT[1])) / (gpSharePct - targetGpPct) : 0;
  const amount = Math.min(Math.max(0, tier2Amount), Math.max(0, full));
  return { gpSharePct, targetGpPct, amount, lpShare: amount * (1 - gpSharePct), gpShare: amount * gpSharePct };
}

function catchUpTier(label: string, amount: number, catchUp: WaterfallCatchUp, bound: HurdleBound | null): WaterfallTier {
  const rest = amount - catchUp.amount;
  return {
    label,
    hurdleAmount: amount,
    lpShare: catchUp.lpShare + rest * TIER2_SPLIT[0],
    gpShare: catchUp.gpShare + rest * TIER2_SPLIT[1],
    lpSplitPct: TIER2_SPLIT[0],
    gpSplitPct: TIER2_SPLIT[1],
    ...(bound ? { bound } : {}),
  };
}

function makeTier(label: string, amount: number, split: [number, number], bound: HurdleBound | null = null): WaterfallTier {
  return {
    label,
    hurdleAmount: amount,
    lpShare: amount * split[0],
    gpShare: amount * split[1],
    lpSplitPct: split[0],
    gpSplitPct: split[1],
    ...(bound ? { bound } : {}),
  };
}

function pct(v: number): string {
  // 1 decimal place, trailing '.0' trimmed so integer hurdles render as '8%'
  // and fractional hurdles render as '8.5%' rather than losing precision to
  // a flat .toFixed(0). Avoids labels like 'Pref to 13% IRR' when the actual
  // hurdle is 12.5%.
  const formatted = (v * 100).toFixed(1);
  return `${formatted.replace(/\.0$/, "")}%`;
}

What to look at: The hurdle amounts compound over the FULL hold (not year-by-year catchup). Single distribution at exit. Tiers cap at total profit so the math can't overpay across the four tiers combined. Annual interim distributions are aggregated into the IRR cash flows but not used to step LPs through hurdles year by year. That simplification is flagged on /methodology/beyond-phase-1.

decision.ts

the verdict rule

The rule that produces PURSUE or PASS, and the only file that does. Two critical tests form a conjunction: project IRR at or above the institutional value-add hurdle, and minimum DSCR at or above 1.00x. Everything else in the file is advisory: it shapes the grade and the sentence the memo reads, and it never moves the verdict. The thresholds are constants at the top of the file, named once and referenced everywhere.

lib/engine/decision.tslive read from disk at render time
// The decision reason canon.
//
// Before this file the product agreed on the verdict and disagreed about
// everything else around it. The 15% hurdle was stated independently, without
// reference to PURSUE_IRR_THRESHOLD, in eight places. The verdict GATE itself
// was re-implemented three times outside the engine: VerdictBreakdown.tsx and
// lib/excel/exporter.ts each carried their own `projectIRR >= 0.15 && minDSCR
// >= 1.0`, and app/methodology restated it in prose. The reason SENTENCE was
// independently authored six times. The two implementations that mattered most
// did not even agree on precision: the engine's sentence said "15.00%" while
// the breakdown table beside it said "≥ 15%", two precisions for one quantity
// on one screen, which is the exact defect lib/format exists to prevent.
//
// None of those copies was wrong today. That is the point: they were all
// correct and independent, so the first change to the gate would have made
// some of them wrong silently, and the reader would have had no way to know
// which surface to believe.
//
// So: one structure, derived from the engine's own logic, computed once, hung
// on ReturnsSummary.decision, and read by every surface that has an opinion
// about why this deal is a PURSUE or a PASS.
//
// This file imports nothing but lib/format. It defines no new business rule.
// Every threshold and every comparison below already existed somewhere in the
// product; this is where they now live exactly once.

import { NULL_TOKEN, dscr as fmtDscr, ltv as fmtLtv, money, multipleAt, percentAt, pts } from "../format";

// ── Thresholds ───────────────────────────────────────────────────────────
// The two that gate the verdict, and the six that inform it.

/** The institutional value-add hurdle the verdict tests project IRR against. */
export const PURSUE_IRR_THRESHOLD = 0.15;
/** Coverage floor. Below it, debt service is not covered in at least one
 *  year of the hold, and the deal cannot be recommended at any IRR. */
export const DSCR_COVERAGE_FLOOR = 1.0;

/** Advisory. Conventional lender sizing, not a gate. */
export const DSCR_LENDER_STANDARD = 1.2;
export const LTV_CONVENTIONAL = 0.75;
export const YIELD_ON_COST_HURDLE = 0.06;

export type RuleStatus = "clears" | "fails" | "n/a";

export type DecisionRule = {
  id:
    | "irr-computable"
    | "dscr-floor"
    | "irr-hurdle"
    | "dscr-lender-floor"
    | "ltv-conventional"
    | "yield-on-cost"
    | "positive-leverage"
    | "no-cap-compression"
    | "hold-within-term";
  /** CRITICAL rules are the conjunction that produces the verdict. ADVISORY
   *  rules shape the grade and the discussion and never gate. */
  kind: "critical" | "advisory";
  /** The quantity under test, named the way the desk names it. */
  metric: string;
  /** The full test, for a table row. */
  test: string;
  /** The deal's value, formatted through lib/format. */
  value: string;
  /** The threshold, formatted at the same precision as the value it is
   *  quoted against. */
  threshold: string;
  status: RuleStatus;
  /** Signed distance from the threshold in the metric's own units, or null
   *  when the comparison is not numeric (hold vs term, exit vs entry). */
  margin: number | null;
  /** The margin as a magnitude with units: "1.49 pts", "0.46x". Direction is
   *  carried by the sentence, never by an arrow or a sign glyph. */
  marginText: string | null;
  /** The one clause this rule contributes beside the verdict:
   *  "Below the 15.00% hurdle by 1.49 pts". */
  marginLine: string;
  /** Engine precedence. Lower decides first, and this order IS the order of
   *  the branches in buildReturnsSummary's reason: a deal whose IRR cannot be
   *  computed is not additionally reported as failing a hurdle. */
  order: number;
  /** Whether a threshold-test table should list this rule unconditionally.
   *  "on-failure" is for the degenerate-input guard, which is a real branch of
   *  the decision chain and not an underwriting test: on a healthy deal a row
   *  reading "resolves: pass" is noise, and on a broken one it is the whole
   *  story. The judgement lives here rather than in each table so the two
   *  tables cannot disagree about it. */
  surfaced: "always" | "on-failure";
};

export type DealDecision = {
  verdict: "PURSUE" | "PASS";
  /** The rule that produced this verdict. On a PASS it is the
   *  highest-precedence failing critical rule; on a PURSUE it is the binding
   *  hurdle the deal had to clear. Never invented, never ranked by anything
   *  other than the engine's own branch order. */
  decisive: DecisionRule;
  /** Every critical rule, in engine precedence order. */
  critical: DecisionRule[];
  /** Every advisory rule. These never move the verdict. */
  advisory: DecisionRule[];
  /** The other critical rules that also failed, in precedence order. Empty on
   *  a PURSUE and on a single-cause PASS. */
  alsoFailing: DecisionRule[];
  /** The advisory rules that missed. Not a reason for the verdict; the reason
   *  a PURSUE still deserves questions. */
  advisoryMisses: DecisionRule[];
  /** The decisive condition as the desk should lead with it: the metric that
   *  produced this verdict and its value. Null only when the cash flows are
   *  degenerate and no metric resolved. */
  headline: { metric: string; value: string } | null;
  /** Everything the verdict still needs to say after the headline: the
   *  measured margin, the coverage clause on a PURSUE, and any secondary
   *  critical rule that also failed. */
  detail: string;
  /** headline and detail as one line, for the surfaces that want prose. The
   *  memo, the xlsx export, the experience page and the preset-check route
   *  read it through ReturnsSummary.verdictReason, and qa:stage pins its PASS
   *  form. It is COMPOSED from the two fields above rather than written
   *  beside them, so a plate that renders the parts and a memo that renders
   *  the whole can never describe the same result differently. */
  sentence: string;
};

export type DecisionFacts = {
  projectIRR: number;
  minDSCR: number;
  loanAmount: number;
  y1NOI: number;
  totalUses: number;
  ltv: number;
  interestRate: number;
  entryCapRate: number;
  exitCapRate: number;
  holdPeriodYears: number;
  termYears: number;
  /** The series projectIRR was solved from (Waterfall.equityCashFlows, Year 0
   *  through the sale year). When the IRR does not resolve, the plate names
   *  why from this series, so the reason is read, never guessed. */
  equityCashFlows: readonly number[];
  /** The sale at the end of the hold, net of selling costs, less the loan
   *  balances it pays off: the figure the waterfall adds to the sale year
   *  (lib/engine/waterfall.ts, saleAtExit). Below zero when the sale does
   *  not cover the loan. Optional: without it the plate is built from the
   *  series alone and never says whether the sale covers the loan. */
  saleLessLoans?: number;
  /** A mezzanine balance is paid off at the sale too, so the plate says
   *  "the loans" rather than "the loan". */
  mezzanineAtSale?: boolean;
};

/** Why a project IRR did not resolve, read from the same series irr() was
 *  given. irr() (lib/engine/finance.ts) hands back a rate only when it is a
 *  root at or below 500%, and none when no cash flow is positive or none is
 *  negative. So, in the order tested:
 *
 *  - never-pays-back: no year is positive.
 *  - no-equity-in: no year is negative, Year 0 included.
 *  - gets-back-less: equity goes in at Year 0 and the undiscounted sum of the
 *    series is below zero. Since the IRR root hotfix of 6 October 2026 this is
 *    the common one: the worked example at a 12.00% exit cap with flat rents
 *    puts in $3,200,250, gets $1,055,093 back over six years and pays
 *    $223,591 more at the sale, and that series has no root at all. It used
 *    to fall to the generic sentence, which read as if the inputs were wrong
 *    when the deal had simply lost money.
 *  - rate-too-high: equity goes in at Year 0, the sum is above zero and the
 *    series changes sign once, so it has exactly one root, and irr() did not
 *    report it, which on one change of sign means it lies above 500%.
 *  - no-usable-rate: everything else (money going in more than once with a
 *    gain overall, a short series, a non-finite year), each with its own
 *    plain sentence below.
 */
export type NoReturnCause = "never-pays-back" | "no-equity-in" | "gets-back-less" | "rate-too-high" | "no-usable-rate";
export function noReturnCause(cashflows: readonly number[]): NoReturnCause {
  if (cashflows.length < 2 || !cashflows.every((c) => Number.isFinite(c))) return "no-usable-rate";
  const hasNeg = cashflows.some((c) => c < 0);
  const hasPos = cashflows.some((c) => c > 0);
  if (hasNeg && !hasPos) return "never-pays-back";
  if (hasPos && !hasNeg) return "no-equity-in";
  if (cashflows[0] < 0) {
    const total = cashflows.reduce((s, c) => s + c, 0);
    if (total < 0) return "gets-back-less";
    // One change of sign, zeros skipped: with Year 0 negative, no year after
    // the first positive one is negative.
    const firstOut = cashflows.findIndex((c) => c > 0);
    if (total > 0 && cashflows.slice(firstOut).every((c) => c >= 0)) return "rate-too-high";
  }
  return "no-usable-rate";
}

/** What the plate and the rule line say when there is no return, built from
 *  the series and its figures through lib/format. Brokers read what the deal
 *  does: what the equity puts in, what it gets back, and where the money goes
 *  in again. The sentences used to end "Check the price, income, expenses and
 *  financing", which reads as the reader's mistake on a deal that is simply
 *  losing money. They give no instruction now.
 *
 *  "It puts in" is the Year 0 equity and "gets back" is every positive year
 *  after it. The multiple quoted beside those dollars is the Project MoIC
 *  on the tile: every dollar distributed over every dollar contributed,
 *  including a capital call in a later year, the same pair of sums as
 *  lib/engine/waterfall.ts. Counting only Year 0 said 0.33x on a deal whose
 *  tile reads 0.31x. The multiple is written "a 0.31x equity multiple" and
 *  never "(0.31x)", because
 *  lib/format prints a negative in accounting parentheses and the plate
 *  already uses them that way ("Min DSCR is also below zero, at (0.49x)").
 *
 *  "The sale does not cover the loan" is said only when the sale year's flow
 *  is below zero AND the sale itself, net of selling costs, falls short of
 *  the balances it pays off (DecisionFacts.saleLessLoans). The sale year also
 *  carries that year's income and debt service, so a negative sale year
 *  alone does not prove the sale fell short, and the plate does not claim
 *  what it has not checked. */
function noReturnText(f: DecisionFacts): { detail: string; marginLine: string } {
  const cf = f.equityCashFlows;
  const cause = noReturnCause(cf);
  const after = cf.slice(1);
  const putIn = cf[0] < 0 ? -cf[0] : 0;
  const back = after.filter((v) => v > 0).reduce((a, b) => a + b, 0);
  const laterIn = -after.filter((v) => v < 0).reduce((a, b) => a + b, 0);
  const holdIn = -after.slice(0, -1).filter((v) => v < 0).reduce((a, b) => a + b, 0);
  const saleYearFlow = cf[cf.length - 1];
  const totalDistribution = cf.filter((v) => v > 0).reduce((a, b) => a + b, 0);
  const totalContribution = cf.filter((v) => v < 0).reduce((a, b) => a - b, 0);
  const multiple = totalContribution === 0 ? multipleAt(Number.NaN, 2) : multipleAt(totalDistribution / totalContribution, 2);
  const loan = f.mezzanineAtSale ? "loans" : "loan";
  const saleShort =
    saleYearFlow < 0 && typeof f.saleLessLoans === "number" && Number.isFinite(f.saleLessLoans) && f.saleLessLoans < 0;
  const saleLine = saleShort ? ` The sale does not cover the ${loan}.` : "";
  const laterLine = laterIn > 0 ? ` The equity pays in ${money(laterIn)} more during the hold.` : "";

  switch (cause) {
    case "never-pays-back":
      return putIn > 0
        ? {
            detail: `No return: the equity puts in ${money(putIn)} and the deal pays none of it back, so there is no rate of return.${laterLine}${saleLine}`,
            marginLine: `The equity puts in ${money(putIn)} and gets none of it back, so there is no rate of return`,
          }
        : {
            detail: `No return: the deal pays the equity nothing, and the equity pays in ${money(laterIn)} during the hold, so there is no rate of return.${saleLine}`,
            marginLine: "The deal pays the equity nothing, so there is no rate of return",
          };
    case "no-equity-in": {
      const loanCovers = Number.isFinite(f.loanAmount) && Number.isFinite(f.totalUses) && f.totalUses > 0 && f.loanAmount >= f.totalUses;
      return {
        detail: loanCovers
          ? `No return: the ${money(f.loanAmount)} loan covers the whole ${money(f.totalUses)} cost, so no equity goes in and there is nothing to measure a return on.`
          : "No return: the debt covers the whole cost, so no equity goes in and there is nothing to measure a return on.",
        marginLine: "No equity goes in, so there is nothing to measure a return on",
      };
    }
    case "gets-back-less": {
      if (back >= putIn) {
        // Back at or above what went in at Year 0, and still short overall:
        // the money that went in again during the hold is what loses it.
        return {
          detail: `No return: the equity gets back ${money(back)} on the ${money(putIn)} it puts in, a ${multiple} equity multiple, but pays in ${money(laterIn)} more during the hold, so in all it gets back less than it puts in and there is no rate of return.${saleLine}`,
          marginLine: "The equity gets back less than it puts in over the hold, so there is no rate of return",
        };
      }
      const lead = `No return: the equity gets back ${money(back)} of the ${money(putIn)} it puts in, a ${multiple} equity multiple, so there is no rate of return.`;
      const tail =
        holdIn > 0
          ? `${laterLine}${saleLine}`
          : saleYearFlow < 0
            ? saleShort
              ? ` The sale does not cover the ${loan}, so the equity pays ${money(-saleYearFlow)} more in the year of the sale.`
              : ` The equity pays ${money(-saleYearFlow)} more in the year of the sale.`
            : "";
      return {
        detail: `${lead}${tail}`,
        marginLine: `The equity gets back ${money(back)} of the ${money(putIn)} it puts in, so there is no rate of return`,
      };
    }
    case "rate-too-high":
      return {
        detail: `No rate of return: the equity gets back ${money(back)} on the ${money(putIn)} it puts in, a ${multiple} equity multiple, which is too high a return to state as a rate.${laterLine}`,
        marginLine: `The equity gets back ${money(back)} on the ${money(putIn)} it puts in, too high a return to state as a rate`,
      };
    default: {
      if (cf.length < 2 || !cf.every((c) => Number.isFinite(c))) {
        return {
          detail: "No return: the yearly cash flows cannot be worked out at these inputs, so there is no rate of return.",
          marginLine: "The yearly cash flows cannot be worked out at these inputs, so there is no rate of return",
        };
      }
      const firstOut = cf.findIndex((c) => c > 0);
      if (cf[0] < 0 && firstOut > 0 && cf.slice(firstOut).some((c) => c < 0)) {
        // Year 0 negative and money going in again after cash has come out:
        // more than one change of sign, which can carry no root or several.
        return {
          detail: "No return: the equity puts money in more than once, with cash paid out in between, and these cash flows give no single rate of return.",
          marginLine: "The equity puts money in more than once, and the cash flows give no single rate of return",
        };
      }
      return {
        detail: "No return: these yearly cash flows give no rate of return.",
        marginLine: "These yearly cash flows give no rate of return",
      };
    }
  }
}

/** The canon's reason this deal has no project IRR, for a surface beside the
 *  plate that has to say why it has nothing to show (the tornado on the
 *  Sensitivities tab, the memo's verdict basis): the irr-computable rule's
 *  margin line when that rule failed, otherwise undefined. Those surfaces
 *  used to carry sentences of their own ("Base IRR is undefined"), which
 *  disagreed with the plate about the same deal. */
export function noReturnLine(decision: DealDecision): string | undefined {
  const rule = decision.critical.find((c) => c.id === "irr-computable");
  return rule && rule.status === "fails" ? rule.marginLine : undefined;
}

/** An all-equity deal produces DSCR = NOI / 0 = Infinity every year, which is
 *  the correct economic answer and the wrong shape for a coverage test. The
 *  strict zero check (not !isFinite) keeps a NaN loan amount from degenerate
 *  inputs masquerading as all-equity. Identical to the check the returns
 *  summary has always made; it lives here now so one definition of
 *  "unlevered" serves the gate, the table and the export. */
export function isAllEquity(loanAmount: number): boolean {
  return Number.isFinite(loanAmount) && loanAmount === 0;
}

/** A margin line reads "Below the 15.00% hurdle by 1.83 pts" on its own and
 *  "…, below the 15.00% hurdle by 1.83 pts." inside the sentence. One string,
 *  two positions; the capital is the only difference and it is mechanical. */
function lowerFirst(s: string): string {
  return s.charAt(0).toLowerCase() + s.slice(1);
}

/** A spread magnitude in basis points, with the sign carried by the sentence
 *  around it rather than by a glyph. lib/format's bps() prefixes "+" and wraps
 *  negatives in parentheses, which is right for a column and wrong mid-clause.
 *  One basis point is a bp; every other count is bps. */
function bpsMagnitude(v: number): string {
  const n = Math.abs(Math.round(v * 10_000));
  return `${n} ${n === 1 ? "bp" : "bps"}`;
}

export function buildDecision(f: DecisionFacts): DealDecision {
  const irrValid = Number.isFinite(f.projectIRR);
  const noReturn = irrValid ? null : noReturnText(f);
  const unlevered = isAllEquity(f.loanAmount);
  const minDSCRValid = Number.isFinite(f.minDSCR);
  const dscrSatisfied = unlevered || (minDSCRValid && f.minDSCR >= DSCR_COVERAGE_FLOOR);
  const clearsHurdle = irrValid && f.projectIRR >= PURSUE_IRR_THRESHOLD;
  const verdict: "PURSUE" | "PASS" = irrValid && dscrSatisfied && clearsHurdle ? "PURSUE" : "PASS";

  const irrThresholdText = percentAt(PURSUE_IRR_THRESHOLD, 2);
  const dscrFloorText = fmtDscr(DSCR_COVERAGE_FLOOR);

  // ── Critical, in the engine's own branch order ─────────────────────────
  const irrComputable: DecisionRule = {
    id: "irr-computable",
    kind: "critical",
    metric: "Project IRR",
    test: "The deal produces a return (Project IRR can be calculated)",
    value: irrValid ? percentAt(f.projectIRR, 2) : NULL_TOKEN,
    threshold: "a return",
    status: irrValid ? "clears" : "fails",
    margin: null,
    marginText: null,
    marginLine: noReturn ? noReturn.marginLine : "Resolves on the modeled cash flows",
    order: 0,
    surfaced: "on-failure",
  };

  const dscrMargin = minDSCRValid ? f.minDSCR - DSCR_COVERAGE_FLOOR : null;
  const dscrFloor: DecisionRule = {
    id: "dscr-floor",
    kind: "critical",
    metric: "Min DSCR",
    test: "Min DSCR covers debt service",
    value: unlevered ? "n/a (all-equity)" : fmtDscr(f.minDSCR),
    threshold: dscrFloorText,
    status: unlevered ? "n/a" : dscrSatisfied ? "clears" : "fails",
    margin: unlevered ? null : dscrMargin,
    marginText: dscrMargin === null ? null : multipleAt(Math.abs(dscrMargin), 2),
    marginLine: unlevered
      ? "All-equity, so there is no debt service to cover"
      : dscrMargin === null
        ? `Coverage could not be computed against the ${dscrFloorText} floor`
        : dscrMargin >= 0
          ? `Clears the ${dscrFloorText} coverage floor by ${multipleAt(dscrMargin, 2)}`
          : `Below the ${dscrFloorText} coverage floor by ${multipleAt(Math.abs(dscrMargin), 2)}`,
    order: 1,
    surfaced: "always",
  };

  const irrMargin = irrValid ? f.projectIRR - PURSUE_IRR_THRESHOLD : null;
  const irrHurdle: DecisionRule = {
    id: "irr-hurdle",
    kind: "critical",
    metric: "Project IRR",
    test: "Project IRR clears the institutional value-add hurdle",
    value: percentAt(f.projectIRR, 2),
    threshold: irrThresholdText,
    status: !irrValid ? "n/a" : clearsHurdle ? "clears" : "fails",
    margin: irrMargin,
    marginText: irrMargin === null ? null : pts(Math.abs(irrMargin)),
    marginLine:
      irrMargin === null
        ? `Cannot be measured against the ${irrThresholdText} hurdle`
        : irrMargin >= 0
          ? `Clears the ${irrThresholdText} hurdle by ${pts(irrMargin)}`
          : `Below the ${irrThresholdText} hurdle by ${pts(Math.abs(irrMargin))}`,
    order: 2,
    surfaced: "always",
  };

  const critical = [irrComputable, dscrFloor, irrHurdle];

  // ── Advisory ───────────────────────────────────────────────────────────
  const yieldOnCost = f.y1NOI / f.totalUses;
  const yocValid = Number.isFinite(yieldOnCost);
  const yocMargin = yocValid ? yieldOnCost - YIELD_ON_COST_HURDLE : null;
  const debtYield = unlevered ? Number.NaN : f.y1NOI / f.loanAmount;
  const debtYieldValid = Number.isFinite(debtYield);
  const lenderMargin = minDSCRValid ? f.minDSCR - DSCR_LENDER_STANDARD : null;
  const ltvMargin = Number.isFinite(f.ltv) ? LTV_CONVENTIONAL - f.ltv : null;
  // An entry cap rate at or below zero is Year 1 NOI at or below zero, and a
  // negative cap rate compares with nothing: the teaser's row read "Exits 882
  // bps wide of the (2.32%) entry cap" with a PASS (broker skeptic, 30
  // September 2026). The row is not applicable then, and says why.
  const entryCapUsable = Number.isFinite(f.entryCapRate) && f.entryCapRate > 0;
  const capMargin =
    Number.isFinite(f.exitCapRate) && entryCapUsable ? f.exitCapRate - f.entryCapRate : null;
  const termMargin = f.termYears - f.holdPeriodYears;

  const advisory: DecisionRule[] = [
    {
      id: "dscr-lender-floor",
      kind: "advisory",
      metric: "Min DSCR",
      test: "Min DSCR clears the conventional lending standard",
      value: unlevered ? "n/a (all-equity)" : fmtDscr(f.minDSCR),
      threshold: fmtDscr(DSCR_LENDER_STANDARD),
      status: unlevered ? "n/a" : minDSCRValid && f.minDSCR >= DSCR_LENDER_STANDARD ? "clears" : "fails",
      margin: unlevered ? null : lenderMargin,
      marginText: lenderMargin === null ? null : multipleAt(Math.abs(lenderMargin), 2),
      // Two floors, each named for whose it is. 1.00x is the COVERAGE floor
      // and it gates the verdict; 1.20x is the LENDER floor and it does not.
      // Calling the second one a "standard" while the DSCR sparkline 400px
      // away labelled the same 1.20x line a "floor" put two names on one
      // level, which is the defect the canon exists to remove. "Floor" is
      // also what the sparkline, the risk explainer and qa:experience have
      // always said.
      marginLine: unlevered
        ? "All-equity, so no lender floor applies"
        : lenderMargin === null
          ? `Coverage could not be computed against the ${fmtDscr(DSCR_LENDER_STANDARD)} lender floor`
          : lenderMargin >= 0
            ? `Holds ${multipleAt(lenderMargin, 2)} above the ${fmtDscr(DSCR_LENDER_STANDARD)} lender floor`
            : `Sits ${multipleAt(Math.abs(lenderMargin), 2)} below the ${fmtDscr(DSCR_LENDER_STANDARD)} lender floor`,
      order: 10,
    surfaced: "always",
    },
    {
      id: "ltv-conventional",
      kind: "advisory",
      metric: "Senior LTV",
      test: "LTV at or below conventional sizing",
      value: unlevered ? "0% (all-equity)" : fmtLtv(f.ltv),
      threshold: fmtLtv(LTV_CONVENTIONAL),
      status: unlevered || (Number.isFinite(f.ltv) && f.ltv <= LTV_CONVENTIONAL) ? "clears" : "fails",
      margin: unlevered ? null : ltvMargin,
      // LTV is a whole-percent quantity in this house (65%, never 65.00%), so
      // its margin is too: "10 pts", not "10.00 pts".
      marginText: ltvMargin === null ? null : pts(Math.abs(ltvMargin), 0),
      marginLine: unlevered
        ? "All-equity, so nothing is levered"
        : ltvMargin === null
          ? `Leverage could not be measured against ${fmtLtv(LTV_CONVENTIONAL)}`
          : ltvMargin >= 0
            ? `Sized ${pts(ltvMargin, 0)} inside conventional ${fmtLtv(LTV_CONVENTIONAL)} sizing`
            : `Sized ${pts(Math.abs(ltvMargin), 0)} above conventional ${fmtLtv(LTV_CONVENTIONAL)} sizing`,
      order: 11,
    surfaced: "always",
    },
    {
      id: "yield-on-cost",
      kind: "advisory",
      metric: "Yield on Cost (Y1)",
      test: "Yield on Cost (Y1) at or above the sponsor-equity hurdle",
      value: percentAt(yieldOnCost, 2),
      threshold: percentAt(YIELD_ON_COST_HURDLE, 2),
      status: !yocValid ? "n/a" : yieldOnCost >= YIELD_ON_COST_HURDLE ? "clears" : "fails",
      margin: yocMargin,
      marginText: yocMargin === null ? null : pts(Math.abs(yocMargin)),
      marginLine:
        yocMargin === null
          ? `Cannot be measured against the ${percentAt(YIELD_ON_COST_HURDLE, 2)} hurdle`
          : yocMargin >= 0
            ? `Clears the ${percentAt(YIELD_ON_COST_HURDLE, 2)} cost hurdle by ${pts(yocMargin)}`
            : `Below the ${percentAt(YIELD_ON_COST_HURDLE, 2)} cost hurdle by ${pts(Math.abs(yocMargin))}`,
      order: 12,
    surfaced: "always",
    },
    {
      id: "positive-leverage",
      kind: "advisory",
      metric: "Y1 debt yield",
      test: "Y1 debt yield exceeds the interest rate (positive leverage)",
      value: unlevered ? "n/a (all-equity)" : percentAt(debtYield, 2),
      threshold: unlevered ? "n/a" : percentAt(f.interestRate, 2),
      status: unlevered ? "n/a" : !debtYieldValid ? "n/a" : debtYield > f.interestRate ? "clears" : "fails",
      margin: unlevered || !debtYieldValid ? null : debtYield - f.interestRate,
      marginText: unlevered || !debtYieldValid ? null : pts(Math.abs(debtYield - f.interestRate)),
      marginLine: unlevered
        ? "All-equity, so there is no leverage to price"
        : !debtYieldValid
          ? "Debt yield could not be computed"
          : debtYield > f.interestRate
            ? `Debt yield runs ${pts(debtYield - f.interestRate)} above the ${percentAt(f.interestRate, 2)} rate`
            : `Debt yield runs ${pts(Math.abs(debtYield - f.interestRate))} below the ${percentAt(f.interestRate, 2)} rate`,
      order: 13,
    surfaced: "always",
    },
    {
      id: "no-cap-compression",
      kind: "advisory",
      metric: "Exit cap",
      test: "Exit cap not below entry (no compression bet)",
      value: percentAt(f.exitCapRate, 2),
      threshold: entryCapUsable ? percentAt(f.entryCapRate, 2) : NULL_TOKEN,
      status: capMargin === null ? "n/a" : capMargin >= 0 ? "clears" : "fails",
      margin: capMargin,
      marginText: capMargin === null ? null : bpsMagnitude(capMargin),
      marginLine:
        capMargin === null
          ? Number.isFinite(f.y1NOI) && f.y1NOI <= 0
            ? "No entry cap rate: Year 1 NOI is not positive"
            : "Entry and exit caps could not be compared"
          : capMargin >= 0
            ? `Exits ${bpsMagnitude(capMargin)} wide of the ${percentAt(f.entryCapRate, 2)} entry cap`
            : `Exits ${bpsMagnitude(capMargin)} inside the ${percentAt(f.entryCapRate, 2)} entry cap, which is a compression bet`,
      order: 14,
    surfaced: "always",
    },
    {
      id: "hold-within-term",
      kind: "advisory",
      metric: "Hold period",
      test: "Hold period within the loan term (no untracked refinance)",
      value: `${f.holdPeriodYears} years`,
      threshold: `${f.termYears} years`,
      status: f.holdPeriodYears <= f.termYears ? "clears" : "fails",
      margin: termMargin,
      marginText: `${Math.abs(termMargin)} ${Math.abs(termMargin) === 1 ? "year" : "years"}`,
      marginLine:
        termMargin === 0
          ? `Sells at the end of the ${f.termYears}-year term`
          : termMargin > 0
            ? `Sells ${termMargin} ${termMargin === 1 ? "year" : "years"} inside the ${f.termYears}-year term`
            : `Runs ${Math.abs(termMargin)} ${Math.abs(termMargin) === 1 ? "year" : "years"} past the ${f.termYears}-year term, so a refinance is implied and not modeled`,
      order: 15,
    surfaced: "always",
    },
  ];

  // ── Which rule decided it ──────────────────────────────────────────────
  // On a PASS: the highest-precedence critical rule that failed, which is the
  // same branch buildReturnsSummary's sentence takes. On a PURSUE: every
  // critical rule cleared, so the decisive one is the binding hurdle the deal
  // had to reach. No cross-unit "thinnest margin" comparison is made, because
  // pts and x are not commensurate and any ranking between them would be
  // invented rather than derived.
  const failing = critical.filter((c) => c.status === "fails").sort((a, b) => a.order - b.order);
  const decisive = verdict === "PASS" ? (failing[0] ?? irrHurdle) : irrHurdle;
  const alsoFailing = failing.filter((c) => c.id !== decisive.id);
  const advisoryMisses = advisory.filter((a) => a.status === "fails").sort((a, b) => a.order - b.order);

  // ── The canonical sentence ─────────────────────────────────────────────
  // The most-read sentence in the product. It is the decisive rule stated in
  // prose, and it is now BUILT FROM that rule rather than written beside it,
  // so the sentence and the margin line under the metric cannot describe the
  // same result differently.
  //
  // Two deliberate changes to the wording it has shipped with:
  //
  // 1. It names the metric. "IRR 13.51% below the 15.00% threshold" made the
  //    reader infer that Project IRR was the metric under test; on a
  //    DSCR-driven PASS the old sentence named a different quantity again.
  //    Every branch now opens with the decisive metric and its value, which
  //    is the shape the brief asks for: PASS / Project IRR 13.51% / below the
  //    hurdle by 1.83 pts.
  // 2. It carries the MARGIN. "Below the hurdle" and "below the hurdle by
  //    0.02 pts" are different investment facts and the product only ever
  //    said the first.
  //
  // The 15% level is called the HURDLE here and everywhere else. It used to
  // be the "threshold" in this sentence and the "hurdle" in the sensitivity
  // census, the heat matrix caption and the Monte Carlo panel: one level,
  // two names, on one desk. qa:stage's pin moves with it.
  const coverageClause = unlevered
    ? "All-equity, so there is no debt service to cover."
    : `Min DSCR ${fmtDscr(f.minDSCR)} covers debt service.`;
  // The secondary failures, so a two-cause PASS never hides one of them. They
  // are appended in engine precedence order, after the decisive one, which is
  // the ranking the brief asks for: surface the primary, give access to the
  // rest.
  //
  // A failing metric below zero is named in words. lib/format prints a
  // negative in accounting parentheses, which is right in a column and reads
  // as an aside in a sentence: on the teaser of 8 October, "Min DSCR (0.56x)
  // is also below the 1.00x coverage floor by 1.56x" read as 0.56x and a
  // subtraction that does not add up (broker skeptic, 30 September 2026).
  // "Gets back less than it put in" is said only when the undiscounted sum
  // of the equity cash flows is negative, which is what a negative IRR means
  // on a series with one change of sign and is checked rather than assumed.
  const equityBack = f.equityCashFlows.reduce((s, v) => s + v, 0);
  const equityLoses = Number.isFinite(equityBack) && equityBack < 0;
  const alsoText = alsoFailing
    .map((c) => {
      if (c.id === "dscr-floor" && minDSCRValid && f.minDSCR < 0) {
        return `${c.metric} is also below zero, at ${c.value}, so the income does not cover any of the debt service.`;
      }
      if (c.id === "irr-hurdle" && irrValid && f.projectIRR < 0) {
        return `${c.metric} is also below zero, at ${c.value}${equityLoses ? ", so the equity gets back less than it put in" : ""}.`;
      }
      return `${c.metric} ${c.value} is also ${lowerFirst(c.marginLine)}.`;
    })
    .join(" ");

  let headline: { metric: string; value: string } | null;
  let detail: string;
  if (noReturn) {
    headline = null;
    detail = noReturn.detail;
  } else if (!dscrSatisfied) {
    headline = { metric: dscrFloor.metric, value: minDSCRValid ? fmtDscr(f.minDSCR) : NULL_TOKEN };
    detail =
      minDSCRValid && f.minDSCR < 0
        ? `Below zero, and under the ${dscrFloorText} coverage floor, so the income does not cover any of the debt service in at least one year.`
        : `${dscrFloor.marginLine}. Debt service is not covered in at least one year.`;
  } else if (!clearsHurdle) {
    headline = { metric: irrHurdle.metric, value: irrHurdle.value };
    detail =
      f.projectIRR < 0
        ? `Below zero${equityLoses ? ", so the equity gets back less than it put in," : ","} and ${lowerFirst(irrHurdle.marginLine)}.`
        : `${irrHurdle.marginLine}.`;
  } else {
    headline = { metric: irrHurdle.metric, value: irrHurdle.value };
    detail = `${irrHurdle.marginLine}. ${coverageClause}`;
  }
  if (alsoText) detail = `${detail} ${alsoText}`;

  const BELOW_ZERO = "Below zero";
  const sentence = headline
    ? detail.startsWith(BELOW_ZERO)
      ? `${headline.metric} is below zero, at ${headline.value}${detail.slice(BELOW_ZERO.length)}`
      : `${headline.metric} ${headline.value}, ${lowerFirst(detail)}`
    : detail;

  return { verdict, decisive, critical, advisory, alsoFailing, advisoryMisses, headline, detail, sentence };
}

What to look at: Read the two CRITICAL rules first, then the line that combines them: a verdict is a conjunction, not a score. The decisive rule is the one the plate on the desk names, and the margin line is the measured distance from the threshold it tested.

The other engine files (grade, validation, sensitivity, tornado, decomposition, scenarios) are not shown here, and the repository is private. If you find a bug, a precision issue, or anything that makes you uncomfortable, email ali@getunderwrite.com with the file and line number.