Status: implemented
Implemented — this change has shipped.
RFC 0024: Pin the iterative solvers so two engines agree on a root
Summary
RFC 0023 made a calculation's reported value reproducible by quantizing it at one boundary. That fixed how a number is rounded, not how it is found. Every closed-form builtin now agrees across engines because the arithmetic is the same; irr() does not, because it is a root search, and its answer is a function of the search — the seed, the order of methods, the bracket, the stopping rule — none of which the protocol pins. This RFC makes irr()'s algorithm normative, so that two conforming hosts given identical cash flows return the identical binary64 root, and therefore the identical receipt digest. xirr() and day-count conventions are explicitly out of scope.
Motivation
§VIII.3 currently carries a convergence note describing the reference implementation's bracket, method order, and iteration cap, and states plainly that these are "documented, not yet normative." Three things are wrong with leaving it there, and the first two are worse than the note admits.
1. The documented bracket does not bind the answer. The note says the implementation "brackets the root on [-0.999, 10.0] … refines with Newton's method, falls back to bisection." calc/builtins.ts does the reverse: Newton first, from a seed of 0.1, capped at 100 iterations; the bracket and its 200-iteration bisection are the fallback. Newton is free to converge outside the bracket, and does:
irr(-1, 20) → 18.999999999994728That is a 1900% return, returned by an engine whose specification says it searches up to 1000%. An implementer who reads §VIII.3 and brackets first — the obvious reading — computes npv(-0.999) and npv(10) with the same sign, fails to bracket, and raises CALC-IRR-DIVERGE. Same input, same spec, one engine returns a number and the other an error. The note is also simply inaccurate as a description of the reference implementation, which this RFC corrects as errata (see Errata).
2. Where roots are not unique, the seed picks the answer. A cash flow with more than one sign change can have more than one real root, and both are correct:
irr(-100, 230, -132) → 0.10.10 and 0.20 both zero this NPV. The engine returns 0.10 because the seed is 0.1 — the answer is an artifact of the starting point, not of the cash flows. An engine seeded at 0.15, or one that bisects, returns 0.20. Nothing in the protocol makes either wrong.
3. Quantization hides this rather than resolving it. §VIII.5 rounds 0.10 and 0.20 to six decimals independently and faithfully. The receipt then records a results_digest over the quantized values, so two engines produce two clean, well-formed, mutually contradictory receipts, and verifyReceipt reports RCP-04 — a corrupted record — for a document that is not corrupt. This is the same failure mode RFC 0023 removed for closed-form calculations, still open for the one builtin that searches.
The exposure is small in practice and sharp in principle: irr appears in the multifamily and every other asset-class pack, so it reaches any deal with a DCF. Conventional cash flows (one sign change) have a unique root and are unaffected by problem 2 — but problem 1 needs no unusual cash flow at all, only a return above 1000%, which a short-hold or highly-levered pro forma produces.
Proposed change
Replace the non-normative note in §VIII.3 with a normative algorithm.
Spec: §VIII.3, irr convergence
irrconvergence (normative).irr(...flows)MUST return the root computed by the following procedure, which is defined so that any implementation using IEEE 754binary64arithmetic in the stated order produces bit-identical results.Let
npv(r) = Σ flows[t] / (1 + r)^tfort = 0 … n-1.
- Domain. The search interval is
lo = -0.999,hi = 10.0. A root outside it is not reported; see step 5.- Bracket. Evaluate
npvatloandhi. If either is non-finite, or ifnpv(lo) * npv(hi) > 0, the procedure fails — see step 5.- Bisection. Bisect for exactly 200 iterations or until
|npv(mid)| < 1e-9or(hi - lo) / 2 < 1e-12, whichever comes first, withmid = (lo + hi) / 2evaluated inbinary64. Retain the half whose endpoints bracket the sign change, comparingnpv(lo) * npv(mid) < 0.- No polish. The bisection result is the answer. An implementation MUST NOT refine it with Newton's method or any other step: Newton's iterates depend on a derivative evaluation order that this document does not pin, and the refinement it buys is below the quantization boundary of §VIII.5.
- Failure. If step 2 fails to bracket, or step 3 exhausts its iterations without meeting a stopping condition, raise
CALC-IRR-DIVERGE. An engine MUST NOT substitute a root found outside[lo, hi].Where
npvhas more than one root in[lo, hi], this procedure selects one of them deterministically as a consequence of the fixed interval and fixed iteration order. Which one is not otherwise specified, and callers MUST NOT depend on it having financial meaning: a cash flow with multiple sign changes has no single internal rate of return, and a host SHOULD surface that as a modeling problem rather than a number.
Superseded on implementation. The paragraph directly above is wrong, and the shipped §VIII.3 says the opposite: an even number of roots in
[lo, hi]meansnpvshares a sign at both endpoints, so step 2 finds no bracket and the procedure raises rather than selecting one. The shipped text also adds an endpoint-root step between 2 and 3. See Errata;spec/UW_PROTOCOL_v1.md§VIII.3 is authoritative.
Bisection alone is bit-reproducible in a way Newton is not. Its iterates are (lo + hi) / 2 and comparisons of products — every step a binary64 operation IEEE 754 requires to be correctly rounded, in an order the text fixes. Newton requires evaluating a derivative sum whose association order is not pinned, and each iterate feeds the next, so a last-ULP difference in the derivative moves the returned root by more than the tolerance.
Library
calc/builtins.ts — irr is rewritten to the procedure above; the Newton block is deleted, not reordered. Additive to the exported surface: nothing new. The returned value changes for the inputs described under Compatibility.
Compatibility analysis
This is a breaking change to irr, deliberately. The alternative is a specification that says one thing and an implementation that does another.
- Existing
.uw.mdfiles — remain valid. Nothing about parsing or the document model changes. A file whose DCF implies an IRR above 1000% (or below -99.9%) computes today and raisesCALC-IRR-DIVERGEafter. That is the intended correction: the engine was answering outside the domain it claims. - Tier-1 Reader / Tier-2 Editor — unaffected; neither evaluates.
- Tier-3 Calc Host — a host matching the current reference implementation becomes non-conforming. Two changes are needed: bracket before searching, and do not polish. Both are deletions.
- Tier-4 Agent Host — unaffected; agents never compute.
- Modules — no manifest change. A module declaring an
irrcalc gets the new behavior automatically. - Receipts — a receipt issued before this change, over a document whose IRR moves, verifies as
unverifiablethrough the existingRCP-07engine-version rule rather thanfailed, exactly as RFC 0023's receipts did. This is the second consecutive release to lean on that rule, which is an argument for shipping both in one protocol version rather than two.
Deprecation path. None is proposed, and the reason is worth stating: the behavior being removed is a value returned from outside the stated domain. There is no correct code depending on it — code that reads irr(-1, 20) as 19.0 is relying on the spec being wrong. A warning period would preserve the divergence between engines for the length of the warning, which is the thing this RFC exists to close.
Protocol version: 1.4.0. Under VERSIONS.md rule 2 this is a strengthening of requirements — text that was explicitly non-normative becomes MUST — which is a minor bump, not a major, even though a previously-conforming host has work to do. Rule 2 already contemplates this: "New required behavior in a 1.x protocol is opt-in for 1.0 tools and becomes normative at the next major."
Conformance impact
Existing fixtures. No fixture currently exercises an out-of-bracket or multi-root IRR, so none needs updating. Worth stating as a finding in its own right: the corpus proved the implementation self-consistent, not correct against its specification. Fixtures that compute an IRR on conventional cash flows (conformance/calc/** and the Tier-3 pack suites) are unaffected — verified by the property that a single-sign-change cash flow has a unique root, which bisection and Newton both find to within the §VIII.5 quantum.
New fixtures.
| Path | Asserts |
|---|---|
tier-3-calc-host/fixtures/irr-01-out-of-bracket | irr(-1, 20) raises CALC-IRR-DIVERGE, not 19.0 |
tier-3-calc-host/fixtures/irr-02-even-root-count | irr(-100, 230, -132) returns the bisection root, pinned exactly — superseded, see Errata: it raises |
tier-3-calc-host/fixtures/irr-03-conventional | A normal DCF's IRR is unchanged from the pre-RFC value |
tier-3-calc-host/fixtures/irr-04-endpoint-root | Roots at exactly -0.999 and 10.0 are found, not rejected — superseded: only 10.0 is well defined |
tier-3-calc-host/fixtures/irr-05-degenerate | All-positive and all-negative flows raise, rather than returning a bracket endpoint |
Fixture 03 is the load-bearing one: it is what proves this RFC does not move the numbers on real deals.
Reference implementation
- Files affected:
packages/uwmd-core/src/calc/builtins.ts(theirrbody),spec/UW_PROTOCOL_v1.md§VIII.3,packages/uwmd-core/src/protocol.ts(PROTOCOL_VERSION→1.4.0),VERSIONS.md,CHANGELOG.md. - API surface: unchanged. No new exports;
irrkeeps its signature and its error code. - Test plan:
- A table test over the five fixture cases above, in
calc/calc.test.ts. - A property test (
calc.property.test.ts) asserting that for any generated cash flow with exactly one sign change and a root inside the bracket,|npv(irr(flows))| < 1e-9— that the returned root is a root, not merely a reproducible number. - A regression test pinning
irr(-1, 20)toCALC-IRR-DIVERGE, named for this RFC so the reason survives. - Excel parity:
IRR()in Excel takes aguessand searches differently, so the pack's Excel emit path needs checking against fixture 02 — parity may have to be documented as approximate for multi-root inputs rather than asserted as exact. This is the one open implementation risk.
- A table test over the five fixture cases above, in
Alternatives considered
Require agreement only after quantization. Instead of pinning the algorithm, require that two engines agree to §VIII.5's decimal places. Weaker and cheaper: any reasonable solver meets it for conventional flows. It fails exactly where the problem is — 0.10 and 0.20 differ far above the sixth decimal, and an out-of-bracket root differs by an order of magnitude. It would let the spec claim interoperability it does not have.
Pin Newton with a specified seed and evaluation order. Keeps current behavior for the multi-root case and is faster. Rejected because pinning Newton means pinning the association order of the derivative sum, the non-finite guards, and the divergence conditions, in enough detail that an independent implementer could reproduce the iterate sequence exactly. That specification is longer than the bisection one and harder to verify, for a convergence speed advantage that is irrelevant at these input sizes.
Widen the bracket instead of enforcing it. Make hi large enough (say 100.0) that irr(-1, 20) stays inside, so nothing breaks. Rejected: it relocates the cliff instead of removing it, and a 10,000% IRR reported without comment is a worse outcome than an error that says the model is off.
Return all roots, or a root set. Honest about the mathematics — a multiple-sign-change cash flow genuinely has several IRRs — but it changes irr's return type from number | null to a collection, which the value model in §VIII.1 does not have, and every consumer of the result would need to choose one anyway. Better addressed by a host-level validation that flags multi-sign cash flows, which is a separate proposal.
Do nothing until v2. The status quo. The cost is that every receipt over a document with an out-of-bracket IRR is a receipt whose digest is engine-specific while claiming to be canonical.
Unresolved questions
Resolved at acceptance (2026-08-15). The two questions this section named as blocking were checked against the tree rather than reasoned about, and neither survived. See Findings at acceptance.
- Excel parity for multi-root inputs. Excel's
IRRtakes aguessand will return a different root than pinned bisection. Invariant 4 (Excel↔calc-engine parity is exact) may need a documented exception forirron non-conventional cash flows, or the emit path may need to write a literal rather than a formula. Resolving this may change the shape of the proposal and is the main reason this RFC isdraftrather thanactive. Not reachable today — no built-in pack declares anirrmetric, so the parity invariant, which is asserted over pack metrics, never exercises the ExcelIRRmapping. The question becomes live the first time a pack usesirr, and belongs to that change. - Should a multi-sign-change cash flow warn? The spec text above says a host
SHOULDsurface it as a modeling problem, without saying how. A validation code (CALC-IRR-AMBIGUOUS, or a validator finding) would make it actionable. Deferred, because it is additive and does not block pinning the solver. nper()is also iterative in some formulations. The reference implementation uses the closed-form logarithm, so it is not affected — but the spec does not say it must, and an implementer who solves it numerically has the same class of problem. Worth an audit of every builtin for hidden iteration before this RFC is accepted. Audit done at acceptance — clean.irris the only builtin that converges. The point stands as a spec gap rather than an implementation one: nothing requiresnperto be closed-form, so an implementer who solves it numerically inherits this problem. Folded into the implementation of this RFC as a one-line normative note, not a blocker.
Findings at acceptance
Three checks were run against the tree before accepting. All three make the change smaller than the draft assumed; none argues against making it.
Iteration audit — clean. Every loop in
calc/builtins.tswas read.irris the only builtin that iterates to convergence: a Newton loop (MAX_ITER, seeded0.1) and a 200-step bisection fallback. Every other loop is a bounded walk over arguments —sum/avg/min/max/coalesceover their variadic list, andnpv's summation overt.nperis the closed-formlog(num/den) / log(1 + rate);pmt,fv, andpvare closed-form. So the proposal's surface is exactly one function.No pack declares an
irrmetric. The motivation above saysirr"appears in the multifamily and every other asset-class pack, so it reaches any deal with a DCF." That is wrong, and the correction matters in both directions.excel-emit.tsmapsirr → IRR, but no pack formula calls it, so today the exposure is limited to third-party modules that declare anirrcalc — narrower than the draft claims. It also means the reason the draft withheld itself fromactive(Excel parity on multi-root inputs) is currently unreachable: parity is asserted over pack metrics, and no pack metric emits anIRRformula.The blast radius is a spec-versus-code divergence, not a wrong number on a live deal. Combined, 1 and 2 say this RFC changes what a module author gets from
irr, and changes nothing any built-in pack computes. Fixture 03 remains the load-bearing one at implementation time.
None of this weakens the case for pinning the solver — an engine that answers 18.999… for a search documented to stop at 1000% is wrong whether or not a pack calls it. It does mean the implementation can proceed without first resolving an Excel-parity question that nothing currently reaches.
Out of scope
xirr() and day-count conventions (ACT/365F, ACT/ACT, 30/360, …) are not proposed here, and are deferred to v2 as recorded in the RFC 0023 review. They need a date model in the value system of §VIII.1, which does not have one, and that is a larger change than pinning a solver. Adding an irregular-interval IRR before the regular one is pinned would compound the divergence rather than resolve it.
Errata
Corrections found while implementing (2026-08-15)
Two of the five fixtures in Conformance impact above describe outcomes the normative procedure cannot produce. The procedure is right; the fixture table was written from intuition about what bisection would do. Both are corrected here rather than in place, so the reasoning survives.
Fixture 02 — a multi-root cash flow raises; it does not return a root. The table says irr(-100, 230, -132) "returns the bisection root, pinned exactly," and the prose under the spec block says the procedure "selects one of them deterministically." Neither is achievable. Roots at 0.10 and 0.20 are an even number of crossings inside the interval, so npv carries the same sign at both ends — npv(-0.999) ≈ -1.32e8 and npv(10) ≈ -80.18, both negative — and step 2 finds no bracket. Bisection can only locate an odd number of roots in an interval; that is inherent to the method, not a detail of this implementation.
The correct outcome is the one the procedure produces: CALC-IRR-DIVERGE. It is also the better outcome, and the RFC argues for it three paragraphs later — "a cash flow with multiple sign changes has no single internal rate of return, and a host SHOULD surface that as a modeling problem rather than a number." An error surfaces it; a silently-chosen root does not. The spec text in §VIII.3 now states this consequence explicitly instead of implying the opposite.
Fixture 04 — only the high endpoint is reachable. The table asks that roots "at exactly -0.999 and 10.0" be found. At hi this is well defined: 1.0 + 10.0 is exact, so npv(10) can be exactly zero, and §VIII.3 step 3 returns it. At lo it is not: 1.0 + (-0.999) is 0.001000000000000001 in binary64, so for a cash flow whose exact root is -99.9%, npv(lo) lands a few ULP either side of zero and its sign decides whether step 2 brackets at all. A root "exactly at -0.999" is not a well-defined binary64 quantity. The fixture covers the high endpoint, and §VIII.3 documents the low one as unreliable rather than pretending otherwise.
Neither correction changes the proposal's substance: irr(-1, 20) still stops being an answer, conventional cash flows still do not move, and the procedure is still bit-reproducible. Both make the specification describe something an independent implementer can actually build.
The 1.3.0 note
The §VIII.3 note added in protocol 1.3.0 describes the reference implementation as bracketing first, refining with Newton, and falling back to bisection at 200 iterations. The implementation runs Newton first from a seed of 0.1 capped at 100 iterations, and uses the bracket only as a fallback. The note is corrected to describe what the code does. That correction did not wait for this RFC — it landed with the change that introduced the note, since a non-normative note which misdescribes the reference implementation is worse than no note: it is exactly what a second implementer builds against. This RFC only adds the pointer, and proposes replacing the note entirely with the normative procedure above.
Prior art
- Excel
IRR/XIRRtake aguessargument and document neither the method nor the tolerance — the reason spreadsheet IRRs are famously irreproducible across Excel, LibreOffice, and Google Sheets. This RFC's approach is the opposite trade: noguess, no choice, one answer. - IEEE 754-2019 §11 (reproducibility) is the model for the argument above: reproducibility follows from pinning the operations and their order, not from tightening tolerances.
- ISDA 2006 Definitions §4.16 (day count fractions) is the reference the deferred
xirrwork will need, and the reason it is deferred: there are seven conventions in common use and picking one is a domain decision, not a numeric one. - RFC 8785 (JSON Canonicalization), already used for receipt digests, is the same principle applied to serialization: agreement comes from removing choices, not from comparing loosely.