fix(api): reject out-of-range assumptions where they enter, not two calls later (graam-harmony #4476) - #61
Conversation
…alls later (graam-harmony #4476) A user typed 1000 into the Prepays (CPR) field on the deal Scenarios screen. /api/calccollateral accepted it with no validation of any kind; CfCore.BuildAssumptionArray de-annualized it as Pow(-9, 1/12) = NaN; the amortizer's Math.Clamp(smm, 0, 1) could not clamp it (every comparison against NaN is false) and `if (balance < 1) break` never fired, so every period came back NaN; and because Program.cs enables AllowNamedFloatingPointLiterals, the endpoint returned 200 OK with NaN in the body. The mistake only surfaced one service call later, as a non-finite-cashflow rejection from /api/waterfall that named the symptom, came from the wrong endpoint, and never mentioned the input the user actually got wrong. - AssumptionValidation rejects non-finite and out-of-[0,100] values at the boundary, across deal-level scalars, their vector forms and every per-asset override, naming the field path, the value, the bound, the unit convention and the likely mistake. Vector messages name the index; per-asset messages name the asset key. Unit wording follows the declared prepaymentType / defaultType, because cpr under SMM is a monthly hazard, not an annual CPR. - Unknown prepaymentType / defaultType strings are now rejected rather than silently modelled as CPR / CDR. A census of graam-harmony and graam-web found every value actually sent is CPR/ABS/SMM and CDR/MDR/ORIGMDR, or the field omitted, so no live caller changes behaviour. - MathUtil.AnnualPercentToMonthlyHazard is the one canonical de-annualization and saturates at 0 and 1 instead of manufacturing NaN. The in-range expression is byte-for-byte unchanged and pinned by a bit-identity test, so no tie-out number moves. NaN is deliberately not special-cased: the boundary validator rejects it, so an internal NaN is an engine bug and stays loud. - The /api/waterfall non-finite message stays as a backstop but now names the class of cause, so a reader who lands on it is pointed upstream. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
End-to-end verification against two live enginesI built this branch and ran it on port 5201, alongside the unpatched production engine already running on 5200, and put the same request to both. The fat-fingered request (
The old engine's answer is the whole defect in one line: 200 OK, 279 rows, every one of them NaN, handed to the caller as if it were data. What this branch returns instead: The no-regression check that mattered most. Same request with Not "close enough" — every field of every row compares equal. That is the constraint about keeping The downstream backstop message, feeding the old engine's NaN rows to each engine's
Through the harmony client, pointed at 5201, which is the path a real user's click takes: That string is what reaches the deal screen once GraamOrg/graam-harmony#4477 lands, so the wording in this PR is what an end user literally reads. |
Tracking issue: graam-harmony #4476 — full causal chain and the DelinqAdvPct finding are there.
The defect
A user typed
1000into the "Prepays" (CPR) field on the deal Scenarios screen. What happened:POST /api/calccollateralacceptedassumptions.cpr = 1000. There was not a single[Range],IValidatableObjector ModelState check anywhere insrc/.CfCore.BuildAssumptionArrayde-annualized it as1 - Pow(1 - 1000/100, 1/12)=Pow(-9, 1/12)= NaN.Amortizer.cs"clamps" withMath.Clamp(smm, 0, 1)— a complete no-op for NaN, since both comparisons are false.if (balance < 1) breakis NaN-blind too, so every period was emitted as NaN, not just one.Program.cssetsJsonNumberHandling.AllowNamedFloatingPointLiterals, so the endpoint returned 200 OK withNaNliterals in the body.POST /api/waterfall, which finally 400'd with:Collateral cashflow at index 0 (2026-08-25) has a non-finite UnscheduledPrincipal value.The error named the symptom, came from the wrong endpoint, two service calls downstream, and never mentioned the input the user actually got wrong.
This is verified, not inferred: reverting
src/on this branch and re-running the new tests reproduces it — thecpr = 1000case returnsOkObjectResult, notBadRequestObjectResult.What happens now
1. Boundary validation —
src/GraamFlows.Api/Validation/AssumptionValidation.csPublic static, returns
string?(null = valid), mirroring theWaterfallController.ValidateRequestconvention. Wired intoCalcCollateralController.Calculatebefore thetry. The response shape stays exactly{ error = "<string>" }— a downstream Python client parses that key.Every value must be finite and within
[0, 100], across:cpr,cdr,severity,delinquency,advancing;assumptions.cdrVector[3] = 4000 is out of range…);assetAssumptionsentry — the message names the asset key (assetAssumptions['LOAN-000042'].cdr = 900…), which over a loan-level tape of thousands of rows is the only thing that makes it findable.Unit wording follows the declared type, because the same field means different things:
prepaymentType: CPRprepaymentType: SMMprepaymentType: ABSdefaultType: MDRdefaultType: ORIGMDRNon-finite gets its own wording:
assumptions.cpr must be a finite number; got NaN.2. Unknown type strings are now rejected — census first
ParsePrepaymentTypereturns CPR for any unrecognized string, so"PSA"or"PercentCPR"was silently mis-modelled as CPR — a wrong answer with no error at all. Rejecting is a behaviour change, so it was gated on a census of both consumer repos:prepaymentType/defaultType/calccollateral. The web app never sends either field; it goes through harmony.sss_to_assumptions.py:68is a{"CPR","ABS","SMM"}dict lookup defaulting to"CPR";:115/:129emit onlyORIGMDR/MDR/CDR.cashflow_analyzer.py:196andwal_validator.py:1116are"CPR" if … else "ABS";wal_validator.py:1485is a literal"CPR".project.py:1964-65sends"SMM"/"MDR".project.py:1471and:1921set the key only when the value is exactly"ABS", so the agent-suppliedparams.get("prepayment_type")at:1627/:2164cannot leak an arbitrary string onto the wire.client.py:221-224is a pass-through, but every in-repo caller feeding it is in the list above.Every observed value is in
{CPR, ABS, SMM}/{CDR, MDR, ORIGMDR}or the field is omitted, so the rejection was added. No live caller changes behaviour; case-insensitive matching and the omitted default are covered by tests.Worth a follow-up, not fixed here:
PrepaymentTypeEnumdeclaresPercentCPRandPSAmembers thatParsePrepaymentTypehas no path to, so they are unreachable from the API today.3. The NaN factory is gone —
MathUtil.AnnualPercentToMonthlyHazardOne canonical de-annualization: saturates to
1.0at/above 100 (matching today's exact-100 result, sincePow(0, 1/12) == 0) and to0.0at/below 0. NaN is deliberately not special-cased — the boundary validator rejects it, so a NaN reaching the engine is an engine bug and should stay loud rather than be silently zeroed into a plausible-looking cashflow. That choice is documented in the XML doc.No tie-out number moves. The in-range expression is literally
1.0 - Math.Pow(1.0 - annualPercent / 100.0, 1.0 / 12.0);/ 100.0was not rewritten as* .01. A test asserts bit identity (BitConverter.DoubleToInt64Bits) against the original inline expression across 0, 0.5, 6, 25, 99.9, 100, so a future algebraic tidy-up fails loudly instead of silently repricing every deal. The only inputs whose behaviour changes are ones the old expression could not evaluate meaningfully: above 100 (was NaN) and below 0 (was a negative hazard the amortizer already clamped to 0).CfCorenow calls it. Note thatBuildAssumptionArrayandBuildReinvestAssumptionMatricesare not two copies of the expression — the reinvestment cohort path callsBuildAssumptionArray, so both de-annualization sites are fixed by the single edit.ConvertToSmmwas measured, and left alone. It usescpr * .01where the helper uses/ 100.0. Over a 15,000,001-point sweep of[0, 100](a 10M-point grid plus 5M random draws) 603,710 values disagreed, the widest by 115,223 ulps just below 100 where1 - x/100cancels hardest. So it is not bit-identical and deduping it would move numbers. Both functions now carry a comment explaining that they differ by rounding and that onlyAnnualPercentToMonthlyHazardis on the engine's assumption path. (A 12-point spread of round numbers showed zero disagreement — the wide sweep is what caught it.)4. The downstream message points upstream
WaterfallController.ValidateRequeststill fires as a backstop, but now adds: "Non-finite collateral almost always means an out-of-range assumption produced it upstream — check that CPR, CDR and severity are percentages between 0 and 100 (6, not 1000) and re-run the collateral projection."Companion PRs
This fix spans three repos:
AssumptionValidationare read verbatim by users on the deal screen.cpr=1000before it is ever sent.Tests
dotnet test: 217 passed / 0 failed before → 271 passed / 0 failed after (+54, no regressions).Reverting
src/and re-running the new tests turns 23 of the 54 red, including the headlinecpr = 1000case, which fails with: "Expected type to be BadRequestObjectResult … but found OkObjectResult." The other 31 are the must-not-regress guards (boundary values0and100accepted, an ordinary request still 200s with non-empty cashflows, every recognized type string in any casing accepted, in-range bit identity) and pass either way by design.Unit/Api/AssumptionRangeValidationTests.cs— pins that each message names the field, the value and the bound, not merely that it is non-empty; vector index; per-asset key; NaN/±Infinity; unit wording per declared type; unknown-type rejection.Unit/AssetCashflowEngine/NonFiniteHazardTests.cs— engine-level regression: a small pool run straight throughCfCore/Amortizerwith rates just above 100 must emit only finiteUnscheduledPrincipal/DefaultedPrincipal/Balance. This is theMath.Clamp(NaN)no-op guarded, plus a check that in-range rates are untouched.Unit/Util/MonthlyHazardConversionTests.cs— the bit-identity tie-out guard and the saturation/NaN-propagation contract.Separate finding — reported, deliberately NOT fixed here
delAdvIntTime/delAdvPrinTimeare built withBuildAssumptionArray(..., convertToMonthly: false, divisor: 1.0, defaultValue: 100.0)— divisor 1.0, wheresevTimeanddelTimenext to them use 100.0.AssetAssumptionsalso defaultsDelinqAdvPctInt = new ConstVector(100). So the value stays in percent whileAmortizer.cs:407-411consumes it as a fraction (1 - delAdvInt), giving1 - 100 = -99.Confirmed empirically on a $1,000,000 6% FRM, period 0:
(1 − 0) = 1is the only correct caseunadvInterest = interest × del × (1 − delAdvInt)=5000 × 0.10 × (1 − 100)=−49,500, theninterest -= unadvInterestinflates interest above what is owed. Negative unadvanced principal follows the same way. It is masked whenever DQ is 0 — which is what most harmony paths send (cashflow_analyzer,wal_validatorboth passdelinquency: 0.0), which is why it has not surfaced.Not fixed in this PR: it changes numbers for every DQ>0 run and needs its own measurement against the reference oracle.
🤖 Generated with Claude Code