一键导入
excel-to-engine
Convert a complex financial Excel model into a JavaScript computation engine with auto-generated test suite and interactive dashboard
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
菜单
Convert a complex financial Excel model into a JavaScript computation engine with auto-generated test suite and interactive dashboard
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
基于 SOC 职业分类
| name | excel-to-engine |
| description | Convert a complex financial Excel model into a JavaScript computation engine with auto-generated test suite and interactive dashboard |
| triggers | ["convert this Excel model","build an engine from this spreadsheet","financial model to code","excel to javascript engine","turn this spreadsheet into code","generate engine from Excel","create computation engine"] |
Convert a financial Excel model (.xlsx) into a JavaScript computation engine with calibration, automated tests, and an interactive dashboard.
This skill runs a 4-phase pipeline:
engine.js with calibrated computations matching Excel at base casetests/eval.mjs that validates engine accuracy against ExcelThis skill is the JS Reasoning Pipeline — best for smaller models (<20 sheets) where Claude should understand the financial logic and build a hand-crafted engine.
For larger models (20+ sheets, millions of cells), use the Rust Pipeline instead:
cd pipelines/rust && cargo build --release
./target/release/rust-parser model.xlsx output-dir --chunked
The Rust pipeline automatically transpiles formulas to JS, generates per-sheet modules, and handles circular references. It processes 3.7M cells in ~3 minutes. See CLAUDE.md for the full eval workflow.
When to use which:
npm install xlsxBefore analyzing anything, ask the user these questions:
.xlsx files in the working directory and list them. Ask: "I found these Excel files — which should I work with?"This context dramatically improves analysis accuracy.
If a comparator (compare-outputs.mjs) or control baseline exists in the project:
issuancePrice or numFutureAcquisitions).Financial models use inconsistent terminology across firms and sectors. When analyzing an Excel model, map any of these equivalent terms to the standardized engine output field names.
All of these refer to the same economic concept — a performance-based payout to operators/managers carved from investment returns:
| Term | Context |
|---|---|
| MIP (Management Incentive Plan) | PE operating companies |
| Profit Interest Plan / Profit Share | Pass-through entities, LLCs |
| Promote | Real estate partnerships |
| Carried Interest Pool | Operating company level (not fund level) |
| Performance Allocation | Tax/legal documents |
| Value Creation Plan (VCP) | European PE |
| Long-Term Incentive Plan (LTIP) | Corporate, listed companies |
| Phantom Equity Plan | Non-equity-issuing entities |
| Co-Investment Plan | GP/management co-invest structures |
Map all of these to mip.payment, mip.triggered, mip.valuePerShare in the engine output.
| Standardized | Equivalent Terms |
|---|---|
| Distribution Waterfall | Promote Structure, Carried Interest Waterfall |
| Return Hurdle | Preferred Return, Pref, Hurdle Rate |
| Catch-Up | GP Catch-Up, Make-Whole |
| Residual Split | Back-End Split, Tail Economics |
| GP Promote | GP Carry, GP Performance Fee |
| LP Preferred Return | LP Pref, LP Hurdle |
| GP Co-Invest | GP Commitment, GP Capital |
Map to waterfall.lpTotal, waterfall.gpCarry, waterfall.tiers.
| Standardized | Equivalent Terms |
|---|---|
| MOIC | MoC, Multiple on Invested Capital, Money Multiple, Return Multiple |
| IRR | Internal Rate of Return, Annualized Return |
| Gross | Pre-Carry, Pre-Promote, Pre-Fee |
| Net | Post-Carry, Post-Promote, Post-Fee, After Carry |
Map to returns.grossMOIC, returns.netMOIC, returns.grossIRR, returns.netIRR.
| Standardized | Equivalent Terms |
|---|---|
| Issuance Price | Strike Price, Grant Price, Unit Price, Share Price |
| PPS (Price Per Share) | Per Share, Per Unit, Value Per Share |
| Pool | Allocation Pool, Share Pool, Unit Pool |
| Dilution | Pool Percentage, Participation Rate |
Map to perShare.gross, perShare.net, mip.valuePerShare.
When analyzing an Excel model, if you encounter any term in the "Equivalent Terms" column, treat it as the standardized term in the "Standardized" column. Use the standardized engine output field names (grossMOIC, netIRR, lpTotal, gpCarry, mipPayment, etc.) regardless of what the Excel model calls them.
Read the Excel workbook and produce a model-map.json that describes the model structure.
Before diving into detailed sheets, search for tabs named "Summary", "Cheat Sheet", "Overview", "Dashboard", or "Key Metrics". These often contain the base case inputs and outputs in a condensed format, saving significant analysis time. Extract base case values from these tabs first, then cross-reference with detail sheets only as needed.
For workbooks with many identically-structured sheets (e.g., per-asset P&Ls), use the fingerprinter to auto-detect row mappings instead of manually inspecting each sheet:
import {
loadWorkbook, buildModelMap, fingerprintWorkbook,
detectYearRow, extractMultiYear, extractByYear,
detectEscalation, classifyAsset, matchLabel,
} from './lib/excel-parser.mjs';
const wb = loadWorkbook('/path/to/model.xlsx');
// 1. Fingerprint all sheets — finds common row patterns across similar sheets
const { sheetMaps, commonPattern, commonSheets, sheetGroups } = fingerprintWorkbook(wb);
// commonPattern: { revenue: { row: 48, label: "Total Revenue" }, ebitda: { row: 67 }, ... }
// commonSheets: ["Asset 1", "Asset 2", ..., "Asset 37"]
// 2. Detect year columns on any sheet in the group
const yearInfo = detectYearRow(wb, commonSheets[0]);
// { yearRow: 39, columnMap: { E: 2023, F: 2023, G: 2024, H: 2025, I: 2026 }, years: [2023, 2024, ...] }
// 3. Extract all fields for a specific reference year
const data = extractByYear(wb, 'Asset 1', 2026, { fieldMap: commonPattern, yearInfo });
// { fields: { revenue: { value: 1500000 }, ebitda: { value: 850000 } }, yearColumn: 'I' }
// 4. Extract multi-year time series for a field
const rentByYear = extractMultiYear(wb, 'Asset 1', commonPattern.rent.row, yearInfo.columnMap);
// { 2023: 500000, 2024: 515000, 2025: 530450, 2026: 546364 }
// 5. Detect escalation rates (catches rent escalation, revenue growth)
const escalation = detectEscalation(rentByYear);
// { rates: { "2023-2024": 0.03, "2024-2025": 0.03 }, avgRate: 0.03, isEscalating: true }
// 6. Auto-classify asset type (leased vs managed)
const classification = classifyAsset(data.fields);
// { classification: 'leased', confidence: 0.85, signals: ['has positive rent → leased', ...] }
Why this matters: The hardest part of building engines from multi-asset models is figuring out which rows contain which data across dozens of identical sheets. The fingerprinter automates this by fuzzy-matching row labels to canonical financial terms, then confirming the pattern is consistent across sheets.
Always specify a reference year when extracting data. The default should be the first full stabilized projection year (typically current year + 1). This avoids pulling stale closing-date values that miss years of escalation.
Common pitfall: Pulling rent values from the acquisition date column instead of the projection year column. A 3% annual escalation over 3-4 years means rents are 10-15% higher at the reference date. The detectEscalation() function helps flag fields where the starting value differs significantly from the projected value.
const wb = loadWorkbook('/path/to/model.xlsx');
const modelMap = buildModelMap(wb, {
inputSheets: ['Assumptions', 'Inputs'], // Adjust to actual sheet names
outputSheets: ['Summary', 'Returns'], // Adjust to actual sheet names
});
const { commonPattern, commonSheets, sheetGroups } = fingerprintWorkbook(wb);
// If sheetGroups shows N sheets with the same pattern, these are per-asset sheets
Review detected inputs and outputs. The parser identifies:
Detect financial patterns — The parser looks for:
Detect year columns and extract by reference year — Don't default to the first data column:
const yearInfo = detectYearRow(wb, commonSheets[0]);
// Pick the first full stabilized projection year
const referenceYear = Math.max(...yearInfo.years.filter(y => y <= new Date().getFullYear() + 1));
for (const sheetName of commonSheets) {
const data = extractByYear(wb, sheetName, referenceYear, { fieldMap: commonPattern, yearInfo });
const type = classifyAsset(data.fields);
console.log(`${sheetName}: ${type.classification} (${type.confidence})`);
}
model-map.json with this structure:{
"version": "1.1.0",
"modelName": "Example Fund Model",
"generatedAt": "2025-01-15T10:30:00Z",
"excelFile": "model.xlsx",
"referenceYear": 2026,
"sheets": ["Assumptions", "Cash Flows", "Waterfall", "Summary"],
"sheetGroups": [
{
"sheets": ["Asset 1", "Asset 2", "Asset 3"],
"pattern": { "revenue": { "row": 48 }, "ebitda": { "row": 67 } },
"count": 3
}
],
"yearColumns": { "E": 2023, "F": 2024, "G": 2025, "H": 2026 },
"inputs": [
{
"name": "Acquisition Price",
"sheet": "Assumptions",
"cell": "C5",
"type": "number",
"format": "currency",
"baseCase": 50000000,
"range": [25000000, 100000000],
"referencedBy": 12
}
],
"outputs": [
{
"name": "Gross MOIC",
"key": "returns.grossMOIC",
"sheet": "Summary",
"cell": "C15",
"type": "number",
"format": "multiple",
"baseCase": 2.15
}
],
"assets": [
{
"sheet": "Asset 1",
"classification": "leased",
"classificationConfidence": 0.85,
"fields": { "revenue": 1500000, "rent": 546364, "rentCover": 1.56 }
}
],
"intermediateCount": 234,
"patterns": {
"hasIRR": true,
"hasMOIC": true,
"hasWaterfall": true,
"hasSensitivity": false
}
}
referencedBy count and let the user curate.format hints: "currency", "percent", "multiple", "integer", "years"key to outputs mapping to the engine return structure (e.g., "returns.grossMOIC")range for each input should be a reasonable sensitivity range (50%-200% of base case for most; tighter for percentages)After extracting data, auto-generate a comparator that reads back from the Excel and validates. This catches errors before they propagate into the engine:
{ totalChecks, passed, failed, warnings }This should run as part of Phase 1, not as a separate step. The eval in Phase 3 tests the engine; cross-sheet validation tests the extraction.
Why this matters: The #1 source of engine failures is not static accuracy — it's getting the response curve wrong when inputs change. Waterfall hurdles, MIP thresholds, and debt covenants create nonlinear breakpoints where a single-point calibration factor breaks down. An engine that matches perfectly at base case can be 50-80% wrong near a waterfall breakpoint.
After extracting base case values, also extract outputs at multiple input values:
import { extractSurface, compareSurfaces, detectBreakpoints } from './lib/sensitivity.mjs';
// Define the key input range to test (e.g., exit multiple from 1.0x to 3.0x)
const inputConfig = {
exitMultiple: { min: 14, max: 26, steps: 7 }, // or whatever the model's primary driver is
};
// If the Excel has a sensitivity/data table, read it directly into this format:
const excelSurface = {
baseCaseInputs: BASE_CASE,
baseCaseOutputs: { 'returns.grossMOIC': 2.15, 'waterfall.gpCarry': 5200000, ... },
inputGrid: { exitMultiple: [14, 16, 18, 20, 22, 24, 26] },
points: [
{ inputs: { ...BASE_CASE, exitMultiple: 14 }, outputs: { 'returns.grossMOIC': 1.52, ... } },
{ inputs: { ...BASE_CASE, exitMultiple: 16 }, outputs: { 'returns.grossMOIC': 1.78, ... } },
// ... one point per grid value
],
};
// If no data table exists, manually read Excel output cells at each input value
// (change the input cell in Excel, read the output cells, record the values)
How to read sensitivity data from Excel:
sensitivity-surface.json alongside model-map.jsonDetect breakpoints early:
const breakpoints = detectBreakpoints(excelSurface, 'waterfall.gpCarry');
// Tells you where the waterfall hurdle crossing is, where MIP triggers, etc.
// Use these to set up the waterfall tiers correctly in Phase 2
This surface data will be used in Phase 2 for multi-point calibration instead of single-point calibration.
These patterns caused 29-60% accuracy divergence in real production use:
1. Never approximate IRR from MOIC.
MOIC^(1/years) - 1 diverges badly for long holds with interim distributions. Always extract the actual cash flow series from the ground truth and pass to computeIRR(). Look for rows labeled "Cash Flow", "Net Cash Flow", "Distributions", "CF to LP/GP".
2. Don't simplify waterfall tiers. Real models have 4+ tiers with IRR hurdles, catch-up provisions, and quarterly compounding. Extract the actual tier structure from ground truth (look for "Preferred Return", "Pref", "Catch-Up", "Tier 1/2/3/4", "Residual Split" labels). Don't flatten to 2 tiers.
3. Watch for compound pref on long holds. 12-year 8% compound pref = 2.52x hurdle — this exceeds many MOIC targets. Detect whether the model uses quarterly cash flow waterfalls (interim distributions reduce the pref base) vs bullet maturity. If quarterly: extract actual pref amount, don't compute.
4. Use parsed engine output, not parametric wrappers. When building downstream apps that consume engine output: read the actual computed values from the engine, don't build simplified parametric models that approximate the engine's logic. The approximations accumulate.
5. Check for extractWaterfallStructure() and extractCashFlowSeries() in lib/excel-parser.mjs.
These helpers auto-detect waterfall tiers, hurdle rates, carry percentages, and cash flow series from ground truth data.
Create engine.js as an ES module. Use pipelines/js-reasoning/templates/engine-template.js as the starting skeleton.
Copy the template to the project directory as engine.js
Fill in BASE_CASE from model-map.json inputs:
export const BASE_CASE = {
acquisitionPrice: 50_000_000,
equityInvested: 25_000_000,
holdPeriodYears: 5,
exitCapRate: 0.055,
// ... all inputs from model-map.json
};
Implement _computeRaw(inputs) — the core calculation logic:
computeIRR() from lib/irr.mjs for IRR calculationscomputeWaterfall() from lib/waterfall.mjs for distribution waterfallsSet EXCEL_TARGETS with known-good values read from the Excel file:
const EXCEL_TARGETS = {
'returns.grossMOIC': 2.15,
'returns.netMOIC': 1.89,
'returns.grossIRR': 0.2134,
'returns.netIRR': 0.1847,
'waterfall.gpCarry': 5_200_000,
};
The engine must return this structure:
{
inputs: { ...inputs },
returns: {
grossMOIC, // Gross multiple on invested capital
netMOIC, // Net multiple (after fees and carry)
grossIRR, // Gross internal rate of return
netIRR, // Net IRR to LPs
},
exitValuation: {
grossExitValue, // Total exit proceeds
netProceeds, // After debt payoff
// ... model-specific fields
},
waterfall: {
lpTotal, // Total LP distributions
gpCarry, // Total GP carried interest
tiers, // Per-tier breakdown
},
mip: { // Management Incentive Plan (if applicable)
triggered, // Boolean
payment, // Total MIP payment
valuePerShare, // Per-share value
},
equityCashFlows: {
years, // [0, 1, 2, ..., N]
draws, // Negative cash flows (investments)
distributions, // Positive cash flows (returns)
},
perShare: {
gross,
net,
},
}
lib/irr.mjs for IRR, lib/waterfall.mjs for waterfalls, lib/calibration.mjs for calibration.Production lesson: A 6-vehicle carry computation project that used the Rust parser achieved only 29-60% accuracy on 4/6 vehicles because it built simplified parametric waterfall wrappers instead of using the actual parsed engine output. The parser extracted the data correctly — the problem was downstream approximation.
Rules to prevent this:
NEVER approximate IRR from MOIC. The formula IRR ≈ MOIC^(1/years) - 1 assumes a single lump-sum exit. Real models have interim distributions, capital calls over time, etc. Always extract the actual cash flow series and use computeIRR() from lib/irr.mjs.
Extract the actual cash flow time series. Look for rows labeled "Cash Flow", "Net Cash Flow", "Distributions", "Pre-Carry Cash Flows" on the carry/promote/returns sheets. Use extractCashFlowSeries() from lib/excel-parser.mjs.
For pref calculations on long-hold models (>7 years), check whether the model uses quarterly/annual cash flow waterfalls with interim distributions (most real models do) vs. bullet maturity. If it uses periodic distributions, compounding 8% over 12 years (=2.52x) will wildly overstate the hurdle. Extract the actual pref amount from the model instead of computing it.
Detect multi-tier waterfalls. Real PE waterfalls have 3-4+ tiers (pref return, catch-up, residual split, and sometimes a secondary IRR hurdle). Use extractWaterfallStructure() from lib/excel-parser.mjs to auto-detect the tier structure.
For the Rust pipeline: If the ground truth has the values you need, use them directly. The chunked engine output has every cell value from Excel. Don't rebuild a simplified model on top of that data.
When building a dashboard or sensitivity tool on top of a parsed engine, anchor on model actuals and scale proportionally rather than recomputing from scratch. This avoids all compounding, interim-distribution, and multi-tier errors.
Pattern (proven on 6 production vehicles — 0.0% error at base case on all 6):
// Store EXACT values from ground truth with _sources metadata for validation
export const MY_VEHICLE = {
_sources: {
groundTruth: 'output-dir', // directory containing _ground-truth.json
cells: {
totalCarry: 'Waterfall!D86', // direct cell lookup
grossMOIC: 'Summary!C15',
'tiers.tier3GP': 'Waterfall!D77', // dot-path into nested objects
'tiers.tier4GP': 'Waterfall!D85',
},
aggregates: { // optional: sum across multiple cells/classes
totalCarry: {
cells: ['ClassA!D86', 'ClassB!D86'],
op: 'sum',
},
},
},
base: {
totalCarry: 41_613_251, // Waterfall!D86
grossMOIC: 2.026, // Summary!C15
prefHurdleMOIC: 1.469, // 1.08^holdYears (or extract from model)
tiers: {
catchUp: 0,
tier3GP: 18_327_170, // Waterfall!D77
tier4GP: 23_286_081, // Waterfall!D85
},
},
};
// Sensitize by computing a carry multiplier
function computeCarrySensitized(targetMOIC) {
if (targetMOIC <= base.prefHurdleMOIC) return 0; // Below pref hurdle
const baseExcess = base.grossMOIC - base.prefHurdleMOIC;
const targetExcess = targetMOIC - base.prefHurdleMOIC;
const multiplier = targetExcess / baseExcess;
return base.totalCarry * multiplier; // Exact at base, proportional elsewhere
}
Why this works better than recomputing:
Always validate after building. Run the validation script to check every _sources.cells entry against ground truth:
node eval/validate-engine.mjs ./my-engine.js --gt-root ./parsed-models/
This catches wrong-sheet, wrong-model, wrong-column, and arithmetic-estimate errors before deployment. Exit code 1 = failures found. Use --strict for 0.01% tolerance, --json for CI output.
When NOT to use this: If you need the engine to run with completely different input assumptions (new assets, changed debt structure, etc.), you need the full transpiled engine. Model-anchored sensitization only works for sensitivity analysis around a known base case.
When building engines from parsed models, always generate both tiers and teach the agent when to use each:
Tier 1: Hand-crafted engine (fast, dashboard-friendly)
Tier 2: Ground truth + chunked modules (cell-level, exact)
_ground-truth.json = every cell value from Excel (millions of cells)chunked/sheets/*.mjs = every formula transpiled to JSHow to decide at runtime: If the user's question maps cleanly to one of the hand-crafted engine's named inputs, use Tier 1. If it requires changing something the hand-crafted engine doesn't expose (e.g., tech headcount, specific cost line items, G&A allocation), switch to Tier 2.
Tier 2: Ground truth + delta approach (recommended over running the full chunked engine):
import { readFileSync } from 'fs';
const gt = JSON.parse(readFileSync('./chunked/model/chunked/_ground-truth.json', 'utf-8'));
// 1. Find the cells you need by searching labels
const labels = Object.entries(gt)
.filter(([k, v]) => typeof v === 'string' && /Total Revenue/i.test(v))
.filter(([k]) => k.startsWith('Technology!'));
// → Technology!H23 = "Total Revenue" (annual section)
// 2. Read annual data for that row
const annualCols = ['L','M','N','O','P','Q']; // projection years
const techRev = annualCols.map(c => gt['Technology!' + c + '23'] || 0);
// 3. Compute your scenario delta
const netNewARR = techRev.map((r, i) => i > 0 ? r - techRev[i-1] : r);
const additionalGA = netNewARR.map(n => n > 0 ? -n : 0); // expense
const cumImpact = additionalGA.reduce((a, b) => a + b, 0);
// 4. Apply delta to base case returns
const afterTaxHit = cumImpact * (1 - 0.25); // 25% tax rate
const baseProfit = gt['Equity!AN346'];
const baseEquity = gt['Equity!AN345'];
const baseMOIC = gt['Equity!AN347'];
const newMOIC = (baseEquity + baseProfit + afterTaxHit) / baseEquity;
This is faster and more reliable than running the full chunked engine (which requires 8GB+ heap and 10+ minutes for large models). The ground truth gives you exact Excel values; you compute only the delta from your scenario.
Why this matters: A hand-crafted engine with techRevenueMultiple as its only tech lever will overstate impact ~6x for a question like "what if G&A = 100% of net new ARR?" because it bluntly cuts the exit multiple. The ground truth approach gives exact cell-by-cell P&L data showing the actual cumulative impact is much smaller.
The most common source of engine failure is incorrect base case values. You MUST extract the EXACT base case from the Excel, not approximate or round them.
18.22, use 18.22 — NOT 18 or 18.2. Read the cell value directly.from openpyxl import load_workbook
wb = load_workbook('model.xlsx', data_only=True)
ws = wb['Assumptions']
exit_multiple = ws['G7'].value # Gets the exact computed value
Net equity proceeds follow this universal formula across ALL financial models:
Net Proceeds = Gross Exit Value - Transaction Costs - Debt Payoff + Cash at Exit
Where:
Common mistake: Using "net debt" (debt - cash) instead of separately handling debt and cash. This causes a ~15% error in net proceeds.
Models define equity basis differently. You MUST determine which definition the Excel uses:
| Definition | Meaning | Typical Context |
|---|---|---|
| Total Commitment | Total equity pledged by LPs | Fund-level models |
| Equity Deployed | Capital actually drawn/invested | Operating company models |
| Peak Equity | Maximum cumulative equity outstanding | Waterfall/promote models |
| Equity at Cost | Sum of all equity draws (no distributions netted) | Cash flow models |
Look in the Excel's "Equity" or "Cash Flow" sheet for the cell that feeds into MOIC: MOIC = Net Proceeds / [equity basis]. That denominator IS the equity basis. Read it directly.
Distribution waterfalls are the #1 source of large deviations. Do NOT simplify the waterfall.
lib/waterfall.mjs with the exact tier structure:import { computeWaterfall } from './lib/waterfall.mjs';
const waterfall = computeWaterfall(netProceeds, equityBasis, [
{ name: 'Preferred Return', hurdle: 0.08, lpSplit: 1.0, gpSplit: 0.0 },
{ name: 'Catch-Up', hurdle: 0.0, lpSplit: 0.5, gpSplit: 0.5 },
{ name: 'Residual 80/20', hurdle: 0.08, lpSplit: 0.8, gpSplit: 0.2 },
{ name: 'Above 12%', hurdle: 0.12, lpSplit: 0.8, gpSplit: 0.2 },
]);
waterfall.lpTotal + waterfall.gpCarry MUST equal netProceeds. If it doesn't, your tier parameters are wrong.Calibration is NOT optional. Without it, engines typically deviate 10-30% from Excel. Here's exactly how to implement it:
// 1. Define Excel target values (read from Excel cells)
const EXCEL_TARGETS = {
grossMOIC: 2.35, // from Excel cell N50 (or wherever MOIC is displayed)
netIRR: 0.1923, // from Excel cell S50
gpCarry: 43_411_674, // from GP Promote sheet total carry
mipPayment: 51_876_337, // from Equity sheet MIP cell
};
// 2. Run the engine at base case WITHOUT calibration to get raw outputs
const rawResult = _computeRaw(BASE_CASE);
// 3. Compute calibration scale factors
const _cal = {};
for (const [key, excelValue] of Object.entries(EXCEL_TARGETS)) {
const rawValue = getNestedValue(rawResult, key); // e.g., rawResult.returns.grossMOIC
_cal[key] = (rawValue !== 0) ? excelValue / rawValue : 1.0;
}
// 4. In computeModel(), apply calibration to raw outputs:
export function computeModel(inputs = {}) {
const raw = _computeRaw({ ...BASE_CASE, ...inputs });
// Apply calibration
raw.returns.grossMOIC *= _cal.grossMOIC;
raw.waterfall.gpCarry *= _cal.gpCarry;
raw.mip.payment *= _cal.mipPayment;
// ... etc
return raw;
}
At base case, calibrated outputs will EXACTLY match Excel. At non-base-case inputs, they'll be close (within 2-5%) because the calibration scale factors are multiplicative.
Single-point calibration (above) assumes the error is constant across the input range. This fails at waterfall breakpoints, MIP thresholds, and other nonlinearities — the engine can be 50%+ wrong near hurdle crossings even though it's exact at base case.
If you extracted a sensitivity surface in Phase 1 (see "Sensitivity Surface Extraction"), use multi-point calibration instead:
import { multiPointCalibrate, extractSurface, compareSurfaces, printSensitivityReport } from './lib/sensitivity.mjs';
// excelSurface was built in Phase 1 from Excel data tables or manual extraction
const { corrections, apply } = multiPointCalibrate(
computeModel, // your engine's raw compute function
BASE_CASE,
excelSurface, // the Excel response surface
{ primaryInput: 'exitMultiple' } // which input drives the key breakpoints
);
// In computeModel(), apply piecewise corrections instead of flat scale factors:
export function computeModel(inputs = {}) {
const raw = _computeRaw({ ...BASE_CASE, ...inputs });
return apply(raw, inputs); // applies segment-specific corrections
}
This fits piecewise-linear corrections that adapt across the input range, with segment boundaries at detected breakpoints. In testing, this improves accuracy from ~40% to ~100% across the full input range.
When to use which:
waterfall.lpTotal is the TOTAL amount LPs receive after the GP takes their carry:
lpTotal = netProceeds - gpCarry
This is NOT the sum of LP distributions from individual waterfall tiers. It's the residual.
Verify: lpTotal + gpCarry === netProceeds (must balance exactly).
A common mistake is summing tier-level LP distributions instead of computing netProceeds - gpCarry. The tier distributions are intermediate calculations; lpTotal is the final LP take-home.
The exit valuation MUST change when exitYear changes. A model exiting in 2028 has lower asset values than one exiting in 2031 because:
How to implement: The Excel has year-by-year projections. Read the exit value components (NOI, revenue, etc.) for EACH possible exit year, not just the base case. Store these as lookup arrays:
// NOI by exit year (read from Excel for each year)
const NOI_BY_YEAR = {
2028: 58_000_000,
2029: 71_997_341, // base case
2030: 85_000_000,
2031: 95_000_000,
};
// In computeModel:
const noi = NOI_BY_YEAR[exitYear] || interpolate(exitYear, NOI_BY_YEAR);
const grossExit = noi * ownedExitMultiple + otherSegments;
If you use a single base-case exit value for all years, your engine will produce identical outputs regardless of exit year — which is wrong and causes ~60% of scenarios to fail.
The MIP (Management Incentive Plan) payment formula is:
mipPayment = dilutionRate × max(0, lpTotal - mipHurdle × equityBasis)
Where:
dilutionRate = typically 10-15% (read from Excel — look for "Class B %", "MIP %", "dilution")lpTotal = LP total AFTER carry (from waterfall)mipHurdle = typically 1.40x (MOIC threshold — look for "hurdle", "return threshold")equityBasis = the equity basis used in MOIC calculationCommon mistakes:
(grossMOIC - hurdle) × equityBasis × rate — this is wrong because grossMOIC already includes carrynetProceeds instead of lpTotal — MIP comes from LP excess, not total proceedslpTotal > hurdle × equityBasis — MIP should be zero when returns are below hurdleThe mip.valuePerShare is simply mipPayment / totalMIPShares where totalMIPShares is a fixed number from the Excel (the incentive pool size).
For models with multiple investment series (Series 1, Series 2, etc.), the issuancePrice input determines how many shares are issued:
totalShares = totalCommitment / issuancePrice
This affects:
perShare.gross = grossPerShareValue = netProceeds / totalSharesperShare.net = netPerShareValue = lpTotal / totalSharesmip.valuePerShare = mipPayment / mipPoolShares (where mipPoolShares may also depend on issuancePrice)If issuancePrice doesn't affect your outputs, your secondary series engine will produce identical results for all issuance prices — which fails 33% of scenarios for that series.
"Gross MOIC" can mean different things depending on context:
Check the comparator and control baseline to confirm which definition is expected. In many PE models, "Gross MOIC" is netProceeds / equityBasis (before carry is split out), while "Net MOIC" is lpTotal / equityBasis (after carry). Getting this wrong cascades into every downstream metric.
lib/waterfall.mjs uses simplified annual compounding. Many Excel models use monthly cash flows with monthly compounding and interim distributions. The library may overestimate carry by 20-30% due to this timing difference.
Options:
waterfall.gpCarry and waterfall.lpTotal to force-match Excel valuesWhen the Excel model is too complex to replicate formula-by-formula (monthly waterfalls, circular references, macro-driven logic), use a data-driven approach:
grossExit = NOI × multiple + constant (linear); carry = f(netProceeds, equityBasis) (tiered)This achieves higher accuracy than physics-based replication for complex models, and is faster to build.
Boolean outputs (like mip.triggered) cannot be linearly interpolated. A MIP can be "triggered" (plan exists) with $0 payment (returns below hurdle). Use explicit threshold logic:
mip.triggered = lpTotal > (mipHurdle * equityBasis);
mip.payment = mip.triggered ? dilutionRate * (lpTotal - mipHurdle * equityBasis) : 0;
Do NOT set triggered = (mipPayment > 0) — the plan can be triggered with zero payment if returns exactly equal the hurdle.
A common mistake is calibrating MOIC and IRR but forgetting to calibrate:
waterfall.lpTotal — must match Excel's LP totalwaterfall.gpCarry — must match Excel's total carrymip.payment — must match Excel's MIP paymentexitValuation.netProceeds — must match Excel's net proceedsCalibrate every output that has a known Excel target value. The more targets you calibrate, the higher your score.
After generating the initial engine, enter an interactive improvement loop. Do NOT skip this phase. The initial engine will typically score 30-60% — the loop gets it to 95%+.
import { selfEval, printComparisonTable, printMenu } from './lib/self-eval.mjs';
import { computeModel, BASE_CASE, EXCEL_TARGETS } from './engine.js';
const result = selfEval(computeModel, BASE_CASE, EXCEL_TARGETS);
printComparisonTable(result);
printMenu(result.score, 0);
Present the comparison table and ask what they want to do:
When the user picks option 1 or 2:
diagnoseFailures() to get prioritized fix suggestionsWhen user accepts (option 3):
engine-eval.jsonRun the eval EARLY — even before the engine is complete. A skeleton engine that crashes on unknown inputs tells you what you're missing. Better to discover you need issuancePrice support after 5 minutes than after 30 minutes of polishing other calculations.
Generate tests/eval.mjs that validates the engine against Excel.
tests/eval.mjs with these test categories:import XLSX from 'xlsx';
import { computeModel, BASE_CASE } from '../engine.js';
import { readCell } from '../lib/excel-parser.mjs';
const EXCEL_PATH = '../path/to/model.xlsx';
const TOLERANCE = 0.01; // 1% tolerance
// ---- Test 1: Base Case Accuracy ----
// Read expected values directly from Excel cells
// Compare engine output at BASE_CASE against Excel within tolerance
// ---- Test 2: Input Cascade ----
// For each input, vary it across its range (5 steps)
// Verify engine doesn't throw errors and outputs are reasonable
// ---- Test 3: Monotonicity Invariants ----
// Higher acquisition price → lower MOIC (all else equal)
// Higher equity → lower leverage → different IRR profile
// Higher exit cap rate → lower exit value
// ---- Test 4: Internal Consistency ----
// LP distributions + GP carry = Net proceeds
// Gross returns > Net returns (fees reduce returns)
// MOIC > 0 when proceeds > 0
// IRR sign matches MOIC direction (MOIC > 1 → IRR > 0)
async function runEval() {
const wb = XLSX.readFile(EXCEL_PATH);
const results = {
baseCaseResults: [],
monotonicityResults: [],
consistencyResults: [],
tolerance: TOLERANCE,
timestamp: new Date().toISOString(),
};
// Run all tests...
// Print clear pass/fail report
// Write results to tests/eval-results.json for the dashboard
console.log('\n' + '='.repeat(60));
console.log(allPassed ? ' ALL TESTS PASSED' : ' SOME TESTS FAILED');
console.log('='.repeat(60));
// Write results for dashboard consumption
fs.writeFileSync('tests/eval-results.json', JSON.stringify(results, null, 2));
}
node tests/eval.mjsIf you extracted a sensitivity surface in Phase 1, add sensitivity validation to the test suite:
import { extractSurface, compareSurfaces, printSensitivityReport } from '../lib/sensitivity.mjs';
// Load the Excel surface (saved in Phase 1)
const excelSurface = JSON.parse(fs.readFileSync('sensitivity-surface.json', 'utf8'));
// Extract engine surface with the same grid
const engineSurface = extractSurface(computeModel, BASE_CASE, {
exitMultiple: { min: 14, max: 26, steps: 7 },
});
// Compare — checks levels AND slopes
const comparison = compareSurfaces(engineSurface, excelSurface);
printSensitivityReport(comparison);
// Fail if slope errors are too high (this catches dynamics errors, not just levels)
const slopeFails = comparison.summary.slopeFailCount;
if (slopeFails > 0) {
console.log(`⚠️ ${slopeFails} slope checks failed — engine responds differently than Excel to input changes`);
}
This is the key test that catches the class of bug where the engine matches at base case but diverges when inputs change. Slope errors > 15% typically indicate a waterfall tier is wrong, a hurdle is at the wrong level, or a nonlinear relationship was linearized.
These are common financial invariants. Adapt to the specific model:
| Input Change | Expected Output Direction |
|---|---|
| Higher acquisition price | Lower MOIC, lower IRR |
| Higher exit value | Higher MOIC, higher IRR |
| Longer hold period | Lower IRR (same MOIC) |
| Higher leverage | Higher equity MOIC (if profitable) |
| Higher management fees | Lower net returns |
| Higher cap rate (exit) | Lower exit value |
| Higher preferred return | More to LP, less GP carry |
LP distributions + GP carry ≈ Net distributable proceedsGross MOIC > Net MOIC (fees and carry reduce returns)Gross IRR > Net IRRMOIC > 1.0 ↔ IRR > 0Total cash in = Total equity drawnWaterfall tiers sum = Total distributedGenerate an interactive HTML dashboard from pipelines/js-reasoning/templates/dashboard/.
Copy the dashboard template to the project's dashboard/ directory
Replace template placeholders in app.js:
{{ENGINE_PATH}} → relative path to engine.js (e.g., '../engine.js'){{MODEL_MAP_PATH}} → relative path to model-map.json (e.g., '../model-map.json'){{EVAL_DATA_PATH}} → relative path to eval results (e.g., '../tests/eval-results.json')Replace title placeholder in index.html:
{{MODEL_NAME}} → model name from model-map.jsonTest the dashboard by opening dashboard/index.html in a browser:
Tab 1 — Model Explorer:
Tab 2 — Eval Results:
The dashboard uses:
Just open index.html in a browser. For local development, use a simple server:
npx serve dashboard/
After running the full pipeline, the project should look like:
your-model/
├── engine.js ← Generated computation engine
├── model-map.json ← Model structure definition
├── tests/
│ ├── eval.mjs ← Test suite
│ └── eval-results.json ← Test results (generated by eval.mjs)
├── dashboard/
│ ├── index.html ← Interactive dashboard
│ ├── styles.css
│ └── app.js
└── lib/ ← Shared libraries (symlinked or copied)
├── irr.mjs
├── waterfall.mjs
├── calibration.mjs
└── excel-parser.mjs
After the engine passes testing, show the user how to use it:
import { computeModel } from './engine.js';
const result = computeModel({ exitMultiple: 22 });
console.log(`Gross IRR: ${(result.returns.grossIRR * 100).toFixed(1)}%`);
<script type="module">
import { computeModel } from './engine.js';
const result = computeModel({ exitMultiple: 22 });
document.getElementById('irr').textContent = (result.returns.grossIRR * 100).toFixed(1) + '%';
</script>
import { computeModel } from './engine.js';
app.get('/api/model', (req, res) => res.json(computeModel(req.query)));
Open dashboard/index.html in any browser — no build step needed.
If the user asks for a Python version, generate engine.py with:
BASE_CASE dictionaryEXCEL_TARGETS dictionarycompute_model(inputs) function signatureThis is opt-in only — do NOT generate Python by default.
For validating any engine (JS reasoning or Rust pipeline):
_sources metadata against ground truth (catches wrong-sheet/wrong-model errors)This separation is critical: the builder has full context, the evaluator has zero context. This prevents overfitting and catches real usability issues.
# Validate engine base case values against ground truth (do this FIRST):
node eval/validate-engine.mjs ./my-engine.js --gt-root ./parsed-models/
# One-command eval (local, no Docker needed):
node eval/run-all.mjs model.xlsx --questions 50
# Or step by step:
node eval/generate-questions.mjs output/chunked --count 50 --output output/test-questions.json
ANTHROPIC_API_KEY=... node eval/blind-eval.mjs output/chunked --questions output/test-questions.json
node eval/analyze-report.mjs output/eval-report.json output/analysis.json
node eval/per-sheet-eval.mjs output/chunked --output output/per-sheet-report.json
eval.mjs after Phase 2 to see how close you are. Iterate on the engine logic until base case accuracy is within 1%.extractWaterfallStructure(groundTruth) and extractCashFlowSeries(groundTruth) in lib/excel-parser.mjs auto-detect multi-tier waterfalls and distribution series._sources metadata when storing ground truth values in an engine. Map every base case field to its exact cell reference. Run node eval/validate-engine.mjs before deploying — it catches wrong-sheet, wrong-model, and wrong-column errors automatically._sources.groundTruth.