@uwmd/core 2.4.0 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/validator.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // Spec: UW_FORMAT_SPEC_v1.md Part V
3
3
  import { DEFAULT_THRESHOLDS, SOURCE_TAGS } from './types.js';
4
4
  import { getSection, getSectionVariant, deepGet } from './parser.js';
5
- import { BUILTIN_REMEDIATIONS, BUILTIN_INCOMPLETE_DATA_POLICIES, lookupIncompleteDataPolicy, getSizeIntensive, DEAL_UNDERWRITING_PROFILE, parseActorSource, isSupportedLocale, STAGE_REQUIREMENTS, requiredSectionsFor } from './protocol.js';
5
+ import { BUILTIN_REMEDIATIONS, BUILTIN_INCOMPLETE_DATA_POLICIES, lookupIncompleteDataPolicy, getSizeIntensive, DEAL_UNDERWRITING_PROFILE, parseActorSource, isSupportedLocale, STAGE_REQUIREMENTS, requiredSectionsFor, CROSS_CHECK_RULE_IDS, CROSS_CHECK_VARIANT_PREFERENCE, RETURN_TAX_BASES, DEFAULT_RETURN_TAX_BASIS, } from './protocol.js';
6
6
  import { EXTERNAL_ANNOTATION_KEY } from './composition.js';
7
7
  import { UW_LITE_SOURCE_EXTENSION } from './lite-bridge.js';
8
8
  import { readGapsContent } from './gaps.js';
@@ -91,15 +91,17 @@ function resolveSectionFieldPath(parsed, path) {
91
91
  export function validateUWFile(parsed, thresholdOverrides) {
92
92
  const thresholds = { ...DEFAULT_THRESHOLDS, ...thresholdOverrides };
93
93
  const issues = [];
94
+ const ledger = newCoverageLedger();
94
95
  checkFinancialValidity(parsed, thresholds, issues);
95
- checkCrossSectionConsistency(parsed, issues);
96
- checkComponents(parsed, issues);
96
+ checkCrossSectionConsistency(parsed, issues, ledger);
97
+ checkComponents(parsed, issues, ledger);
97
98
  checkCapitalStack(parsed, issues);
98
- checkLeaseUpSchedule(parsed, issues);
99
+ checkLeaseUpSchedule(parsed, issues, ledger);
99
100
  checkCashFlowSeries(parsed, issues);
100
101
  checkWaterfall(parsed, issues);
101
- checkSizeIntensive(parsed, issues);
102
- checkSectionReadiness(parsed, issues);
102
+ checkSizeIntensive(parsed, issues, ledger);
103
+ checkSectionReadiness(parsed, issues, ledger);
104
+ checkReturnsTaxBasis(parsed, issues);
103
105
  checkLocale(parsed, issues);
104
106
  checkAssetClassIdentifier(parsed, issues);
105
107
  checkMetaIntegrity(parsed, issues);
@@ -107,6 +109,23 @@ export function validateUWFile(parsed, thresholdOverrides) {
107
109
  checkSourceVocabulary(parsed, issues);
108
110
  checkScopeReadiness(parsed, issues);
109
111
  checkDataQuality(parsed, issues);
112
+ // CC-16: one info issue per section that was present as a variant map no
113
+ // cross-check could resolve (§5.3, RFC 0037). Emitted after every check has
114
+ // run so the message can name every rule the map silenced.
115
+ for (const [sectionId, entry] of ledger.unresolvable) {
116
+ const codes = [...entry.codes].sort();
117
+ issues.push({
118
+ code: 'CC-16', severity: 'info', section: sectionId,
119
+ message: `CC-16: ${sectionId} is present as ${entry.variants.length} variants (${entry.variants.join(', ')}) and none is named default or base; ${codes.join(', ')} ${codes.length === 1 ? 'was' : 'were'} skipped — name a default variant or state the section once (§5.3, RFC 0037)`,
120
+ value: entry.variants,
121
+ });
122
+ }
123
+ // Every registered rule owes a coverage entry; a rule no check reached is
124
+ // reported rather than silently missing.
125
+ for (const code of CROSS_CHECK_RULE_IDS) {
126
+ if (!ledger.coverage[code])
127
+ markSkipped(ledger, code, 'not_applicable', 'not reached');
128
+ }
110
129
  // Enrich every issue with BUILTIN_REMEDIATIONS title/remediation/spec_ref
111
130
  // when the code matches the registry. Keeps inline messages (which carry
112
131
  // deal-specific values) and adds canonical remediation copy.
@@ -127,6 +146,7 @@ export function validateUWFile(parsed, thresholdOverrides) {
127
146
  errors,
128
147
  warnings,
129
148
  info,
149
+ coverage: orderedCoverage(ledger),
130
150
  };
131
151
  }
132
152
  // ─── §5.2 Financial validity thresholds ──────────────────────────────────────
@@ -245,132 +265,346 @@ function checkFinancialValidity(parsed, t, issues) {
245
265
  }
246
266
  }
247
267
  }
248
- // ─── §5.3 Cross-section consistency checks ───────────────────────────────────
249
- function checkCrossSectionConsistency(parsed, issues) {
250
- const rentRoll = getSection(parsed, 'rent_roll');
251
- const os = getSectionVariant(parsed, 'operating_statement', 't12')
252
- ?? getSectionVariant(parsed, 'operating_statement', 'default')
253
- ?? getSection(parsed, 'operating_statement');
254
- const debtStructure = getSection(parsed, 'debt_structure');
255
- const sourcesUses = getSection(parsed, 'sources_uses');
256
- const valuation = getSection(parsed, 'valuation');
257
- const noiModel = getSection(parsed, 'noi_model');
258
- const dcf = getSection(parsed, 'dcf');
259
- const stressTests = getSection(parsed, 'stress_tests')
260
- ?? getSectionVariant(parsed, 'stress_tests', 'default');
261
- const dueDiligence = getSection(parsed, 'due_diligence');
268
+ function newCoverageLedger() {
269
+ return { coverage: {}, unresolvable: new Map() };
270
+ }
271
+ function markEvaluated(ledger, code) {
272
+ ledger.coverage[code] = { status: 'evaluated' };
273
+ }
274
+ function markSkipped(ledger, code, reason, detail) {
275
+ if (ledger.coverage[code]?.status === 'evaluated')
276
+ return;
277
+ ledger.coverage[code] = { status: 'skipped', reason, ...(detail ? { detail } : {}) };
278
+ }
279
+ function noteUnresolvable(ledger, sectionId, variants, code) {
280
+ const entry = ledger.unresolvable.get(sectionId) ?? { variants, codes: new Set() };
281
+ entry.codes.add(code);
282
+ ledger.unresolvable.set(sectionId, entry);
283
+ }
284
+ /** Coverage in registered-rule order, so `--json` output is stable. */
285
+ function orderedCoverage(ledger) {
286
+ const out = {};
287
+ for (const code of CROSS_CHECK_RULE_IDS) {
288
+ const entry = ledger.coverage[code];
289
+ if (entry)
290
+ out[code] = entry;
291
+ }
292
+ return out;
293
+ }
294
+ function resolveCrossCheckSection(parsed, sectionId, preferred = []) {
295
+ const entry = parsed.sections[sectionId];
296
+ if (!entry)
297
+ return { state: 'absent', block: null };
298
+ if (!isVariantMap(entry))
299
+ return { state: 'resolved', block: entry };
300
+ const map = entry;
301
+ for (const key of [...preferred, ...CROSS_CHECK_VARIANT_PREFERENCE]) {
302
+ const block = map[key];
303
+ if (block)
304
+ return { state: 'resolved', block, variant: key };
305
+ }
306
+ const keys = Object.keys(map);
307
+ if (keys.length === 1)
308
+ return { state: 'resolved', block: map[keys[0]], variant: keys[0] };
309
+ return { state: 'unresolvable', block: null, variants: keys.sort() };
310
+ }
311
+ /**
312
+ * Resolve every section a rule reads. On any failure the rule's skip is
313
+ * recorded (the first failure's reason wins; every unresolvable section is
314
+ * noted for CC-16) and `null` is returned.
315
+ */
316
+ function requireSections(ledger, parsed, code, needs) {
317
+ const out = {};
318
+ let skip = null;
319
+ for (const need of needs) {
320
+ const sectionId = need[0];
321
+ const preferred = need.length > 1 ? need[1] : [];
322
+ const r = resolveCrossCheckSection(parsed, sectionId, preferred);
323
+ if (r.state === 'resolved') {
324
+ out[sectionId] = { block: r.block, ...(r.variant ? { variant: r.variant } : {}) };
325
+ continue;
326
+ }
327
+ if (r.state === 'unresolvable')
328
+ noteUnresolvable(ledger, sectionId, r.variants, code);
329
+ skip ??= { reason: r.state === 'absent' ? 'section_absent' : 'variant_unresolvable', detail: sectionId };
330
+ }
331
+ if (skip) {
332
+ markSkipped(ledger, code, skip.reason, skip.detail);
333
+ return null;
334
+ }
335
+ return out;
336
+ }
337
+ function num(v) {
338
+ return typeof v === 'number' && Number.isFinite(v) ? v : undefined;
339
+ }
340
+ function checkCrossSectionConsistency(parsed, issues, ledger) {
262
341
  // CC-01: Rent roll GPR must match OS GPR within 3%
263
- if (rentRoll && os) {
264
- const rrGPR = deepGet(rentRoll.content, 'gross_potential_rent')
265
- ?? deepGet(rentRoll.content, 'totals.gross_potential_rent');
266
- const osGPR = deepGet(os.content, 'gross_potential_rent')
267
- ?? deepGet(os.content, 'income.gross_potential_rent');
268
- if (rrGPR != null && osGPR != null && osGPR !== 0) {
269
- const pctDiff = Math.abs(rrGPR - osGPR) / osGPR;
270
- if (pctDiff > 0.03) {
271
- issues.push({ code: 'CC-01', severity: 'warning', section: 'rent_roll', field: 'gross_potential_rent', message: `CC-01: Rent roll GPR ($${rrGPR.toLocaleString()}) differs from Operating Statement GPR ($${osGPR.toLocaleString()}) by ${(pctDiff * 100).toFixed(1)}% (threshold: 3%)`, value: pctDiff });
342
+ {
343
+ const got = requireSections(ledger, parsed, 'CC-01', [['rent_roll'], ['operating_statement', ['t12']]]);
344
+ if (got) {
345
+ const rentRoll = got['rent_roll'].block;
346
+ const os = got['operating_statement'].block;
347
+ const rrGPR = num(deepGet(rentRoll.content, 'gross_potential_rent')) ?? num(deepGet(rentRoll.content, 'totals.gross_potential_rent'));
348
+ const osGPR = num(deepGet(os.content, 'gross_potential_rent')) ?? num(deepGet(os.content, 'income.gross_potential_rent'));
349
+ if (rrGPR == null)
350
+ markSkipped(ledger, 'CC-01', 'field_absent', 'rent_roll.gross_potential_rent');
351
+ else if (osGPR == null || osGPR === 0)
352
+ markSkipped(ledger, 'CC-01', 'field_absent', 'operating_statement.gross_potential_rent');
353
+ else {
354
+ markEvaluated(ledger, 'CC-01');
355
+ const pctDiff = Math.abs(rrGPR - osGPR) / osGPR;
356
+ if (pctDiff > 0.03) {
357
+ issues.push({ code: 'CC-01', severity: 'warning', section: 'rent_roll', field: 'gross_potential_rent', message: `CC-01: Rent roll GPR ($${rrGPR.toLocaleString()}) differs from Operating Statement GPR ($${osGPR.toLocaleString()}) by ${(pctDiff * 100).toFixed(1)}% (threshold: 3%)`, value: pctDiff });
358
+ }
272
359
  }
273
360
  }
274
361
  }
275
362
  // CC-02: UW value in valuation must match LTV denominator in debt_structure
276
- if (valuation && debtStructure) {
277
- const uwValue = deepGet(valuation.content, 'underwritten_value')
278
- ?? deepGet(valuation.content, 'purchase_price');
279
- const loanAmt = deepGet(debtStructure.content, 'loan_amount');
280
- const ltvInDebt = deepGet(debtStructure.content, 'ltv');
281
- if (uwValue != null && loanAmt != null && ltvInDebt != null && uwValue > 0) {
282
- const impliedLTV = loanAmt / uwValue;
283
- const diff = Math.abs(impliedLTV - ltvInDebt);
284
- if (diff > 0.005) {
285
- issues.push({ code: 'CC-02', severity: 'warning', section: 'debt_structure', field: 'ltv', message: `CC-02: Implied LTV (${(impliedLTV * 100).toFixed(2)}%) from loan/value does not match stated LTV (${(ltvInDebt * 100).toFixed(2)}%) — check valuation.underwritten_value vs debt_structure.ltv`, value: diff });
363
+ {
364
+ const got = requireSections(ledger, parsed, 'CC-02', [['valuation'], ['debt_structure']]);
365
+ if (got) {
366
+ const valuation = got['valuation'].block;
367
+ const debtStructure = got['debt_structure'].block;
368
+ const uwValue = num(deepGet(valuation.content, 'underwritten_value')) ?? num(deepGet(valuation.content, 'purchase_price'));
369
+ const loanAmt = num(deepGet(debtStructure.content, 'loan_amount'));
370
+ const ltvInDebt = num(deepGet(debtStructure.content, 'ltv'));
371
+ if (uwValue == null || uwValue <= 0)
372
+ markSkipped(ledger, 'CC-02', 'field_absent', 'valuation.underwritten_value');
373
+ else if (loanAmt == null)
374
+ markSkipped(ledger, 'CC-02', 'field_absent', 'debt_structure.loan_amount');
375
+ else if (ltvInDebt == null)
376
+ markSkipped(ledger, 'CC-02', 'field_absent', 'debt_structure.ltv');
377
+ else {
378
+ markEvaluated(ledger, 'CC-02');
379
+ const impliedLTV = loanAmt / uwValue;
380
+ const diff = Math.abs(impliedLTV - ltvInDebt);
381
+ if (diff > 0.005) {
382
+ issues.push({ code: 'CC-02', severity: 'warning', section: 'debt_structure', field: 'ltv', message: `CC-02: Implied LTV (${(impliedLTV * 100).toFixed(2)}%) from loan/value does not match stated LTV (${(ltvInDebt * 100).toFixed(2)}%) — check valuation.underwritten_value vs debt_structure.ltv`, value: diff });
383
+ }
286
384
  }
287
385
  }
288
386
  }
289
387
  // CC-03: the senior loan reconciles across sources_uses, debt_structure, and
290
388
  // (when present) the capital_stack senior_debt tranche — one senior view stated
291
- // once and agreeing everywhere (RFC 0026 §4.24).
389
+ // once and agreeing everywhere (RFC 0026 §4.24). Two legs; the rule is
390
+ // evaluated when either leg compared.
292
391
  {
293
- const suLoan = deepGet(sourcesUses?.content ?? {}, 'sources.loan_amount')
294
- ?? deepGet(sourcesUses?.content ?? {}, 'sources.debt_proceeds');
295
- const dsLoan = deepGet(debtStructure?.content ?? {}, 'loan_amount');
296
- if (sourcesUses && debtStructure && suLoan != null && dsLoan != null && Math.abs(suLoan - dsLoan) > 100) {
297
- issues.push({ code: 'CC-03', severity: 'error', section: 'sources_uses', field: 'sources.loan_amount', message: `CC-03: Loan amount in Sources & Uses ($${suLoan.toLocaleString()}) does not match Debt Structure loan amount ($${dsLoan.toLocaleString()})`, value: Math.abs(suLoan - dsLoan) });
392
+ const su = resolveCrossCheckSection(parsed, 'sources_uses');
393
+ const ds = resolveCrossCheckSection(parsed, 'debt_structure');
394
+ if (su.state === 'unresolvable')
395
+ noteUnresolvable(ledger, 'sources_uses', su.variants, 'CC-03');
396
+ if (ds.state === 'unresolvable')
397
+ noteUnresolvable(ledger, 'debt_structure', ds.variants, 'CC-03');
398
+ const suLoan = su.block ? (num(deepGet(su.block.content, 'sources.loan_amount')) ?? num(deepGet(su.block.content, 'sources.debt_proceeds'))) : undefined;
399
+ const dsLoan = ds.block ? num(deepGet(ds.block.content, 'loan_amount')) : undefined;
400
+ let evaluated = false;
401
+ if (suLoan != null && dsLoan != null) {
402
+ evaluated = true;
403
+ if (Math.abs(suLoan - dsLoan) > 100) {
404
+ issues.push({ code: 'CC-03', severity: 'error', section: 'sources_uses', field: 'sources.loan_amount', message: `CC-03: Loan amount in Sources & Uses ($${suLoan.toLocaleString()}) does not match Debt Structure loan amount ($${dsLoan.toLocaleString()})`, value: Math.abs(suLoan - dsLoan) });
405
+ }
298
406
  }
299
407
  const senior = seniorDebtTranche(parsed);
300
408
  if (senior) {
301
- const seniorAmount = typeof senior['amount'] === 'number' ? senior['amount'] : undefined;
409
+ const seniorAmount = num(senior['amount']);
302
410
  const reference = dsLoan ?? suLoan;
303
- if (seniorAmount != null && reference != null && Math.abs(seniorAmount - reference) > 100) {
304
- issues.push({ code: 'CC-03', severity: 'error', section: 'capital_stack', field: 'tranches.senior_debt.amount', message: `CC-03: the capital_stack senior_debt tranche ($${seniorAmount.toLocaleString()}) does not match the senior loan amount ($${reference.toLocaleString()})`, value: Math.abs(seniorAmount - reference) });
411
+ if (seniorAmount != null && reference != null) {
412
+ evaluated = true;
413
+ if (Math.abs(seniorAmount - reference) > 100) {
414
+ issues.push({ code: 'CC-03', severity: 'error', section: 'capital_stack', field: 'tranches.senior_debt.amount', message: `CC-03: the capital_stack senior_debt tranche ($${seniorAmount.toLocaleString()}) does not match the senior loan amount ($${reference.toLocaleString()})`, value: Math.abs(seniorAmount - reference) });
415
+ }
305
416
  }
306
417
  }
418
+ if (evaluated)
419
+ markEvaluated(ledger, 'CC-03');
420
+ else if (su.state === 'unresolvable')
421
+ markSkipped(ledger, 'CC-03', 'variant_unresolvable', 'sources_uses');
422
+ else if (ds.state === 'unresolvable')
423
+ markSkipped(ledger, 'CC-03', 'variant_unresolvable', 'debt_structure');
424
+ else if (su.state === 'absent')
425
+ markSkipped(ledger, 'CC-03', 'section_absent', 'sources_uses');
426
+ else if (ds.state === 'absent')
427
+ markSkipped(ledger, 'CC-03', 'section_absent', 'debt_structure');
428
+ else
429
+ markSkipped(ledger, 'CC-03', 'field_absent', suLoan == null ? 'sources_uses.sources.loan_amount' : 'debt_structure.loan_amount');
307
430
  }
308
431
  // CC-04: Sources must equal uses in sources_uses (within $1)
309
- if (sourcesUses) {
310
- const totalSources = deepGet(sourcesUses.content, 'total_sources');
311
- const totalUses = deepGet(sourcesUses.content, 'total_uses');
312
- if (totalSources != null && totalUses != null && Math.abs(totalSources - totalUses) > 1) {
313
- issues.push({ code: 'CC-04', severity: 'error', section: 'sources_uses', field: 'total_sources', message: `CC-04: Sources ($${totalSources.toLocaleString()}) do not equal Uses ($${totalUses.toLocaleString()}) — difference: $${Math.abs(totalSources - totalUses).toLocaleString()}`, value: Math.abs(totalSources - totalUses) });
432
+ {
433
+ const got = requireSections(ledger, parsed, 'CC-04', [['sources_uses']]);
434
+ if (got) {
435
+ const sourcesUses = got['sources_uses'].block;
436
+ const totalSources = num(deepGet(sourcesUses.content, 'total_sources'));
437
+ const totalUses = num(deepGet(sourcesUses.content, 'total_uses'));
438
+ if (totalSources == null)
439
+ markSkipped(ledger, 'CC-04', 'field_absent', 'sources_uses.total_sources');
440
+ else if (totalUses == null)
441
+ markSkipped(ledger, 'CC-04', 'field_absent', 'sources_uses.total_uses');
442
+ else {
443
+ markEvaluated(ledger, 'CC-04');
444
+ if (Math.abs(totalSources - totalUses) > 1) {
445
+ issues.push({ code: 'CC-04', severity: 'error', section: 'sources_uses', field: 'total_sources', message: `CC-04: Sources ($${totalSources.toLocaleString()}) do not equal Uses ($${totalUses.toLocaleString()}) — difference: $${Math.abs(totalSources - totalUses).toLocaleString()}`, value: Math.abs(totalSources - totalUses) });
446
+ }
447
+ }
314
448
  }
315
449
  }
316
450
  // CC-05: NOI used for DSCR must match noi_model.net_operating_income within 1%
317
- if (noiModel && debtStructure) {
318
- const modelNOI = deepGet(noiModel.content, 'net_operating_income');
319
- const debtNOI = deepGet(debtStructure.content, 'underwritten_noi')
320
- ?? deepGet(debtStructure.content, 'noi_used_for_dscr');
321
- if (modelNOI != null && debtNOI != null && modelNOI > 0) {
322
- const pctDiff = Math.abs(modelNOI - debtNOI) / modelNOI;
323
- if (pctDiff > 0.01) {
324
- issues.push({ code: 'CC-05', severity: 'warning', section: 'debt_structure', field: 'underwritten_noi', message: `CC-05: NOI used for DSCR ($${debtNOI.toLocaleString()}) differs from noi_model NOI ($${modelNOI.toLocaleString()}) by ${(pctDiff * 100).toFixed(2)}% (threshold: 1%)`, value: pctDiff });
451
+ {
452
+ const got = requireSections(ledger, parsed, 'CC-05', [['noi_model'], ['debt_structure']]);
453
+ if (got) {
454
+ const noiModel = got['noi_model'].block;
455
+ const debtStructure = got['debt_structure'].block;
456
+ const modelNOI = num(deepGet(noiModel.content, 'net_operating_income'));
457
+ const debtNOI = num(deepGet(debtStructure.content, 'underwritten_noi')) ?? num(deepGet(debtStructure.content, 'noi_used_for_dscr'));
458
+ if (modelNOI == null || modelNOI <= 0)
459
+ markSkipped(ledger, 'CC-05', 'field_absent', 'noi_model.net_operating_income');
460
+ else if (debtNOI == null)
461
+ markSkipped(ledger, 'CC-05', 'field_absent', 'debt_structure.underwritten_noi');
462
+ else {
463
+ markEvaluated(ledger, 'CC-05');
464
+ const pctDiff = Math.abs(modelNOI - debtNOI) / modelNOI;
465
+ if (pctDiff > 0.01) {
466
+ issues.push({ code: 'CC-05', severity: 'warning', section: 'debt_structure', field: 'underwritten_noi', message: `CC-05: NOI used for DSCR ($${debtNOI.toLocaleString()}) differs from noi_model NOI ($${modelNOI.toLocaleString()}) by ${(pctDiff * 100).toFixed(2)}% (threshold: 1%)`, value: pctDiff });
467
+ }
325
468
  }
326
469
  }
327
470
  }
328
471
  // CC-06: DCF Year 1 NOI must be consistent with noi_model projections
329
- if (noiModel && dcf) {
330
- const modelNOI = deepGet(noiModel.content, 'net_operating_income');
331
- const dcfY1NOI = deepGet(dcf.content, 'annual_cash_flows[0].noi')
332
- ?? deepGet(dcf.content, 'annual_cash_flows[0].net_operating_income');
333
- if (modelNOI != null && dcfY1NOI != null && modelNOI > 0) {
334
- const pctDiff = Math.abs(modelNOI - dcfY1NOI) / modelNOI;
335
- if (pctDiff > 0.02) {
336
- issues.push({ code: 'CC-06', severity: 'warning', section: 'dcf', field: 'annual_cash_flows[0].noi', message: `CC-06: DCF Year 1 NOI ($${dcfY1NOI.toLocaleString()}) deviates from noi_model NOI ($${modelNOI.toLocaleString()}) by ${(pctDiff * 100).toFixed(2)}%`, value: pctDiff });
472
+ {
473
+ const got = requireSections(ledger, parsed, 'CC-06', [['noi_model'], ['dcf']]);
474
+ if (got) {
475
+ const noiModel = got['noi_model'].block;
476
+ const dcf = got['dcf'].block;
477
+ const modelNOI = num(deepGet(noiModel.content, 'net_operating_income'));
478
+ const dcfY1NOI = num(deepGet(dcf.content, 'annual_cash_flows[0].noi')) ?? num(deepGet(dcf.content, 'annual_cash_flows[0].net_operating_income'));
479
+ if (modelNOI == null || modelNOI <= 0)
480
+ markSkipped(ledger, 'CC-06', 'field_absent', 'noi_model.net_operating_income');
481
+ else if (dcfY1NOI == null)
482
+ markSkipped(ledger, 'CC-06', 'field_absent', 'dcf.annual_cash_flows[0].net_operating_income');
483
+ else {
484
+ markEvaluated(ledger, 'CC-06');
485
+ const pctDiff = Math.abs(modelNOI - dcfY1NOI) / modelNOI;
486
+ if (pctDiff > 0.02) {
487
+ issues.push({ code: 'CC-06', severity: 'warning', section: 'dcf', field: 'annual_cash_flows[0].noi', message: `CC-06: DCF Year 1 NOI ($${dcfY1NOI.toLocaleString()}) deviates from noi_model NOI ($${modelNOI.toLocaleString()}) by ${(pctDiff * 100).toFixed(2)}%`, value: pctDiff });
488
+ }
337
489
  }
338
490
  }
339
491
  }
340
492
  // CC-07: Exit cap rate in dcf must be consistent with stress test cap rate scenarios
341
- if (dcf && stressTests) {
342
- const dcfExitCap = deepGet(dcf.content, 'assumptions.exit_cap_rate')
343
- ?? deepGet(dcf.content, 'exit_cap_rate');
344
- const stressExitCap = deepGet(stressTests.content, 'base_case.exit_cap_rate');
345
- if (dcfExitCap != null && stressExitCap != null && Math.abs(dcfExitCap - stressExitCap) > 0.005) {
346
- issues.push({ code: 'CC-07', severity: 'warning', section: 'stress_tests', field: 'base_case.exit_cap_rate', message: `CC-07: Exit cap rate in DCF (${(dcfExitCap * 100).toFixed(2)}%) differs from stress test base case (${(stressExitCap * 100).toFixed(2)}%)`, value: Math.abs(dcfExitCap - stressExitCap) });
493
+ {
494
+ const got = requireSections(ledger, parsed, 'CC-07', [['dcf'], ['stress_tests']]);
495
+ if (got) {
496
+ const dcf = got['dcf'].block;
497
+ const stressTests = got['stress_tests'].block;
498
+ const dcfExitCap = num(deepGet(dcf.content, 'assumptions.exit_cap_rate')) ?? num(deepGet(dcf.content, 'exit_cap_rate'));
499
+ const stressExitCap = num(deepGet(stressTests.content, 'base_case.exit_cap_rate'));
500
+ if (dcfExitCap == null)
501
+ markSkipped(ledger, 'CC-07', 'field_absent', 'dcf.assumptions.exit_cap_rate');
502
+ else if (stressExitCap == null)
503
+ markSkipped(ledger, 'CC-07', 'field_absent', 'stress_tests.base_case.exit_cap_rate');
504
+ else {
505
+ markEvaluated(ledger, 'CC-07');
506
+ if (Math.abs(dcfExitCap - stressExitCap) > 0.005) {
507
+ issues.push({ code: 'CC-07', severity: 'warning', section: 'stress_tests', field: 'base_case.exit_cap_rate', message: `CC-07: Exit cap rate in DCF (${(dcfExitCap * 100).toFixed(2)}%) differs from stress test base case (${(stressExitCap * 100).toFixed(2)}%)`, value: Math.abs(dcfExitCap - stressExitCap) });
508
+ }
509
+ }
347
510
  }
348
511
  }
349
- // CC-08: Appraised value in due_diligence must match valuation.appraised_value
350
- if (dueDiligence && valuation) {
351
- const ddAppraisedVal = deepGet(dueDiligence.content, 'appraisal.appraised_value');
352
- const valAppraisedVal = deepGet(valuation.content, 'appraised_value');
353
- if (ddAppraisedVal != null && valAppraisedVal != null && Math.abs(ddAppraisedVal - valAppraisedVal) > 1000) {
354
- issues.push({ code: 'CC-08', severity: 'warning', section: 'due_diligence', field: 'appraisal.appraised_value', message: `CC-08: Appraised value in due_diligence ($${ddAppraisedVal.toLocaleString()}) does not match valuation.appraised_value ($${valAppraisedVal.toLocaleString()})`, value: Math.abs(ddAppraisedVal - valAppraisedVal) });
512
+ // CC-08: Appraised value in due_diligence must match valuation.appraised_value.
513
+ // due_diligence's variants are sub-documents (§2.8), so the `appraisal`
514
+ // variant is preferred and, when it is the resolved block, the value may
515
+ // sit at its root rather than under an `appraisal` key.
516
+ {
517
+ const got = requireSections(ledger, parsed, 'CC-08', [['due_diligence', ['appraisal']], ['valuation']]);
518
+ if (got) {
519
+ const dd = got['due_diligence'];
520
+ const valuation = got['valuation'].block;
521
+ const ddAppraisedVal = num(deepGet(dd.block.content, 'appraisal.appraised_value'))
522
+ ?? (dd.variant === 'appraisal' ? num(deepGet(dd.block.content, 'appraised_value')) : undefined);
523
+ const valAppraisedVal = num(deepGet(valuation.content, 'appraised_value'));
524
+ if (ddAppraisedVal == null)
525
+ markSkipped(ledger, 'CC-08', 'field_absent', 'due_diligence.appraisal.appraised_value');
526
+ else if (valAppraisedVal == null)
527
+ markSkipped(ledger, 'CC-08', 'field_absent', 'valuation.appraised_value');
528
+ else {
529
+ markEvaluated(ledger, 'CC-08');
530
+ if (Math.abs(ddAppraisedVal - valAppraisedVal) > 1000) {
531
+ issues.push({ code: 'CC-08', severity: 'warning', section: 'due_diligence', field: 'appraisal.appraised_value', message: `CC-08: Appraised value in due_diligence ($${ddAppraisedVal.toLocaleString()}) does not match valuation.appraised_value ($${valAppraisedVal.toLocaleString()})`, value: Math.abs(ddAppraisedVal - valAppraisedVal) });
532
+ }
533
+ }
355
534
  }
356
535
  }
357
536
  // CC-09: Annual debt service in stress_tests base case must match debt_structure.annual_debt_service
358
- if (stressTests && debtStructure) {
359
- const stressADS = deepGet(stressTests.content, 'base_case.annual_debt_service');
360
- const debtADS = deepGet(debtStructure.content, 'annual_debt_service');
361
- if (stressADS != null && debtADS != null && Math.abs(stressADS - debtADS) > 500) {
362
- issues.push({ code: 'CC-09', severity: 'warning', section: 'stress_tests', field: 'base_case.annual_debt_service', message: `CC-09: Annual debt service in stress test base case ($${stressADS.toLocaleString()}) differs from debt_structure ($${debtADS.toLocaleString()}) by $${Math.abs(stressADS - debtADS).toLocaleString()}`, value: Math.abs(stressADS - debtADS) });
537
+ {
538
+ const got = requireSections(ledger, parsed, 'CC-09', [['stress_tests'], ['debt_structure']]);
539
+ if (got) {
540
+ const stressTests = got['stress_tests'].block;
541
+ const debtStructure = got['debt_structure'].block;
542
+ const stressADS = num(deepGet(stressTests.content, 'base_case.annual_debt_service'));
543
+ const debtADS = num(deepGet(debtStructure.content, 'annual_debt_service'));
544
+ if (stressADS == null)
545
+ markSkipped(ledger, 'CC-09', 'field_absent', 'stress_tests.base_case.annual_debt_service');
546
+ else if (debtADS == null)
547
+ markSkipped(ledger, 'CC-09', 'field_absent', 'debt_structure.annual_debt_service');
548
+ else {
549
+ markEvaluated(ledger, 'CC-09');
550
+ if (Math.abs(stressADS - debtADS) > 500) {
551
+ issues.push({ code: 'CC-09', severity: 'warning', section: 'stress_tests', field: 'base_case.annual_debt_service', message: `CC-09: Annual debt service in stress test base case ($${stressADS.toLocaleString()}) differs from debt_structure ($${debtADS.toLocaleString()}) by $${Math.abs(stressADS - debtADS).toLocaleString()}`, value: Math.abs(stressADS - debtADS) });
552
+ }
553
+ }
363
554
  }
364
555
  }
365
556
  // CC-10: Purchase price in sources_uses.uses must match valuation.purchase_price
366
- if (sourcesUses && valuation) {
367
- const suPP = deepGet(sourcesUses.content, 'uses.purchase_price');
368
- const valPP = deepGet(valuation.content, 'purchase_price');
369
- if (suPP != null && valPP != null && Math.abs(suPP - valPP) > 100) {
370
- issues.push({ code: 'CC-10', severity: 'error', section: 'sources_uses', field: 'uses.purchase_price', message: `CC-10: Purchase price in Sources & Uses ($${suPP.toLocaleString()}) does not match valuation.purchase_price ($${valPP.toLocaleString()})`, value: Math.abs(suPP - valPP) });
557
+ {
558
+ const got = requireSections(ledger, parsed, 'CC-10', [['sources_uses'], ['valuation']]);
559
+ if (got) {
560
+ const sourcesUses = got['sources_uses'].block;
561
+ const valuation = got['valuation'].block;
562
+ const suPP = num(deepGet(sourcesUses.content, 'uses.purchase_price'));
563
+ const valPP = num(deepGet(valuation.content, 'purchase_price'));
564
+ if (suPP == null)
565
+ markSkipped(ledger, 'CC-10', 'field_absent', 'sources_uses.uses.purchase_price');
566
+ else if (valPP == null)
567
+ markSkipped(ledger, 'CC-10', 'field_absent', 'valuation.purchase_price');
568
+ else {
569
+ markEvaluated(ledger, 'CC-10');
570
+ if (Math.abs(suPP - valPP) > 100) {
571
+ issues.push({ code: 'CC-10', severity: 'error', section: 'sources_uses', field: 'uses.purchase_price', message: `CC-10: Purchase price in Sources & Uses ($${suPP.toLocaleString()}) does not match valuation.purchase_price ($${valPP.toLocaleString()})`, value: Math.abs(suPP - valPP) });
572
+ }
573
+ }
371
574
  }
372
575
  }
373
576
  }
577
+ // ─── §4.9 dcf.returns.tax_basis (RFC 0038) ───────────────────────────────────
578
+ /**
579
+ * The tax basis every metric in `dcf.returns` is stated on: the declared
580
+ * `returns.tax_basis` when it is a registered value, else the spec default
581
+ * (`pre_tax`). Consumers comparing return metrics across documents call this
582
+ * rather than re-deriving the default.
583
+ */
584
+ export function getReturnTaxBasis(parsed) {
585
+ const dcf = resolveCrossCheckSection(parsed, 'dcf').block;
586
+ const v = dcf ? deepGet(dcf.content, 'returns.tax_basis') : undefined;
587
+ return typeof v === 'string' && RETURN_TAX_BASES.includes(v)
588
+ ? v
589
+ : DEFAULT_RETURN_TAX_BASIS;
590
+ }
591
+ // RT-01: a stated tax_basis outside the closed set. Absent (or null) means
592
+ // the default and is not an issue.
593
+ function checkReturnsTaxBasis(parsed, issues) {
594
+ const dcf = resolveCrossCheckSection(parsed, 'dcf').block;
595
+ if (!dcf)
596
+ return;
597
+ const v = deepGet(dcf.content, 'returns.tax_basis');
598
+ if (v === undefined || v === null)
599
+ return;
600
+ if (typeof v === 'string' && RETURN_TAX_BASES.includes(v))
601
+ return;
602
+ issues.push({
603
+ code: 'RT-01', severity: 'error', section: 'dcf', field: 'returns.tax_basis',
604
+ message: `RT-01: dcf.returns.tax_basis must be one of ${RETURN_TAX_BASES.join(', ')} (found ${JSON.stringify(v)}); omit it to mean ${DEFAULT_RETURN_TAX_BASIS} (format §4.9, RFC 0038)`,
605
+ value: v,
606
+ });
607
+ }
374
608
  // ─── §4.23 Mixed-use components (RFC 0019) ───────────────────────────────────
375
609
  // The eight income classes admissible as a mixed-use component. `land` is
376
610
  // excluded (its NOI model nets negative) and `mixed_use` cannot nest in itself.
@@ -381,13 +615,18 @@ const ADMISSIBLE_COMPONENT_CLASSES = new Set([
381
615
  // Validates the `components` section: the CC-11 asset-class gate, the CC-12
382
616
  // footing check (property NOI == Σ component NOI), and the section-internal
383
617
  // MU-* rules. A no-op for any document without a components section.
384
- function checkComponents(parsed, issues) {
618
+ function checkComponents(parsed, issues, ledger) {
385
619
  const components = getSection(parsed, 'components');
386
- if (!components)
620
+ if (!components) {
621
+ markSkipped(ledger, 'CC-11', 'not_applicable', 'no components section');
622
+ markSkipped(ledger, 'CC-12', 'not_applicable', 'no components section');
387
623
  return;
624
+ }
388
625
  const assetClass = parsed.frontmatter.asset_class;
389
626
  // CC-11: a components section is only valid under asset_class mixed_use.
627
+ markEvaluated(ledger, 'CC-11');
390
628
  if (assetClass !== 'mixed_use') {
629
+ markSkipped(ledger, 'CC-12', 'not_applicable', 'CC-11 fired');
391
630
  issues.push({
392
631
  code: 'CC-11', severity: 'error', section: 'components', field: 'asset_class',
393
632
  message: `CC-11: a components section is only valid when asset_class is mixed_use (found "${String(assetClass)}")`,
@@ -462,6 +701,8 @@ function checkComponents(parsed, issues) {
462
701
  // when every admissible component states a numeric NOI — a missing one is
463
702
  // MU-04's job, not a footing mismatch.
464
703
  const noiModel = getSection(parsed, 'noi_model');
704
+ if (!noiModel)
705
+ markSkipped(ledger, 'CC-12', 'section_absent', 'noi_model');
465
706
  if (noiModel) {
466
707
  const propNOI = deepGet(noiModel.content, 'net_operating_income');
467
708
  const compNOIs = admissible
@@ -469,7 +710,12 @@ function checkComponents(parsed, issues) {
469
710
  ? raw['net_operating_income']
470
711
  : undefined)
471
712
  .filter((v) => typeof v === 'number');
713
+ if (propNOI == null)
714
+ markSkipped(ledger, 'CC-12', 'field_absent', 'noi_model.net_operating_income');
715
+ else if (admissible.length === 0 || compNOIs.length !== admissible.length)
716
+ markSkipped(ledger, 'CC-12', 'field_absent', 'components[*].net_operating_income');
472
717
  if (propNOI != null && admissible.length > 0 && compNOIs.length === admissible.length) {
718
+ markEvaluated(ledger, 'CC-12');
473
719
  const sum = compNOIs.reduce((a, b) => a + b, 0);
474
720
  if (Math.abs(sum - propNOI) > 1) {
475
721
  issues.push({
@@ -590,36 +836,49 @@ function checkStackContent(content, section, prefix, issues) {
590
836
  // hard gate has INCOMPLETE_DATA_POLICIES. The five applicability
591
837
  // preconditions are normative (§5.3): each guards against the rule judging a
592
838
  // document whose missing size is some *other* rule's diagnosis.
593
- function checkSizeIntensive(parsed, issues) {
839
+ function checkSizeIntensive(parsed, issues, ledger) {
594
840
  // 1. UWX record, not a compiled UW Lite summary — Lite states size in its
595
841
  // own grammar, and its compiled envelope carries the x_uw_lite_source
596
842
  // extension (surfaced under `extensions` by fromUWEnvelope, under
597
843
  // `sections` by a re-parse of the serialized UWX).
598
- if (parsed.sections[UW_LITE_SOURCE_EXTENSION] || parsed.extensions?.[UW_LITE_SOURCE_EXTENSION])
844
+ if (parsed.sections[UW_LITE_SOURCE_EXTENSION] || parsed.extensions?.[UW_LITE_SOURCE_EXTENSION]) {
845
+ markSkipped(ledger, 'CC-13', 'not_applicable', 'compiled UW Lite summary');
599
846
  return;
847
+ }
600
848
  // 2. Deal-record profile only. An absent profile is the plain underwriting
601
849
  // record; any other declared profile has no property section by design.
602
850
  const profile = parsed.frontmatter['document_profile'];
603
- if (profile != null && profile !== DEAL_UNDERWRITING_PROFILE)
851
+ if (profile != null && profile !== DEAL_UNDERWRITING_PROFILE) {
852
+ markSkipped(ledger, 'CC-13', 'not_applicable', `document_profile ${String(profile)}`);
604
853
  return;
854
+ }
605
855
  // 3. Recognized class with a primary size field (not mixed_use, §XIII.2;
606
856
  // not an unrecognized class, §XIII.3).
607
857
  const assetClass = parsed.frontmatter.asset_class;
608
- if (typeof assetClass !== 'string')
858
+ if (typeof assetClass !== 'string') {
859
+ markSkipped(ledger, 'CC-13', 'not_applicable', 'no asset_class');
609
860
  return;
861
+ }
610
862
  const intensive = getSizeIntensive(assetClass);
611
- if (!intensive)
863
+ if (!intensive) {
864
+ markSkipped(ledger, 'CC-13', 'not_applicable', `${assetClass} has no primary size field`);
612
865
  return;
866
+ }
613
867
  // 4. A property section exists — a missing section is a different defect
614
868
  // with a different remedy (RFC 0027, unresolved question 5).
615
869
  const property = getSection(parsed, 'property') ?? getSectionVariant(parsed, 'property', 'default');
616
- if (!property)
870
+ if (!property) {
871
+ markSkipped(ledger, 'CC-13', 'section_absent', 'property');
617
872
  return;
873
+ }
618
874
  // 5. Not externalized (RFC 0021) — the directive is not the section.
619
875
  // Presence of the key is the whole test, as in the Lite projection.
620
876
  if (EXTERNAL_ANNOTATION_KEY in property.annotation ||
621
- EXTERNAL_ANNOTATION_KEY in property.content)
877
+ EXTERNAL_ANNOTATION_KEY in property.content) {
878
+ markSkipped(ledger, 'CC-13', 'not_applicable', 'property is externalized');
622
879
  return;
880
+ }
881
+ markEvaluated(ledger, 'CC-13');
623
882
  // §VIII.2's unwrap rule, exactly as the calc evaluator applies it: a block
624
883
  // storing the envelope shape keeps its payload one level down at `content`.
625
884
  // CC-13 judges the payload the pack divides by — never the wrapper.
@@ -649,7 +908,7 @@ function checkSizeIntensive(parsed, issues) {
649
908
  // same scan shows deal_stage declarations state where a deal is going, not
650
909
  // what the file contains (all twelve worked examples fail their declared
651
910
  // stage's list); info reports the gap without refusing or nagging.
652
- function checkSectionReadiness(parsed, issues) {
911
+ function checkSectionReadiness(parsed, issues, ledger) {
653
912
  // CC-14 preconditions mirror CC-13's 1 and 2 (RFC 0028 §1). Precondition 3
654
913
  // (not externalized) is satisfied structurally: an externalized-but-
655
914
  // unresolved section still parses as a block, so it is present here.
@@ -657,6 +916,12 @@ function checkSectionReadiness(parsed, issues) {
657
916
  const profile = parsed.frontmatter['document_profile'];
658
917
  const isDealRecord = profile == null || profile === DEAL_UNDERWRITING_PROFILE;
659
918
  const hasProperty = hasStageSection(parsed, 'property');
919
+ if (isCompiledLite)
920
+ markSkipped(ledger, 'CC-14', 'not_applicable', 'compiled UW Lite summary');
921
+ else if (!isDealRecord)
922
+ markSkipped(ledger, 'CC-14', 'not_applicable', 'not a deal record');
923
+ else
924
+ markEvaluated(ledger, 'CC-14');
660
925
  let cc14Fired = false;
661
926
  if (!isCompiledLite && isDealRecord && !hasProperty) {
662
927
  cc14Fired = true;
@@ -765,10 +1030,12 @@ function checkLeaseUpContent(content, variant, issues) {
765
1030
  }
766
1031
  }
767
1032
  }
768
- function checkLeaseUpSchedule(parsed, issues) {
1033
+ function checkLeaseUpSchedule(parsed, issues, ledger) {
769
1034
  const entry = parsed.sections['lease_up_schedule'];
770
- if (!entry)
1035
+ if (!entry) {
1036
+ markSkipped(ledger, 'CC-15', 'not_applicable', 'no lease_up_schedule');
771
1037
  return;
1038
+ }
772
1039
  const variants = isVariantMap(entry)
773
1040
  ? Object.entries(entry)
774
1041
  : [['default', entry]];
@@ -791,15 +1058,29 @@ function checkLeaseUpSchedule(parsed, issues) {
791
1058
  // within LEASE_UP_STABILIZED_TOLERANCE. Tolerance-checked, not exact: the
792
1059
  // trajectory endpoint and the stabilized-year projection are two different
793
1060
  // models of stabilization (RFC 0008).
1061
+ // CC-15 reads the base variant ONLY (RFC 0008: non-base variants are
1062
+ // exempt by design — a downside is supposed to disagree). So the §5.3
1063
+ // lone-variant fallback does not apply here, and a map with no base or
1064
+ // default is `not_applicable`, not unresolvable: nothing was silenced.
794
1065
  const base = isVariantMap(entry)
795
1066
  ? (entry['base'] ?? entry['default'])
796
1067
  : entry;
1068
+ if (!base)
1069
+ markSkipped(ledger, 'CC-15', 'not_applicable', 'no base variant');
797
1070
  const stabilizedNoi = base
798
1071
  ? deepGet(base.content, 'stabilized_summary.annualized_noi')
799
1072
  : undefined;
800
- const modelNoi = deepGet(getSection(parsed, 'noi_model')?.content, 'net_operating_income');
1073
+ const noiModel = getSection(parsed, 'noi_model');
1074
+ const modelNoi = deepGet(noiModel?.content, 'net_operating_income');
1075
+ if (base && !noiModel)
1076
+ markSkipped(ledger, 'CC-15', 'section_absent', 'noi_model');
1077
+ else if (base && !(typeof stabilizedNoi === 'number' && Number.isFinite(stabilizedNoi)))
1078
+ markSkipped(ledger, 'CC-15', 'field_absent', 'lease_up_schedule.stabilized_summary.annualized_noi');
1079
+ else if (base && !(typeof modelNoi === 'number' && Number.isFinite(modelNoi) && modelNoi !== 0))
1080
+ markSkipped(ledger, 'CC-15', 'field_absent', 'noi_model.net_operating_income');
801
1081
  if (typeof stabilizedNoi === 'number' && Number.isFinite(stabilizedNoi) &&
802
1082
  typeof modelNoi === 'number' && Number.isFinite(modelNoi) && modelNoi !== 0) {
1083
+ markEvaluated(ledger, 'CC-15');
803
1084
  const drift = Math.abs(stabilizedNoi - modelNoi) / Math.abs(modelNoi);
804
1085
  if (drift > LEASE_UP_STABILIZED_TOLERANCE) {
805
1086
  issues.push({
@@ -905,12 +1186,13 @@ function checkCashFlowSeries(parsed, issues) {
905
1186
  checkCashFlowContent(block.content, variant, issues);
906
1187
  }
907
1188
  }
908
- // ─── Distribution waterfall (RFC 0035, §4.27) ────────────────────────────────
1189
+ // ─── Distribution waterfall (RFC 0035 + RFC 0036, §4.27) ─────────────────────
909
1190
  //
910
1191
  // Structural rules only, the standing split: the ladder grammar, the cash
911
1192
  // reference, and the capital precondition are validation; the allocation
912
1193
  // arithmetic belongs to `verifyWaterfall` (waterfall.ts). Multi-variant.
913
1194
  const RATIO_QUANTUM = 1e-4;
1195
+ const RATE_QUANTUM = 1e-6;
914
1196
  function sumsToOne(a, b) {
915
1197
  return typeof a === 'number' && typeof b === 'number' &&
916
1198
  Number.isFinite(a) && Number.isFinite(b) &&
@@ -937,6 +1219,8 @@ function checkWaterfallContent(content, variant, parsed, issues) {
937
1219
  const ORDER = { return_of_capital: 0, preferred_return: 1, catch_up: 2, split: 3 };
938
1220
  let prevRank = -1;
939
1221
  let splitCount = 0;
1222
+ let prevEm = null;
1223
+ let prevIrr = null;
940
1224
  const seenSingleton = new Set();
941
1225
  rows.forEach((tier, i) => {
942
1226
  const type = tier && typeof tier === 'object' ? tier.type : undefined;
@@ -982,19 +1266,51 @@ function checkWaterfallContent(content, variant, parsed, issues) {
982
1266
  if (!sumsToOne(t.lp_share, t.gp_share)) {
983
1267
  wf01(`tiers[${i}]`, 'split lp_share and gp_share must be fractions in [0,1] summing to 1.0');
984
1268
  }
985
- if (t.until_lp_irr !== undefined) {
986
- wf01(`tiers[${i}].until_lp_irr`, 'until_lp_irr is reserved for a future RFC and refused (RFC 0035 §C)');
987
- }
1269
+ // Hurdles (RFC 0035 `until_lp_em`, RFC 0036 `until_lp_irr`): each in
1270
+ // range, a capped split paying the LP, the terminal split uncapped by
1271
+ // either field, and each kind of hurdle strictly increasing down the
1272
+ // ladder (a later tier hurdled at or below an earlier one has
1273
+ // capacity zero by construction and would pay nothing, silently).
1274
+ let emOk = false;
1275
+ let irrOk = false;
988
1276
  if (t.until_lp_em != null) {
989
- if (!(typeof t.until_lp_em === 'number' && t.until_lp_em > 0)) {
1277
+ if (!(typeof t.until_lp_em === 'number' && Number.isFinite(t.until_lp_em) && t.until_lp_em > 0)) {
990
1278
  wf01(`tiers[${i}].until_lp_em`, 'until_lp_em must be a positive equity multiple');
991
1279
  }
992
- else if (!(typeof t.lp_share === 'number' && t.lp_share > 0)) {
1280
+ else {
1281
+ emOk = true;
1282
+ }
1283
+ }
1284
+ if (t.until_lp_irr != null) {
1285
+ if (!(typeof t.until_lp_irr === 'number' && t.until_lp_irr > 0 && t.until_lp_irr < 1)) {
1286
+ wf01(`tiers[${i}].until_lp_irr`, 'until_lp_irr must be a fraction in (0, 1) — an IRR hurdle rate, 0.12 not 12');
1287
+ }
1288
+ else {
1289
+ irrOk = true;
1290
+ }
1291
+ }
1292
+ if (t.until_lp_em != null || t.until_lp_irr != null) {
1293
+ if ((emOk || irrOk) && !(typeof t.lp_share === 'number' && t.lp_share > 0)) {
993
1294
  wf01(`tiers[${i}].lp_share`, 'a capped split must pay the LP (lp_share > 0) or the cap can never bind');
994
1295
  }
995
1296
  if (i === rows.length - 1) {
996
- wf01(`tiers[${i}].until_lp_em`, 'the final split must be uncapped capped ladders need a terminal residual tier');
1297
+ const field = t.until_lp_irr != null && t.until_lp_em == null ? 'until_lp_irr' : 'until_lp_em';
1298
+ wf01(`tiers[${i}].${field}`, 'the final split must be uncapped — capped ladders need a terminal residual tier');
1299
+ }
1300
+ }
1301
+ if (emOk) {
1302
+ const em = t.until_lp_em;
1303
+ if (prevEm !== null && !(em > prevEm + RATIO_QUANTUM)) {
1304
+ wf01(`tiers[${i}].until_lp_em`, `until_lp_em (${em}) must exceed the earlier tier's hurdle (${prevEm}) — a later tier hurdled at or below an earlier one can never pay`);
1305
+ }
1306
+ prevEm = prevEm === null ? em : Math.max(prevEm, em);
1307
+ }
1308
+ if (irrOk) {
1309
+ const irr = t.until_lp_irr;
1310
+ if (prevIrr !== null && !(irr > prevIrr + RATE_QUANTUM)) {
1311
+ wf01(`tiers[${i}].until_lp_irr`, `until_lp_irr (${irr}) must exceed the earlier tier's hurdle (${prevIrr}) — a later tier hurdled at or below an earlier one can never pay`);
997
1312
  }
1313
+ prevIrr = prevIrr === null ? irr : Math.max(prevIrr, irr);
998
1314
  }
999
1315
  }
1000
1316
  });