Skip to content

Changelog

All notable changes to the RWA Calculator are documented in this file.

The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.

[Unreleased]

Added

  • The release now gates the artifact it is about to publish, not just the source tree it was built from. v0.3.25 was tagged, released and announced before anything discovered that its wheel could not be uploaded: uv build resolves the build backend fresh and the current backend emits Metadata-Version: 2.5, while pypa/gh-action-pypi-publish was pinned at v1.14.0, whose Twine 6 rejects it. Nothing in this repository had changed. scripts/check_distribution.py now reads the metadata version out of every built wheel and sdist, reads the pinned publisher version out of publish.yml, and fails when the first outruns what the second accepts — the two components float independently, so only reading them together sees the skew. It runs from deploy.py::build_release and from the CI build job, with uvx twine check dist/* alongside it for the malformed-distribution class. Worth stating plainly, because it is the counter-intuitive part: twine check on its own does not catch this, since any Twine installable today accepts 2.5 and is therefore green on exactly the wheel that fails.
  • A version-stamped generator can no longer be left out of the release. Three generated targets embed the package version, so bumping it is by itself enough to make them stale — and deploy.py runs the test suite before the bump, so the suite measures a tree in which the staleness does not yet exist. tests/contracts/test_release_regeneration.py discovers version-stamped generators by inspection rather than from a list, and fails when one is not regenerated by the release script; a companion test fails if the sweep ever comes back empty, so a drifted heuristic cannot pass vacuously.

  • arch_check check 19: no type=Path CLI argument in scripts/. An operator-supplied string used to construct a path is an arbitrary-file-read sink, which SonarCloud reports as pythonsecurity:S8707 and which fails the new_security_rating quality gate outright. Three scripts had shipped the construct and each was fixed individually, so the rule was graduated out of prose into a check. Deliberately not fixed with a containment guard: commit a5d34c0d records that route being tried twice, producing correct code both times, with the taint engine reporting the finding regardless. Use choices= over a literal Path mapping, or read the fixed location and let callers import the function.

Running it for the first time found nine instances nobody had counted, across coverage_report.py, defect_injection.py, impact_report.py and parity_gate.py. They ship as a shrink-only CLI_PATH_ARG_ALLOWLIST and are filed for draining rather than fixed here. Note coverage_report.py is on that list despite an earlier commit having already fixed this rule in the same file — the previous pass removed one argument and left another, which is what per-instance fixing looks like from the outside.

Changed

  • Both escapes are written up in docs/development/escape-log.md, each with an escape class, a named gate change and a red observed against the genuine pre-fix commit — 6f513697 for the regeneration hole, 451e97db for the metadata skew. The first carries a note on the gate-not-run class: its prescription reads "move the gate earlier", and here the fix is to move it later, after the mutation it should catch.

[0.3.25] - 2026-08-11

Added

  • The correctness estate now gates instead of merely measuring. The independent validation layers merged on 2026-08-08 could all report a blind spot and none of them could fail a build over one. Six changes close that:
  • Coverage is a gate. scripts/coverage_report.py --check runs in CI as the coverage-ratchet job and is mirrored for local development by tests/contracts/test_coverage_ratchet.py, one of whose tests asserts that CI still invokes the script — the failure being fixed here was an inert ratchet, not a missing one. Six metrics in scripts/coverage_baseline.json ratchet directionally: published-rule binding counts, live cells and template-cell liveness may not fall, dead cells and never-evaluated rules may not rise. Measured over the current 16-run matrix, the estate stands at 12.85% template-cell liveness (8,193 live cells of 63,746 declared), 55,553 dead cells, 785 never-evaluated rules, and 257 (CRR) / 289 (Basel 3.1) published rules binding, and the baseline now carries a provenance block naming the runs behind those numbers — so a matrix change reports as INVALID rather than as a regression, and the right response is to re-measure rather than to drop a portfolio until the old numbers fit. The figures it replaces reproduced at neither the matrix they were taken on nor today's: they had stopped describing the estate before the matrix grew, and nothing could tell, because a baseline that records no matrix cannot say what it describes. The same pass adds cells_live as a genuine floor at 8,193: a ratio whose denominator shrinks with its numerator is not a floor, and neither is a count of the complement — dropping any region less live than the estate average improved both cell metrics while real coverage fell. The floor is in the ratchet's metric list ahead of being banked in the baseline, so --check raises on the missing key until the accompanying re-bank lands.

    Note the scope. Every one of these metrics is value-insensitive: liveness counts cells that are non-null, and "binding" counts a rule that reaches PASS or FAIL. This gate therefore makes blind spots visible and cannot see a wrong number in a cell that stays populated — that remains the supervisory register's job. - Vacuity is ratcheted. A supervisory rule whose operands are all null or zero evaluates to VACUOUS, and that count was informational: a change that emptied a column flipped its rules PASSVACUOUS and left every test in the register green. A two-way ratchet over 218 baselined known-vacuous rules now fails a PASSVACUOUS regression and requires the list to shrink when a rule activates, so a rule that is evaluable today cannot become unevaluable without a written reason. - The defect-injection harness runs where uv does not. DEFECT_INJECTION_PYTHON retargets the whole gate ladder at a named interpreter through a single chokepoint, with a pre-flight import probe and a hard error on any command it cannot rewrite. Previously every gate was spawned via uv run; where that path is unusable each gate fails to spawn, every failure scores as a detection, and the harness publishes a detection rate near 100% while detecting nothing. That is measured rather than theoretical — on the sandbox this was developed in, uv run exits 2 with Could not acquire lock … Read-only file system. The detection rate itself is still unpublished: no campaign has been scored yet. - Credit risk mitigation has independent oracle coverage — CRM joins the stdlib-only shadow calculator, which is the only layer that can catch a wrong constant rather than a changed one. - Independent cell re-derivation extends beyond OV1, so more published template cells are derived from instruction text rather than compared against recorded engine output. - CI fires on the branches the development loop actually pushesbatch/**, claude/**, feat/** and the rest, not master alone. The loop pushes to feature and per-item batch branches and only opens a PR at the end, so a master-only trigger meant the majority of commits never met CI at all. - A defect found in output is now closed by its escape-log entry, not by its fix commit. docs/development/escape-log.md had a seven-class escape taxonomy, a "verified red" requirement, and zero entries while defects were reaching published output. /postmortem now refuses to conclude without an escape class, a named gate change and recorded evidence the new gate was observed red before the fix, with three permitted routes to producing that red; and the log is seeded with six entries. Four are the escapes this project had already established — the ungated coverage ratchet, unratcheted vacuity, the unmeasured detection rate, and oracle disagreements parked as strict xfails (a register that grew from four entries to eleven, eight of them understating capital, during the batch that wrote the entry describing that mechanism). That last one needed an eighth class, caught-and-parked: the other seven all answer why didn't a gate catch it, and there the gate caught it and the record of the finding became its resting place. The other two came out of writing them up — a measured escape (a term dropped from a C 02.00 subtotal, with a full supervisory run reporting 8 passed alongside it, because the live ERROR rule that would catch it ships in this repo and is never evaluated), and a defect in a gate that had not yet shipped, which carries no class because all eight presume a defect that reached production. - The regulatory skills no longer state regulatory values. scripts/generate_regulatory_tables.py now renders pack values into <!-- BEGIN/END GENERATED --> regions inside the basel31 and crr skill reference files (33 fragments across 17 files), and the new scripts/check_skill_values.py fails any percentage written into skill prose outside those regions, with a justified ALLOWANCES list for genuine exceptions (verbatim article quotations, published EBA rule expressions). Both are gated by tests/contracts/test_docs_freshness.py. Skill prose now carries only what the pack cannot — precedence, scope, mechanics, PRA-vs-BCBS divergences and traps — and names pack entries instead of quoting values. A what-changed.md fragment renders the CRR↔B3.1 divergence table by comparing the resolved packs, so it maintains itself. The generator hard- fails (exit 2) on a fragment id with no marker, a marker no fragment fills, or an entry name in neither pack — so a pack rename cannot silently empty a skill. - Per-article confidence matrix. scripts/generate_confidence_matrix.py joins five evidence layers per regulatory article (@cites snapshot, cited pack entries, oracle records, a heuristic test scan, a source-name scan) into docs/development/confidence-matrix.md plus a machine-readable snapshot, tiered HIGH / MEDIUM / LOW / UNCITED / GAP. GAP (32 articles) is the actionable coverage-hole list; the SA-CCR cluster is correctly shown as implemented-but-uncitable (watchfire index gap), not as missing. Freshness-gated by tests/contracts/test_confidence_matrix_freshness.py. - Differential shadow fuzzing. tests/oracle/derivations/branch_sa.py turns the shadow calculator into a callable stdlib-only SA branch oracle, and tests/properties/test_differential_shadow.py fuzzes the engine against it: a 49-case deterministic matrix exhaustive over entity × CQS × framework plus a hypothesis fuzz on top. No engine/shadow disagreement found on any in-scope input under either regime; excluded branches are enumerated, never silent. - Cross-regime delta regression. tests/properties/test_regime_deltas.py runs one all-SA portfolio under both CRR and Basel 3.1 and asserts a curated, cited delta map: 8 no-change legs (exact RW equality), 4 changed legs with exact values both sides (corporate CQS3 100%→75%, institution CQS2 50%→30%, SME rated/unrated via the Art. 501 supporting-factor removal), and a bookkeeping identity tying each regime's total RWA to the sum of per-leg deltas. Every value re-derived from the source PDFs in adversarial review. - Docs dead-link ratchet. scripts/check_doc_links.py counts broken relative links and dead intra-page anchors across docs/ (76 at baseline, banked in scripts/docs_link_baseline.json) and two-way ratchets the count in the contract suite: a new dead link fails, a fixed one must be banked. Burn-down tracked as P4.56/P1.309. - Independent validation system. Six components addressing the fact that the estate's ~10,500 tests almost all compare against recorded engine output, so they detect change rather than wrongness. Plan: Independent Validation System. - tests/properties/ — 185 regulation-derived properties (conservation, structural invariants, monotonicity, homogeneity, output-floor identities, template row-axis coverage). Finds wrongness without anyone deriving an expected value. 157s. - tests/oracle/ — the independent shadow calculator grown from 3 to 132 exposures, stdlib-only and self-enforced by test_derivations_never_import_rwa_calc. The only component that can catch a wrong constant. - tests/conformance/ — an externally-authored classification decision table (710 combinations, 602 in scope, 2,396 assertions; an unmapped combination is a hard failure) and independent OV1 cell re-derivation from Annex II text. - scripts/impact_report.py — change-impact reporting over 128,127 template cells at four grains, with appeared/disappeared cells reported loudly and an allowlist requiring a written reason. - scripts/coverage_report.py + coverage_baseline.json — estate-wide published-rule and template-cell coverage, ratcheted. - scripts/defect_injection.py — defect-injection scorecard: 22 mutants, a data-driven gate ladder, and reachability as a first-class verdict.

Security

  • An unrecognised report-template or run identifier is no longer written into a log line. Three log statements interpolated a value taken straight from an HTTP route or query parameter (reporting/lineage.py, api/rest.py, ui/views/report_templates.py), which lets a caller forge log records (CWE-117). Each now echoes back the matching literal held by the server — the key stored in the run registry, the lineage plan registry, or the run's template catalogue — so a recognised id logs exactly as before, and an unrecognised one is replaced by <unknown> / <unregistered> rather than reproduced. Filtering control characters out of the caller's string (_safe_log_token, retained as a second layer) narrows what can be injected; substituting our own literal removes the caller's bytes from the record.
  • The pre-commit gate no longer permits a source distribution's setup scripts to run. scripts/pre_commit_gate.sh invokes uv run on every commit; both call sites now pass --no-build --no-sync, so the gate executes in the environment the developer already has instead of resolving (and potentially building) one. The two flags are a pair — --no-build alone fails outright, because the editable local project has no binary distribution to install from. Re-sync explicitly with uv sync --all-groups after changing dependencies.

Changed

  • The test suite no longer oversubscribes the machine: 10m50s → 3m56s on the reference dev box, with identical results. Polars sizes its thread pool from the core count the first time it is imported, and pytest-xdist sized its worker fleet the same way via -n auto. On a 16-core box that was 16 workers each holding a 16-thread pool — 256 threads contending for 16 cores, each pool carrying its own buffers — while practically every test here pushes a handful of rows through the pipeline, so that parallelism bought nothing.

tests/conftest.py now sets POLARS_MAX_THREADS=1 (via os.environ.setdefault, before the first import polars, so it applies in the controller and in every xdist worker), and addopts pins -n 8 instead of -n auto because the binding constraint is RAM rather than cores. Measured on the same commit: 652s → 237s wall, peak system memory 4819 MB → 4419 MB, with the failure set unchanged. On a fully green tree the memory saving is larger — a green-to-green comparison measured 7301 MB → 4350 MB peak and available memory at the low-water mark rising from 508 MB to 3459 MB.

Two guards come with it. tests/contracts/test_polars_thread_cap.py asserts the pool size actually agrees with the environment, because setting the variable after the first import polars is a silent no-op that costs the whole win while leaving every test green. And the CI benchmark job now exports the real core count, so benchmarks keep measuring production-like throughput; setdefault is what lets that override win. Note that POLARS_MAX_THREADS=0 must never be used — Polars accepts it at import and then panics at compute time with "Worker threads cannot be set to 0".

-v is also dropped from addopts: 11k verbose result lines were pushed through the xdist protocol and scrolled past, and --tb=short already gives the diagnostic on failure. Override per-invocation as usual (-n auto, -n 0, -v); a single-file run is much faster serially — 13.4s → 2.3s for tests/unit/test_ccf.py, since below roughly a dozen files the worker startup costs more than the tests. - One work queue. DOCS_IMPLEMENTATION_PLAN.md is retired: its open items merged into IMPLEMENTATION_PLAN.md (Tier 5 is now the docs queue that /next-docs drains; migrated items keep their D-codes), and the misfiled code items it had accumulated moved into the code tiers. One list, one audit cadence, no cross-file bookkeeping. - docs/data-model/regulatory-tables.md is now generated. Rendered from the resolved rulepacks by scripts/generate_regulatory_tables.py (all ~250 cited entries, CRR and Basel 3.1 side by side, citations included) and freshness-gated by tests/contracts/test_docs_freshness.py — the page can no longer drift from the packs. Never hand-edit it. - .claude/LESSONS.md is now tracked in git. It never was, so it existed only in the main checkout and was absent from every git worktree — meaning every agent dispatched into a worktree was told to read it and got "file does not exist". - Corrected LESSONS.md A2: sub-agents can read the regulatory PDFs. The limitation is the Read tool (no pdftoppm), not the agent — pymupdf via Bash works, and the entry was routing agents to transcriptions it elsewhere records as wrong. - Added hypothesis to the dev dependencies (portfolio search and shrinking).

Fixed

  • PyPI publishing works again: the release action was too old to accept the metadata the build now emits. pypa/gh-action-pypi-publish was pinned at v1.14.0, whose bundled Twine rejects core packaging metadata 2.5 — InvalidDistribution: '2.5' is not a valid metadata version — and uv build resolves its build backend fresh, so the wheel started declaring Metadata-Version: 2.5 without anything in this repository changing. v0.3.24 published on 5 August and v0.3.25 did not. Bumped to v1.14.2, which carries Twine 7 precisely to accept 2.5. The failure surfaced only at upload, after the version bump, tag and GitHub Release already existed — nothing in the local release flow builds and validates a distribution the way PyPI does.
  • The release script now regenerates every version-stamped generated artifact. docs/data-model/regulatory-tables.md, docs/development/confidence-matrix.md and tests/contracts/data/confidence_snapshot.json each embed the package version, so bumping it is by itself enough to make all three stale and turn their freshness contract tests red. scripts/deploy.py regenerated only the citation matrix and the dependency graph, and it runs the test suite before the bump — so the suite could not observe the staleness the bump was about to create, and the first thing to see it was CI on the release commit. The two generators now run in build_release, and the three targets are staged for the release commit alongside tests/contracts/data/citation_snapshot.json, which the citation-matrix regeneration had likewise been writing but never staging. in two distinct ways. Four paired an unbounded whitespace run against a neighbour that could also match whitespace (\s* beside a dot-matches-all .*?, or beside a negated class), so on input that failed to match the engine retried every split of the gap; these now use negated classes, an anchored possessive run, or caller-side stripping.

The other two began an unanchored pattern with an unbounded run (\s+and\s+, [A-Z_]+\(), so the search re-entered the same run at every offset. That cost is paid across start positions, so neither a possessive quantifier nor a lookbehind gate removes it — both were tried and measured no better. The unbounded run is now gone from the head of each: the prerequisite splitter matches a single \sand\s (surrounding whitespace was already stripped by its caller), and the BoE label parser splits the cell on its | terminator in Python and matches each segment anchored.

Affected: the docs heading scanner (scripts/check_doc_links.py), the BoE label and severity cell parsers (scripts/extract_validation_rules.py), the EBA dimensional-filter parser (reporting/validations/evaluate.py), the prerequisite splitter (reporting/validations/rules.py) and the qualified-reference parser (reporting/validations/scope.py).

Measured on pathological non-matching input, the two rewritten scans go from quadratic to flat — 500ms → 0.03ms at n=8,000. Every rewrite was checked for behavioural equivalence against the pattern it replaces: over the real committed rule extracts (1,011 EBA prerequisite strings, 539 of them carrying a conjunction, and 4,100 BoE label cells rebuilt from their parsed values — 0 divergences), by 200,000 randomised inputs per pattern, and by the docs dead-link census returning its banked count of 76 unchanged.

One case is deliberately narrowed: a BoE label segment no longer parses if non-whitespace garbage precedes its kind token. The workbook does not produce that shape — a segment begins with its kind token — and requiring it is the more faithful reading of the documented format. - PS1/26 Art. 154(4A)(b): the 10% IRB mortgage RWEA floor is now confined to non-defaulted retail exposures secured by residential immovable property (P1.319). The engine gated the floor on a bare MORTGAGE|RESIDENTIAL substring match against exposure_class, so it also floored defaulted retail mortgages, which the article excludes by name. The gate is now a positive equality on retail_mortgage — the engine's closest available proxy for the Art. 147(5B)(d)(ii) subclass — combined with a non-defaulted test on the is_defaulted carrier.

This lowers RWA. The floor is only ever added to RWEA, so narrowing its scope can only remove capital. Measured on the irb-classes Basel 3.1 portfolio: one affected exposure, −20,000.00 of RWEA. The Basel 3.1 output floor does not bind on that portfolio (U-TREA 68.96m against 0.6 × S-TREA 35.83m), so the reduction is unoffset there; in general TREA = max(U-TREA, floor) bounds the effect at no more than the row-level delta. Set containment was proven (new ⊆ old), so no non-defaulted retail mortgage loses the floor.

Two limbs of the original finding did not survive audit. commercial_mortgage and residential_mortgage are SA-bound and cannot reach the IRB branch in production. The article's UK-property condition remains unimplemented — no property-country carrier exists on the sealed re_split_exit edge (cp_country_code is the obligor's country) — and its oracle case stays a strict xfail rather than being quietly claimed as covered.

Firms should note the residual: a retail obligor secured only on commercial property is still classified retail_mortgage and still receives the floor, because the classifier's property-collateral test spans both property types. That is conservative, strictly narrower than the previous behaviour, and tracked separately. - Two wrong values in the basel31 skill, both now impossible to reintroduce. Corporate CQS5 under Basel 3.1 was stated as 100% in three places (references/sa-risk-weights.md and twice in references/what-changed.md) against PS1/26 Art. 122(2) Table 6, the pack and the engine, all of which say 150%; and the QRRE limit was stated as GBP 100k against a pack value of GBP 90k. Both were prose-only defects — the engine was always correct — but the skills are read by every role-agent before it designs a scenario, and an earlier defect of exactly this class (CRR institution CQS 2 quoted as the Basel 3.1 ECRA 30% rather than 50%) demonstrably seeded a wrong scalar into the P8.20 fixture. Recorded as a graduated lesson in .claude/LESSONS.md. - Corporate risk-weight citation label. The CRR pack's corporate_risk_weights citation (and the regime-delta suite's citations) now reference CRR Art. 122 Table 6 — Table 5 is Art. 121's unrated-institution table. Values were always correct; the pointer was not. - Stale register note. The supervisory-validation register's caution_on_plan_doc_definitions note described the already-fixed C 07.00 col 0200 origin-basis defect in the present tense; rewritten past-tense with the resolution in both the JSON register and the test's REGISTER_NOTES. - Nothing yet — the findings below are recorded as strict xfails, not fixed, because each moves published template numbers and needs its own preserve-or-fix decision.

Removed

  • The four back-compat shells left by the Phase 4 stage migration are deleted. engine/classifier.py, engine/hierarchy.py, engine/re_splitter.py and engine/fx_converter.py had been reduced to 23–29 lines apiece — a docstring, a re-export, and a module logger that could never emit — while their implementations lived in engine/stages/{classify, hierarchy,re_split,fx}/. No production code imported them; they were kept alive by 47 test and script files. Every importer now names the stage package directly, and rwa_calc.engine re-exports HierarchyResolver from stages.hierarchy.

This was masking a broken test. test_p6_26_qrre_coupling_constant.py::test_qrre_coupling_todo_marker_removed read Path(hierarchy_module.__file__).read_text() to assert a forbidden TODO(qrre-coupling) marker was absent. Once the implementation moved, __file__ resolved to the 28-line shell, so the guard scanned a file that could never contain the marker and passed unconditionally. It now scans every module in the stages/hierarchy/ package — verified to fail when the marker is reintroduced into facility_undrawn.py. The private constant _FACILITY_QRRE_COUPLED_COLUMNS was being re-exported through the shell solely to satisfy that test's hasattr probe.

Two further artefacts went with them: tests/contracts/test_logging_contract.py asserted the module-logger contract against the three shell paths (now the real recipe modules), and _NAMESPACE_LOGGER_NAMES in tests/integration/test_logging_pipeline.py was dead — defined, never read.

New arch_check check 18 bans pure re-export shells under engine/: a module with no defs whose body is only imports plus a docstring, __future__, __all__ or the module logger. REEXPORT_SHELL_ALLOWLIST is empty by design.

Known issues (found by the new validation layers, all recorded as strict xfails)

  • A Basel 3.1 equity leg is calculated and then dropped. risk_weight resolves to 2.5 (PS1/26 Art. 133) and rwa_pre_factor, rwa_post_factor and rwa_final are all null. 3,750,000 of RWEA and 300,000 of own funds leave the submission with no error, no null cell and no failing published rule.
  • C 02.00 IRB class rows do not foot to their approach totals. ~13.6m (CRR) and ~14.6m (Basel 3.1) of A-IRB RWEA on the rich portfolio, and 6.56m of F-IRB sovereign RWEA on the irb-classes portfolio, are counted in the approach total and absent from every of-which row. corporate_sme is never looked up; row 0310 is keyed on central_government, which is not an ExposureClass value.
  • CRR Art. 121(1) Table 5 is never applied to the institution class. Unrated institutions take a flat 100% instead of their sovereign's ladder. Conservative at CQS 1-2, coincidentally correct at 3-5, and under-weighted at CQS 6 (100% against a required 150%).
  • PS1/26 Art. 154(4A)(b)'s 10% floor is applied outside its scope — no non-defaulted gate, no retail gate, no UK-property gate. The UK limb is not mis-gated but unrepresentable: nothing under engine/irb/ reads an obligor or property country column.
  • Pillar 3 high_risk reaches no CR4/CR5 class row under Basel 3.1SA_DISCLOSURE_CLASSES row 11 maps to an empty tuple.
  • Five classification defects (B31 large-corporate F-IRB aliases; QRRE per-individual double-count; sync_irb_exposure_class overwriting the Art. 147(3) class so MDBs, international organisations and covered bonds cannot reach IRB).

[0.3.24] - 2026-08-05

Fixed

  • Guaranteed exposures published their collateral once per leg. CRM physically splits a guaranteed exposure into __G_<guarantor> and __REM legs, but the columns pro-rated onto those legs omitted every collateral valuation, so each leg inherited the full value. COREP C 08.01/02 cols 0180-0210, C 07.00 col 0130 and Pillar 3 CR7-A all bind those carriers on the origin basis — both legs land in one reported row — so an N-leg split disclosed N x the collateral actually held. The seven collateral valuations are now split pro-rata. No capital number moves: apply_collateral runs before apply_guarantees, so the SA Comprehensive-Method E* is already spent by split time. The Art. 231 waterfall allocations (crm_alloc_*, total_collateral_for_lgd) are deliberately NOT split — their only post-split consumer is the blended LGD input floor, a rate over the unsplit ead_for_crm, so splitting the numerator alone would have silently raised it.

Changed

  • PyPI development status promoted from 2 - Pre-Alpha to 3 - Alpha.

[0.3.23] - 2026-08-05

Fixed

  • CRM substitution: the covered part's exposure value and RWEA never followed it to the guarantor. A guaranteed exposure's covered part correctly left the obligor's sheet through the pre-CCF flow columns, but every exposure-value and RWEA column stayed keyed to the obligor. A £10m slotting loan guaranteed by a CQS1 corporate at 20% reported cols 0100/0110/0150 = £10m on the guarantor's C 07.00 sheet with col 0200 = 0 and col 0220 = 0 — the £2m of RWEA appeared nowhere on the template. With a 0%-RW sovereign guarantor col 0220 = 0 is right by coincidence, which is how it survived.
  • Annex II is explicit. Col 0200 is the "exposure value after taking into account value adjustments, all credit risk mitigants and conversion factors"; substitution is a credit risk mitigant. Cols 0090/0100 say the covered part is "deducted from the obligor's exposure class and subsequently assigned to the protection provider's".
  • Fixed across C 07.00, C 08.01, C 08.06, C 09.01, C 09.02, C 02.00, OV1 and CR10, which had to move together: several are defined by cross-reference to one another, so moving one alone makes two templates state different values for a quantity the regulator defines as identical.
  • Geography follows the same split, as PS1/26 Annex II §3.4 ¶87 prescribes: original exposure pre-CCF by the immediate obligor, exposure value and RWEA by the ultimate obligor. A GB borrower guaranteed by a German institution now reports its exposure value on the DE sheet.
  • A declined guarantee no longer migrates anything. Where the engine declines a guarantee because it would not improve capital (CRR Art. 193(1) — "no exposure ... shall produce a higher risk-weighted exposure amount ... than an otherwise identical exposure ... with no credit risk mitigation"), the exposure kept its own risk weight for capital but still moved class and approach for reporting, booking a substitution outflow and inflow for protection that was never recognised. A 70% slotting weight was published on the SA central-governments sheet, where no such weight exists.
  • A substituted-away leg no longer distorts the slotting grid. C 08.06 and CR10 kept a covered part that had left the slotting approach, so a category holding one substituted and one plain leg reported the EAD-weighted blend of their weights — 60.5% inside a fixed Art. 153(5) 90% band, with CR10 printing 90% beside an implied 60.5%.
  • The covered part no longer appears as the guarantor's specialised lending. sl_type is the obligor's characteristic, so a slotting-origin inflow was populating "of which: specialised lending" on a corporate guarantor's sheet. Annex II admits those rows only for exposures applying the Art. 122B treatment, which a substituted-in part does not.
  • Nineteen published EBA/BoE supervisory validation rules stop breaking, including four Error-severity. Zero new breaks across both regimes and all six portfolios.

[0.3.22] - 2026-08-03

Fixed

  • C 08.01 / C 08.02 reported every guarantee twice — once in the CRM substitution block and again in the CRM-in-LGD block. Cols 0150 (guarantees) and 0160 (credit derivatives) bound Sum("guaranteed_portion") behind the same protection_type predicate cols 0040/0050 already use, so a single £400k guarantee was published as a £400k "(-)" substitution outflow at col 0040 and a £400k LGD mitigant at col 0150 on the same sheet.
  • The two blocks are mutually exclusive by instruction. Annex II partitions unfunded protection by effect — "Guarantees shall be reported in column 0040 where the adjustment is not made in the LGD. Where the adjustment is made in the LGD, the amount of the guarantee shall be reported in column 0150" — and the cols 0150-0210 heading bars the other side outright: "CRM techniques that have an impact on LGD estimates as a result of the application of the substitution effect of CRM techniques shall not be included in these columns". PS1/26 Annex II partitions the same population by named method: 0040/0050 are the Risk-Weight Substitution and Parameter Substitution Methods, 0150/0160 the Article 183 LGD Adjustment Method.
  • The engine only ever produces the substitution half. engine/irb/guarantee.py::apply_guarantee_substitution implements SA risk-weight substitution (Art. 235), parameter substitution (Art. 161(3) / CRE22.70-85) and double default (Art. 153(3)) — none of which is the LGD Adjustment Method. Cols 0150/0160 therefore now report the recorded constant 0.0, the convention cols 0170-0173 already followed. The funded half of the block (cols 0180-0210, Art. 197/199 collateral) is untouched and keeps reporting: collateral genuinely is an LGD mitigant.
  • The two bindings did not even agree in magnitude. Cols 0040/0050 read the Annex II block-capped twin (c08_prot_guaranteed); cols 0150/0160 read the raw carrier. On a leg where the cap bit, the template published a full-value 0150 against a scaled-down 0040 with nothing to flag the divergence.
  • No golden file moved. Cols 0150/0160 are 0.0 in every committed C 08.01/02 expected output because no reporting golden portfolio carries a guaranteed IRB leg — which is exactly why the duplication survived. The four new regression tests supply that missing coverage.
  • Known residual, tracked separately: Annex II routes double-default unfunded protection to col 0220 instead of col 0040, and a double-default leg is not substituted at all, so it should raise no outflow. Today such a leg reports in 0040/0070 and 0220. Correcting it moves the col 0090 waterfall, so it is not bundled here.
  • The release changelog promoter truncated every multi-line bullet to its first line. scripts/_deploy_changelog.py collected only lines beginning - when moving [Unreleased] into a new version section, so wrapped continuation text and nested sub-bullets were silently dropped — a bullet running to dozens of lines promoted as a single unclosed sentence fragment. It caught the 0.3.21 release, where 118 lines of release notes across two bullets collapsed to two fragments; the notes were restored and the tag moved before either was pushed. The parser now accumulates each top-level bullet together with the indented lines that follow it. Every pre-existing unit test used single-line bullets, which is why the defect survived: the two new regression tests assert a wrapped bullet with nested sub-bullets, and a bullet containing a blank line, both promote intact.

[0.3.21] - 2026-08-02

Fixed

  • The CRM substitution block on C 07.00 / C 08.01 / C 08.02 removed guaranteed exposure more than once, and lost it entirely when the guarantor sat outside the reporting template. Six published validation rules already shipped in this repo pin the arithmetic and every one of them was breached. The whole block was uncovered — every CRM-substitution cell in all 70+ frozen golden files was exactly 0.0, because no golden portfolio contained a guaranteed exposure that migrates exposure class.
  • Col 0070 is the block subtotal, not an independent sum. It was Sum(guaranteed_portion) gated on "the guarantor's class differs from the obligor's", which reported 0 for a same-class guarantee while col 0040 still showed the amount, and ignored col 0060 (Art. 232 other funded credit protection) entirely. It is now 0040 + 0050 + 0060 — EBA v1663_m / v1665_m and BoE boe_b0747 / boe_b0761, all live.
  • Col 0090 deducted the same money twice, subtracting both the 0040/0050/0060 breakdown and the 0070 subtotal. It is now 0020 - 0035 - 0070 + 0080 — EBA v1662_m / v0347_m and BoE boe_b0746 / boe_b0760. The Basel 3.1 col 0035 on-balance-sheet netting term is row-scoped: boe_b0746_1 drops it on the off-balance-sheet row family, because Art. 166(3) netting of loans and deposits cannot reduce an off-balance-sheet row. The off-BS memo col 0100 re-derived the same arithmetic independently and carried the same defect; it now binds the same per-leg subtotal so the two cannot drift apart.
  • Same-class substitutions now produce a matching inflow. The inflow side was gated on a class change while the outflow side was not, so a guarantee whose guarantor sat in the obligor's own class reported an outflow with nothing coming back — money left the return. Annex II, both templates and both regimes: "Inflows and outflows within the same exposure classes … shall also be considered."
  • Substitutions crossing the SA/IRB boundary now reach the right template. Each template derived its inflow from its own approach-filtered population, so an IRB exposure guaranteed by an SA counterparty was deducted on C 08.01 and added back nowhere. A new reporting/corep/crm_substitution.py computes the inflow once over the whole sealed population and routes it by the sealed post-substitution approach — SA guarantor to C 07.00 (Art. 235 risk-weight substitution), IRB guarantor to C 08.01 (Art. 161 parameter substitution). The sheet axis is now the union of the classes present and the classes receiving an inflow, so a guarantor class with no exposure of its own still gets a sheet, and a template with no native population of its own is still emitted when it is the only home for an inflow.
  • Amounts on existing returns are unchanged: no frozen golden number moved and the supervisory-validation register is unaffected, because no committed portfolio exercised the block. Firms reporting guaranteed exposures will see col 0070 populate where it previously read zero, col 0090 rise by the amount that was being deducted twice, and inflows appear on the guarantor's sheet.
  • The inflow now lands on the published row decompositions, not only the Total row. C 08.01 decomposes its total row twice over the same columns and both are live ERROR rules — boe_b0744 on the balance-sheet axis and boe_b0745 / v0338_m on the IRB treatment axis — and C 07.00 does the same on its risk-weight axis (v0312_m / boe_b0719). A Total-row-only inflow breached all of them by exactly the inflow. The inflow is now split by balance-sheet side, by post-substitution IRB treatment, and by risk-weight band, and landed on the matching rows.
  • Recorded decision R12 is superseded (it shipped in 0.3.18 as "C 08.02 deliberately does not receive the cross-class CRM substitution inflow … no output change"). Four live ERROR rules — boe_b0752_8 / boe_b0752_9 / boe_b0814_07 / boe_b0814_08 — require {C 08.01 r0070, c0080/c0090} = sum({C 08.02, same col}), which an inflow-free C 08.02 cannot satisfy. R12's reasoning survives intact: per-grade attribution is unsound because the origin-basis ledger carries the obligor's grade and never the guarantor's. So the inflow lands on C 08.02's existing "Unassigned" residual row — an inflow whose guarantor grade the ledger does not carry is an exposure with no assigned grade — and no graded row carries any of it.
  • The post-model-adjustment disclosure carriers are rebased onto the substituted basis. All four were left on the borrower basis after substitution re-blends the RWA, so {c0260} = sum({c0251..c0254}) (boe_b0751 / boe_b0763, live) diverged by exactly the Art. 235 relief on every guaranteed leg. Col 0251 is now rwa_pre_adjustments x retained_share + guaranteed_portion x guarantor_rw with each adjustment scaled by the retained share — which is also substantively right, since only the retained share of a mortgage-floor or unrecognised-exposure overlay survives into reported RWEA and the substituted part carries no model overlay. No RWA number moves; this is the disclosure decomposition only.
  • Coverage: the new portfolio is wired into the supervisory validation register for both regimes — the first in the estate with non-zero substitution cells. On it, failing rules fall CRR 6 → 3 and Basel 3.1 18 → 13.
  • Recorded residuals, both banked in validation_known_breaks.json with written reasons: (1) the outflow subtotal counts every route in the block, but only the unfunded routes carry a destination class — the sealed ledger holds a guarantor class for guarantee and credit-derivative legs only — so C 07.00's funded limbs and C 08.01's col 0060 still produce an outflow with no matching inflow; closing it needs an issuer class sealed per collateral leg. (2) C 07.00 col 0200 sums raw EAD over the ORIGIN population and never reflects substitution while cols 0100→0110→0150 net it (v0308_m / v8726_m / boe_b0471 / boe_b0556) — a genuine defect in our output, not a published-rule artifact. Note this is a build shortfall against a recorded decision, not a recorded decision with a measured cost: Phase 7 decision F3 cites "C 07.00 col 0200 basis" as the definition of the post-substitution basis (it keys Pillar 3 CR4 cols c-f and all CR5 rows on it), i.e. it assumed col 0200 was already post-substitution. The F4 slice that actually built C 07.00 made only the 0040/0110/0150 waterfall substitution-aware and never extended that treatment to col 0200 or the CCF buckets. It is not fixed here because col 0200 feeds the CCF-bucket identities and the C 08.01 cousins 0110/0260 sit on the same basis, so rebinding col 0200 alone risks making the return inconsistent elsewhere; it needs its own scoped change and golden regen.
  • The fixed PD scale on C 08.03 / C 08.05 and Pillar 3 CR6 / CR9 was a flat ladder of invented bands; it is now the published hierarchical scale. Both templates carried 17 mutually exclusive PD buckets (0.00 to < 0.03%, 0.03 to < 0.05%, …, 50.00 to < 100%) that matched the published row count but not a single published row label. The real scale — Regulation (EU) 2021/451 Annex I for C 08.03/08.05, PRA PS1/26 Annex I for OF 08.03/08.05, and PS1/26 Annex XXI for UKB CR6 / UK CR9 — is hierarchical: eight top-level bands partition [0, ∞) and four of them repeat their span as a finer sub-breakdown on the rows immediately below, so a parent row overlaps its children and equals their sum. Row 0080 is 0.75 to < 1.75 under the parent 0.75 to < 2.50, not 0.75 to < 1.00. Every exposure was therefore reported against a mislabelled PD range, and the four published parent/child sum rules per template (EBA v09753v09756 and v09757v09760, BoE boe_b0767boe_b0770 and boe_b0773boe_b0776) could never hold at any figure, because parent and children were disjoint by construction.
  • COREP now resolves its row axis per regime: 17 rows under CRR, and 18 under Basel 3.1, where OF 08.03 / OF 08.05 split the 0.00 to < 0.10 child at 0.05% (rows 0015 / 0025 replace CRR's row 0020). Pillar 3 does not follow — UKB CR6 and UK CR9 keep the coarser band in both regimes, so the two estates now carry their own tables (get_c08_03_pd_ranges / CR6_PD_RANGES).
  • The hierarchy needs two row keys, since an exposure sits in exactly one leaf band while a parent spans several. A new reporting/corep/pd_scale.py derives both (c08_pd_range = leaf, c08_pd_parent = enclosing parent), keeping every row a single equality term for row_terms and the drill-down predicates. CR6/CR9 already keyed range predicates, so they needed only the corrected table.
  • Amounts are unchanged; this is a row-axis correction. A band that previously emitted one mislabelled row now emits the correctly-labelled leaf plus its parent, carrying the same figures. Anything aggregating across PD rows must now sum the leaf bands only — summing every emitted row double-counts.
  • Known supervisory-validation breaks fall 28 → 14 with no new breaks: all 14 removed are the PD-scale sum rules the register had already diagnosed as "a structural template defect, not a data gap". CRR irb-classes FAIL 9 → 1, Basel 3.1 16 → 10, with two further rules now executable.

[0.3.20] - 2026-08-01

Added

  • Supervisory reporting validation rules are now a first-class reference in the repo. The EBA and BoE both publish the machine-readable validation rules that supervisors run against a submitted return; until now nothing in this project referenced them, and reporting/tieouts.py carried just five hand-curated cross-template ties as the only in-house analogue. scripts/download_docs.py now fetches both sources into docs/assets/ — the EBA validation-rule workbook (sheet v3.0(3.0.1) is the framework version matching current CRR reporting) and the BoE banking XBRL taxonomy validations v4.0.0 zip, from which the "Banking reporting" workbook holding the OF tables is extracted. DocEntry gained a general extract_member / extract_as archive mechanism to support this; extraction is idempotent, honours --force / --dry-run, and re-extracts from an already-downloaded archive without a network call. A new scripts/extract_validation_rules.py filters both workbooks to the credit-risk templates this project produces and emits a committed, pure-ASCII JSON extract to docs/reference/validation-rules/ (the raw workbooks are gitignored, so the JSON is the durable artefact); --check re-extracts and fails non-zero if the committed files would change. The extract carries 1,011 EBA rules (588 live) for C 02.00 / C 07.00.x / C 08.0x / C 09.0x / C 34.x and 820 BoE rules (808 live) for OF02 / OF07 / OF08 / OF09, preserving each rule's severity, type, table set, row/column/sheet scope and formula — plus, for 427 BoE rules, the eba_equivalent id that cross-references the two regimes. Both the crr and basel31 skills gained a references/reporting-validation-rules.md covering the formula grammar, severity semantics, scope expansion, and how a {r0010, c0200} reference maps onto our generated frames (column names are the COREP column codes; row_ref carries the row code). Note for consumers: a BoE rule's expression is not self-contained — the row/column/sheet binding lives in a separate scope field, so boe_b0529 reads {t: OF07.00.01.01} = 0 and only scope reveals it means {r0140, c0220} = 0.
  • The supervisory validation rules are now enforced — in the test suite and before a submission. src/rwa_calc/reporting/validations/ evaluates the published EBA and BoE rules against the generated COREP/Pillar 3 bundles: one parser for both grammars, scope expansion (the EBA's rows/columns columns and the BoE's separate scope(...) field — in both sources a formula is meaningless without it, 161 live EBA formulas carry no row reference and 79 BoE expressions carry no binding at all), the Arithmetic approach distinction (Interval is rounding-tolerant, Point is exact), the If value missing policy (treat as zero vs do not run rule, which give materially different answers), and a liveness rule that counts a deactivated-then-reactivated EBA rule as enforced — 741 rules, not the 588 that filtering on status alone returns. Three codes: VAL001 an Error-severity break (blocks the return), VAL002 a Warning (explainable), and VAL003 insufficient coverage. VAL003 exists because the checker originally failed open: an empty bundle returned no findings — because all 741 rules were unevaluated, not because anything passed — so a caller writing the obvious if not errors: submit() would have filed a return on which nothing was checked. It has two limbs, separately consumable via the finding's field_name: no_rule_executed (the estate was never reached — this blocks) and template_not_covered (an emitted template had no rule run against it — reported, not blocking, because our C 34.x templates are stubs emitting 1 row × 1 column and blocking on them would reject correct CCR returns). The predicate lives once, on ValidationReport.coverage_shortfall, which the VAL003 finding also reads, with a test asserting the finding fires exactly when the property reports a shortfall and carries the same reason. Production: ResultExporter.validate_submission returns a frozen SubmissionValidationResult whose is_submittable is not blocking_breaks and was_checked — never inferred from an empty finding list — alongside export_validation_report (one row per rule outcome, with five run-level constants repeated on every row so the coverage caveat survives any sort or filter) and GET /api/validations, where a blocked submission is a 200 with is_submittable: false, never an error status. Test gate: tests/acceptance/reporting/test_supervisory_validations.py runs six portfolio × regime combinations and ratchets against a committed register at tests/expected_outputs/reporting/validation_known_breaks.json — no break outside the baseline, and no baseline entry that no longer breaks, so a fix must shrink it deliberately. The register records 75 known-broken rules and 4 known-uncovered templates, each with a written reason rather than a hash. Current position, honestly stated: is_submittable is false on both frameworks (CRR 18 blocking / 15 warnings of 257 rules executed; Basel 3.1 34 / 6 of 413), and the off-balance-sheet and ccr_crr estates pass. Coverage is reported against its denominator everywhere, and NOT_EVALUATED is never counted as a pass.
  • CalculationConfig.firb_fixed_maturity — the CRR Art. 162(1) fixed F-IRB supervisory maturity election (P1.249). Art. 162(1) first sentence requires an institution that "has not received permission to use own LGDs and own conversion factors for exposures to corporates, institutions or central governments and central banks" to assign M = 0.5 years to repo-style exposures "and to all other exposures M of 2,5 years"; the second sentence lets the Art. 143 permission substitute the per-exposure Art. 162(2) derivation instead. Only the 0.5y repo-style limb was implemented — every other F-IRB row took the date-derived Art. 162(2) M clipped to [1, 5] with no route to the fixed 2.5y. Setting CalculationConfig.crr(..., firb_fixed_maturity=True) now pins F-IRB non-repo-style exposures (derivatives included — Art. 162(1) carves out repos and securities-or-commodities lending only) to 2.5 years, leaving the repo-style 0.5y limb, A-IRB rows, an explicit effective_maturity input and the Art. 162(3) one-day carve-out untouched. The default is False, so no existing number moves: the election is a firm-permission fact, and which limb applies is set by the firm's Art. 143 permission. For a GBP 1m F-IRB corporate at PD 1% / LGD 45% with a 2.0-year residual, the election raises RWA from 911,289.01 to 978,558.09 (+7.38%, the MA(2.5)/MA(2.0) ratio); it lowers RWA where the date-derived M exceeds 2.5 years. The election is inert under Basel 3.1 — PS1/26 Art. 162(1) reads "[Note: Provision left blank]" and Art. 162(2) puts both the Foundation and the Advanced IRB Approach on the Art. 162(2A) methods — enforced by the CRR-only pack Feature firb_fixed_supervisory_maturity, with the 2.5y value homed on the cited pack scalar firb_fixed_supervisory_maturity_years.

Changed

  • The lease exposure-value input convention is now documented where a data provider will find it (P1.279). CRR Art. 166(4) (IRB) and Art. 134(7) (SA), and their PS1/26 counterparts Art. 166A(4) and Art. 134(7), all state that "the exposure value for leases shall be the discounted minimum lease payments" — the requirement is regime- and approach-invariant, and the same drawn_amount column feeds both. The engine cannot compute a discounted MLP even in principle (no minimum-lease-payment schedule and no discount-rate input exists anywhere), so the convention is documented on the LOAN schema page and in data/schemas.py rather than implemented, and the residual-value leg is distinguished from the receivable leg. No marker field was added, deliberately: the only validation that would justify one needs inputs that do not exist, and an inert Boolean would read as a compliance attestation while carrying zero assurance — every other attestation on these schemas gates real behaviour. The zero-cost alternative (product_type = "finance_lease") is documented instead, with a warning that product_type is not inert: the classifier reads it for infrastructure, development-finance and mortgage signals, so a value such as MORTGAGE_FINANCE_LEASE would trip mortgage detection. The Art. 201/213-215 third-party residual-value-guarantee limb of the same provisions remains unimplemented and is recorded as tracked separately.

Fixed

  • An exposure secured by a mortgage on commercial immovable property is now reported in Article 112(1)(i), not under its counterparty's class. Such an exposure was taking the correct real-estate risk weight while being reported as a corporate: 10,000,000 of commercial-real-estate lending sat on the C 07.00 corporates sheet at 50%. The cause is that the SA dispatcher's is_commercial_re_class predicate routes into the Art. 126 / Art. 124H-124I commercial branch off the raw property_type column as well as off the class string, so the risk weight followed the security while the class stayed with the counterparty — and the classifier could never correct it, because the real-estate loan-splitter, the only producer of commercial_mortgage, explicitly excludes income-producing property (has_income_cover) from the split and routes it down the whole-loan path instead. Both frameworks say the security is the classifying fact and both rank it the same way. PS1/26 Art. 112(2) Table A2 is explicit — "Where an exposure meets the criteria for more than one exposure class it shall be assigned to the exposure class that has the highest position in Table A2" — with real estate at row (7) (criteria: "Exposures for which a risk-weight treatment is set out in Articles 124 to 124L"), retail at (14) and corporates at (15). COREP Annex II gives the CRR twin: ¶62's prioritisation ranks "Exposures secured by mortgages on immovable property" 6th, above corporates and retail at 9th, and ¶58 notes class (i) is the one class where "a protection effect is intrinsically part of the definition of an exposure class". CRR Art. 124(1) carries the same logic in the operative text ("An exposure or any part of an exposure fully secured by mortgage on immovable property … except for any part of the exposure which is assigned to another exposure class"). The fix is a reporting-side overlay, not a classifier change, because Annex II ¶60 scopes the prioritisation to "the assignment of the Original exposure pre-conversion factor by exposure classes, without prejudice to the specific treatment (risk weight) that each specific exposure shall receive": the aggregator's _add_exposure_class_applied — which already re-maps defaulted and SME-managed-as-retail rows for exactly this reason — gained a commercial-real-estate limb that reuses the dispatcher's own predicate, so the reported class and the applied risk weight cannot drift apart again. It sits below the default limb and above the retail one, matching both rankings, and leaves high-risk / equity / covered-bond rows (which outrank real estate) and rows already in a real-estate class alone. The routing exposure_class is untouched, so approach selection, CRM and the risk-weight tables are unaffected and no RWA moves in either regime (verified across all five committed portfolios: CRR 145,511,467.29 and Basel 3.1 137,449,963.91 on the rich portfolio, unchanged to the cent). Three class→row maps that had no entry for the real-estate classes beyond retail_mortgage are filled in the same pass, each of which was silently dropping exposure rather than mis-placing it — a commercial_mortgage or splitter-emitted residential_mortgage leg mapped to no row at all: C09_01_SA_CLASS_MAP (CRR C 09.01 row 0090, a pre-existing latent gap that this change makes reachable and that a unit test had pinned as the then-current behaviour), Pillar 3 SA_DISCLOSURE_CLASSES row 9 (CR4 / CR5), and both s0010 entries in the validation sheet map. Golden movement is a pure re-allocation, paired to the cent on every frame: a new corep__c07_00__commercial_mortgage sheet; C 07.00 corporates −10,000,000 exposure / −5,000,000 RWEA (CRR) and −10,000,000 / −10,000,000 (B31); C 09.01 GB+TOTAL r0070 → r0090; C 02.00 r0130 → r0150 (CRR 13,380,950 → 8,380,950 and 140,000 → 5,140,000; B31 17,175,000 → 7,175,000 and 98,333 → 10,098,333); Pillar 3 CR4 r7 → r9 and CR5 r7 → r9 within the same risk-weight bucket. Under B31 the OF 09.01 "of which: regulatory CRE" sub-row 0092 populates for the first time (10,000,000), which is the follow-on the C 09.01 module docstring recorded as pending "if the classifier's IPRE-CRE scoping changes". The residential side was checked and needed no change: retail_mortgage is correctly in class (i) — the priority ranking puts the security above retail, and C 07.00 row 0040 is an "of which: … Residential property" of that class, which only parses if the class is the wider set — and no residential exposure anywhere in the estate is mis-classed, because the residential predicate has no property_type limb and so never applies a real-estate risk weight to a non-real-estate class.
  • COREP C 08.01 / OF 08.01 and C 08.02 / OF 08.02 no longer double-count Article 199 collateral, which was reducing the reported exposure and driving "exposure after CRM substitution" NEGATIVE. Col 0060 ("other funded credit protection") summed the raw immovable-property / receivables / other-physical collateral carriers, which were already being reported in their proper home — the CRM-in-LGD-estimates block at cols 0190/0200/0210 — so the same amount appeared twice on one sheet: once correctly, and once as an exposure reduction. Observed live at 500,000 of real estate against a 300,000 residential mortgage, putting −500,000 in col 0060 and driving col 0090 ("exposure after CRM substitution effects pre-conversion factors") to −200,000 on a supervisory return. This is the same defect as the C 07.00 col 0080 one, and the same remedy — removal, not capping. Cols 0040-0060 sit under "CRM techniques with SUBSTITUTION effects on the exposure": the route where the protection provider's risk replaces the obligor's, i.e. an effect on PD. Annex II says so of col 0060 three times over — "Collateral that has an effect on the PD of the exposure shall be capped …"; "Where own estimates of LGD are not used, Article 232(1) CRR applies" (the Art. 200(1) list — third-party deposits, pledged life policies, instruments repurchased on request — treated as a guarantee); and decisively the routing sentence "Where an adjustment is made in the LGD, that amount shall be reported in column 170". PS1/26 is blunter: "Other funded credit protection that is treated as a guarantee in accordance with Article 232 … shall be included. Other funded credit protection that is not treated as a guarantee … shall be reported in 0172." Immovable property, receivables and other physical collateral are LGD mitigants under both IRB variants — recognised through Art. 230 where own LGD estimates are not used and through Art. 181(1)(e)-(f) where they are — never through substitution, so they never touch PD. Annex II routes them by article to a block whose heading excludes substitution effects outright ("CRM techniques that have an impact on LGD estimates as a result of the application of the substitution effect of CRM techniques shall not be included in these columns") and whose columns cite the paragraphs: 0190 REAL ESTATE "Article 199(2), (3) and (4)"; 0200 OTHER PHYSICAL COLLATERAL "Article 199(6) and (8)"; 0210 RECEIVABLES "Articles 199(5) and 229(2)". Col 0060 now reads the Art. 232 carriers only (life_ins_collateral_value, third_party_deposit_value — the same pair C 07.00 reads), so the CRM waterfall closes at the full exposure rather than merely narrowing: col 0090 goes −200,000 → 300,000, and col 0190 still carries the whole 500,000, so nothing is lost. The Annex II cap on what legitimately remains in the block is retained and applied per leg to the block total (0040+0050+0060), shedding proportionally per the C 07.00 precedent — "shall be capped at the exposure value" (0040-0050) and "at the value of the original exposure pre conversion factors" (0060) — using the pre-conversion-factor basis throughout, because the block feeds the pre-CCF waterfall; it is inert on every committed portfolio now that the Art. 199 collateral is out, and kept because an Art. 232 deposit or a guarantee genuinely can exceed the exposure it covers. Col 0070 (the substitution outflow) stays outside the cap block: Annex II defines it as "the covered part of the original exposure pre-conversion factors", i.e. already a portion of the cap basis, and including it would make the cap bite on legs that are not over-collateralised at all, because a guaranteed leg that migrates class is counted by the waterfall in both col 0040 and col 0070 — the same double-subtraction fix-c07 removed from C 07.00, currently inert here (no leg in any committed portfolio sits in both) and recorded as a separate follow-up. The col 0100 off-balance-sheet memo, which independently re-derives the same components, reads the same carriers so it cannot contradict col 0090. No committed golden in the crr, b31, ccr_* or offbs_* sets moves — none of those portfolios carries non-financial IRB collateral; the movement is in the new irb_classes_crr set (col 0060 −500,000 → 0, col 0090 −200,000 → 300,000).
  • COREP C 08.02 / OF 08.02 no longer reports slotting exposures against an obligor grade, C 08.01/02 emit their two missing Basel 3.1 columns, and C 08.07 / OF 08.07's coverage percentages and roll-out row axis now match the DPM. Four independent defects across the IRB templates, closing 18 published supervisory validation rules. (1) OF 08.02 was reporting the whole slotting book under an "Unassigned" grade row. PS1/26 Annex II §3.3.4 paragraph 77A is explicit — "Institutions shall complete this template in respect of exposures subject to the AIRB approach and the FIRB approach, but not in respect of exposures subject to the slotting approach" — and §3.3.2 paragraph 76 says the same structurally under both frameworks: "CR IRB 2 provides a breakdown of total exposures assigned to obligor grades or pools (exposures reported under row 0070 of CR IRB 1)", where row 0070 is the F-IRB/A-IRB union and slotting reports separately on row 0080 ("SPECIALISED LENDING SLOTTING APPROACH: TOTAL"). A slotting leg has no PD-derived obligor grade by construction (Art. 153(5)), so the retired code banded the entire 75,000,000 slotting book onto C 08.02's residual "Unassigned" row and broke the cross-template identity {OF08.01 r0070} = sum({OF08.02}) on every shared column. C 08.02 now reads the IRB non-slotting book, exactly as C 08.03/05 already did, so a slotting-only class emits no C 08.02 sheet at all. Closes boe_b0752_2/_9/_10/_29/_32/_34/_36 and boe_b0814_01/_08/_09/_18/_20 (12 Error-severity rules). The OF 08.01 side needed no change and got none: it was already right. (2) Col 0251 (RWEA pre-adjustments) read 0.0 on every slotting row. rwa_pre_adjustments is produced by apply_post_model_adjustments, which runs on the formula-IRB branch only, so it is null on slotting legs and a plain Sum filled it to 0.0 — leaving {c0260} = sum({c0251;0252;0253;0254}) comparing a populated 52,500,000 total against empty components. It now coalesces per leg to the leg's own RWEA (the R10a expected-loss pattern, same cause): a value no-op on formula-IRB legs, and correct on slotting legs, which carry none of the three Art. 153(5A)/154(4A) adjustments cols 0252-0254 report — PS1/26 makes this explicit for col 0254 ("This column shall not be reported for sheets relating to the FIRB approach or the slotting approach"). Closes boe_b0751 and boe_b0763. (3) Col 0104 was never emitted. "Institutions shall report the value reported in column 0090 after adjusting for the reduction in exposure due to the Financial Collateral Comprehensive Method reported in columns 0101-0103"; the published identity states it additively as {c0104} = sum({c0090;0101;0102}) (col 0103 is an "of which" sub-item of 0102). It is now filled by a post-execute pass — not a Formula cell, because all three inputs are themselves Formula cells and the executor refuses a formula that references a formula — reproducing col 0090 while the FCCM-under-slotting carriers stay unwired, with the subtraction written out so the cell stays truthful the day one is. Closes boe_b1040. (4) C 08.07 reported 0-100 percentages, and OF 08.07's row axis was misaligned by one class. Both instruction sets define cols 0030/0040/0050 as a quotient — "Institutions shall calculate this percentage by dividing (1) by (2)" (PS1/26 Annex II §3.3.10.2), "the exposure subject to the Standardised approach before CRM over the total exposure in that exposure class in column 0020" (COREP Annex II §3.3.6.2) — and every published rule bounds the cell at 1, not 100 ({c0030} <= 1, {c0050} <= 1, {c0030} + {c0040} + {c0050} = 1); the retired code multiplied by 100. Separately, OF 08.07 carried nine class rows 0180-0260 with a sovereign row, a Total on 0270 and a row 0280 the DPM does not have. PS1/26 Art. 147B(1) lists exactly eight roll-out classes — institutions; specialised lending; corporate purchased receivables; financial/large/other general corporates; QRRE; retail secured by residential property; retail purchased receivables; other retail — with no sovereign class (PS1/26 withdraws the IRB approach for sovereigns), and Annex II §3.3.10.2 puts them on rows 0180-0250, the Total on 0260 ("the sum of the values reported in rows 0180-0250 for each of columns 0060-0150") and the aggregate permanent-partial-use materiality percentage on 0270. The axis is rebuilt accordingly; row 0210 unions corporate and corporate-SME as the single Art. 147B(1)(d) class, rows 0200/0240 render structurally null (purchased receivables have no Art. 147(2) counterpart in this taxonomy), and — the number-changing part — the Total row is now the union of the eight roll-out classes rather than the whole population, so equity, other non-credit obligation assets and sovereigns correctly fall outside it. Closes v09769_m and v09771_m; v09796_m / boe_b0778 now hold on every populated row and boe_b0779 on cols 0010/0020/0060-0150 (see the two recorded residuals below). C 08.07 moved to its own module reporting/corep/c08_07.py — it shares none of the C 08.01/02 value surface — which is the per-template split c08.py's docstring already recorded as the honest long-term answer. Golden movement is confined to corep__c08_*.
  • COREP C 07.00 / OF 07.00 no longer reports SA immovable-property collateral as a CRM substitution effect, and the Annex II CRM waterfall now foots. Column 0080 summed the raw, uncapped real-estate / receivables / other-physical collateral value into a column Annex II defines as "Other funded credit protection, Article 232 CRR". None of that collateral belongs there: CRR Art. 199 is headed "Additional eligibility for collateral under the IRB Approach" and admits immovable property, receivables and other physical collateral only for firms calculating under IRB, while Annex II names exactly three admissible items for cols 0050-0100 — financial collateral under the Financial Collateral Simple Method, other funded credit protection under Art. 232 (i.e. the Art. 200(1) list: third-party deposits, pledged life policies, instruments repurchased on request), and eligible unfunded credit protection. Under SA an immovable-property security drives the exposure class and its Art. 124-126 risk weight instead, so reporting it as a substitution effect double-counted the same benefit. On a realistic 60%-LTV mortgage it put 666,667 of property against a 400,000 exposure, drove col 0110 to -266,667, tripped the max(0, ...) Art. 223(3) E floor on col 0150 and broke every identity downstream. Three changes land together: col 0080 now reads the Art. 232 carriers (life_ins_collateral_value, third_party_deposit_value); the whole 0050-0080 block is capped per exposure at its own gross-net-of-provisions value, as Annex II requires ("Collateral that has an effect on the exposure value ... shall be capped at the exposure value"), shedding any excess proportionally; and col 0090 is now the Annex II outflow subtotal 0050+0060+0070+0080 rather than guaranteed_portion for class-migrating rows only, with col 0110 correspondingly reduced to 0040 - 0090 + 0100 — it previously subtracted the components AND the outflow, removing every substitution twice. The E floor itself is unchanged: it is a correct Art. 223(3) transcription and simply stops firing. 33 published supervisory validation rules stop breaking, including v10293_s / boe_b0667 (no cell may be negative), the waterfall identities v0305_m/v0306_m/v0307_m/v0308_m and their BoE equivalents boe_b0694/boe_b0697/boe_b0699/boe_b0471, and 18 of-which-row comparisons that failed only because the total they were measured against was negative. Only the C 07.00 goldens move, and only on the two collateralised sheets (corporate and retail_mortgage, both regimes).
  • COREP C 09.01 / OF 09.01's three "of which: SME" rows now populate, and both geographical-breakdown templates report a genuinely PRE-conversion original exposure in column 0010. Two independent defects in the geographical breakdown, both of which presented as missing disclosure rather than a wrong total, which is why neither had ever been caught by a tie-out. (1) The of-which SME rows were structurally dead. C 09.01's row keying is a reverse lookup over C09_01_SA_CLASS_MAP, whose values are the parent row keys (corporate, retail, retail_mortgage); nothing ever mapped onto corporate_sme / retail_sme / mortgage_sme, so rows 0075 / 0085 / 0095 short-circuited to a permanent all-null before any SME filter could run. Annex II and PS1/26 Annex II define all three identically — "Same definition as for row 0020 of [OF] CR SA template" — so each now keys its parent row's class union narrowed by c09_sme, which is C 07.00's own row-0020 SME ladder (sme_supporting_factor_eligible, falling back to the class-name test) reproduced verbatim: selecting on anything else could not tie out against the template the instruction points at. The narrowing term is basis-independent, so it survives the two-basis _either_pred union that keeps the 0020 defaulted memo on the original-class basis. B31 row 0095 is re_sme rather than mortgage_sme and is untouched — it narrows the real-estate class union by the raw is_sme borrower flag, mirroring C 07.00's RE memo rows (recorded R7). This closes seven published rules: v5773_q-v5776_q and boe_b0225-boe_b0227 (the cross-template ties to C 07.00 row 0020 on the corporates sheet — 500,000 gross / 500,000 exposure value / 500,000 pre-SF RWEA / −119,050 SME adjustment / 380,950 post-SF RWEA), plus the Error-severity v0411_m ({r0075} <= {r0070}), which was failing on column 0081 for a subtle reason worth recording: the SME supporting-factor adjustment is a "(-)"-labelled negative figure, so a null of-which row read as zero and 0 >= −119,050 is false. A missing disclosure was therefore breaking an inequality rule in the direction that looks like an over-statement. (2) Column 0010 reported a post-conversion figure. C 09.01 col 0010 is "Same definition as for column 0010 of CR SA template" and C 09.02 col 0010 is "Same definition as for column 0020 of CR IRB template" — both of which sum the sealed per-side gross carriers — but the geo templates bound ead_gross, which is drawn + CCF-adjusted undrawn. On an entirely drawn book the two coincide, which is why the rich portfolio never showed it; on the off-balance-sheet portfolio C 09.01 reported the corporates row as 12,300,000 against C 07.00's 22,500,000, the whole 10,200,000 of off-BS nominal having been silently converted away inside a column whose title is pre-conversion. Both templates now bind SafeSum(reporting_gross_on_bs, reporting_gross_off_bs, …), C 09.01 additionally carrying the counterparty-credit-risk term C 07.00's col 0010 carries (its population is C 07.00's, including the derivative netting sets, whose per-side carriers are null by design) and C 09.02 carrying none, exactly as C 08.01 col 0020 carries none. The two defaulted-subset twins that share the definition — C 09.01 col 0020 ("Original exposure pre-conversion factors for those exposures … classified as exposures in default") and C 09.02 col 0030 — follow the same ladder. A synthetic frame carrying no raw gross input at all keeps the retired single-column pick, since ensure_gross_side_carriers would give it structurally all-null carriers. This closes v5769_q / boe_b0222. Golden movement is confined to corep__c09_01__{GB,TOTAL} in the crr, b31 and ccr_b31 sets (row 0075 populating); no C 09.02 number moves on any committed portfolio, because every IRB fixture leg is drawn. Recorded as still failing and not a C 09.x defect: v6051_m ({r0170} = Σ rows excluding r0100) breaks only at columns 0010/0075/0080/0090, which belong to table C 09.01.a, not the C 09.01.b the rule is scoped to — the evaluator binds both DPM variants to our single union frame and has no column partition, so the rule is being evaluated on the other variant's columns. On C 09.01.b's own columns (0020, 0081) it passes, and C 09.01.a's counterpart rule v6050_m, which differs only by including r0100, passes on all 12 coordinates. The pair is in fact independent corroboration of this project's recorded two-basis C 09.01 design: r0100 belongs in the .a total because the primary columns key the applied class (defaulted is its own Art. 112(1)(j) class), and must be excluded from the .b total because those columns key the original class and would double-count it.
  • COREP C 07.00 / OF 07.00 columns 0160-0190 (the off-balance-sheet CCF breakdown) now populate, and report the correct quantity. Four defects stacked in one block, and because the outcome was empty cells rather than wrong numbers, nothing in the estate ever objected — the supervisory validation rules over those columns registered as unevaluable rather than failing, which is why this survived the template's whole life. (1) Dead carrier: the bucket expression read ccf_applied, a column no pipeline run produces — it exists only in data/schemas.py, analysis/recon_registry.py and the C 07.00 module itself, while every sibling template (corep/c08.py, pillar3/cr5.py, pillar3/cr6.py) reads the sealed ccf. The read was inside an if "ccf_applied" in cols guard, so it silently did nothing instead of raising. The carrier is now a named ladder resolved via the repo's existing pick(), ccf first, and a module logger warns when the buckets are about to publish null despite there being off-balance-sheet gross to break down. (2) Wrong quantity: the cells summed post-conversion EAD, but Annex II heads the block "breakdown of the fully adjusted exposure value of off-balance-sheet items by conversion factors" — i.e. pre-conversion. They now sum reporting_gross_off_bs, which is pre-conversion by construction (contingent → nominal_amount, facility_undrawn → undrawn_amount, loan → 0.0), is an unconditional AGGREGATOR_EXIT edge column, and — decisively — is what col 0010 already sums on the off-side and from which 0150 derives, so the buckets decompose 0150 by construction rather than by coincidence. (3) Narrowing never applied: the off-side restriction was gated on bs_type, a column the aggregator never seals (its own docstring says so), so on the ledger path on-balance rows were never excluded. A drawn loan carries ccf = 0.0, which is a real CRR bucket (Annex I low-risk → col 0160), so on-balance loans were landing in the 0% bucket; their gross there is 0.0 so no number moved, but the cell had quietly stopped being a statement about off-balance-sheet items. Both sites now route through one helper so they cannot drift apart again. (4) obs_product never reached the CCF stage: neither hierarchy coercion projected it, so the Art. 111(1) product→risk_type fill in engine/ccf.py was dead end-to-end and a documentary credit supplying only obs_product fell to the 50% medium-risk default instead of 20%. It is now declared on contracts/edges.py::_hierarchy_resolved_columns() (required=False with a null_meaning, propagating to the hierarchy/classifier/CRM exits in one place) and projected through unify.py and facility_undrawn.py; it is deliberately not on _calc_output_common_columns(), so it is still stripped at the calculator exit as the input-domain column it is. No existing golden moves — the main and CCR reporting portfolios are entirely drawn loans, and the empty-subset contract keeps their cells null rather than flipping to 0.0; the CCR portfolio's drawn loan at ccf 0.0 passing unchanged is itself the proof the narrowing works. New offbs_{crr,b31} goldens (8 exposures across every CCF band in both regimes) pin the behaviour: the buckets sum to 18,500,000 = c0150 on row 0080 in both regimes (closing the live Error rule v6364_m), row 0070 is null in every bucket, and the SA exposure-value waterfall closes exactly on generated cells — boe_b0471 at 22.5 − 0.9(3.0) − 0.8(1.5) − 0.6(6.0) − 0.5(6.0) = 12.0m under Basel 3.1, and the CRR analogue at 22.5 − 3.0 − 1.2 − 6.0 = 12.3m, whose (1−CCF) coefficients differ because col 0160 is the 0% unconditionally-cancellable bucket under CRR and the 10% bucket under Basel 3.1. The two coefficient sets are pinned separately so they cannot be conflated. Converging the seven synthetic test/fixture files that still supply ccf_applied onto ccf is recorded as a follow-up.
  • Non-finite (NaN/±inf) values in raw input tables are now nulled at the pipeline entry with a new DQ011 error, instead of poisoning rwa_final — and, under Basel 3.1, blanking the ENTIRE portfolio through the output floor. A NaN in any float input column (a guarantee amount_covered / percentage_covered, a loan drawn_amount / effective_maturity, a rating pd, ...) survives every downstream arithmetic step — Polars float .sum() propagates NaN — so it flowed through the CRM guarantee split into ead_final / risk_weight / rwa_final on the affected __G_/__REM legs, surfacing only at the aggregator as an AGG001 error with the rows excluded from portfolio totals. Because the poisoned legs report under the guarantor's approach, the failure presented as "IRB/RIRB exposures guaranteed by a standardised counterparty" when the defect was the input data all along; only pd/lgd were previously scrubbed (to their regulatory floors, AGG002), and the lgd scrub still left rwa_final NaN. Two independent layers close it. (1) Pipeline-entry gate (contracts/validation.py::scrub_non_finite_values, called by run_with_data so both the file-loader and in-memory entry paths are covered): every float column of every RAW_TABLE_EDGES frame is scanned in one aggregate pass per table; non-finite values are replaced with null — the documented degradation value, handled by downstream null semantics instead of blanking totals — and one aggregate DQ011 error per affected (table, column) records the count and up to five row references. A clean bundle passes through untouched. (2) Output-floor guard (engine/aggregator/_floor.py): the Art. 92 para 2A portfolio sums previously propagated one row's NaN into U-TREA/S-TREA and, via the pro-rata shortfall, into EVERY floor-eligible row's post-floor rwa_final (observed live: one NaN guarantee input blanked a whole B31 portfolio). Rows with a non-finite pre-floor RWA are now excluded from the floor computation — no share, no total contribution, their own (AGG001-flagged) value preserved — and a non-finite sa_rwa follows the existing null convention (counts as 0 in S-TREA). The AGG001/AGG002 aggregator net is unchanged and still catches anything minted mid-pipeline. No clean-data number moves in either regime; a NaN-carrying book previously reporting AGG001 exclusions (or a NaN B31 portfolio total) now reports finite totals plus DQ011s naming the offending source columns. The nested CCR/SFT composite bundles are outside the entry gate (the AGG001 net still covers them); extending the gate there is recorded as a follow-up.
  • CRR central-government and central-bank IRB exposures are no longer floored at 0.03% (P1.277). CRR Art. 160(1) is exhaustive about its scope — "The PD of an exposure to a corporate or an institution shall be at least 0,03 %" — and retail is floored by the separate Art. 163(1); neither article reaches central governments or central banks, so the CRR pd_floors bundle's sovereign: 0.03% was a conservative over-statement. It is now 0. Because every CRR floor was previously identical, _pd_floor_expression collapsed to a single scalar and never executed its per-class ladder under CRR; making the bundle non-uniform activates that ladder, which moves two surfaces. Direct exposures: a CGCB F-IRB row with a modelled PD below 3bp keeps its PD — on EAD 1,000,000 at PD 0.01%, LGD 45%, M 2.5y the risk weight falls from 15.31% to 7.98% (RWA 153,101.81 → 79,841.93). Guaranteed exposures: the three guarantor-class call sites in engine/irb/guarantee.py (the Art. 161(3)/160(4) substitution floor, its double-default variant and the expected-loss mirror) begin routing by the guarantor's class under CRR, so a central-government guarantor's substituted PD is likewise unfloored — correct under Art. 161(3), which benchmarks the covered portion against "a comparable, direct exposure to the guarantor", itself unfloored. Basel 3.1 is unchanged (PS1/26 Art. 160(1) does floor sovereigns, at 0.05%), corporates, institutions and all retail classes keep the 0.03% floor, and a row whose guarantor class is unknown still takes the conservative 0.03%. No reporting golden moves: the floor only binds below 3bp, and the two fixture rows in that range are a corporate (whose Art. 160(1) floor is unchanged) and the p2_36 unit-fixture sovereign — the repo's only central-government IRB row with a sub-3bp PD, whose RWA does fall from 153,101.81 to 79,841.93 but which has no golden-output entry. That row is now pinned absolutely so the unfloored sovereign arm cannot regress unnoticed. Residual conservative gap recorded: an international_organisation IRB row still takes the 0.03% corporate arm although Art. 147(3)(c) assigns 0%-risk-weight IOs to the central-government class.
  • The Art. 161(5)(b) LGD* floor now divides by the Art. 230(1) exposure basis instead of the post-CCF EAD (P1.248, follow-up). PS1/26 Art. 161(5)(b) requires LGD* to be "calculated using the Foundation Collateral Method in accordance with the Credit Risk Mitigation (CRR) Part", and Art. 230(1) states the denominator as E × (1 + H_E). The blend divided by ead_gross — the post-CCF value — which over-weights the secured share on every off-balance-sheet row, and because every substituted LGD_S (0/10/10/15%) sits below every LGD_U (25/30/50%), over-weighting the secured share lowers the floor. A £1,000,000 undrawn commitment at CCF 40% with £200,000 of eligible cash collateral was floored at 12.5% where Art. 230(1) requires 20% (RWA 134,683.78 instead of 215,494.05 — 37.5% under-capital on that row), and the error scales with 1 − CCF. The pre-fix expression was not even a convex combination: a fully collateralised off-balance-sheet row returned a 37.5% "floor", above the 25% LGD_U ceiling. The denominator is now ead_for_crm × (1 + exposure_volatility_haircut), sourced from a single shared engine/crm/expressions.py::lgd_star_exposure_basis_expr that the F-IRB lgd_star_expr and the SA Art. 228(1) gross-up also consume. Retail off-balance-sheet blended floors move the same way and in the same direction; no golden moves, because the golden portfolio contains no A-IRB row with recognised collateral.
  • CRR IRB now assigns non-named multilateral development banks to the INSTITUTIONS exposure class (P1.276). CRR Art. 147(3)(b) admits only the Art. 117(2) named (0% risk weight) MDBs to the central-government IRB class; Art. 147(4)(c) assigns "exposures to multilateral development banks which are not assigned a 0 % risk weight under Article 117" to the institutions class. The classifier previously left every MDB's exposure_class_irb on its Standardised label (mdb) — sync_irb_exposure_class overwrote the mapped IRB class — so a non-named MDB could only match an IRB model permission recorded under an mdb exposure class, never the institution-class permission Art. 147(4)(c) actually engages. The reroute is gated on the new cited crr_non_named_mdb_institution_irb_class pack Feature and is CRR-only: PS1/26 Art. 147(3)(f) assigns ALL MDBs to the quasi-sovereign class with no 0%-risk-weight qualifier, so Basel 3.1 is unchanged, as are mdb_named rows in both regimes. The change is parameter-neutral given the approach — the IRB correlation ladder is identical across MDB / institution / central-government, the FI 1.25× scalar is driven by counterparty flags rather than class, the PD floor is selected from the (unchanged) Standardised exposure_class, and no COREP or Pillar 3 class row reads exposure_class_irb — but it is approach-changing, and therefore RWA-changing, for any firm whose non-named-MDB IRB permission is recorded under central_govt_central_bank or mdb rather than institution, in both directions. No golden moves, because the only non-named MDB fixture carries a CQS rating but no internal_pd and no model_permissions row, so it cannot pass the IRB gate.
  • Third-country PSEs without an HM Treasury supervisory-equivalence determination now take the CRR Art. 116(5) flat 100% risk weight, in BOTH regimes (P1.252). Art. 116(5): "When competent authorities of a third country jurisdiction, which apply supervisory and regulatory arrangements at least equivalent to those applied in the United Kingdom, treat exposures to public sector entities in accordance with paragraph 1 or 2, institutions may risk weight exposures to such public sector entities in the same manner. Otherwise the institutions shall apply a risk weight of 100 %." The requirement is live under Basel 3.1 too: PS1/26 Art. 116 scopes paragraphs 1-3 to "UK public sector entities" and admits third-country PSEs only via Art. 116(3A) "[f]or the purpose of Article 116(5) of CRR", recording Art. 116(5) itself as "[Note: Provision not in PRA Rulebook]" because the Treasury equivalence power stays in CRR. No jurisdiction predicate existed anywhere in the SA override chains, so a German unrated PSE backed by a CQS-1 sovereign was weighted at 20% under both regimes (RWA 20,000,000 on a GBP 100m exposure instead of 100,000,000). A new nullable counterparty input is_equivalent_jurisdiction now gates all three PSE treatments — the Art. 116(1) Table 2 sovereign-derived lookup, the Art. 116(2) Table 2A own-rating weight and the Art. 116(3) short-term 20% — via engine/sa/jurisdiction.py::pse_jurisdiction_not_permitted_expr, with the 100% homed on the cited pack scalar pse_non_equivalent_jurisdiction_rw. NULL MEANS NOT EQUIVALENT: equivalence is an affirmative Treasury determination, so an absent assertion cannot manufacture one and Art. 116(5)'s own residual governs; a null cp_country_code likewise cannot prove UK-ness. UK PSEs never consult the flag, so an entirely UK book is unaffected and needs no data migration. The gate is regime-invariant — no pack Feature and no regime branch. A second, distinct gate implements the same articles' UK-only scoping of the Art. 116(3) short-term preferential: pse_short_term_eligible_expr restricts the 20% to UK PSEs, so an equivalent third-country PSE with an original maturity of three months or less now takes its Table 2 / Table 2A weight (50% at CQS 2) rather than 20%. PS1/26 Art. 116(3A) remaps "UK public sector entities" for paragraphs 1 and 2 only — paragraph 3 keeps its literal UK scope — and CRR Art. 116(5) admits third-country PSEs only "in accordance with paragraph 1 or 2"; the conservative reading is applied in both regimes to avoid an anti-conservative divergence on the same population. Firms holding third-country PSEs must populate is_equivalent_jurisdiction=True where a Treasury determination exists, or those exposures will be weighted at 100%.
  • Basel 3.1 A-IRB corporate and institution exposures that are only PARTIALLY secured are now floored on the PRA PS1/26 Art. 161(5)(b) LGD* blend instead of the flat 25% unsecured floor (P1.248). Art. 161(5) splits the A-IRB LGD input floor in two: point (a) is "a flat 25% floor value for unsecured exposures to corporates and for exposures where the institution chooses not to take into account funded credit protection", while point (b) covers "secured and partially secured exposures where the institution chooses to take into account funded credit protection" and sets the floor to the Art. 230 / 231 LGD* value computed with 25% substituted for LGDU and LGDS of 0% (financial collateral), 10% (receivables), 10% (immovable property) and 15% (other physical collateral). The EAD-weighted blend was implemented but gated to retail_other / retail_qrre only, so every corporate and institution A-IRB row fell through to the flat unsecured floor no matter how much collateral the model recognised — a GBP 10m corporate loan with GBP 4m of recognised cash collateral was floored at 25% (blend: 0.6 x 25% + 0.4 x 0% = 15%), giving RWA 6,734,189.18 instead of 4,040,513.51 at PD 2% / M 3y. The blend now applies to every exposure class except retail_mortgage, which keeps its flat 5% floor under Art. 164(4)(a); LGDU routes 50% QRRE / 30% retail other / 25% otherwise (Art. 161(5)(b)(iii)). Rows with no recognised protection are unaffected — they stay on the Art. 161(5)(a) flat floor — as are F-IRB rows (supervisory LGDs are not floored) and CRR, which has no A-IRB LGD floors.
  • Basel 3.1 F-IRB and Slotting commitments flagged is_uk_residential_mortgage_commitment now receive the PRA PS1/26 Art. 111(1) Table A1 Row 4(b) 50% conversion factor (P1.251). Art. 166C(1) sets the F-IRB / Slotting off-balance-sheet exposure value using "the conversion factor that would be applicable to the off-balance sheet item under the Standardised Approach, as set out in ... Article 111", which includes the PRA-specific Row 4(b) 50% factor for "UK residential mortgage commitments that are not subject to a conversion factor of 10% or 100%". The Row 4(b) override was written to the SA conversion-factor carrier only, so a B31 F-IRB row kept its generic bucket rate (Row 5 "other commitments" 40%, or Row 6 20%) — a GBP 1m fully-undrawn OC commitment took EAD 400,000 instead of 500,000 (RWA 369,267.21 instead of 461,584.01 at PD 1% / LGD 45% / M 2.5y, a 20% EAD understatement). The override now lands on both carriers (engine/ccf.py::CCFCalculator._apply_uk_residential_mortgage_ccf, mirroring _apply_purchased_receivable_ccf), with the Row 4(b) carve-out tested per carrier so Row 7 UCC (10%) and Row 1/2 (100%) rows stay untouched. SA and Slotting numbers are unchanged (they already read the patched SA carrier), and the flag remains inert under CRR, where F-IRB commitments take the Art. 166(8)(d) 75% supervisory factor.
  • CRR non-named MDB guarantors now price from the institution risk-weight tables instead of the Basel 3.1 MDB Table 2B (P1.253). CRR Art. 117(1): "Exposures to multilateral development banks that are not referred to in paragraph 2 shall be treated in the same manner as exposures to institutions. The preferential treatment for short-term exposures as specified in Articles 119(2), 120(2) and 121(3) shall not be applied." There is no MDB risk-weight table in CRR — the dedicated Table 2B (CQS 2 = 30%, unrated = 50%) is PRA PS1/26 Art. 117(1)(a)/(b) only. The shared guarantor expression (engine/sa/guarantor_rw.py::build_guarantor_rw_expr, compiled by both the SA and the IRB guarantee-substitution paths) applied Table 2B under both regimes, so a CRR MDB guarantor was anti-conservative at two CQS bands: CQS 2 30% → 50% (Art. 120 Table 3) and unrated 50% → 100% (Art. 121 unrated institution fallback, conservative absent a guarantor-sovereign CQS join). On a GBP 1m exposure fully guaranteed by a CQS 2 non-named MDB that is 200,000 of RWA relief withdrawn (300,000 → 500,000). CQS 1 / 3 / 4 / 5 / 6 are unmoved (the two tables coincide there), the Art. 117(2) named-MDB 0% carve-out is untouched in both regimes, the Art. 120(2) short-term Table 4 is deliberately not consulted for MDBs, and Basel 3.1 keeps Table 2B. The direct (non-guarantor) CRR MDB path in sa/risk_weights.py was already correct; this aligns the guarantor path with it.
  • COREP C 02.00 / OF 02.00 rows 0070-0211 now route SA RWEA by the exposure classes the pipeline actually seals, closing five Error-severity EBA validation rules. C02_00_SA_CLASS_MAP was keyed on a vocabulary no run has ever produced — central_government, regional_government, public_sector_entity, multilateral_development_bank, secured_by_property, higher_risk, other_items, retail — against a sealed reporting_class_origin that carries ExposureClass members (central_govt_central_bank, rgla, pse, mdb, retail_mortgage, high_risk, other, ...). A key that never matches does not raise: its row simply zero-fills, so RWEA in an unmapped class dropped out of the 0070-0211 breakdown entirely while the parent row 0060 (computed independently as the SA approach total) still carried it. Two classes were affected — corporate_sme and other — and a third, retail_mortgage, was mapped to row 0140 Retail when Art. 112(1)(i) puts it in row 0150. Rows 0070-0211 are "See CR SA template" (COREP Annex II §1.3.1; PS1/26 Annex II §1.3.1), i.e. each is a published identity against the C 07.00 sheet for the same Art. 112(1) letter, so the map's groupings are now the C 07.00 sheet groupings (validations/scope.py::_C07_SHEETS / _OF07_SHEETS) letter for letter: 0130 (g) takes corporate + corporate_sme + specialised_lending, 0140 (h) takes retail_other + retail_qrre only, and 0150 (i) takes retail_mortgage plus the Art. 124A/124H loan-splitter legs — the same union as C 09.01 row 0090. Rows 0190 (n) and 0200 (o) stay unmapped, matching the empty sheet keys for s0014/s0015. Five live Error rules go from FAIL to PASS: v0207_m (r0060 = Σ r0070..r0211, previously short by 400,950 on the rich CRR portfolio), v4240_i (r0130 = C 07.00 s0008: 13,000,000 → 13,380,950), v4241_i (r0140 = s0009: 327,500 → 187,500), v3334_i (r0150 = s0010: 0 → 140,000) and v3338_i (r0211 = s0017: 0 → 20,000); the Basel 3.1 frame moves the same four rows across all three columns. Only the class rows move — the 0010/0050/0060/0220 totals and every reporting/tieouts.py tie are computed independently of this map and are unchanged. A new contract test ties each class row to its CR SA sheet code in both regimes and rejects any map key that is not an ExposureClass member, so this class of defect cannot recur silently. Recorded separately as NOT fixed here: the C 02.00 row AXIS itself still diverges from both Annexes (our r0040 is "TOTAL OWN FUNDS REQUIREMENTS", a row neither Annex defines, where both put credit RWEA; the IRB block is shifted; r0020/r0030 are absent). None of the five rules above depends on it.

[0.3.19] - 2026-07-22

Fixed

  • COREP C 08.03 cols 0010/0020 (on-/off-balance-sheet gross exposure) now include the undrawn commitment legs they were silently dropping, so the reported exposure value (col 0040) no longer vastly exceeds on-BS + off-BS gross with no inflow to explain the gap. The fix seals per-side gross carriers at the aggregator and repoints every gross cell across the reporting estate to them, replacing the per-template balance-sheet ladders; no golden movement in either regime. The on-/off-balance-sheet discriminator mapped loan → on and contingent/facility → off, leaving the synthetic facility_undrawn (undrawn commitment headroom) leg null on both sides — so it was excluded from both gross columns while its exposure value stayed in col 0040 (reproduced on a mixed band: loan 5,000 + contingent 2,000 + facility_undrawn 4,000 with EAD 3,000 → 0010=5,000, 0020=2,000, 0040=9,000). Two new sealed aggregator-exit columns replace the null-prone discriminator for gross reporting: reporting_gross_on_bs (loan / contingent / facility_undrawn / legacy facility legs → drawn + accrued interest, each clipped at 0; null only when both components are null; null for CCR / settlement legs) and reporting_gross_off_bs (contingent → nominal; facility_undrawn → undrawn headroom counted exactly once; loan → 0.0; legacy facility alias → max(nominal, undrawn); null for CCR / settlement legs), both declared on the AGGREGATOR_EXIT edge (contracts/edges.py; CRR Art. 111). Every gross cell repoints to the side carriers: C 07.00 col 0010 (now sources contingent nominal and accrued interest as on-BS original exposure per Art. 166), C 08.01/02 cols 0020/0030, C 08.03 cols 0010/0020 (the reported defect) and its col 0030 CCF weight, C 08.06 cols 0010/0030 (undrawn headroom no longer double-counted), and Pillar 3 CR4 a/b, CR5 ba/bb, CR6 b/c, CR10 a/b. The CR4 col b / CR5 col bb off-BS gross double-count is corrected: facility_undrawn rows carry the headroom in both the nominal and undrawn carriers (aliased), so the retired nominal + undrawn sum counted it twice — the side carrier counts it once. A new reporting/pillar3/irb_scope.py narrows the CR6/CR10 population locally — counterparty-credit-risk and settlement legs are excluded (disclosed in the CCR-series per EU 2021/637 Annex XXII/XXIII, the IRB mirror of the CR4/CR5 sa_scope decision) and the facility_undrawn commitment is reclassified off-balance-sheet — closing the recorded R3 follow-up. The obsolete whole-bucket fallbacks are deleted (C 08.03 cols 0010/0020; C 08.06 col 0030): the side carriers are row-level and null outside their side, so a band with no off-BS rows sums 0.0 naturally. The template-local balance-sheet ladders (c07_bs, c08_bs, filter_off_bs) map facility_undrawn → off for the EAD "of which" side splits. The sealed reporting_on_balance_sheet contract is untouched (it belongs to consumers that make their own scope decisions); COREP C 08.x keeps its CCR legs in the population (their exposure value / RWEA stay in cols 0040/0090, and the null side carriers keep them out of the 0010/0020 gross columns per Annex II). No golden movements in either regime. Ref: CRR Art. 111 (off-balance-sheet items / CCFs); Art. 166 (IRB exposure value, including accrued interest); Reg (EU) 2021/451 Annex II (C 07.00, C 08.01/02/03/06); EU 2021/637 Annex XXII/XXIII (Pillar 3 CR6/CR10 credit-risk scope); PRA PS1/26 Art. 111.

[0.3.18] - 2026-07-22

Changed

  • Pillar 3 CCR1, CCR2, CCR3 and CCR8 converted from imperative to declarative TemplateSpecs and instrumented for report-cell lineage — the FINAL declarative conversion of the reporting estate (R27c). Pure refactor: the pillar3__ccr1/ccr3/ccr8 goldens are byte-identical in both regimes, and the R5 CCR8 CCP-scoping unit suite passes untouched. A new reporting/pillar3/ccr.py module hosts the four generate_ccrN bodies as specs run through the one cellspec.execute executor, plus each template's ccrN_plans / ccrN_frames for lineage; the four Pillar3Generator._generate_ccrN dispatch routers keep their signatures (the R5 unit suite calls _generate_ccr8 / _generate_ccr1 directly), and the Pillar-3 CCR population helper (_collect_ccr_rows, the include_sft flag) moves to ccr.py — kept LOCAL rather than sharing corep.c34.collect_ccr_rows because it gates only on exposure_reference and returns an empty frame (not None) for an empty selection, the exact contract the retired _ccr_rows carried. CCR1 sums the SA-CCR EAD (col a) over the ccr__ netting-set population (FCCM SFTs excluded) and the non-QCCP default-risk RWEA (col b) over the derived ccr1_default_risk complement (~((cp_entity_type == ccp) & cp_is_qccp.fill_null(True)); CRR Art. 107(2)(a)). CCR2 reads the portfolio cva_rwa roll-up as a broadcast per-row constant (FirstNonNull, the C 34.04 idiom), presence-gated exactly like the imperative generator (None under CRR — no CVA charge). CCR3 allocates the SA-CCR EAD across the CR5 risk-weight bands via the module-derived ccr3_band label (a first-match ±0.005 assignment reproducing _ccr3_band_eads; the bands do not overlap, so first-match equals the imperative per-band filter), with an "Other" catch-all and a Total row. CCR8 keys the include_sft=True CCP population (a CCP-faced SFT IS a CCP exposure, CRR Art. 301(1)(b)) restricted to cp_entity_type == ccp, split by the derived ccr8_qccp flag into QCCP / non-QCCP / Total — the R5 CCP restriction preserved exactly (a bilateral counterparty reaches neither row). CCR1/CCR3/CCR8 tie out against the CCR derivatives oracle in the lineage harness (the rich portfolio has no derivatives); CCR2 is pinned by a seeded lineage unit pin (no producing CVA fixture). The now-unused imperative helpers (_ccr_rows, _ccr_rwa, _ccr8_sum, _ccr3_band_eads, _first_non_null, _col_sum, _make_row, _build_df) are removed, along with the pre-existing dead _safe_sum / _null_row / _ead_weighted_avg / _filter_by_approach extraction leftovers — the Pillar3Generator is now a pure dispatch-and-export shell. With this item every declarative template is instrumented; the ONLY uninstrumented template left is C 02.00 (a pre-pass kernel-plus-thin-shell hybrid with no TemplateSpec to read). Ref: CRR Art. 439 (Part 8 CCR disclosures), Art. 274(2), Art. 306(1), Art. 444(e); PRA PS1/26 App.1 CVA Part Ch.4.2–4.4, Disclosure Art. 456.
  • COREP C 34.02 (SA-CCR EAD per netting set) converted from imperative to declarative TemplateSpec and instrumented for report-cell lineage — the first MULTI-SHEET counterparty-credit-risk template with drill-down (R27b). Pure refactor: the c34_02__NS_* goldens are byte-identical in both regimes. generate_c34_02 now builds one sheet per netting set through the one cellspec.execute executor, keyed on the netting_set_id stripped from the ccr__ reference prefix (the C 08.04 multi-sheet pattern); each sheet's plan frame is that netting set's slice of the pre-filtered SA-CCR population (FCCM SFTs excluded via the shared collect_ccr_rows), and the single row 0010 sums that set's ead_final (CRR Art. 274(2) EAD = alpha * (RC + PFE) per netting set — the Sum verb's fill_null(0.0) matches the retired group_by(...).agg(...) exactly). The COREPGenerator._generate_c34_02 dispatch router keeps its signature and empty-dict semantics; c34_02_plans / c34_02_frames expose the per-netting-set execution plans for lineage, tied out on the NS_CCR_QCCP netting set the CCR derivatives oracle produces. The now-unused c34_frame imperative frame-builder is removed. With C 34.02 declarative, the imperative reporting residual shrinks to C 02.00 (a kernel-plus-thin-shell hybrid) and the Pillar 3 CCR1–8 family (R27c).
  • COREP C 34.01, C 34.04 and C 34.08 converted from imperative to declarative TemplateSpecs and instrumented for report-cell lineage — the first counterparty-credit-risk templates with drill-down (R27a). Pure refactor: the C 34.01 / C 34.08 goldens are byte-identical, and the CVA C 34.04 (no producing golden fixture) is byte-identical to the CVA-A1 unit estate. A new reporting/corep/c34.py module hosts the three generate_c34_0x bodies as specs run through the one cellspec.execute executor, plus each template's c34_0x_plans / c34_0x_frames for lineage; the COREPGenerator._generate_c34_0x dispatch routers keep their signatures (the R5 C 34.08 unit suite calls _generate_c34_08 directly), and the shared CCR population helpers (collect_ccr_rows / collect_default_fund) move to c34.py as the CCR home — C 34.02 (per netting set, still imperative until R27b) reads collect_ccr_rows back. C 34.01 pre-filters its plan frame to the SA-CCR netting-set population (the CR8 pattern — ccr__-prefixed rows, FCCM SFTs excluded), summing EAD (col 0010) and RWEA (col 0020). C 34.08 keys three heterogeneous populations on one full-ledger plan: rows 0010/0020 partition the CCP subset (cp_entity_type == ccp) by the derived c34_qccp flag (cp_is_qccp.fill_null(True) — the R5 CCP restriction is preserved exactly, so a bilateral OTC counterparty reaches neither row), while row 0030 keys the CCR_DEFAULT_FUND risk type. C 34.04 (Basel 3.1 only, gated on a positive cva_rwa) reads the portfolio BA-CVA roll-up as a broadcast per-row constant (FirstNonNull, the OV1 row-26 idiom) — a row-backed cell that does not reconcile to a signed total. C 34.01 / C 34.08 tie out against a separate CCR derivatives source in the lineage harness (the rich portfolio has no derivatives); C 34.04 is pinned by a seeded lineage unit pin. Only C 02.00 (a kernel hybrid), C 34.02 and the Pillar 3 CCR1–8 family remain imperative.
  • Report-cell lineage (drill-down) now covers Pillar 3 CR9, CR9.1 and CR10 — the FINAL instrumentation item, closing the declarative template estate (R26). Pure refactor: no golden or reported-figure change. cr9.py and cr10.py each gained a <t>_plans(results, cols, framework, errors) -> dict[str, SheetPlan] builder (plus cr9_1_plans) that the thin generate_<t> iterates, so a cell's spec and its reported value key identically, and a _Provider was registered in LINEAGE_PLANS. CR9 / CR9.1 key the compound f"{approach} - {leaf class}" sheet on the obligor basis (the CR6 basis — Annex XXII bars substitution effects) and are Basel 3.1 only: like CMS1/CMS2 under CRR, cr9_plans yields {} on a CRR run, so a CRR lineage request degrades to a clean no-lineage. Their value cells are counts, weighted averages, arithmetic means and intra-row formulas — no Sum cell, so the tie-out sweep reconciles a whole sheet by predicate-match count rather than a signed total (CR9 forces defaulted legs into the 100% band via cr9_alloc_pd and drops empty PD bands post-execute). CR9.1 is empty on the real portfolio (the engine produces neither ecai_pd_mapping nor external_rating_equivalent), so it carries a seeded unit pin instead of an acceptance tie-out. CR10 keys per subtemplate (sl_type, plus the CRR equity sheet); its fixed col c risk weight is unbound in the spec and stamped post-execute, so the drill-down reports it as the template's empty policy and reads the display weight from the reported frame (the C 08.06 unbound-0070 precedent; the equity sheet's col b is unbound the same way), while every other cell (a/b/d/e/f) reconciles as a Sum/SafeSum. Tie-out cases added for cr9 (two B31 compound sheets), cr10 (project_finance and equity, CRR); unit pins added for the instrumentation shape, the CR10 unbound col c, the CR9 compound sheet key and the seeded CR9.1 parity. With this item every declarative template is instrumented — the only remaining gaps are the imperative C 02.00 (a pre-pass kernel hybrid) and the imperative C 34.x / CCR1–8 counterparty-credit-risk family, which expose no TemplateSpec to read.
  • Report-cell lineage (drill-down) now covers the two COREP C 09 geographical-breakdown templates and Pillar 3 CR6 — C 09.01 (SA exposures by country), C 09.02 (IRB exposures by country) and CR6 (IRB by exposure class and PD range, per obligor class) (R25). Pure refactor: no golden or reported-figure change. Each generator was split into a <t>_plans(results, cols, framework, errors) -> dict[str, SheetPlan] builder plus a thin generate_<t> that executes each plan, and a _Provider was registered in LINEAGE_PLANS. The two C 09 templates share a _c09_0x_prepared builder and a _country_frames splitter with their reported generators (so a cell's plan and its reported value key identically — TOTAL first, then one sheet per sorted non-null cp_country_code), and they are the first C 09-family sign-aware sweep: both pass _C09_NEGATIVE_COLS ({0081,0082,0121,0122}) explicitly, and the CRR supporting-factor adjustment columns fire non-zero and negative on the tie-out fixture (C 09.01 col 0081, C 09.02 col 0121), so the sweep reconciles the negated, row-backed deduction cells against their legs' positive magnitudes. C 09.01 carries the recorded two-basis row model: a primary cell keys the applied Art. 112 class (reporting_class_origin) while the 0020 "Defaulted exposures" memorandum keys the raw original class (exposure_class) plus the defaulted flag, so on a defaulted leg whose applied class moved the two cells of the same row drill different legs — pinned by a unit test, and the Basel 3.1 tie-out case additionally sweeps R7's real-estate rows 0090/0091 (its supporting-factor columns being CRR-only). C 09.02's value-dependent unweighted-mean fallback (_c09_02_avg_postfix, an override when a subset's total EAD is non-positive) stays a generate post-step on the reported frame the drill-down reads; it changes no cell's legs, no fixture subset triggers it, and — recorded limitation — the sign-aware sweep does not reconcile a WeightedAvg cell, so it is not that fallback's tripwire (unlike C 08.03's sum fallback). CR6 keys the obligor basis (reporting_class_origin — Annex XXII bars substitution effects, the opposite basis from CR4/CR5), executes each per-class spec over the whole alloc-PD frame (the classes_origin predicate lives in the cell specs), forces every defaulted leg into the 100% PD band (row 17) via the derived cr6_alloc_pd column (pinned by a unit test), and injects its String PD-range label into col a post-execute (not an addressable numeric cell — skipped by the value-column sweep, the C 08.02 col-0005 precedent); it carries no "(-)"-labelled deduction column, so negative_cols is empty. The fidelity tie-out sweep gains four cases — C 09.01 TOTAL (CRR and Basel 3.1), C 09.02 TOTAL (CRR) and CR6 corporate (CRR). No ratchet moved: c09.py and cr6.py stay far under the max_reporting_module_loc cap, the extraction added no multi-candidate pick ladder (the reporting_multi_candidate_picks count is unchanged), and no new @cites was added. No golden movement, no RWA/EAD/exposure change. Ref: Reg (EU) 2021/451 Annex II (C 09.01/02); CRR Art. 112/147/452(g); PRA PS1/26 Annex II/XXII (OF 09.01/02, CR6).
  • Report-cell lineage (drill-down) now covers the last three C 08 instrument templates — COREP C 08.03 (IRB by PD range) and C 08.05 (PD back-testing), both per exposure class, and C 08.06 (IRB slotting specialised lending), per SL type (R24). Pure refactor: no golden or reported-figure change. Each generator was split into a <t>_plans(results, cols, framework, errors) -> dict[str, SheetPlan] builder plus a thin generate_<t> that executes each plan, and a _Provider was registered in LINEAGE_PLANS. C 08.03 and C 08.05 share a sparse PD-range row axis (only populated buckets emit a row, each keyed on the derived c08_pd_range band carried in row_terms). C 08.05 is execute-only (R13 deleted its rate postfix); C 08.03 keeps two post-execute passes on the reported frame (the on/off-balance-sheet whole-bucket fallback on cols 0010/0020; the provisions ladder on col 0110). Recorded limitation surfaced in the C 08.03 scope wording: on a loans-only book that fallback fires for col 0020 (off-balance-sheet split empty) but is a value no-op — both the off-balance-sheet binding and the whole-bucket fallback sum to 0.0 — so no divergence arises today, with the tie-out sweep as the tripwire. C 08.06 keys sheets by SL type (CRR's IPRE absorbs HVCRE; Basel 3.1 splits HVCRE; empty SL types emit no sheet) and is the first template with a per-sheet spec: the row set is number-neutral but the empty-row set is per sheet, and an empty non-Total category row carries a fixed display risk weight in col 0070 (a zero-fill artefact, not a measured weighted average), so that one cell is left unbound — the drill-down reports the template's empty policy and reads the value from the reported frame rather than a WeightedAvg with no legs whose value would contradict the screen; live rows and Total rows compute normally with the 0030 nominal / 0040 clamp / 0070 first-non-null live fixes and the provisions ladder applied on the reported frame. The fidelity tie-out sweep gains three cases — one representative sheet each (C 08.03 corporate, C 08.05 corporate, C 08.06 project_finance). Ratchet note: the extraction bumped max_reporting_module_loc (2099 → 2320, the exact post-R24 line count, zero slack) — the mechanical additive cost of the cells/plans builders with their mandated docstrings; c08.py alone needs a bump per wave (unlike the c07/cr4/cr8/cr7a extractions) because it hosts seven templates in one module. Splitting c08.py into per-template modules remains a deferred follow-up. Ref: Reg (EU) 2021/451 Annex II (C 08.03/05/06); CRR Art. 153/158/166 (IRB), Art. 452(g)/453 (disclosure); PRA PS1/26 Annex XXII.
  • Report-cell lineage (drill-down) now covers the two remaining C 08 instrument templates — COREP C 08.01 (IRB totals) and C 08.02 (IRB by PD grade), both per exposure class (R23). Pure refactor: no golden or reported-figure change. Each generator was split into a <t>_plans(results, cols, framework, errors) -> dict[str, SheetPlan] builder plus a thin generate_<t> that executes each plan, and a _Provider was registered in LINEAGE_PLANS. C 08.01 carries the first large Annex II §1.3 "(-)" deduction set through lineage since C 07.00 (cols 0035/0040/0050/0060/0070/0102/0103/0256/0257/0290); the tie-out sweep proves the sign-aware reconciliation on a live negated cell (col 0256 fires non-zero on the corporate_sme sheet). C 08.01's Total-row col 0080 — the cross-sheet CRM substitution inflow — is a side_context cell whose plan threads the real per-class value, so it drills down rather than being refused; C 08.02 deliberately holds col 0080 at a constant 0.0 at grade grain (the recorded R12 disposition), and its string row-label col 0005 (injected post-execute) is skipped by the tie-out's numeric value-column enumeration. Ratchet note: the extraction bumped max_reporting_module_loc (2016 → 2099, the exact post-R23 line count, zero slack) — the mechanical additive cost of the spec/row-pred/plans builders with their mandated docstrings plus this recorded rationale; c08.py alone needed the bump (unlike the c07/cr4/cr8/cr7a extractions) because it hosts seven templates in one module. Splitting c08.py into per-template modules is recorded as a deferred follow-up.
  • Report-cell lineage (drill-down) now covers four more templates — and the FIRST multi-sheet instrumentations since C 07.00: COREP C 08.04 and Pillar 3 CR7-A, plus the single-frame COREP C 08.07 and OF 02.01 (R22). Pure refactor: no golden or reported-figure change. Each template's generator was split into a <t>_plans(results, cols, framework, errors) -> dict[str, SheetPlan] builder plus a thin generator that executes the plan, and each registers in LINEAGE_PLANS. C 08.04 (IRB RWEA flow, per exposure class) is the CR8-clone flow: c08_04_plans builds the per-class current-period plans (no prior frame threaded), so its opening (row 0010, a prior_period cell) and residual (row 0080, a formula deriving from it) rows are REFUSED by the drill-down with the same distinct 404 as CR8's rows 1/8 (honesty rule 1 — the current-period ledger cannot reproduce a prior-period figure); the reported generate_c08_04 keeps threading the external prior frame, and c08_04_frames is the lineage-facing generator. CR7-A (extent of IRB CRM techniques, per origin approach) is a clean per-sheet extraction — no prep and no post-execute pass beyond the in-spec column-c formula, so generate_cr7a executes the plans directly and stays the provider generator; plans() and generate() key their sheets identically (the origin approach). C 08.07 (IRB scope of use, single frame) and OF 02.01 (output-floor comparison, single frame) each carry post-execute passes that stay on the reported frame (C 08.07's col-0040 percentage rescale and its fixed structural-null rows via c08_07_frames; OF 02.01's fixed out-of-scope rows via of_02_01_frames) — the drill-down reads a cell's value from the reported frame, so it honours them rather than contradicting the sheet, and C 08.07's fixed-null rows read as unbound cells. OF 02.01 is Basel 3.1 only (like CMS1/CMS2): its plans() yield nothing under CRR, so a CRR lineage request degrades to the same clean 404 as an uninstrumented template rather than crashing, and it ties out against the Basel 3.1 run; it deliberately does not re-apply the OutputFloorConfig entity gate in the lineage view (the no-config view mirrors the tie-out's reported frame). The fidelity tie-out sweep gains four cases — one representative sheet each (C 08.04 corporate, CR7-A advanced_irb, and the two single frames). Recorded limitation surfaced in the C 08.07 scope wording: because col 0040 is a Sum(ead_final) rescaled to a percentage post-execute, on a book that carried Art. 148 roll-out legs its drill-down legs would sum to the raw EAD rather than the reported percentage — no fixture carries the is_under_irb_rollout input today, so the cell is empty (0.0) and the sweep does not exercise the divergence. No golden movement, no RWA/EAD/exposure change. Ref: Reg (EU) 2021/451 Annex II (C 08.04/07, OF 02.01); CRR Art. 148 (C 08.07 roll-out), Art. 453(g) (CR7-A); PRA PS1/26 Annex XXII, Art. 92 para 2A (OF 02.01 scope).
  • Report-cell lineage (drill-down) now covers four more templates — the single-frame Pillar 3 OV1, CR5, CMS1 and CMS2 (R21). Pure refactor: no golden or reported-figure change. Each template's generator was split into a <t>_plans(results, cols, framework, errors) -> dict[str, SheetPlan] builder plus a thin generator that executes the plan, and each registers in LINEAGE_PLANS as a single_frame=True provider (cells report sheet = null; the sheet query parameter is ignored). OV1 is the first instrumented template with an out-of-frame side_context cell (row 27's OF-ADJ, from the output-floor summary) and a first_non_null cell (row 26's output-floor multiplier). Because the reported template is generated WITH the run's summary but the drill-down's plan carries none, row 27 would render null against a real figure on the screen — so the resolver REFUSES it with a distinct 404 (cell reads an out-of-frame side value this drill-down does not carry), on both the REST and UI surfaces, exactly as CR8 refuses its prior-period rows (honesty rule 1: a drill-down never disagrees with the screen). The refusal is CONDITIONAL, gated on the cell's SideContext value being absent from the plan's own context: C 07.00's col 0100 is also a side_context cell but its plan threads the real per-sheet substitution inflow, so it stays drillable. generate_ov1 keeps its distinct signature to thread the summary, while the new ov1_frames is the provider generator. OV1's plan frame carries the derived CCR discriminators so rows 1-5 (credit risk excluding CCR) and the CCR block (rows 6/7/UK8a/9) drill down on the same risk_type-keyed cut the generator ran, and the row-24 Art. 48(4) 250%-RW memo's recorded "other-items" approximation is surfaced in the scope wording. CR5's plan frame is the SA credit-risk population carrying the derived cr5_rw_bucket (the Art. 123B pre-multiplier banding) and cr5_unrated (the external_cqs rating-presence flag, with its recorded guarantee-substitution limitation). CMS1 / CMS2 are Basel 3.1 only: their plans() yield nothing under CRR, so a CRR lineage request degrades to the same clean 404 as an uninstrumented template rather than crashing; the fidelity tie-out sweep gained a Basel 3.1 run so the CMS pair ties out against real figures. generate_cr5 / generate_cms1 / generate_cms2 now return the lineage-shaped single-frame dict (the dispatch router unwraps it, None under CRR for the CMS pair). No golden movement, no RWA/EAD/exposure change. Ref: CRR Art. 438/444(e)/452/453; Art. 48(4) (OV1 row 24); PRA PS1/26 Art. 456(1) (CMS1/CMS2), Annex II/XX/XXII.
  • Report-cell lineage (drill-down) now covers four more templates — the single-frame Pillar 3 CR4, CR6-A, CR7 and CR8 (R20). Pure refactor: no golden or reported-figure change. Each template's generate_<t> was split into a <t>_plans(results, cols, framework, errors) -> dict[str, SheetPlan] builder plus a thin generator that executes the plan, so reporting.lineage reads the very spec the generator runs (rather than a second copy that could drift). The four register in LINEAGE_PLANS as single_frame=True providers — their cells report sheet = null and the sheet query parameter is ignored. CR4's plan frame is the SA credit-risk population (counterparty-credit-risk / settlement legs dropped, the facility_undrawn commitment reclassified off-balance-sheet), and its per-column class basis split (origin class for the pre-CRM columns, post-substitution class for the post-CRM columns) is preserved exactly. CR8 is the first template whose opening row (a prior_period cell) and residual row (a formula deriving from it) carry prior-period figures; because the drill-down runs on the current-period ledger only it cannot reproduce them, so those cells return a distinct 404 (cell derives from the prior period; drill-down covers the current-period ledger only) on both the REST and UI surfaces, rather than a 200 with a null that would contradict a comparative-period report (honesty rule 1: a drill-down never disagrees with the figure on the screen). The IRB non-slotting population filter moved from the Pillar 3 generator into cr8.py (irb_non_slotting_population) so the reported figure and the lineage view read one population. The fidelity tie-out sweep is parametrised over all five instrumented templates.
  • Report-cell lineage (drill-down) machinery generalised so the remaining declarative templates can be instrumented in a few lines each, and the drill-down 404s now carry a reason (R19). No golden or behaviour change to C 07.00 lineage. The cell-lineage feature shipped with exactly one instrumented template (C 07.00 in LINEAGE_PLANS); the shared machinery still carried C07-shaped assumptions that would have mis-signed or mis-typed any second template. Five generalisations, all additive: (1) the SheetPlan execution-plan container moved out of corep/c07.py into a new shared reporting/plans.py, so every template's <t>_plans() returns the same dataclass instead of being typed against C 07.00's — c07.py imports it back, a number-neutral extraction (C 07.00 goldens byte-identical). (2) SheetPlan.negative_cols (the COREP Annex II §1.3 "(-)" deduction set, negated post-execute) is now a required field — it previously defaulted to C 07.00's set, a silent mis-sign risk for any future template sharing refs like 0030/0050/0090; each template now passes its own set (or frozenset()) explicitly. (3) single-frame templates (cr4, cr7, cr8, ov1, cms1/2, c08_07, of_02_01, …) can register via a new _Provider(single_frame=True) flag — their cells report sheet = None and their plans()/generate() return a one-entry dict, resolved by a new _resolve_sheet_key helper. (4) GET /api/lineage now normalises an empty-string sheet query to None (matching the UI), and both the REST endpoint and the UI route return differentiated 404stemplate not instrumented for lineage vs unknown cell vs unknown run — instead of one undifferentiated "no lineage available"; a consistency guard logs loudly if a template's plans() and generate() key their sheets differently (a silent-null drift otherwise). (5) the fidelity tie-out (tests/acceptance/reporting/test_lineage_tieout.py) is now a per-template harness parametrised over _TIEOUT_CASES, running the full sweep (cell value == reported, kind consistency, predicate satisfaction, sign-aware reconciliation) for each instrumented (template, sheet) — a new template earns its tie-out by adding one tuple, not by cloning the file. No new template is instrumented in this change (that is R20-R26); the per-template recipe is documented in docs/features/report-cell-lineage.md. No golden movement, no RWA/EAD/exposure change — C 07.00 lineage is byte-identical, this is drill-down plumbing plus a clearer 404 contract. Ref: Regulation (EU) 2021/451 Annex I/II (COREP); CRR Part 8 (Pillar 3); Annex II §1.3 (the "(-)" sign convention).

Fixed

  • Pillar 3 CR10 (slotting) column c now carries the maturity-correct fixed risk weight — each supervisory category splits into its two remaining-maturity rows (< 2.5y / >= 2.5y) instead of stamping the >= 2.5-year weight on every category row (R18a). Column c ("This is a fixed column. It shall not be altered" — PRA Disclosure (CRR) Part Annex XXIV; CRR Art. 153(5) Table A) was stamped from single-scalar maps carrying only the >= 2.5-year band (non-HVCRE Strong 70% / Good 90%; HVCRE Strong 95% / Good 120%) and applied to every Strong/Good row regardless of maturity. A Strong exposure with remaining maturity < 2.5 years therefore displayed 70% while the engine had already correctly risk-weighted it at the preferential 50% (Art. 153(5): Strong 50%, Good 70% under 2.5 years). This was a disclosure-accuracy defect, not a capital one — the RWEA (col e) always used the applied weight; only the displayed fixed weight was wrong. Because col c is fixed and Table A gives two weights per Strong/Good category, a single category row cannot represent a category that mixes maturities, so the fix is row-level. CR10.1–CR10.4 now split each of the five supervisory categories into its two remaining-maturity rows and close with two maturity-split Total rows (6 → 12 rows), mirroring COREP C 08.06 / OF 08.06 over the same Art. 153(5) Table A so the two slotting disclosures stay row-consistent. Each (category × band) row carries its own fixed weight (non-HVCRE Strong 50%/70%, Good 70%/90%, flat 115%/250%/0% otherwise; the Basel 3.1 HVCRE sheet CR10.5 takes Strong 70%/95%, Good 95%/120%). Legs route on the sealed is_short_maturity discriminator via a module-derived cr10_is_short column with the C 08.06 asymmetric fallback: a frame lacking the maturity column sends every leg to the >= 2.5-year band, leaving the < 2.5-year rows empty with their fixed weights still displayed. The CR10.5 equity sheet (Art. 155(2)) is not maturity-banded and is untouched. Golden impact: crr/b31 pillar3__cr10__project_finance.ndjson restructure 6 → 12 rows — the single Strong project-finance leg (remaining maturity to 2031, >= 2.5y) now sits in the "Strong — remaining maturity >= 2.5 years" row (col c 70%, EAD/RWEA/EL unchanged) with the "Strong — remaining maturity < 2.5 years" row empty at its fixed 50%; the crr pillar3__cr10__equity.ndjson is byte-identical. EAD, RWEA and expected loss are unchanged — this corrects only the displayed fixed weight and the row layout. Ref: CRR Art. 153(5) Table 1 / PS1/26 Art. 153(5) Table A (slotting weights incl. < 2.5y preferential split); PRA Disclosure (CRR) Part Annex XXIV (UKB CR10, col c "fixed"); Art. 438(e).
  • Pillar 3 UKB CMS2 row 0041 ("Of which are FIRB") column c now reports the F-IRB corporate sub-population's own actual RWA instead of that plus the entire standardised + equity corporate book (R18b). The predicate summed rwa_final over corporate-class legs whose origin approach was in (foundation_irb, standardised, equity), so an of-which-F-IRB sub-row disclosed the F-IRB corporate RWA plus the whole SA and equity corporate RWA — over-stating the sub-row by the entire standardised book. PRA PS1/26 Annex II defines CMS2 col c as the sum of (i) the IRB (incl. slotting) RWA and (ii) the SA RWA of the row's population; an of-which-F-IRB row's population holds no SA legs, so col c collapses to the F-IRB actual RWA (= col a) — exactly as the sibling row 0042 (of which A-IRB) mirrors its col a. The predicate is narrowed to foundation_irb corporates only (col a's population); col b (the SA re-computation of col a's population) and col d (full-SA at the parent corporate level) are unchanged, and the now-unused _STANDARDISED_APPROACHES local is removed. CMS1 has no of-which-by-approach sub-rows, so the defect cannot occur there (checked). Golden impact: b31 pillar3__cms2.ndjson row 0041 col c 66,919,060.92 → 48,244,060.92 (= col a; the 18,675,000 standardised + equity corporate RWA dropped); ccr_b31 pillar3__cms2.ndjson row 0041 col c 2,500,000 → 0.0 (that CCR fixture carries no F-IRB corporates — the standardised corporate CCR RWA stays in the parent row 0040 col c). Every other CMS2 cell is byte-identical. Ref: PRA PS1/26 Annex II (UKB CMS2 col c = "sum of IRB RWA + SA RWA" of the row's population); Art. 456(1)(b).
  • Pillar 3 CR5 "Of which: unrated" (CRR col q / UKB col ae) now reports only the exposure value with no nominated-ECAI assessment, instead of restating 100% of every class row as unrated (R17). The column was bound to the identical Sum(reporting_ead) + row-membership predicate as the row's own Total column, so every SA class row disclosed its entire post-CRM exposure as "unrated" — even for rated sovereigns, institutions and corporates the engine assigns real CQS-driven external risk weights to. The documented meaning (CRR Art. 444(e) / PS1/26 Annex XX; EBA Annex XX instructions) is the exposure value for which no nominated-ECAI credit assessment is available — an input availability fact, applied uniformly to every class row and independent of whether the class treatment uses the rating (a retail leg on the flat 75% RW that nonetheless carries an ECAI assessment counts as rated; an unrated corporate counts as unrated). The recorded finding noted sa_cqs is stripped by the aggregator seal (which is why COREP C 07's 0230/0235 ECAI split is structurally null in production), but the leg's own Art. 138-resolved external rating already survives sealed on AGGREGATOR_EXIT_EDGE as external_cqs (_calc_output_common_columns, null = no own ECAI assessment). CR5 now derives a module-owned cr5_unrated = external_cqs.is_null() discriminator column (the established cr5_rw_bucket / C 08 c08_unrated_corp derived-column idiom) and conjoins it into the unrated cell's predicate — no new sealed column, no engine change, the lightest fix. Because external_cqs partitions each row's legs into rated / unrated, the column now satisfies unrated ≤ Total and rated + unrated == Total per row. Frames lacking the carrier (synthetic unit frames that supply no rating info) fall back to all-unrated, so the pre-R17 column-equals-Total behaviour is preserved there (mirrors the C 08 unrated-corporate discriminator's else pl.lit(True) fallback). On the fixtures external_cqs equals the SA-adjusted cqs on every SA leg (no MDB-institution-lift or Art. 139(2B) SL-nulling edge cases), so the choice of the pure-input external_cqs carrier is number-identical to cqs while matching the instruction wording exactly. Known limitation (recorded): a guarantee-substituted __G_ leg keeps the OBLIGOR's external_cqs (own ratings only; the CRM split never repoints it) while CR5 bands that leg in the GUARANTOR's class row, so a rated-guarantor leg from an unrated obligor counts as unrated in the guarantor's row — repointing to the guarantor's rating would be an engine change, out of R17 scope; unrated ≤ Total still holds and no fixture carries guarantees, so no golden is affected. Golden impact is confined to the four pillar3__cr5.ndjson files (rich crr/b31, ccr_crr/ccr_b31); only column q (CRR) / ae (B31) moves, every band / Total / Other-Deducted / on-off-BS cell is byte-identical. Rich portfolio (RP-LN-SOV CQS 1, RP-LN-INST CQS 2, RP-LN-CORP-RATED + RP-LN-CRE CQS 3 are rated; the rest unrated): row 1 Central governments 1,000,000 → 0, row 6 Institutions 2,000,000 → 0, row 7 Corporates 18,500,000 → 3,500,000 (= the 3,000,000 unrated corporate + 500,000 corporate SME, the 15,000,000 rated dropped), row 17 Total 23,250,000 → 5,250,000. CCR portfolio (LN_CCR_CORP CQS 2, the only SA credit-risk leg): row 7 Corporates 5,000,000 → 0 and row 17 Total 5,000,000 → 0. EAD, RWA and risk weights are untouched — this corrects only the unrated-exposure memorandum column. Ref: CRR Art. 444(e) (CR5, col q); PRA PS1/26 Annex XX (UKB CR5, col ae); Art. 138 (external assessment resolution); Reg (EU) 2021/451 / EBA Annex XX (CR5 "of which: unrated" instructions).
  • Pillar 3 UKB OV1 no longer carries the pre-floor RWEA row 4a or the six pre-floor capital-ratio rows 5a/5b/6a/6b/7a/7b — those are UKB KM1 rows, wrongly grafted onto OV1 (R16, part b). The authoritative PS1/26 Annex II ("Template UKB OV1 — Overview of risk-weighted exposure amounts. Fixed format", pp. 1-9) runs rows 1..29 whose only output-floor lines are row 26 (output floor multiplier, Art. 92(5)) and row 27 (output floor adjustment, Art. 92); rows 25 and 28 are "Empty set in the UK". There is no 4a and no 5a-7b. Those seven rows are UKB KM1 (Key Metrics) rows: KM1 4a "Total risk-weighted exposure amounts (RWEA) (pre-floor)" and KM1 5b/6b/7b the pre-floor CET1/Tier 1/Total capital ratios (KM1 5a/6a/7a are the fully loaded ECL accounting model ratios, not pre-floor). P1.162 had added them to B31_OV1_ROWS reading them as mandatory UKB OV1 output-floor rows; that was wrong. The seven P3Rows are removed from B31_OV1_ROWS (25 → 18 rows, now matching the fixed template's CCR-block-before-equity-rows ordering), and the whole capital-ratio-override feature that fed rows 5a-7b is deleted with them — Pillar3CapitalRatioOverrides (contracts/config.py + contracts/__init__.py export), the ReportingContext.capital_ratio_overrides field and its side_value ratio keys, the capital_ratios kwarg on Pillar3Generator.generate_from_lazyframe / ResultExporter.export_to_pillar3 / export_pillar3_facts, and the six *_ratio_pre_floor* query params on GET /api/export/{fmt} — no dead plumbing left behind. Pre-floor RWEA and pre-floor ratios genuinely belong to UKB KM1, a Key Metrics template this calculator does not produce; that is now a documented gap (the pre-floor RWEA component is still surfaced as pre_floor_rwa in OF 02.01 / UKB CMS1), not a set of mislabelled OV1 rows. Docs corrected (docs/features/pillar3-disclosures.md, docs/framework-comparison/disclosure-differences.md). Golden impact: the two Basel 3.1 OV1 goldens lose those seven rows (b31 and ccr_b31 pillar3__ov1.ndjson); every surviving cell — including row 29 (Total) — is byte-identical. CRR OV1 is untouched (it never carried them). Ref: PRA PS1/26 Annex II ("Template UKB OV1" rows 1-29; "Template UKB KM1" rows 4a / 5b / 6b / 7b); Art. 92(5)/92 (output floor); Art. 447 / 438(b) (KM1).
  • Pillar 3 OV1 row 24 ("Amounts below the thresholds for deduction (subject to 250% risk weight)") is now the Art. 48(4) threshold-item memo instead of any leg risk-weighted at 250% (R16, part a). PS1/26 Annex II defines OV1 row 24 as the items subject to a 250% risk weight specifically under CRR Art. 48(4) — deferred-tax assets from temporary differences and significant investments in a financial-sector entity's CET1, each below the 10%-of-CET1 deduction threshold of Art. 48(1) — disclosed "for information purposes only as the amount included here is also included in row 1". The cell's predicate was reporting_rw in [2.495, 2.505] over the whole ledger, so under Basel 3.1 it swept in the Art. 133 equity holdings that weight exactly 250%: the reference rich B31 book mis-stated its 2,500,000 listed-equity RWEA in row 24. The sealed ledger carries no positive Art. 48(4) flag, so row 24 is now a recorded approximation (pinned in tests/unit/reporting/pillar3/test_r16_ov1_row24_threshold.py): it sums rwa_final over legs whose origin class is the SA "Other items" bucket (reporting_class_origin == "other" — the CR4/CR5 row-16 class under which "items below deduction thresholds" are filed) and whose reporting_rw is in the 250% band. Restricting to "other items" is what excludes equity — equity exposures are definitionally not Art. 48(4) threshold-deduction items. The residual (within the "other" class we cannot distinguish a genuine Art. 48(4) item from a hypothetical non-threshold 250% "other item") is recorded in the reporting/pillar3/ov1.py module docstring. The narrowing is regime-clean (identical predicate under CRR and Basel 3.1). Golden impact: the b31 OV1 golden's row 24 moves 2,500,000 → null (col c 200,000 → null); CRR row 24 was already null (CRR equity weights 290% under the simple approach, not 250%) and is unchanged. EAD, RWA and risk weights are untouched — this corrects only which legs the row-24 memo counts. Ref: CRR Art. 48(4), Art. 48(1); PS1/26 Annex II "Template UKB OV1" row 24.
  • COREP C 09.01 / OF 09.01 col 0080 and C 09.02 / OF 09.02 col 0110 ("RWEA pre supporting factors") now report the true pre-Art. 501/501a RWEA instead of the post-factor value, so the disclosed SME / Infrastructure supporting-factor relief is no longer silently zeroed on every row of both geographical-breakdown templates (R15). The two CRR geo templates each carry a supporting-factor column block — "RWEA pre supporting factors", the "(-) SME supporting factor adjustment", the "(-) Infrastructure supporting factor adjustment", and "RWEA after supporting factors" (C 09.01 cols 0080/0081/0082/0090; C 09.02 cols 0110/0121/0122/0125) — but the declarative specs bound the pre-SF column to the same post-factor carrier the post-SF column uses (pick(rwa_final, rwa)) and left the two "(-)" adjustment columns as structural nulls. So on every populated row 0080 == 0090 (and 0110 == 0125), the adjustment columns were blank, and the entire SME/infrastructure supporting-factor benefit — a figure C 07.00 (col 0215/0216) and C 08.01 (col 0255/0256) disclose correctly — vanished from the geographical breakdown. The fix binds the pre-SF column to the sealed rwa_pre_factor carrier (the pre-supporting-factor RWA snapshot, falling back to the post-SF ladder when it is not sealed) and the two adjustment columns to Σ(rwa_pre_factor − post-SF RWEA) over each factor's applied subset, negated per the COREP Annex II §1.3 "(-)" convention — the verbatim C 07.00 / C 08.01 mechanism, including the retired asymmetric dedicated flag names (sme_supporting_factor_applied / infrastructure_factor_applied) and their sealed-ledger fallback (is_sme / is_infrastructure conjoined with the generic supporting_factor_applied). The post-SF columns are untouched, so 0080 + 0081 + 0082 = 0090 and 0110 + 0121 + 0122 = 0125 now foot on every row and every country sheet, including the Total. The change is scoped by column presence, not by regime branching: Basel 3.1 removed supporting factors, so OF 09.01/09.02 carry none of these refs and are structurally untouched (a new module post-step negates only 0081/0082/0121/0122, an absent-column no-op on B31 frames). rwa_col is now resolved once per generate call and threaded into both the delta derivation and the cell bindings, so the delta's subtrahend is guaranteed identical to the post-SF cell's carrier (and the multi-candidate pick count is unchanged). Golden impact. On the reference portfolio the one SME-supported book moves each geo template's corporate rows. CRR C 09.01 (SA): row 0070 ("Corporates", which fans corporate + corporate_sme in) and the Total row 0170 gain col 0080 13,380,950 → 13,500,000 / 16,228,450 → 16,347,500 and col 0081 null → −119,050, while col 0082 and every other populated row's 0081/0082 move null → +0.0 (a measured zero, matching C 07.00's own convention). CRR C 09.02 (IRB): row 0030 ("Corporates"), row 0050 ("Of which: SME") and the Total row 0150 gain col 0110 +2,584,368.95 (row 0050: 13,606,784.12 → 16,191,153.07) and col 0121 null → −2,584,368.95, with 0122 and the other populated rows moving null → +0.0. The cross-template reconciliation ties out exactly: C 09.02 row 0050 pre − post (2,584,368.95) equals the magnitude of C 08.01's corporate_sme Total col 0256, and C 09.01 row 0070 pre − post (119,050) equals C 07.00's corporate_sme col 0216. The ccr_crr C 09.01 goldens move only null → +0.0 on their populated rows (the CCR portfolio holds no supporting-factor exposure); all Basel 3.1 goldens are byte-identical without regeneration. The correctness proof lives in new synthetic-frame unit tests (tests/unit/reporting/corep/test_c09_01.py::TestC0901SupportingFactorColumns, test_c09_02.py::TestC0902SupportingFactorColumns — pre keys rwa_pre_factor, the negated per-factor adjustment, footing, the no-supporting-factor pre == post fallback, B31 columns absent, and template-to-template consistency against C 07.00's 0215/0216/0220 and C 08.01's 0255/0256/0260 pairs). EAD, RWA and risk weights are untouched — this surfaces a supporting-factor adjustment the disclosure was already computing elsewhere but never showing in the geographical breakdown. Ref: CRR Art. 501 (SME supporting factor) / Art. 501a (infrastructure supporting factor); Reg (EU) 2021/451 Annex I/II (C 09.01/09.02); PRA PS1/26 Annex I/II (OF 09.01/09.02, supporting factors removed).
  • COREP C 08.07 / OF 08.07 col 0040 ("Of which: % subject to a roll-out plan") is now populated from a new optional input flag instead of rendering a structural 0.0 forever, so genuine CRR Art. 148 sequential-implementation exposures are no longer indistinguishable from permanent-partial-use SA in col 0030 (R14). C 08.07 ("IRB scope of use") reports, per Art. 147 exposure class over the FULL population, the split of each class between the IRB approach (col 0050) and the standardised approach — and the SA share is itself split between permanent partial use (col 0030, Art. 150) and exposures subject to an approved roll-out plan (col 0040, Art. 148: SA today, scheduled to move to IRB). The declarative generator bound cols 0010/0020/0030/0050 but never assigned a CellSpec to col 0040 for any row in either framework, so it was the template empty_cell="zero" default (a hard 0.0) on every sheet, and the entire SA share landed in col 0030 — the two Art. 148 vs Art. 150 populations were unreportable. No RWA/capital figure was wrong (the aggregate SA % = 0030 + 0040 and the IRB % were correct); the defect was solely the missing split. Whether an SA exposure sits under a roll-out plan is a firm-owned input fact (its approved sequential-implementation plan), not derivable from the calculation, and no discriminator existed. The fix adds a new optional Boolean input column is_under_irb_rollout (default False) to the loan / facility / contingent schemas and threads it as a pure pass-through — loader → hierarchy (unify loans/contingents coerce + facility_undrawn) → classifier → CRM → calc branch → aggregator — declared on HIERARCHY_RESOLVED_EDGE (optional, default False, Boolean null-fill, mirroring the Art. 113(6) intragroup_zero_rw_eligible carrier) and on _calc_output_common_columns / AGGREGATOR_EXIT_EDGE as a conditional column (inject=False, the R6 equity_method pattern) so it reaches the sealed reporting ledger. The guarantee and RE splits copy it to each leg unchanged (a Boolean flag is not in _stock_split_cols, so it is never pro-rata split — same as is_sme / is_adc); CCR/SFT synthetic rows resolve to False via the edge null-fill. C 08.07 then derives c0807_rollout = (SA-treated) AND is_under_irb_rollout and reports col 0040 = roll-out EAD / row-total EAD × 100 while col 0030 drops to the SA share EXCLUDING the roll-out slice (permanent partial use), so 0030 + 0040 == the whole SA coverage % the pre-R14 col 0030 reported. An IRB leg that happens to carry the flag is excluded (a roll-out plan carves the SA slice only). Col 0040 first carries the roll-out EAD Sum (the executor has no "percentage of another cell" verb) and is rescaled to a percentage in a post-execute step (_c08_07_rollout_pct); col 0030's Formula subtracts the same roll-out EAD, so with no roll-out data col 0040 is 0.0 and col 0030 reduces to the whole SA share bit-identical to the pre-R14 formula (x − 0.0 == x). Applies identically under CRR C 08.07 and Basel 3.1 OF 08.07. Golden impact: none — no shipped fixture supplies is_under_irb_rollout, so on every golden the loader injects it all-False, the roll-out subset is empty, col 0040 stays 0.0 and col 0030 keeps the whole SA share (all four corep__c08_07 frames across both frameworks byte-identical without regeneration). The correctness proof lives in new synthetic-frame unit tests (tests/unit/reporting/corep/test_c08_07.py::TestC0807RolloutPlan — roll-out %, permanent-partial %, 0030 + 0040 == the aggregate, IRB % unchanged, IRB-flag exclusion, zero-denominator guard, absent-column backwards compatibility, B31), an edge-contract test (tests/contracts/test_r14_rollout_plan_edge.py) pinning the carrier on every sealed edge from the raw lending tables to the aggregator exit, and an end-to-end carriage test (tests/integration/test_r14_rollout_plan_carriage.py) proving the flag survives the full pipeline onto the sealed results frame (drawn loan legs and the synthetic facility_undrawn row). EAD, RWA and risk weights are untouched — this corrects only the IRB-scope-of-use SA-coverage split disclosure. Ref: CRR Art. 148 (sequential IRB implementation / roll-out plans), Art. 150 (permanent partial use); Reg (EU) 2021/451 Annex II (C 08.07); PRA PS1/26 Annex I/II (OF 08.07).
  • COREP C 08.05 / OF 08.05 col 0040 ("Observed average default rate") is now internally consistent with col 0020 — it no longer silently divides by the current-period obligor count when a prior-year cohort is supplied (R13). C 08.05 is the IRB PD back-testing sheet: col 0020 reports the number of obligors at the end of the previous year, col 0030 the obligors that defaulted during the year, and col 0040 the observed default rate. The declarative executor computes col 0040 as a Formula = col 0030 / col 0020 (the correct, internally consistent rendering — defaults over the prior-year cohort, which is exactly the denominator col 0020 reports). But a conditional post-execute pass (_c08_05_rate_postfix), carried verbatim through the Phase-7 declarative migration from the retired imperative generator, recomputed col 0040 dividing by the CURRENT-period distinct obligor count whenever a caller supplied real prior_year_obligor_count data — so exactly when the better prior-year denominator became available, the disclosure went inconsistent (col 0040 ≠ col 0030 / col 0020 as rendered), and col 0050's copy-fallback ("Average historical annual default rate", which mirrors col 0040 when no historical carrier exists) inherited the mismatch. The fix deletes the postfix: the executor's first-pass Formula already divides col 0030 by col 0020 (the prior-year cohort when prior_year_obligor_count is supplied, else the current-period fallback col 0020 itself reports), so aligning the postfix to col 0020 would make it a mathematical no-op — dead code — and removing it also restores col 0050's inheritance of the corrected rate. The zero/degenerate-denominator guard is unchanged (a zero prior-year cohort yields 0.0, per the template's existing convention). The accepted cross-period proxy mechanic is deliberately unchanged: col 0030 is a current-period defaulted count and col 0020 a prior-period cohort, so col 0040 = 0030 / 0020 is a cross-period proxy — that is the template's accepted PD-back-testing mechanic (Annex II C 08.05), not the defect; the defect was solely the postfix switching the denominator to the current-period count. Golden impact: none — no shipped reporting fixture supplies prior_year_obligor_count, so col 0020 always falls back to the current obligor count and the postfix denominator equalled col 0020 anyway; the postfix never fired differently on any golden (all 28 C 08.05 frames across both frameworks are byte-identical without regeneration). The correctness proof lives in new synthetic-frame unit tests (tests/unit/reporting/corep/test_c08_05.py::TestC0805PriorYearDenominator) that construct a bucket whose prior-year sum (15) deliberately differs from the current-period distinct count (3) — the pin that would have caught the bug — asserting col 0040 = col 0030 / col 0020, that the current-period count is used nowhere, that col 0050 inherits the corrected rate, and the zero-denominator guard. EAD, RWA and risk weights are untouched — this corrects only the PD-back-testing observed-default-rate disclosure. Ref: CRR Art. 180 (PD validation); Reg (EU) 2021/451 Annex I/II (C 08.05); PRA PS1/26 Annex I/II (OF 08.05).
  • COREP C 08.02 deliberately does not receive the cross-class CRM substitution inflow (cols 0080/0090) — recorded as a monitored divergence, not silent drift (R12). No output change. The verified finding: C 08.01 applies the per-destination-class substitution inflow (col 0080 "Substitution inflows (+)", and its contribution to the 0090 "Exposure after CRM substitution pre CCFs" waterfall) to its Total row only, via ReportingContext.substitution_inflow; C 08.02 (the by-obligor-grade breakdown) never applies it, so on a destination-class sheet col 0080 is 0.0 on every grade row and col 0090 is short by the inbound amount. The disposition — after establishing the right treatment — is no change, because per-grade attribution is unsound in the sealed origin-basis ledger, not merely unimplemented. (1) Every C 08 sheet keys reporting_class_origin (the obligor basis — the recorded number-neutral convergence decision), so a guaranteed leg substituted from class X into class Y physically sits in X's origin sheet (correctly reported there as an OUTFLOW, col 0070, at the obligor's grade); the inflow into Y is composed of legs that live in other sheets and never appear in Y's partition. (2) That leg carries the obligor's pd_floored / cp_internal_rating_grade, never the guarantor's — IRB parameter substitution computes the guarantor RW/EL inside a local swap-restore window without overwriting the leg's own PD/grade (engine/irb/guarantee.py::_apply_parameter_substitution; pd_floored derives from the row's own pd), and under CRR the guarantor is SA-RW-substituted with no guarantor PD grade at all. So banding the inflow to a grade would require the guarantor's rating grade sealed per-leg (a deferred engine enhancement the origin-basis ledger does not carry) — banding by the leg's own grade would misattribute it to a foreign obligor's grade in a different class's rating scale. The exclusion mirrors C 08.01's inflow-on-Total-row-only convention and C 07.00's class-level-scalar convention; C 08.02 additionally has no Total row (its rows are data-driven grades/PD-bands plus "Unassigned"), so there is not even a constraint-free home for a class-level scalar. Monitored consequence, now pinned: on a destination sheet the sum over grade rows of col 0080 is 0.0 (≠ C 08.01 Total 0080) and the sum of col 0090 is short of C 08.01 Total 0090 by exactly the inflow, while the OUTFLOW side (col 0070) reconciles — pinned in tests/unit/reporting/corep/test_c08_02.py::TestC0802SubstitutionInflowDisposition and the module docstring's recorded-decision block, so it is a monitored decision rather than latent drift. Golden impact: none — the shipped C 08 reference portfolios carry no cross-class CRM substitution (R2), so cols 0080/0090 are already inflow-free on every golden; this change is documentation plus a synthetic-frame divergence pin, with no EAD/RWA/exposure movement anywhere. Ref: Reg (EU) 2021/451 Annex II (C 08.01/02 share the CRM-substitution column block); PRA PS1/26 Annex XXII (obligor-basis reporting bars substitution effects from the grade breakdown); CRR Art. 235 (SA risk-weight substitution) / Art. 161(3) (IRB parameter substitution).
  • COREP C 08.01/02 "of which: off balance sheet" memo columns 0100 and 0120 are now computed on their correct bases instead of being swapped (R11). The two "of which: off balance sheet" columns sit in different column groups and therefore report different quantities, but the generator had them crossed. Col 0100 sits in the POST-CRM PRE-CCF group (headed by the 0090 "Exposure after CRM substitution pre CCFs" waterfall), so it must report the off-balance-sheet share of that pre-conversion-factor amount — but it was bound to Sum(ead_final) over the off-BS legs, i.e. the post-CCF exposure value. Col 0120 sits in the EXPOSURE VALUE (post-CCF) group and must report the off-BS share of ead_final — but it was hardwired to null. The fix is a swap plus a re-derivation: 0120 now carries Sum(ead_final) over the c08_bs == "off" legs (exactly what 0100 used to compute — a move, not a rewrite), and 0100 is derived per row in the new _c08_off_bs_pre_ccf post-step as the off-BS slice of the 0090 waterfall, mirroring _crm_waterfall component-for-component on positive magnitudes read from the raw ledger: off-BS gross (0020: floored drawn+undrawn) − off-BS guarantees (0040) − off-BS credit derivatives (0050) − off-BS other funded collateral (0060) − off-BS substituted portion (0070). The 0090 waterfall itself, R10's EL/provisions work, and the Annex II §1.3 "(-)" negation ordering are untouched. Recorded decision — the 0080 substitution inflow is excluded from 0100: it is a total-row cross-sheet scalar (ReportingContext.substitution_inflow, a per-destination-class aggregate) with no leg-level on/off-BS attribution, so an off-BS memo cannot claim a share of it — this matches the 0090 convention that the inflow only lands on the constraint-free total row. The executor has no intra-row sub-waterfall verb, so 0100 is a module post-step (the established C 08 idiom, alongside _provisions_postfix and the C 08.03 balance-sheet fallback), applied to both C 08.01 and C 08.02, which share the value surface. Golden impact. The reference reporting portfolio is loans-only (every IRB leg is on-balance-sheet), so there is no off-BS IRB exposure: col 0100 stays 0.0 and the only movers are col 0120 null → 0.0 on the populated rows of all sixteen corep__c08_01__* / corep__c08_02__* files (CRR + B31) — 0120 now behaves as a proper "of which" memo (a measured 0.0 on an on-BS-only book, matching col 0100's own convention) rather than a hardwired blank. The non-trivial pre-CCF-vs-post-CCF behaviour (0100 > 0120 when a 50%-CCF off-BS facility is present, and 0100 == the off-BS row's own 0090) is proven by new synthetic-frame unit tests in test_c08_01.py / test_c08_02.py. EAD, RWA and risk weights are untouched — this corrects only the two off-balance-sheet memorandum disclosures. Ref: Reg (EU) 2021/451 Annex I/II (C 08.01/02 column layout: 0090/0100 POST-CRM pre-CCF, 0110/0120 exposure value post-CCF); CRR Art. 111 / Art. 166 (exposure value and conversion factors); PRA PS1/26 Annex I/II (OF 08.01/02).
  • COREP C 08.01/02 expected-loss columns 0280/0282 now report the real slotting expected loss on specialised-lending sheets instead of a masked 0.0, and the C 08.01/02/03/06 provisions ladder now reads a sealed carrier instead of a column the aggregator seal strips (R10). Two independent defects on the IRB templates. (a) EL masking on slotting sheets. Col 0280 (expected loss; the Basel 3.1 pre-post-model-adjustment figure, twinned with 0282 "EL after post-model adjustments") read el_pre_adjustment whenever that column existed, filling its null values to 0.0. el_pre_adjustment / el_after_adjustment are produced only by the formula-IRB post-model-adjustment step (engine/irb/adjustments.py::apply_post_model_adjustments), so they are NULL on slotting legs (whose EL comes from the slotting calculator and rides on expected_loss), while the aggregator injects those columns onto the sealed frame for the whole run under both frameworks (CRR's disabled branch copies expected_loss into el_pre_adjustment). The Sum-with-null-fill therefore reported a hard 0.0 for slotting EL on the specialised-lending sheets — even though C 08.06 col 0090 (Sum("expected_loss")) reported the same slotting EL correctly (a cross-template inconsistency). Cols 0280/0282 now resolve through a per-leg coalesce (_prepare derives c08_el_pre = coalesce(el_pre_adjustment, expected_loss) and c08_el_after = coalesce(el_after_adjustment, expected_loss)): the formula-IRB adjustment EL where non-null, else the base expected_loss. This is a value no-op on formula-IRB legs (there el_pre_adjustment == expected_loss) and surfaces the true slotting EL on slotting legs — a per-leg coalesce, deliberately not the retired column-presence branch. (b) Provisions ladder fell back to a stripped column. The shared _provisions_postfix (serving C 08.01/02 col 0290, C 08.03 col 0110, C 08.06 col 0100) preferred the SCRA/GCRA base sum and, when it netted to ~0, fell back to provision_held — but provision_held is an input pass-through the aggregator seal strips (not on AGGREGATOR_EXIT_EDGE), so "provision_held" not in cols returned early on every real submission and the provisions cells rendered 0.0. The fallback now reads the sealed provision_allocated when the frame carries no provision_held. Carrier decision — provision_allocated, not C 07.00's provision_deducted (R9): the Art. 111(2) drawn-first deduction is SA-only, so provision_deducted = provision_on_drawn + provision_on_nominal is structurally 0.0 on every IRB/slotting leg (engine/crm/provisions.py: IRB/Slotting → provision_on_drawn = 0, provision_on_nominal = 0), whereas provision_allocated is allocated for all approaches (it feeds the IRB EL shortfall/excess) — so it is the only sealed carrier that is regulatorily meaningful for the IRB book; provision_deducted was considered and rejected as it would report a constant 0.0. The recorded C 08 per-cell granularity is preserved (aggregate scra/gcra over the row subset, swap the whole cell when it nets to ~0), distinct from C 07.00's per-row ladder; provision_held stays the preferred fallback where a synthetic frame supplies it, so the change is a strict superset of the retired behaviour. Golden impact. Part (a): confined to the four corep__c08_01__specialised_lending / corep__c08_02__specialised_lending files (CRR + B31). On the reference portfolio the specialised-lending slotting book carries 300,000 expected loss: C 08.01 rows 0010 (Total) / 0020 (on-balance-sheet) / 0080 (slotting-approach Total) col 0280 move 0.0 → 300,000.0 (B31 additionally moves col 0282 the same), and C 08.02 row "Unassigned" col 0280 (B31 also 0282) moves 0.0 → 300,000.0 — each equal to C 08.06[project_finance] col 0090 (300,000.0, unchanged — the cross-template tie-out). Part (b): no golden movers — the reference portfolio's IRB/slotting legs carry zero provision_allocated, so col 0290/0110/0100 stay 0.0; the fix's proof lives in new synthetic-frame unit tests (test_c08_el_provisions.py). EAD, RWA and risk weights are untouched — this changes only the EL and provisions memorandum disclosures. Ref: CRR Art. 158 / PRA PS1/26 Art. 153(5A), 158(6A) (IRB EL and post-model adjustments); CRR Art. 111(2) (SA drawn-first provision deduction, SA-only); Reg (EU) 2021/451 Annex I/II (C 08.01/02/03/06).
  • COREP C 07.00 column 0030 ("(-) Value adjustments and provisions associated with the original exposure") now reports the firm's actual SA provisions instead of a hard 0.0, so the net-exposure waterfall (col 0040 = 0010 − 0030) no longer collapses to the gross figure on every real submission. The cell was bound to SafeSum(("scra_provision_amount", "gcra_provision_amount")), but those two columns are input pass-throughs that are stripped at the aggregator exit (never sealed onto the reporting ledger) — so under the COREP empty_cell="zero" policy SafeSum rendered a hard 0.0 on every real run, indistinguishable from "no provisions", understating the Annex II provisions disclosure and leaving col 0040 equal to the gross original exposure. Col 0030 now resolves through a preference ladder with the same preference order as C 08.01/02's provisions ladder — the SCRA/GCRA input pass-throughs win when a book supplies them non-degenerately, otherwise the cell reads the sealed SA provision_deducted carrier (CRR Art. 111(2): provision_on_drawn + provision_on_nominal) — but at a different granularity: C 07's pick is per exposure row (each row takes its own scra/gcra else its own provision_deducted, then the cell sums), whereas C 08 decides per cell (aggregate scra/gcra over the row subset, swap the whole cell when it nets to ~0); the two differ only on a frame where some rows carry scra/gcra and others do not, and per-row is the deliberate, test-pinned C 07 contract. provision_deducted is the exact amount the drawn-first EAD math removes from the gross exposure (on-balance for EAD = drawn − provision_on_drawn, nominal = nominal − provision_on_nominal), so col 0040 reconstructs the engine's own net-of-provisions basis. provision_allocated was considered and rejected: it can exceed provision_deducted for an over-provisioned exposure (the deduction is capped at drawn+nominal by the min() in provisions.py), so it would over-report 0030 and break the 0040 = engine-net-basis identity. The carrier is derived pre-execute (a c07_provision discriminator column in _prepare) rather than as a post-execute patch like C 08's 0290, because C 07's col 0040 Formula must consume the corrected magnitude in the same pass; the Annex II §1.3 "(-)" display negation of 0030 is unchanged (a zero deduction stays +0.0). CCR synthetic rows (SFT / derivative netting sets) carry a null/zero provision_deducted, so they contribute nothing to 0030. The cell is now lineage source-backed (a measured zero, not "cannot compute"). Golden impact: none — neither the rich reporting portfolio nor the CCR reporting portfolio carries any SA provisions (provision_deducted = 0.0 on every row), so both golden gates are byte-identical; the fix's proof lives in new synthetic-frame unit tests. EAD, RWA and risk weights are untouched — this changes only the provisions/net-exposure disclosure columns. Ref: CRR Art. 111(2) (SA drawn-first provision deduction); Reg (EU) 2021/451 Annex II §1.3 (C 07.00 cols 0030/0040).
  • COREP C 02.00 / OF 02.00 row 0420 ("Equity IRB") now holds only equity actually treated under the IRB approach, so the SA-vs-IRB approach breakdown is correct under both frameworks. The template booked the whole equity book into row 0420 unconditionally — even under Basel 3.1, where PS1/26 Art. 147A removes the IRB equity treatment (all equity is standardised) so row 0420 cannot legitimately hold anything — while the same RWA was simultaneously booked into the SA breakdown (row 0060 total + class row 0210). The headline figures were never wrong (rows 0010/0040/0050 are flat ledger sums that count equity once, and the 0060 + 0220 = 0050 footing held with equity parked in 0060), but row 0420 asserted an IRB-equity population that under Basel 3.1 does not exist and under CRR should contain only Art. 155 equity, not SA-treated equity. The C 02.00 pre-pass kernel (reporting/corep/c02.py::_aggregate_by_approach) now partitions the equity book on the sealed equity_method discriminator (the R6 conditional aggregator_exit column: sa = Art. 133, irb_simple = Art. 155(2), pd_lgd = Art. 155(3)): IRB-method equity (irb_simple / pd_lgd) reports at row 0420 and folds into the IRB total (row 0220), excluded from the SA rows; SA-method equity — and any equity leg whose method the ledger did not seal (defensive null) — stays in the SA breakdown (rows 0060 / 0210), excluded from 0420. The routing is data-driven (no regime branch): under Basel 3.1 every equity leg is stamped sa (Art. 147A), so row 0420 empties by construction, and the 0060 + 0220 = 0050 footing is preserved under either routing because IRB-method equity leaves the SA total exactly as it enters the IRB total. Golden impact is confined to the two corep__c_02_00 files. Basel 3.1: row 0420 empties (col 0010/0020/0030 2,500,0000.0); the SA rows 0060/0210 keep the 2,500,000 equity book and every other cell — including the totals — is unchanged. CRR (the reference fixture's equity is a listed 1,000,000 holding risk-weighted at 290% under Art. 155(2), so it is genuine IRB equity): row 0420 correctly keeps 2,900,000, while the double-booked SA side now empties — row 0060 19,128,45016,228,450 and class row 0210 2,900,0000.0 — and the IRB total row 0220 gains it (126,383,017.29129,283,017.29); the totals (0010/0040/0050) and the footing are unchanged. EAD, RWA and risk weights are untouched — this moves RWA between approach-breakdown rows only. Ref: CRR Art. 155 (IRB equity); PRA PS1/26 Art. 147A (Basel 3.1 IRB equity removed); CRR Art. 92 (own funds requirements); Reg (EU) 2021/451 Annex II (C 02.00).
  • COREP OF 09.01 (geographical breakdown, SA) now reports Basel 3.1 real-estate exposures and specialised-lending granularity instead of leaving those rows permanently null. Under Basel 3.1 the "Real estate exposures" parent row 0090 and its of-which sub-rows 0091-0095 (regulatory residential RE / regulatory commercial RE / other RE / land ADC / SME), plus the SA specialised-lending of-which rows 0071-0073 (object / commodities / project finance), were structurally null: _c09_01_row_pred resolved every row by a single reverse lookup into the shared C09_01_SA_CLASS_MAP, and the B31-only row keys (real_estate, re_*, sl_*) have no map value, so the predicate was None and _null_empty_rows nulled the rows permanently. For specialised lending this lost only granularity (SL maps to corporate, so the money stayed visible in the corporate parent row 0070), but for real estate it was a missing-money defect: the B31 RE reporting classes (retail_mortgage retail RRE, plus the SA loan-splitter's residential_mortgage / commercial_mortgage secured legs) matched no class row at all, so RE exposure was absent from every class row and survived only in the country Total — the class rows no longer summed to the Total. A new _c09_01_re_sl_pred branch (consulted before the reverse-map lookup, so CRR row keys never reach it) keys row 0090 on the RE class union over the applied/original basis, splits 0091-0094 by the sealed property_type / is_adc discriminators and a derived null→True regulatory-RE flag (c09_re_qualifying, matching C 07.00's c07_qualifying_re), routes 0095 by is_sme, and keys 0071-0073 by the basis-independent sl_type; the row-emptiness helper _either_pred was corrected to keep an RE sub-row's basis-independent discriminator terms (previously it dropped them, which would have un-nulled a residential sub-row whenever any commercial RE existed). Income-producing commercial RE classified as corporate (its sealed reporting_class_origin) is deliberately not pulled into the RE rows — that keeps row 0090 a clean class partition and matches the CRR row 0070 treatment. CRR C 09.01 is untouched (the shared class map is unchanged; the RE/SL keys occur only in B31_C09_01_ROWS). Golden impact is confined to the two b31 c09_01 files (TOTAL + the single GB country sheet): on the reference rich portfolio the one retail residential mortgage (400,000 EAD, 98,333.33 RWEA), previously missing from every class row, now populates row 0090 and row 0091 (0010/0075 exposure 400,000, 0090 RWEA 98,333.33); the Total row and every other row are unchanged, and the class rows now sum to the Total. EAD, RWA and risk weights are untouched — this surfaces geographical-breakdown exposure the disclosure was already computing but never showing. Ref: PRA PS1/26 Art. 124A-124L (real estate as a standalone SA class); Art. 122A (SA specialised lending); Reg (EU) 2021/451 Annex I/II (OF 09.01).
  • Pillar 3 CR10.5 ("Equities under the simple risk-weighted approach") now discloses a firm's Art. 155(2) equity RWEA instead of a structurally empty sheet. Under CRR, equity risk-weighted with the IRB simple approach (Art. 155(2): 190% sufficiently-diversified private equity / 290% exchange-traded / 370% other) must be disclosed in CR10.5 (Art. 438(e)), but generate_cr10 filtered the whole CR10 population to reporting_approach_origin == "slotting" while equity legs seal as reporting_approach_origin == "equity" — so CR10.5 was force-emitted empty (and, worse, carried the slotting supervisory-category rows Strong/Good/Satisfactory/Weak/Default rather than the three equity RW bands). Two changes fix it. (1) The equity calculator now records which CRR equity method it applied on a new sealed equity_method discriminator (sa = Art. 133, irb_simple = Art. 155(2), pd_lgd = Art. 155(3)) — the existing sealed reporting_method collapses every equity leg to EQUITY and cannot tell them apart. It is a conditional (inject=False) aggregator_exit column: present on any run holding equity, simply absent from an equity-free run, so the eager-backed seal stays a shallow DataFrame.lazy() wrap. (2) CR10 now draws per-subtemplate populations — CR10.1–4 from the slotting book, CR10.5 from the simple-RW equity legs (reporting_approach_origin == "equity" and equity_method == "irb_simple", so Art. 133 SA and Art. 155(3) PD/LGD equity are excluded) — with three fixed-RW band rows (190/290/370%) plus a Total; each leg lands in the band matching its applied reporting_rw. Equity is an on-balance-sheet holding with no off-BS/CCF split, so col a mirrors col d (exposure value) and col b is null; col f discloses the Art. 158(7) equity EL. CR10.5 is still force-emitted (empty, with its fixed RW column) when a CRR firm has IRB equity permission but no simple-RW holdings. Basel 3.1 is untouched — Art. 147A removes IRB equity, so UKB CR10 has no equity sub-template (it carries HVCRE at CR10.5). Golden impact is confined to the one CRR pillar3__cr10__equity file: on the reference rich portfolio the single 1,000,000 listed equity holding, previously silently dropped, now discloses in the 290% "Exchange-traded" band and the Total — col a/d exposure 1,000,000, col e RWEA 2,900,000 (= 1,000,000 × 290%), col f EL 8,000 (Art. 158(7) 0.8%). EAD, RWA and risk weights are untouched — this surfaces an equity RWEA the disclosure was already computing but never showing. Ref: CRR Art. 155(2) (IRB simple equity); Art. 158(7) (equity EL); Art. 438(e) (CR10.5); PRA PS1/26 Art. 147A (Basel 3.1 IRB equity removed); Reg (EU) 2021/451 Annex XXIV.
  • Pillar 3 CCR8 and COREP C 34.08 ("Exposures to central counterparties") now scope to CCP counterparties only — the whole bilateral OTC book no longer inflates the non-QCCP rows. Both templates split CCP exposures into a QCCP row and a non-QCCP row, but the non-QCCP figure was computed as the global complement of the QCCP-trade discriminator (~((cp_entity_type == "ccp") & cp_is_qccp.fill_null(True))), so it swept in every ordinary bilateral derivative counterparty — an institution, corporate or any non-CCP netting set — even though those are not exposures to a central counterparty at all (they belong in CCR1/CCR2, CRR Art. 439(f)/(h)). The non-QCCP row is now the CCP-restricted complement — cp_entity_type == "ccp" and ~cp_is_qccp.fill_null(True), i.e. a counterparty that IS a central counterparty but one the firm does not treat as qualifying (Art. 107(2)(a)) — and CCR8's Total (row 21) sums only the CCP population (row 1 + row 2). CCR8 also now includes CCP-faced FCCM SFTs (include_sft=True before the CCP restriction): a securities-financing transaction cleared through a CCP is a CCP exposure under Chapter 6 Section 9's material scope (CRR Art. 301(1)(b)), matching how Pillar 3 OV1 already routes it to row UK8a; a bilateral SFT is dropped by the CCP restriction, so C 07.00 row 0090's SFT population is unaffected. C 34.08 is now emitted only when the portfolio actually has CCP exposures (a CCP trade leg or an Art. 308/309 default-fund contribution) — a book of purely bilateral derivatives, whose c34_08 field is now None, matches Annex II's CCP-exposures intent and the bundle's own docstring (catalog/facts/export already tolerate an absent c34_08, as they must for a CCR-free book). CCR1/CCR2/CCR3 and C 34.01/02 (the SA-CCR analysis-by-approach and per-netting-set templates, which cover the whole derivatives book) are untouched, so the bilateral netting set still reports there. Golden impact is confined to the four CCR-portfolio files: on the reference portfolio (one QCCP-cleared swap, one bilateral institution swap) the bilateral swap leaves the non-QCCP rows — CRR CCR8 row 2 2,748,345.55/5,496,691.10 (RWEA/EAD) → null/null and row 21 Total 2,858,279.37/10,993,382.20109,933.82/5,496,691.10 (the QCCP row alone); C 34.08 row 0020 5,496,691.10/2,748,345.55 (EAD/RWEA) → 0.0/0.0; Basel 3.1 the analogous cells (1,462,778.17/4,875,927.25, Total 1,560,296.72/9,751,854.5097,518.54/4,875,927.25; C 34.08 row 0020 → 0.0). EAD, RWA and risk weights are untouched — this changes only which legs the two CCP-exposure disclosures count. Ref: CRR Art. 439(i) (CCR8/C 34.08 CCP exposures); Art. 306(1) (QCCP trade RW); Art. 107(2)(a) (non-QCCP treated as institution); Art. 301(1)(b) (SFTs in the CCP material scope); Reg (EU) 2021/451 Annex II (C 34.08).
  • Basel 3.1 equity now contributes its standardised-equivalent RWA to the disclosed S-TREA — OF 02.01, C 02.00 and CMS1/CMS2 no longer silently drop it. Under PS1/26 Art. 147A the IRB equity treatment is removed, so equity is standardised-only and an equity leg's standardised-equivalent RWA IS the (pre-floor) RWA the equity calculator already produces. But equity bypasses the SA calculator, so its sa_rwa carrier was null, and every disclosure that sums sa_rwa over the whole book — OF 02.01 col 0040 (S-TREA), C 02.00 col 0020 (SA-equivalent TREA; and its x0.08 own-funds twin on row 0040), CMS1 col d and CMS2 col d — omitted equity's contribution on any Basel 3.1 run holding equity. The aggregator's equity-prep step (engine/aggregator/_equity_prep.py) now populates equity's sa_rwa as its own pre-floor RWA, gated on the output_floor pack Feature so it mirrors the SA calculator's own sa_rwa gate exactly (no sa_rwa column is minted on CRR frames that never carry one — which also keeps C 08.01/02 col 0276 hard-null under CRR). Equity is not floor-eligible, so the output-floor base (_s_trea/_u_trea), rwa_final and every capital figure are unchanged: this is a disclosure-completeness fix, not a capital change. On the reference rich portfolio the disclosed S-TREA rises 161,655,833.33 → 164,155,833.33 (+2,500,000 = the one listed equity holding's 1,000,000 EAD × 250% Basel 3.1 SA risk weight), while TREA/U-TREA stay 137,449,963.91 and the floor stays non-binding; the "OF 02.01 col 0040 == C 02.00 col 0020" single-S-TREA tie-out continues to hold. Goldens move on the four affected b31 templates only. EAD, RWA, risk weights and the output floor itself are untouched. Ref: PRA PS1/26 Art. 147A (IRB equity removed); Art. 133(3) (SA equity 250%); Art. 92 para 2A (output-floor S-TREA); Reg (EU) 2021/451 Annex II (OF 02.01, C 02.00, CMS1/CMS2).
  • Pillar 3 CR4/CR5 now compute every cell over one SA-credit-risk population, so the templates internally reconcile. The sealed reporting_on_balance_sheet discriminator is null for synthetic exposure types outside {loan, facility, contingent}, so counterparty-credit-risk legs were excluded from the on/off-balance-sheet split cells (CR4 cols a-d, CR5 ba/bb) while remaining in every class-total / risk-weight-band / RWEA cell — CR4 disclosed RWEA for exposure its own exposure columns never showed (the ccr_crr golden's Total exposure-weighted density printed an impossible 1.07). A new shared scope helper (reporting/pillar3/sa_scope.py::sa_credit_risk_population) now applies one population to both templates: SA-CCR / FCCM-SFT netting-set legs (ccr_netting_set), CCP default-fund contributions (ccr_default_fund) and settlement failed trades (ccr_failed_trade) are excluded from every cell — they are CCR-series / settlement disclosures (Art. 439; Art. 307-309; Art. 378-380), not Art. 444(e) SA credit risk (this also makes the population regime-independent instead of leaning on Basel 3.1's standardised_ccr output-floor relabel, which had left CRR wrong and B3.1 accidentally right) — and facility_undrawn synthetic legs are kept and reported off-balance-sheet (a genuine undrawn commitment: gross feeds col b/bb, post-CCF EAD feeds cols c/d, per Art. 111). COREP C 07.00's deliberately CCR-inclusive population (Annex II rows 0090-0130) is untouched, as is the sealed column itself (CR6/CR10 make their own scope calls). Golden impact: the two ccr_crr CR4/CR5 files only — the two SA-CCR derivative legs leave every cell (CR4 Total col e 5,358,279 → 2,500,000; density 1.0716 → 0.5, now ≤ 1). EAD, RWA and risk weights are untouched — this changes only which legs the two disclosure templates count. Ref: CRR Art. 444(e); Art. 439; Art. 378-380; Art. 307-309; Art. 111.
  • COREP C 07.00 and C 08.01/02 now report every "(-)"-labelled deduction column as a negative figure, so the supporting-factor and CRM-substitution waterfalls foot per Annex II §1.3. COREP Annex II Section 1.3 requires columns whose label carries a leading "(-)" to be reported as negative values; the estate applies this via a per-module post-execute negation pass, but two template surfaces had incomplete column sets and emitted these deductions as positive magnitudes. C 07.00 (corep/c07.py) now also negates the CRR supporting-factor adjustments 0216 ("(-) SME supporting factor adjustment") and 0217 ("(-) Infrastructure supporting factor adjustment", Art. 501/501a), so the RWEA block foots as 0215 + 0216 + 0217 = 0220 under the display convention instead of the columns fighting the "(-)" label. C 08.01/02 (corep/c08.py) — which previously negated only col 0290 — now negates the full "(-)" set on its shared value surface: the CRM substitution outflows 0040/0050/0060/0070 (both frameworks), Basel 3.1's on-balance-sheet netting adjustment 0035 and slotting financial-collateral adjustments 0102/0103 (structural-null today, so the negation is a no-op that keeps the sign truthful if a carrier is ever wired), and the CRR supporting-factor adjustments 0256/0257, bringing C 08 into line with C 07 (whose col 0035 was already negated). The negation runs strictly after the CRM waterfall (0090 = 0020 − 0040 − 0050 − 0060 − 0070 + 0080) and every intra-row formula have consumed the positive magnitudes, so no footing is double-flipped; the pass intersects with the frame's columns, so the framework-specific members (0035/0102/0103 under B3.1, 0256/0257 under CRR) are no-ops in the regime where the column is absent. A zero deduction is normalised to +0.0 via a shared _negate_expr used identically in both modules (plain float negation flips the IEEE sign bit and Polars keeps the −0.0, so an explicit zero branch is required — the previous (−col) + 0.0 did not clear it). Because C 07.00 is the lineage-instrumented template, its cell-lineage sign field (read from SheetPlan.negative_cols) now correctly labels 0216/0217 as negated. Golden impact is confined to the CRR corporate-SME sheets where these columns fire non-zero (C 07.00 col 0216, C 08.01/02 col 0256): the magnitudes are unchanged, only the sign flips. EAD, RWA and the net-exposure waterfalls are untouched — this changes only the display sign of the disclosure deduction columns. Ref: Reg (EU) 2021/451 Annex II §1.3 (C 07.00, C 08.01/02); CRR Art. 501/501a (supporting factors).
  • Gross-exposure template cells can no longer report a negative figure when a book uses the negative-deposit netting convention. A negative drawn_amount / interest is a deliberate on-balance-sheet netting input (CRR Art. 195/219): a deposit or credit balance offsets the loans that share its netting_agreement_reference. The EAD path already floored these locally (drawn_for_ead / interest_for_ead clip at 0), but the raw carrier columns sealed negative onto the aggregator exit, so every COREP/Pillar 3 "original / gross exposure" cell that summed them (C 07.00 col 0010, C 08.01/02 cols 0020/0030, C 08.03 col 0010, C 08.06 cols 0010/0030, CR4 col a, CR5 cols ba/bb, CR6 cols b/c, CR10 cols a/b) could understate — or go negative — because a deposit's −200,000 was added straight into the gross figure. The aggregator's reporting projection now seals four floored gross carriers — reporting_gross_drawn / reporting_gross_interest / reporting_gross_nominal / reporting_gross_undrawn = the raw amount clipped at 0 (nulls preserved, never filled to 0 — anti-conservative), computed after the CRM guarantee split so they are leg-consistent — and every gross-exposure cell reads these instead of the raw twins (COREP falls back to the raw column when the sealed twin is absent on older synthetic frames; Pillar 3's pre-built specs read the sealed twin, mirrored onto unit frames by the reporting-ledger shim). On books where every carrier is already non-negative the clip is the identity, so all existing goldens are byte-identical; only a book carrying a negative on-balance amount moves, and it moves from a wrong (understated/negative) figure to the correct floored one. EAD, RWA and the risk-weight are untouched — this changes only how the gross-exposure disclosure columns are summed. A new non-blocking DQ010 data-quality warning flags a bare negative drawn_amount / interest that carries no netting_agreement_reference — a negative balance that cannot net against anything is a data error (a genuine netted deposit under a shared reference is silent). Ref: CRR Art. 111 (SA gross exposure value); Art. 166 (IRB exposure value); Art. 195/219 (on-balance-sheet netting).

[0.3.17] - 2026-07-21

Added

  • Multi-entity reporting: run individual, sub-consolidated, and consolidated submissions from one dataset. Two new optional input tables — config/reporting_entities (a reporting-hierarchy registry) and mapping/book_entity_mapping (booking book → reporting entity) — plus new optional intragroup_entity_reference / guarantor_entity_reference / book_code tag columns on the exposure and guarantee schemas, let a scoped run resolve one entity's population: consolidated/sub-consolidated submissions take the entity's subtree with intragroup exposures and guarantees eliminated (CRR Art. 6 / 11-18), individual submissions take the entity alone with intragroup positions retained. Each scope is a full, independent pipeline run — its own output floor, prior-period file, and run-index fingerprint/reconciliation workspace — via a new no-op-by-default resolve_scope pipeline stage (six SCP001–SCP006 data-quality codes, never raised). reporting_entity / reporting_basis are available on CalculationConfig, CreditRiskCalc, and the calculator/comparison/reconciliation REST endpoints and UI forms; a new GET /api/entities + /hierarchy page renders the registry tree. Unscoped runs are provably byte-identical to before. See Multi-Entity Reporting.
  • Art. 113(6) core-UK-group 0% risk weight for intragroup exposures on solo returns. On an individual-basis run, an SA-routed intragroup exposure is assigned a 0% risk weight when both the reporting entity and the tagged intragroup counterparty carry core_uk_group=True in the reporting-entity registry (CRR Art. 113(6), retained under PS1/26 — the pack intragroup_zero_rw Feature is enabled in both regimes). The scope resolver computes the per-row eligibility on the new intragroup_zero_rw_eligible carrier (default False, so the treatment is pure opt-in), which is threaded through the hierarchy → classifier → CRM edges to a final SA risk-weight override applied after every standard assignment and CRM adjustment. It is keyed on a row's own intragroup tag: a guarantee-split leg of an eligible loan inherits the 0%, while an external loan merely guaranteed by a group member does not (Art. 113(6) covers direct exposures to members, not protection from them). Scope this wave: SA lending exposures (facilities, loans, contingents, undrawn commitments). Excluded, by design: IRB exposures (the Art. 150(1)(e) permanent-partial-use route reclassifies them to SA upstream), equity holdings in group entities, and CCR/SFT netting sets. consolidated/sub_consolidated runs are unaffected (intragroup rows are eliminated before weighting) and core_uk_group=False books are byte-identical to before. See Multi-Entity Reporting.

[0.3.16] - 2026-07-20

Fixed

  • The "Real-estate split" stage no longer stalls for minutes on portfolios with unattested equity collateral — the error-channel dedup is linear again, and CRM018 is one rolled-up warning instead of one per row. The P1.271 listing gate (v0.3.14) conservatively rules equity collateral with unknown index membership / listing ineligible, and emitted a CRM018 warning per gated row — 13k+ warnings on a 100k-exposure book when the attestation columns are unpopulated. The re_split stage adapter dedups splitter errors against the CRM channel with a list-membership scan that is O(N×M) in the two lists, which the flood turned quadratic: ~11s of a ~20s pipeline run at 100k scale, surfacing in the rwa-ui stepper as the "Real-estate split" step hanging (reported in the field as c.40s → 5min+, run abandoned). Fixed at both ends: the dedup builds a set of the prior CRM errors once (frozen-dataclass equality preserved via hashing — identical semantics, linear cost), and _record_non_main_index_equity_ineligible rolls CRM018 up to a single count-carrying warning per run, following the splitter's RE002–RE004 per-cause idiom. Measured at 100k (CRR): re_splitter 11,080ms → 254ms, total run 19.9s → 8.1s, error channel 13,361 → 4 entries. The gate itself (value zeroing + eligibility clearing) is untouched — RWA outputs are identical; only warning cardinality and wall time change. Ref: CRR/PS1-26 Art. 197(1)(f)/198(1)(a).

[0.3.15] - 2026-07-20

Added

  • COREP C 08.04 (IRB RWEA flow statement) can now actually state a flow — it gains the prior-period capability its Pillar 3 counterpart always had. CR8 could populate opening/closing RWEA and a signed residual from previous_period_results; C 08.04 had no prior-period wiring anywhere, so its flow columns were permanently null by construction. COREPGenerator.generate()/generate_from_lazyframe() now accept previous_period_results (default None), threaded to the C 08.04 module exactly as the Pillar 3 generator threads CR8's: row 0010 (opening RWEA) evaluates the same population predicate over the prior sealed frame via the executor's PriorPeriod binding, row 0090 (closing) is unchanged, and row 0080 ("Other") is the signed residual closing − opening so the statement foots by construction — the attribution driver rows (0020–0070) stay null rather than inventing decompositions the engine cannot support (mirroring CR8, PS1/26 Annex XXII para 11). Population symmetry is the load-bearing property and is review-verified plus test-pinned: opening and closing both read the non-slotting IRB population keyed on the sealed obligor class (reporting_class_origin), so this period's opening equals last period's reported closing — a prior slotting row cannot leak into the opening, a class new this period gets a null opening with its full closing in the residual, and a fully-run-off class emitting no sheet is a recorded, documented limitation of the per-class-sheet pattern. Without a prior period the output is byte-identical to before. The test shim seals synthetic prior frames through the same reporting-ledger projection as current frames. The capability is wired end-to-end: the shared prior_run_id contract on GET /api/export/{fmt} and the UI templates route now covers the COREP formats too (the prior run's sealed results parquet is what gets threaded — the shape C 08.04's obligor-class keying requires), so the flow statement populates in the workbook, the cell-fact feed and the on-screen viewer alike. Ref: Reg (EU) 2021/451 Annex II C 08.04; PS1/26 Annex XXII para 11.
  • The flow, comparative and ratio rows a real user could never populate now populate — prior-period and capital-ratio inputs reach the generators end-to-end. Pillar3Generator always accepted previous_period_results, capital_ratios and output_floor_summary (feeding CR8's RWEA flow statement and the CMS1/OV1 pre-floor ratio rows), but no delivery path ever supplied them — every workbook and on-screen view carried null flow and ratio rows, structurally. Three fixes compound: (1) CalculationResponse gains output_floor_summary — the aggregator computed it every Basel 3.1 run and the formatter silently dropped it at the API boundary; it now threads automatically into every Pillar 3 export (OV1's floor-adjustment row populates from the run's own data, no caller input), survives run-index persistence (a reloaded run exports the same floor rows it exported before the restart — round-tripped through run_index.json with tolerant loading of pre-existing files), and is pinned by real unmocked pipeline-run tests (Basel 3.1 populates; CRR is None by design). (2) Comparatives are explicit, never guessed: GET /api/export/{fmt} and the UI templates route gain prior_run_id, resolved with the same strictness as the reconcile contract — unknown id 404s, a failed run, mismatched framework or a reporting date not strictly earlier 422s with the reason — because flow rows silently keyed to the wrong prior period would misstate a disclosure (book identity is documented as the caller's responsibility, per the reconcile precedent). With a valid prior run, CR8's opening RWEA and signed residual populate; template-bundle caching keys on (run_id, prior_run_id) so a with-prior view never cross-serves a without-prior request. (3) The six pre-floor capital ratios (cet1/tier1/total, actual and transitional) are optional query parameters mapping onto Pillar3CapitalRatioOverrides. Ref: PS1/26 Annex XXIV (UK CR8); PS1/26 Annex II (UKB OV1/CMS1).
  • Cell-fact export — a machine-mappable feed of every reported cell, and exports that finally say whose return they are (reporting/facts.py). The COREP/Pillar 3 workbooks are a human review artifact: merged two-row banners, fixed filenames (rwa_corep.xlsx), no entity or period anywhere — a vendor filing tool (the realistic road to an XBRL/BEEDS submission) cannot cleanly ingest them, and the artifact does not identify which firm or reference date it belongs to. build_fact_frame() flattens a generated template bundle into one row per cell — template_id, sheet, row_ref/row_name, col_ref/col_name, value, text_value, framework — reusing the same catalog traversal the on-screen viewer trusts, preserving the load-bearing null-vs-0.0 distinction end-to-end (parquet and ndjson both round-trip nulls, test-pinned), splitting String cells (grade labels, PD-range labels) into text_value rather than dropping them, and nulling non-finite floats exactly as the Excel writer does. A frozen FilingMetadata (reporting date, framework, run id, optional entity identifier, generator version) stamps constant columns onto the fact rows, writes a metadata sheet into each workbook (via write_string throughout — a free-form entity identifier beginning with = lands as a literal string, never a live formula, pinned by an XML-level test against the classic spreadsheet-injection class), and derives stamped download filenames (rwa_corep_CRR_2026-06-30.xlsx) from server-validated fields only — the unsanitised entity identifier deliberately never reaches a filesystem path. ResultExporter gains export_corep_facts/export_pillar3_facts (parquet/ndjson) and GET /api/export/{fmt} gains the four *_facts_* formats plus an entity_identifier query parameter; existing workbooks are byte-identical when no metadata is supplied. Ref: Reg (EU) 2021/451 Annex I/II; PS1/26 Annex I/II.
  • Cross-template tie-outs — the templates now check each other (reporting/tieouts.py). Until now nothing asserted that the COREP schedules agree among themselves or with the Pillar 3 disclosures — the one thing that separates "template data" from a return a firm can sign. check_cross_template_consistency(corep, pillar3, framework) runs a curated list of five comparable-aggregate ties — the C 02.00 grand-total RWEA against OV1 row 29, the C 02.00-internal credit-risk roll-up (0050 = 0060 + 0220), the C 07.00 sheet aggregate against C 02.00's SA line net of equity (equity folds into C 02.00[0060] by design but C 07.00 has no equity sheet — the naive tie is wrong and only passes on equity-free books), the C 08.01 sheet aggregate against C 02.00's IRB line, and C 08.01 against OV1 rows 3+4+5 on the shared reporting_approach_origin basis — and emits any breach on the standard CalculationError channel (TIE001, business-rule category), never an exception. Tolerance follows the golden convention (rtol 1e-9 with a small absolute floor) because Polars float sums are legitimately non-deterministic at byte level. Just as deliberately, the module records six non-comparable pairs with their regulatory reasons — UK CR6/CR7/CR9 sit on the obligor basis (PS1/26 Annex XXII bars substitution effects from back-testing), C 08.07 keys the raw Art. 147 origination class, C 09.01 is two-basis, CR4 mixes bases per column block — so a future maintainer can't "fix" the gap by asserting equalities the regulation forbids. A missing template, absent row or null cell fail-safe skips the tie (null means "not produced", not zero — and a partially-resolvable multi-row term skips rather than summing what it found). All five ties foot to zero on real CRR and Basel 3.1 pipeline runs. New: tests/unit/reporting/test_tieouts.py, tests/acceptance/test_tieouts_pipeline.py. Ref: CRR Art. 92(3); Reg (EU) 2021/451 Annex II; PS1/26 Annex XXII.

Changed

  • Every consumer-read aggregator frame is now contract-sealed, and the exit seal has exactly two sanctioned call sites. The sealed results ledger was the only brand-checked frame on AggregatedResultBundle — the by-class/by-approach/by-class-method summaries, the floor-impact frame and the supporting-factor frame that the UI cards and analysis layer read had no schema guarantee at all, and the exit edge was independently re-sealed in a second module for the BA-CVA broadcast column. Five new EdgeContracts (declared faithfully from the producers in _summaries.py/_floor.py/_supporting_factors.py) are sealed at the producer and registered in SEALED_FRAME_FIELDS, so a drifted frame now fails loudly at bundle construction; a new reseal_with() helper in contracts/edges.py is the one sanctioned post-aggregator mutate-and-rebrand (it re-runs conform() and raises on any column not declared on the edge — the CVA path now goes through it), with the two-seal-point invariant documented on AGGREGATOR_EXIT_EDGE itself; and a REPORTING_SURFACE frozenset names the 14 canonical reporting columns (the ten reporting_* projections, guarantee_rwa_benefit, rwa_final/ead_final/rwa_pre_floor) so a template author no longer has to guess which of the ~313 exit-edge columns are the surface and which are calculation carry-through. The shared test fixture now refuses to null-complete an under-populated summary frame (three fixtures were silently relying on that — now populated with the real producer columns), and a new unit drift-guard strict-seals the real summary producers' output against their edges. Number-neutral: sealing and annotation only — zero expression, filter or group-by changes. The previous format double-framed the grid — a scroll pane with a near-invisible border inside a padded panel — so on wide templates (C 07.00's 28 columns, C 08.02's 49) columns were chopped mid-digit at an edge that read as accidental clipping, and on tall templates the pane's horizontal scrollbar sat below the fold, leaving no visible way to reach the remaining columns. The grid card is now itself the window: a header strip (template title, sheet, column count, and the template/sheet picker folded in as a slim toolbar — reclaiming ~200px of page chrome) over a scroll pane sitting flush inside the card frame, so columns clip exactly at the card border like a freeze pane. The pane is fitted to the viewport remainder (report-grid.js, with a CSS fallback), keeping its horizontal scrollbar on screen at all times, and pane scrollbars are now thin and visible on the dark canvas. Frozen headers, row labels, cell keys, drill-down links and the null-vs-zero distinction are unchanged.

[0.3.14] - 2026-07-19

Added

  • The report-template pages now use the full desktop width, with a frozen header and row labels. Regulatory templates are wide — C 08.02 under Basel 3.1 has 49 value columns, C 07.00 has 28 — and the 1100px reading measure (right for prose, wrong for a return) showed only ~6 of them at a time. Three changes compound: the report pages opt into a full-bleed container (prose keeps its measure via page-intro); the grid becomes a bounded scroll pane, which is what gives position: sticky a scrollport to work against — the group band and column refs stay pinned at the top, and the row ref/name columns are frozen to the left, so scrolling right no longer loses track of which row you are on; and the grid runs at a compact density with tabular numerals, so digits line up column-wise. Roughly 6 visible columns becomes ~20 on a 1920px display and ~29 on a 2560px one — C 07.00 fits on screen in one go. This also fixes two latent bugs: the sticky header did nothing (the wrapper had no height cap, so it never scrolled internally), and the two header rows both pinned to top: 0, which would have stacked them on the same line. The column count is now shown next to the sheet title. Cell keys, drill-down links and the null-vs-zero distinction are unchanged.
  • Click a report cell to see what produced it. The template viewer's cells are now drill-down links: clicking one opens its lineage — the reported value, the sum of contributions and whether the two reconcile (allowing for the deduction-column sign convention; a genuine mismatch is surfaced as a defect rather than hidden), what the cell measures, the criteria a leg must satisfy, the population those criteria ran over, and the contributing exposure legs. The page states plainly when a cell cannot be explained by exposures at all: it derives from other cells (0040 = 0010 − 0030), it comes from outside the ledger, its sources are never produced (so a reported 0.0 is the empty-cell policy, not a measured zero), or no legs match it. Criteria on template-derived discriminators (a risk-weight band, a CCF bucket) are labelled as such, so they are not mistaken for sealed facts about the exposure. Cells are only offered as links on templates that have lineage — a link never leads to a shrug, and asking for lineage anyway is a clean 404. New: ui/views/lineage.py, cell_lineage.html.
  • Report-cell lineage — ask a reported figure which exposures and rules produced it (GET /api/lineage). Given a cell key (template, sheet, row ref, column ref) the drill-down returns what the cell means — its metric, its filter criteria, the scope of its population, its basis (aggregator_exit) and its sign convention — plus the ledger legs that fed it. Lineage is a query, never a stored index: a cell's lineage IS its CellSpec (the Phase 7 declarative executor made C 07.00 col 0220 literally CellSpec(Sum("rwa_final"), predicate=…)), so the drill-down reads the very spec the generator executes and re-runs that same RowPredicate over the same prepared frame. It never re-implements a template's row selection — a second copy could silently disagree with the figure actually reported. To make that reuse possible without duplication, c07.py now exposes c07_plans() (a number-neutral split of plan-building from execution; the 95 goldens are byte-unchanged). Two honesty rules are pinned by the tie-out suite: cell_value is read from the generated template (the number the user clicked is ground truth, never recomputed), and contribution_total is reported separately with a sign flag reconciling the two across the Annex II §1.3 deduction-column negation. Cells are classified into six kinds that fall straight out of the binding vocabulary — rows (contributing legs), formula (derives from other cells, e.g. 0040 = 0010 − 0030), side_context, prior_period, constant, unbound — so every cell gets a truthful answer, not just the summable ones. Crucially, a cell whose source columns the engine never produces (C 07.00 col 0030 sums SCRA/GCRA provisions) still reports 0.0 under the COREP zero policy; lineage flags it is_source_backed: false with missing_columns and a null contribution_total, distinguishing "we computed zero" from "we cannot compute this". A contributor is a leg, not an exposure (guarantee substitution splits an exposure), so every row carries reporting_leg_role, both class endpoints and source_exposure_reference. Coverage is explicit: C 07.00 today; any other template — including the still-imperative C 34.x / CCR1–8, which have no TemplateSpec to read — is a clean 404, never a re-derived guess. New: reporting/lineage.py, features/report-cell-lineage.md.
  • Report templates are now viewable in the UI — the COREP and Pillar III templates a run produced, on screen, sheet by sheet. Until now the templates existed only as a downloaded workbook: reading one figure meant exporting Excel. A run's results page links a new viewer (/results/{run_id}/templates) with a template picker (grouped COREP / Pillar III), a sheet picker for the per-sheet templates (C 07.00 / C 08.x by exposure class, C 09.0x by country, C 34.02 by netting set, CR6/CR9 by class), and the regulatory grid — row refs and names down the side, 4-digit column refs across the top under their logical group band. Two additive read endpoints back it: GET /api/templates (the templates this run produced, with their sheet keys) and GET /api/templates/{template_id} (one sheet's column headers and rows). Templates are generated once per run and cached, so switching template or sheet re-renders rather than recalculates. Cells are shown exactly as the generator produced them, including the Annex II §1.3 "(-)" sign convention — nothing in the viewer recomputes, re-signs or re-fills a value. Crucially, a null cell renders distinctly from a reported zero ( vs 0): an inert/empty row or a never-produced source is not the same statement as a computed zero, and flattening them would misstate the return. Every value cell carries its cell key (template_id, sheet, row_ref, col_ref) as data attributes — the address a report cell is known by, and the handle the forthcoming cell-lineage drill-down attaches to (docs/plans/report-cell-lineage.md). New: reporting/catalog.py (one uniform view over both generator bundles), ui/views/report_templates.py, features/report-template-viewer.md.
  • The results output now carries a canonical reporting projection — ten reporting_* columns that name the guarantee-substitution ledger once, at the source (Phase 7 S2). The per-exposure results frame has always physically been a two-leg substitution ledger (CRM splits each guaranteed exposure into __G_<guarantor> guaranteed legs and __REM/__REM_FL/__REM_SEN retained legs), but no column said so: COREP, Pillar 3, the UI and the reconciliation each re-derived class/approach/method from a different mix of the raw twins (exposure_class vs _applied vs _post_crm; approach_applied vs _post_crm), which is exactly where the pre/post-CRM complexity crept in. The aggregator now computes, after the residual multiplier and the output floor, and seals on AGGREGATOR_EXIT_EDGE: reporting_class (post-substitution class, = exposure_class_post_crm, Art. 235), reporting_class_origin (obligor applied class, = exposure_class_applied, Art. 112), reporting_approach/reporting_approach_origin (the post-/pre-substitution approach twins), reporting_method (the STD/FIRB/AIRB/SLOTTING/EQUITY label of the post-substitution approach, materialised from method_label_expr), reporting_leg_role (whole/guaranteed/retained — names the physical leg split so nobody sniffs reference suffixes; COREP C 07.00 substitution outflow/inflow reconstruct as two sums over the guaranteed legs), reporting_on_balance_sheet (declared at source from exposure_type, the same rule the reporting kernel applies today; null = neither side), and reporting_subclass/reporting_ead/reporting_rw aliases. No consumer reads the new columns yet — this slice is provably cell-neutral (all 95 reporting goldens structure-identical; full suite green) — the summaries, reconciliation, reporting templates and UI retarget to them in the follow-on slices of the Phase 7 plan. Pinned by tests/unit/test_aggregator.py::TestReportingProjection. Ref: CRR Art. 235 / Art. 112; Reg 2021/451 Annex I C 07.00.
  • Reconciliation can now reuse a calculation you already ran, instead of silently re-running the whole pipeline. CreditRiskCalc.reconcile() embedded an unconditional calculate(), so the calculate → reconcile flow cost two full engine runs over the same data. reconcile() now accepts calculation= (an already-completed CalculationResponse; the embedded run is skipped and the cached results parquet is reconciled directly), and a new rwa_calc.api.run_index makes the reuse safe by construction: each calculation is fingerprinted by its parameters plus a stat-based (relpath, size, mtime_ns) signature of every input file, captured before the run, and find_reusable recomputes the signature at lookup — any input change, a failed run or a vanished results parquet misses and forces a recompute. The UI reconciliation form offers a pre-ticked "Use results from the calculation completed at …" checkbox when a fresh matching run exists (a passive "input data has changed" note when only the data moved), re-verifies at submit time and degrades silently to a full run on any miss; on reuse the progress stepper ticks every engine stage instantly and parks on the reconcile tail. POST /api/reconcile gains an optional run_id for the same reuse over HTTP with the opposite contract — an explicit run_id is an instruction, so unknown ids 404 and a mismatched framework/date, failed run or expired results 422, never a silent recompute. Covered by tests/unit/api/test_run_index.py, tests/unit/api/test_service_reconcile_reuse.py, and new cases in tests/integration/test_ui_reconciliation.py + tests/integration/test_rest_api.py.
  • Calculation reuse now covers every flow and survives an app restart. Follow-ons to the reconciliation-reuse feature above: (1) persistence — the UI app configures run_index persistence at startup, so each UI run's parquet cache lives under $RWA_STATE_DIR/runs/<run_id>/ (default ~/.rwa_calc/), registrations write through to run_index.json (capped at the 10 most recent runs, oldest evicted), and a restart reloads the index and re-registers the runs so both the reuse offers and their /results/{run_id} pages come back; run directories are never deleted mid-session — unreferenced ones are swept at the next startup, when nothing can be serving them. (2) Every run seeds the pool — the comparison page formats and indexes both of its embedded framework runs (the comparison itself still computes from the rich bundle: its capital-impact attribution needs floor-impact/pre-factor frames a cached response does not persist), a full reconciliation's embedded calculation is exposed as ReconciliationResponse.calculation and indexed by the worker (a recon-first session gets the reuse on its next run), and POST /api/calculate / POST /api/comparison index their runs too. (3) Calculator banner — when the calculator form's pre-filled values match a fresh indexed run, a non-blocking "already ran — view its results" banner links straight to the existing results page. Covered by new persistence unit tests in tests/unit/api/test_run_index.py (restart round-trip, corrupt-file tolerance, cap eviction, orphan sweep) and new integration tests in tests/integration/test_ui_app.py + test_ui_reconciliation.py (banner, comparison seeding, recon-first seeding, reuse-survives-restart) + test_rest_api.py.

Added (Phase 7 — declarative reporting groundwork)

  • Guarantee RWA relief is now a first-class, reconcilable number: the sealed per-leg guarantee_rwa_benefit column lands on the results output (Phase 7 decision F8, recorded). Reconciliation previously tied out guarantee EAD only — when a firm's legacy numbers disagreed on the relief a guarantee produced, the mismatch diffused into unattributable blended risk-weight and RWA deltas. The aggregator now seals, per physical guaranteed leg, ead_final × guarantee_benefit_rw — the leg's EAD times (borrower-basis risk weight minus the substituted risk weight), which is exactly the difference between the two Art. 235 terms for the covered portion. The recorded definition is pre-supporting-factor and pre-floor (the branch snapshots the delta before Art. 501/501a and the portfolio output floor, isolating the substitution effect), and because it reads the applied delta it ties exactly to the relief the engine granted — including the Art. 153(3) double-default override and the Art. 160(4) no-better-than-direct floor on IRB parameter substitution. Retained, whole and non-beneficial legs carry 0.0; the column is null where the substitution machinery never ran — slotting legs (the recorded slotting-guarantee zero-relief gap is deliberately kept visible, not papered over as "no relief due") and unguaranteed runs. Reconciliation gains the additive guarantee_rwa_benefit component (skipped with a REC001 warning when a legacy extract doesn't map it), so relief mismatches get their own component row. Pinned by aggregator unit tests (per-leg arithmetic, multi-guarantor additivity, the null-not-zero contract) and a full-pipeline hand-calc acceptance twin on the P1.110 book: a 1M loan, borrower corporate CQS 5 (150% both regimes), guarantor corporate CQS 3 → benefit 500,000 under CRR (150%−100%) and 750,000 under Basel 3.1 (150%−75%). Additive and number-neutral for every existing output; the reporting goldens passed without regeneration. Ref: CRR/PS1-26 Art. 235, Art. 236/161, Art. 153(3), Art. 160(4).
  • The declarative executor gains its grouped-aggregation kernel — template generation is 7–15× faster and the full dev-loop test suite drops from ~12 to ~4.5 minutes (Phase 7, recorded follow-up closed). The executor previously filtered the frame once per cell (rows × columns eager filters), and each eager expression-filter carries ~7ms of query-plan overhead regardless of frame size — measured at 26s/76s/63s for a single C 08.01/C 07.00/CR6 generate call on 10–14k-row frames. cellspec.execute() now builds one subset per distinct predicate (cells share their row's few predicates; RowPredicate is frozen and hashable), compiles all predicate masks in a single select per frame, and filters by boolean mask (~10× cheaper than expression filters); prior-period masks compile against the prior frame's own columns so the presence-tolerant semantics are unchanged. Two new public batched helpers — cellspec.subset_rows (keyed batched subsets) and cellspec.matched_counts (one select of mask sums, no filters at all) — replace the per-row pred.apply loops in the C 07/C 08/C 09 module post-passes. Number-neutral by construction (identical subsets feed the identical evaluation): the reporting golden gate passed without regeneration, and all 988 reporting unit tests pass unchanged. Measured: C 08.01 14.9×, C 07.00 13.1×, CR6 6.8×; the golden acceptance gate fell 65s→14s.
  • The Phase 7 capstone lands: the reporting test estate is split per template, the remaining consumers converge on the sealed ledger, and the architecture is ratcheted (Phase 7 Sn). The 9,525-line test_corep.py — the single-file xdist straggler — splits into tests/unit/reporting/corep/ (one file per template family, a dedicated cross-template file preserving the C 07+C 08 cross-checks, and a shared builders module), cutting the unit-suite wall time by roughly a third with exact collection parity. The reconciliation collapse drops its dead RWA/EAD candidate ladders for the sealed names; both template generators re-type their entry points onto a structural ResultsSource protocol so the reporting layer no longer imports upward from api/ — retiring the two "Retired by Phase 7" import-direction inversions for real. Three new shrink-only ratchets bank the reporting estate (max_reporting_module_loc, the multi-candidate pick() ladder census, and a per-template test-file ceiling), and analysis/ gains its missing upward import-direction rule. Recorded §9 scope-downs (in the plan doc): the raw-class read-ban is re-scoped per the F3/F4 recorded bases (COREP keys raw exposure_class by design), the check-17 extension to reporting's framework == branching is deferred as slice-sized, the sealed-column read-allowlist is sequenced behind the F6 per-column decisions, and the watchfire source_paths widening is blocked on the instrument allowlist (Reg 2021/451 is not expressible). The /next-items reviewer gains the reporting-slice criteria (no bulk-regen-to-green; number-changing slices carry decisions; each slice names its D1–D10 kill), and the forced-single-stream list adds the reporting-projection files in all three of its synced homes.
  • COREP C 02.00 / OF 02.00 (the master own-funds roll-up) moves to reporting/corep/c02.py — completing the COREP credit-risk estate; only the C 34.x CCR family remains on the legacy path (Phase 7 S8). This template is the recorded exception to the cellspec executor: a portfolio-total cross-approach roll-up whose rows are pre-computed aggregates with value-dependent fallbacks, ported per the plan's Kind-9 mandate as typed pre-pass aggregation kernels feeding a thin row-assembly shell. The one structural change is that the generator's three instance-state dicts (the IRB class/approach map, the slotting SL-type map, and the B31 SME/FSE/property sub-row map — previously set, read and reset across methods) become pure-function returns; every behaviour is otherwise relocated verbatim and pinned: the output-floor column reporting the post-floor total (rwa_final is already post-floor — rwa_pre_floor feeds only the floor-activated indicator with its 0.01 epsilon), equity RWA appearing in three rows by design while the flat total counts it once, the many-to-one accumulating SA class map, the sub-row split fallbacks that place the whole total in one bucket when no sub-data exists, exposure_subclass as the canonical corporate split signal with the flag heuristic as fallback, the F-IRB/A-IRB asymmetry in financial-large-corporate routing, the Basel 3.1 column policy (approach parents zero the comparison columns while sub-rows mirror them), the currency-mismatch memo row with null comparison columns outside the TREA total, the zero-fill vs null-fill row regimes, and the entity-scoped floor indicator rows. Exactly number-neutral: goldens passed without regeneration in both regimes; 64 C 02.00 unit tests, 38 reporting-basis tests and the 7 P2.41 exposure-subclass acceptance pins pass unchanged. Ref: CRR Art. 92; PRA PS1/26 Art. 92 para 2A/3A/5, Art. 123B, Art. 147A(1)(e)/(f).
  • COREP OF 02.01 (the Basel 3.1 output-floor comparison) is declarative (Phase 7 S8). The imperative generator method and its row builder move to reporting/corep/of02.py as one TemplateSpec: rows 0010 (credit risk excluding CCR) and 0080 (Total) carry identical full-portfolio values — the recorded collapse for a credit-risk-only calculator, where the Total is deliberately not a sum of the risk-type rows — and the six out-of-scope risk-type rows (CCR, CVA, securitisation, market, operational, other) stay a fixed all-null set via a post-pass. The modelled column sums rwa_pre_floor (the pre-floor carrier — deliberately, since rwa_final is already post-floor), the standardised columns sum sa_rwa, and U-TREA is the intra-row sum of the two per Annex II §1.3.2. The B31-only and entity-scope gates stay outside the executor: CRR returns a None bundle field (no frame), and the exemption reads only OutputFloorConfig.is_floor_applicable() — the delegate keeps its extra output_floor_config keyword, a recorded signature divergence from the other declarative delegates. An empty portfolio still yields the full 8-row frame with zeros on the populated rows. Exactly number-neutral: goldens passed without regeneration; all 80 OF 02.01 and reporting-basis unit tests pass unchanged. Ref: PRA PS1/26 Art. 92 para 2A/3A; PS1/26 Annex II §1.3.2.
  • COREP C 09.01 and C 09.02 (the geographical breakdowns) are declarative (Phase 7 S8). Both per-country generators and their 29 imperative helpers and 7 constant maps move out of generator.py into reporting/corep/c09.py — including the five helpers already orphaned by the earlier C 07/C 08 ports and the IRB pre-filter dispatch plumbing. One spec per template per framework executes once per country sheet ("TOTAL" first over the whole population — null-country rows included — then one sheet per sorted country code). Both templates keep their raw exposure_class row keying, which differs deliberately from C 07.00's applied ladder: a defaulted SA exposure stays in its raw class row and lights the "of which defaulted" column while the "Exposures in default" row stays null, and under Basel 3.1 the reclassified real-estate mortgage appears only in the Total row — both golden-verified behaviours, preserved not fixed. Also preserved verbatim: C 09.01's permanently-null sub-rows (the reverse-map keying short-circuits before the SME/short-term/CIU/real-estate filters ever run — recorded dead code), the deliberately narrow column ladders (a single gross column, a two-wide RWEA ladder with no post-factor RWA, the CRR "pre supporting factors" RWEA equalling the post-factor value with structurally-null adjustment columns), C 09.02's PD/LGD averages as raw ratios weighted by final EAD reading lgd_post_crm only — with the retired unweighted-mean fallback on zero-EAD subsets reproduced as a module post-step — the per-cell defaulted null-vs-zero asymmetry, and the two distinct non-SME filters (the null-dropping corporate variant vs the null-keeping anti-join). The port is exactly number-neutral: the golden gate passed without regeneration across all 8 C 09 frames in both regimes, and all 80 C 09 unit tests pass unchanged. Ref: Reg (EU) 2021/451 Annex I/II (C 09.01/02); PRA PS1/26 Annex I/II (OF 09.01/02); CRR Art. 112/147.
  • COREP C 08.06 and C 08.07 are declarative — the C 08 family is now fully through the one executor (Phase 7 S8). The two imperative generators and their eleven helpers (the SL-type/HVCRE router, the category×maturity row filter, the zero-fill builder, the four C 08.06 value computers, the C 08.07 group-by aggregator and per-row value computer, plus the already-orphaned deduction-negation residue) are deleted from generator.py; both templates live in reporting/corep/c08.py. C 08.06 (specialised-lending slotting, one sheet per SL type) preserves its per-row two-branch policy as a module post-pass: an empty non-Total row zero-fills every cell and reports the row definition's fixed display risk weight ("50%" → 0.5) in the risk-weight column, while live rows — and both maturity-split Total rows, even when empty — compute on data with per-cell null policy. Preserved verbatim and pinned: the framework-divergent HVCRE routing (CRR's IPRE sheet absorbs HVCRE only when is_hvcre exists; Basel 3.1 splits HVCRE into its own sheet), the asymmetric no-maturity-column fallback (short band empties, long band absorbs the category), the permanently-empty "substantially stronger" sub-rows, CRR's preference for post-supporting-factor RWEA, the post-CRM carrier fallback to original exposure, the whole-subset nominal fallback for the off-balance original column, the first-non-null risk weight on zero-EAD subsets, and the SCRA/GCRA→provision_held provisions ladder; SL types with no exposures emit no sheet. C 08.07 (IRB scope of use) reads the FULL population — SA enters every denominator, a null approach falls to the SA side, and slotting counts as IRB — keyed on the raw exposure_class; the coverage percentages are intra-row Formulas guarding zero denominators to 0.0; the structural-null rows are a fixed set (the opposite of C 07.00's empty-subset rule — empty real-class rows stay 0.0); and the Basel 3.1 SA-RWEA "other" column is derived as total-minus-IRB so the Annex II additive identity holds by construction. One recorded finding: the C 08.07 materiality-column gating on output_floor_config was dead code — the flag was threaded but never read, and the materiality columns are unconditionally null on every reporting basis (pinned by the existing reporting-basis tests) — so the declarative signature drops the parameter outright. The port is exactly number-neutral: the golden gate passed without regeneration in both regimes, and all 120 C 08.06/07 unit tests pass unchanged. Ref: CRR Art. 153(5), Art. 147(2), Art. 148, Art. 150; Reg (EU) 2021/451 Annex I/II (C 08.06/07); PRA PS1/26 Art. 153(5) Table A, Art. 147B/150(1A) (OF 08.06/07).
  • The entire COREP C 08 IRB family (C 08.01/02/03/04/05) is declarative in one slice — including C 08.02, whose data-driven String-keyed rows were previously judged an executor misfit (Phase 7 S8). The five imperative generators and their shared value engine (the ~40-column computer with seven column helpers, the per-class builders, the grade-row builder, the PD-range computers) are deleted; all five run through the one cellspec executor from reporting/corep/c08.py, sharing one framework-agnostic value surface filtered per regime's column set. C 08.02 lands via the CR9.1 pattern: rows are discovered at generate time (distinct firm rating grades when supplied, else the populated fixed PD bands, plus an "Unassigned" residual) over a derived key column, and the String obligor-grade column 0005 is injected post-execute from the row name. Every recorded subtlety is preserved verbatim and pinned: the column-presence-vs-value-nullness distinction (a present-but-null el_pre_adjustment masks expected_loss on slotting sheets — the golden behaviour), the Annex II negation set being column 0290 only (the CRM waterfall 0090 = 0020−0040−0050−0060−0070+0080 runs on positive magnitudes), maturity reported in days (years ×365), C 08.04's deliberately narrower RWA ladder, C 08.03's whole-bucket on/off-balance-sheet fallback, the value-dependent SCRA/GCRA→provision_held provisions ladder (a shared post-step), C 08.05's null-filled arithmetic means and point-in-time prior-year/historical fallbacks, the Basel 3.1 allocate-on-pre-floor-PD/report-post-floor split, sparse bucket emission with the 9999 "Unassigned" row, and the LFSE sub-splits gated on cp_apply_fi_scalar column presence. All five dicts keep the raw exposure_class keying (identical to the sealed obligor basis for IRB rows — probe-verified zero movers); the cross-sheet substitution inflow reuses the C 07 ReportingContext side input. The port is exactly number-neutral: the golden gate passed without regeneration across all 28 C 08 frames in both regimes, and all 732 COREP unit tests (including the P4.20 internal-grade suite) pass unchanged. Ref: CRR Art. 142-191, Art. 153, Art. 180, Art. 501/501a; Reg (EU) 2021/451 Annex I/II; PRA PS1/26 Annex I/II (OF 08.0x).
  • COREP C 07.00 is declarative — the first COREP template through the one executor, and the substitution outflow/inflow keystone (Phase 7 S8, decision F4). The SA credit-risk template's imperative estate (the 24-column value computer, the CRM/CCF/RWEA column helpers, the risk-weight-band section builder, the four row-section subset builders and their C 07.00-only filter family) is deleted; the per-class sheets now run as one TemplateSpec per framework in reporting/corep/c07.py, executed per obligor-class sheet over the sealed ledger. F4 is executed as preserve-verbatim: sheets key the obligor applied-class ladder (identical to the sealed reporting_class_origin), specialised lending merges into corporate before keying, the substitution outflow keeps the raw pre/post-class semantics via a derived flag, and the cross-sheet inflow is precomputed per destination class and threaded to the total row through a new ReportingContext.substitution_inflow side input — so the net-exposure waterfall Formula (0110) consumes it in-pass, with the COREP Annex II §1.3 "(-)" sign convention applied as a post-step after the waterfalls consume positive magnitudes. Row subsets (defaulted/SME/materially-dependent/qualifying-RE ladders, RW bands, CCF buckets, SL types and phases, PPU/roll-out provenance, equity-transitional and currency-mismatch memo rows) become tolerant-equals terms over module-derived discriminator columns (RowPredicate.equals now accepts Boolean values), with empty or source-less rows rendering all-null exactly as before and never-produced cells (CCR splits, ECAI splits without sa_cqs, CCF buckets without ccf_applied) staying structurally null. The port is provably number-neutral: the golden gate passed without regeneration across all 16 per-class sheets in both regimes, and all 713 COREP unit tests pass unchanged — no test shim was needed because the module keeps its raw-column keying (the ledger two-leg equivalence stays pinned by the aggregator oracle; the COREP ledger-field convergence and a COREP ledger shim are recorded follow-ups). Also recorded: the S8-pre golden authoring and the Pillar 3 CCR1/2/3/8 family are deferred on the legacy path, and the C 08 family is the next slice (C 08.02's data-driven String-keyed rows are the known misfit). Ref: CRR Art. 111-113, Art. 501/501a; COREP Annex II C 07.00 ¶40-43/¶56/¶56A; PRA PS1/26 Annex II/App. 17.
  • Pillar 3 CMS1/CMS2 are declarative — completing the Pillar 3 credit estate — and the two output-floor comparison templates finally agree on the total actual RWA: equity-approach exposures now count on the standardised side of CMS2, as the instructions require (Phase 7 S8). The imperative bodies and the five CMS2 row helpers are deleted; both templates live in reporting/pillar3/{cms1,cms2}.py as static Basel-3.1-only specs (the Art. 92(3A) internal-model gate — verified against the PS1/26 Annex II instructions, which have no CRR heritage), and the router's per-approach frame plumbing they consumed is retired. The recorded fix: the modelled-vs-standardised split now uses the explicit origin-approach complement ("standardised", "equity") — "exposures calculated according to the SA for credit risk include equity exposures subject to the IRB Equity Transitional" — where the retired CMS2 added only the standardised approach's RWA to its per-class totals, leaving its "Subordinated debt, equity and other own funds" row at 0.0 against a 2.5M equity book and its Total exactly 2.5M short of CMS1's on the reference portfolio (CMS1 already used the complement and is unchanged). CMS2 column c is now simply the row's total actual RWA across all approaches; the golden diff is those two cells, and a new unit test pins the CMS1==CMS2 total reconciliation. Also recorded: CMS2 rows key the origination class (the CR6-A pattern — column b is the SA recomputation "of exposures reported in column (a)", the same population, so substitution never moves a row; unit-pinned with a two-leg fixture); columns b/d read the pre-supporting-factor sa_rwa S-TREA carrier (the engine's floor convention — the floor's fallback-path post-factor divergence is a recorded follow-up); the F-IRB/A-IRB sub-row shapes and the recorded-null purchased-receivables/IPRE-HVCRE sub-rows are preserved verbatim. CMS unit coverage moved to tests/unit/reporting/pillar3/test_cms.py. Ref: PRA PS1/26 Art. 456(1)(a)/(b), Art. 2a; PS1/26 Annex II (UKB CMS1/CMS2 instructions).
  • Pillar 3 CR9/CR9.1/CR10 are declarative, closing the F3 class-basis decision — and defaulted obligors can no longer escape CR9's 100% PD band (Phase 7 S8). The three imperative bodies (the per-leaf CR9 loop with its six value helpers and mixed-type schemas, the per-grade CR9.1 loop, the four CR10 helpers) are deleted; the templates live in reporting/pillar3/{cr9,cr10}.py and run through the one executor. CR9's sheets key the obligor basis — reporting_class_origin × reporting_approach_origin, refined by the Annex XXII leaf taxonomy whose SME/property/financial-large discriminators stay a module-owned typed expression ported verbatim (including the absent-column degradation rules) — because the instructions bar substitution effects from back-testing sheets; CR10 has no class axis at all (supervisory categories × sl_type), which closes F3: every Pillar 3 class key now reads a recorded ledger basis. The port is exactly number-neutral (the golden gate passed without regeneration). One recorded fix rides along, unit-pinned with zero golden impact: CR9's band allocation now forces defaulted obligors into the 100% band via a derived allocation column ("All defaulted exposures shall be included in the bucket representing PD of 100%") — the retired code bucketed them at model PD and was saved only by fixtures that happened to carry PD=1.0. Preserved verbatim as recorded: the sparse-row emission (only populated PD bands plus Total), the single-run point-in-time proxies for the obligor/default-rate columns including their live carrier ladders (prior_year_obligor_count, historical_annual_default_rate — the true prior-period/five-year series are a recorded follow-up), CR9.1's silent-empty gate on the never-produced ECAI scope columns, the CRR IPRE+HVCRE merge and equity force-emit in CR10, and CR10's fixed Art. 153(5) risk-weight column (a module post-step, populated even on empty categories). The Basel-3.1-only gate stays; the CRR heritage UK CR9/CR9.1 (coarser taxonomy) is a recorded scope-out. Vocabulary grew Mean.scale; CR9.1's ECAI-grade rows are the estate's one data-driven row axis (a generate-time spec). Unit coverage moved to tests/unit/reporting/pillar3/test_cr9_cr10.py with new obligor-basis substitution and defaulted-band pins. Ref: PRA PS1/26 Art. 452(h), Art. 180(1)(f), Annex XXII paras 12-15; CRR Art. 438(e), Art. 153(5), Art. 155(2); PS1/26 Annex XXIV.
  • Pillar 3 CR6/CR6-A/CR7/CR7-A are declarative, on the obligor class basis the IRB disclosure instructions mandate — and two latent defects are fixed: the CRR CR7 "Retail — Secured by immovable property" row now actually sums the A-IRB mortgage class, and defaulted IRB exposures now always land in CR6's 100% PD band (Phase 7 S8, F3 second tranche). The four imperative bodies (per-class CR6 loop, the 14 CR7 row handlers, the CR6-A/CR7-A value computations) are deleted; each template lives in reporting/pillar3/{cr6,cr6a,cr7,cr7a}.py and runs through the one executor — CR6 as one spec per obligor-class sheet (the PD-range label stays a String column via a module-owned post-step), CR7-A as one spec per origin approach. The class basis is the OPPOSITE of the CR4/CR5 tranche, because the instructions say so verbatim: CR6 allocates each obligor's exposures "without considering any substitution effects due to CRM" and CR7-A discloses "in accordance with the exposure class applicable to the obligor" — so CR6 sheets and CR7/CR7-A rows key reporting_class_origin × reporting_approach_origin, with substitution carried by the column pairs (CR7 a→b, CR7-A m→n), never a sheet move; CR6-A keys the origination class (its Art. 147-shaped row axis has no defaulted sink). Number-neutral on the reference portfolio except one recorded fix: the CRR CR7 row 8 handler summed (retail_other, retail_qrre) — byte-identical to row 9 and contradicting its own label — and now sums retail_mortgage (golden row 8 flips to null; the portfolio has no A-IRB mortgage). The second fix is invisible to the goldens but pinned by a new unit test: the engine's defaulted-IRB treatment overrides RWA but never the model PD, so CR6's band allocation previously bucketed a defaulted obligor at its model PD; the derived allocation column now forces defaulted rows to the 100% band ("All defaulted exposures shall be included in the bucket representing PD of 100%"). Preserved verbatim as recorded: the Basel 3.1 pre-input-floor allocation / post-floor reporting PD split (PS1/26 Annex XXII mandates both sides — allocate on pd, report pd_floored/lgd_floored ×100), the CR7 a==b / CR7-A m==n approximations (the hypothetical pre-credit-derivative and no-substitution RWEAs need engine carriers — recorded follow-ups), CR6's permanently-null provisions column, and CR6-A's constant-zero roll-out column. Unit coverage moved to tests/unit/reporting/pillar3/test_cr6_cr7.py, including new obligor-basis substitution pins (both legs of a guaranteed IRB exposure stay on the obligor's sheet). Ref: CRR Art. 452(b)/(g), 453(g)/(j); PRA PS1/26 Annex XXII; COREP Annex II C 08.01/C 08.03.
  • Pillar 3 CR4/CR5 are declarative, and their class rows finally report the applied/substituted Art. 112 class — a defaulted SA exposure now appears in "Exposures in default" instead of hiding in its origination class row (Phase 7 S8, recorded decision F3 first tranche). The imperative _compute_cr4_values/_compute_cr5_values/_cr5_row_predicate bodies are deleted; both templates now live as cell specifications in reporting/pillar3/cr4.py/cr5.py and run through the one executor over the full sealed ledger. The number-changing part is the class basis: the retired code keyed every CR4/CR5 row on the classifier's raw exposure_class, which is neither of the two bases the COREP C 07.00 heritage mandates (Annex II ¶56/¶56A: first-step obligor assignment, second-step substitution reallocation; EBA Q&A 2018_4093) — the UK/UKB Annex XX disclosure instructions are silent, so the COREP mapping decides. Now CR4 columns a/b ("before CCF/CF and CRM") key on reporting_class_origin (the obligor's applied class, C 07.00 col 0010 basis), while CR4 columns c/d/e/f and every CR5 figure key on reporting_class (post-substitution, C 07.00 col 0200 basis — the covered leg of a guaranteed exposure reports in the protection provider's row, at the substituted risk weight in CR5's bands). On the reference portfolio the entire golden diff is one exposure: the defaulted corporate (EAD 1.0M, RW 150%) moves from row 7 "Corporates" to row 10 "Exposures in default" in both templates under both regimes; grand totals are unchanged. Also recorded: CR5's "Of which: unrated" provably equals the Total (the sa_cqs read was dead — the engine never produces it and the output seal strips undeclared columns; an engine rating-presence column is the F6 fix path); the Art. 123B currency-mismatch pre-multiplier banding is a typed transform owned by cr5.py; and the B31 9f/9g split-leg role lists are preserved verbatim, with their known coverage gaps (mixed-collateral secured_rre/secured_cre and Art. 124H(3) whole legs; residual-leg dual membership) recorded as follow-ups in the Phase 7 plan rather than silently changed. Vocabulary grew three primitives sized to this family: SafeSum (multi-column gross sums), presence-tolerant half-open between bands, and any_of predicate unions. Unit coverage moved to tests/unit/reporting/pillar3/test_cr4_cr5.py, including new substitution and defaulted-mover tests pinning the split basis. Ref: CRR Art. 444(e); PRA PS1/26 Annex XX; COREP Annex II C 07.00 ¶40-43/¶56/¶56A/¶65; CRR Art. 235/112(2); EBA Q&A 2018_4093.
  • Pillar 3 OV1 is declarative (Phase 7 S8). The overview-of-RWEAs template — totals, per-approach rows, the pre-floor row and capital-ratio rows, the 250%-RW memo, the Basel 3.1 equity sub-approach memo rows, the output-floor rows, and the 8% own-funds shim — now lives as cell specifications in reporting/pillar3/ov1.py and runs through the one executor; the imperative generator body and its ten helper functions are deleted. Three vocabulary verbs were added for it (SideContext for out-of-frame scalars like OF-ADJ and the capital-ratio overrides, FirstNonNull for the broadcast floor multiplier, and presence-tolerant equals predicates for the equity sub-approach discriminators the output seal strips today — recorded permanently-null cells). Behaviour-identical: the per-approach rows key on the recorded pre-substitution basis (reporting_approach_origin = approach_applied), all Pillar 3 unit tests and the OV1 goldens pass unchanged.
  • The declarative reporting executor exists, and the first template runs through it (Phase 7 S7). reporting/cellspec.py defines the closed cell vocabulary (Sum, Mean, WeightedAvg, Ratio, Count, PriorPeriod, Formula), row predicates over the canonical reporting-ledger columns, and one execute() that turns a TemplateSpec (the frozen row/column layout constants paired with per-cell bindings) into a template DataFrame — with the COREP-zero vs Pillar-3-null empty-cell drift as a per-template policy, exactly two escape hatches (an intra-template Formula over already-computed cells, and the ReportingContext side inputs), and no expression DSL. Pillar 3 CR8 (the IRB RWEA flow statement) is the pilot: its cell semantics now live in reporting/pillar3/cr8.py::CR8_SPEC and the generator method is a thin dispatch-router delegation — all existing CR8 tests and goldens pass unchanged. Remaining templates migrate family-by-family per the Phase 7 plan. Pinned by tests/unit/reporting/test_cellspec.py.
  • The rulepack now carries the regime's reporting template inventory (Phase 7 S6). A new cited ReportingTemplateSet rule shape names which COREP and Pillar 3 template families each regime's reporting framework comprises, plus the variant token that will select each template's regime-specific layout in the declarative reporting layer: the CRR entry is the Reg (EU) 2021/451 Annex I COREP CR/CCR set + the Part Eight Pillar 3 set (cited CRR Art. 430); the Basel 3.1 entry adds OF 02.01 (output floor) and CMS1/CMS2 (cited PS1/26). The entry is content-hashed like every pack value — changing a regime's template set is a new pack version — and is readable via resolve(regime, date).reporting(). A typed ReportingContext (reporting/metadata.py) pairs the resolved set with the out-of-frame side inputs templates need beyond the sealed results ledger (output-floor summary, prior-period results, capital-ratio overrides, reporting-basis/institution-type elections). Nothing consumes the metadata yet — the generators still key on framework strings until the per-template strangler slices land — so this is behaviour-neutral (all goldens unchanged). Pinned by tests/unit/rulebook/test_reporting_metadata.py.

Changed

  • COREP now reads the sealed reporting ledger end-to-end — the number-neutral convergence completion (Phase 7). C 07.00's sheet key collapsed from the exposure_class_applied/exposure_class ladder to the single sealed reporting_class_origin, and its population filters SA on reporting_approach_origin; C 08.01–05's per-class sheet keys, the IRB population (_irb_population/_non_slotting), C 08.06's slotting population and C 08.01's approach predicates all key the sealed origin names; C 09.02's class predicates and approach picks likewise. C 08.07 alone deliberately keeps the raw exposure_class sheet key — its rows are the Art. 147 origination taxonomy over the full population, which has no "defaulted" class. Attribute/derived reads (SME str.contains fallbacks, defaulted ladders, substitution-inflow class columns) stay raw by recorded decision. Zero golden changes; no regen. The per-class sheet partitions now drop null class keys (a sealed frame always carries the column; a sourceless typed-null row belongs to no sheet), and the remaining 14 COREP unit files moved onto LedgerShimCorepGenerator so the unit estate exercises the production input contract. The test shims' __new__ return types are now annotated, clearing the shim-typing diagnostic family repo-wide (ty 125 → 6).
  • The never-populated cached_path field was removed from ValidationResponse and the POST /api/validate JSON response — it was a vestige of an earlier caching idea, superseded by the calculation run index above.
  • Two dead output columns were removed from the results frame (Phase 7 S3): exposure_class_for_sa and substitute_rw. exposure_class_for_sa was computed by the classifier (defaulted-priority + SL→corporate variant of the SA class) and carried across five stage edges, but nothing in src/ ever read it — it was superseded by the aggregator-computed exposure_class_applied (now also aliased as reporting_class_origin), which additionally handles SME-managed-as-retail and row-level defaults; its defaulted-priority and SL semantics remain pinned at the aggregator (tests/unit/test_exposure_class_applied.py) and on the live exposure_class_sa column. substitute_rw was initialised to a null literal in the CRM EAD setup and never written again — the SA path writes substitution into risk_weight/guarantor_rw — so every persisted value was null. Consumers of the results parquet that referenced either column (there are none known) should read reporting_class_origin / guarantor_rw instead. Cell-neutral: all 95 reporting goldens structure-identical; full suite green.

Fixed

  • The equity IRB-Simple path now emits the Art. 158(7) expected-loss amount — computed as EL rate × exposure value and paired with the Art. 155(2) simple risk-weight bucket (P1.247, CRR Art. 158(7)). Under the Art. 155(2) simple risk-weight approach the engine assigned only a risk_weight (190%/290%/370%) and _calculate_rwa emitted only rwa/rwa_final — no expected_loss column was ever produced on the simple path (the only equity EL was on the Art. 155(3) PD/LGD branch). The Art. 158(7) EL amount — a required computation, disclosed in the COREP C 08 / Pillar 3 IRB expected-loss columns — was therefore silently zero for every equity IRB-Simple exposure. The simple path now emits expected_loss = EL rate × ead_final, with the EL rate read from a new cited rulepack entry equity_irb_simple_el (packs/crr.py, Citation("CRR", "158(7)")) and routed by a when-chain that shares its bucket predicates with the RW chain, so each rate pairs with its weight: 0.8% for private equity in sufficiently diversified portfolios (190%) and exchange-traded/listed equity (290%), 2.4% for all other equity (370%), and 0.0% for central-bank equity (0%). Regulatory scope — equity EL is not fed to the Art. 159 comparison. UK CRR Art. 159 (crr.pdf p.155, verbatim) subtracts only the "Article 158(5), (6) and (10)" EL amounts from provisions, and Art. 36(1)(d) (PRA Rulebook Own Funds Part) deducts only that Art. 159 negative amount; Art. 158(7)/(8)/(9) equity EL is outside both, and Art. 155(2) sets equity RWA = RW × exposure value with no EL gross-up (crr.pdf p.152). The emitted amount is therefore a disclosure quantity only — the aggregator continues to feed compute_el_portfolio_summary the IRB and slotting frames alone (equity is never pooled), so the EL-vs-provisions shortfall/excess and the CET1/T2 figures are unchanged. Regime scoping: confined to the CRR IRB-Simple path by the existing equity_irb_approaches_available feature — under Basel 3.1 all equity routes to SA (IRB equity removed) and the simple EL is never emitted; the CRR SA path (Art. 133) likewise emits none. Worked cases: exchange-traded EAD £200,000 → EL = 0.008 × 200,000 = £1,600 (RW 290%); other equity EAD £100,000 → EL = 0.024 × 100,000 = £2,400 (RW 370%); with an IRB corporate book of EL £50,000 vs provisions £30,000 the Art. 159 shortfall stays £20,000 — the equity £2,400 is not added. Number-neutral across every existing portfolio: the full unit + acceptance + integration + contracts + oracle suite is green with no golden, oracle or acceptance figure moved (no capital or reporting cell consumes equity EL — CR10's equity sheet keys the slotting-origin population, which equity rows never enter). Pinned by tests/unit/crr/test_p1_247_equity_simple_el.py (per-bucket EL rates, EL↔RW pairing, central-bank zero, EAD scaling, the SA/Basel-3.1 no-emission controls, and the pack-entry check) and the CRR acceptance twin tests/acceptance/crr/test_p1_247_art_158_7_equity_simple_el.py (end-to-end EL emission plus the Art. 159-exclusion guard: an equity simple row alongside a corporate IRB book leaves the shortfall unmoved). Ref: CRR Art. 158(7) (PRA Rulebook (CRR Firms); Art. 158 omitted from onshored CRR by SI 2021/1078); Art. 159; Art. 155(2); Art. 36(1)(d).
  • The large-corporate F-IRB-only subclass test now measures revenue at the highest level of consolidation — a small subsidiary of a > GBP 440m group is F-IRB-only instead of keeping A-IRB (P1.245, PS1/26 Art. 147(4C)(b)(ii) with Art. 147A(1)(e)). PS1/26 Art. 147(4C)(b)(ii) assigns a corporate to the financial-/large-corporates subclass — restricted to F-IRB only by Art. 147A(1)(e)(ii) — when its annual revenue exceeds GBP 440m "taken at the highest level of consolidation which is performed and at which audited financial statements are available … the average annual amount over the last three years". engine/stages/classify/approach.py compared the counterparty's own point-in-time cp_annual_revenue against the threshold with no group roll-up, so a small subsidiary of a large group kept A-IRB where PS1/26 mandates F-IRB — an RWA understatement (own-estimate A-IRB LGD is typically below the F-IRB supervisory LGD). The classifier now rolls revenue up the resolved counterparty hierarchy: a new attributes.with_group_annual_revenue derives cp_group_annual_revenue = max(the counterparty's own annual_revenue, its ultimate_parent_reference's own annual_revenue) via a self-join on the sealed counterparty lookup — a parent's own annual_revenue is, by convention, its consolidated audited-accounts turnover, so the ultimate parent (the top of the group) carries the highest-consolidation figure. The large-corp gate in _apply_b31_approach_restrictions reads cp_group_annual_revenue; the entity-level cp_annual_revenue is deliberately left untouched for the Art. 4(1)(128D) SME size test and the Art. 501 supporting factor, which correctly key off the entity's own turnover. max (not a source-preference coalesce) is the conservative direction for a test that forces F-IRB — neither a small subsidiary figure nor a data-anomalous small parent figure can let a large obligor escape. Null composition: max_horizontal ignores nulls, so a null own turnover under a revenue-bearing parent yields the parent figure (the subsidiary is resolved and no longer trips the CLS008 conservative-large warning); a standalone corporate (null ultimate_parent_reference) yields its own; both-null falls through to the pre-existing conservative-large default (CLS008). A new CLS011 classification warning records a roll-up-driven flip — own turnover at/below GBP 440m but group turnover above it — for audit transparency (mirroring CLS010); CLS008 and CLS011 are mutually exclusive, and CLS008's filter was re-keyed onto the group figure. 3-year averaging (Art. 147(4C)(b)(ii) second sentence) is a documented deferral: the schema carries a single point-in-time annual_revenue, so the most-recent-figure convention is used; multi-year revenue inputs are a future schema enablement. CRR has no such subclass — the whole branch (and roll-up) is gated on the approach_restrictions_b31_applicable feature and is a no-op under CRR, so no config.is_crr branch is introduced. Worked case — a subsidiary with £50m own revenue under a £500m parent, £1m senior corporate loan at PD 1% / modelled LGD 35% / M 3y with corporate A-IRB+F-IRB model permission: under Basel 3.1 it flips A-IRB (LGD 35%, RW 0.7225, RWA £722,482) → F-IRB (supervisory LGD 40%, RW 0.8257, RWA £825,694, +14.3%); the null-own-revenue subsidiary under the same parent flips identically; the small-group control (£50m sub under a £50m parent) stays A-IRB (RWA £722,482); a standalone £500m corporate is F-IRB regardless (own-large branch unchanged). Under CRR all four stay A-IRB (RW 0.8177 — higher than the B31 A-IRB weight because CRR retains the 1.06 scaling factor). The roll-up is proved end-to-end (the hierarchy stage resolves each subsidiary's ultimate parent from org_mappings, then the classifier rolls group revenue up that chain). Number-neutral across every existing portfolio: the full unit + acceptance + integration + contracts + oracle suite is green with no golden, oracle or acceptance figure moved — no existing fixture carried a corporate under a revenue-bearing parent (via org_mappings) that was routed to A-IRB under Basel 3.1. Pinned by tests/unit/classifier/test_p1_245_group_revenue_rollup.py (roll-up flip, null-own-under-large-parent, small-group and standalone controls, LGD clearing, the CRR no-subclass control, and CLS011 emission + its negative controls + CLS008 composition) and the CRR/Basel 3.1 acceptance twins tests/acceptance/basel31/test_p1_245_group_revenue_rollup.py (all four scenarios end-to-end through the full pipeline, and the flip-raises-RWA check). Ref: PRA PS1/26 Art. 147(4C)(b)(ii); Art. 147A(1)(e); Art. 4(1)(128D) (SME size, own turnover); Art. 501 (SME supporting factor, own turnover).
  • A revolving retail exposure is now admitted to the QRRE sub-class only when it is to an individual, unsecured, and — to the extent undrawn — unconditionally cancellable; a secured or non-cancellable revolving retail line is demoted to RETAIL_OTHER (P1.244, CRR Art. 154(4)(a)-(b); PS1/26 Art. 147(5A)(a)-(b)). CRR Art. 154(4)(a)-(c) and PS1/26 Art. 147(5A)(a)-(c) restrict the qualifying revolving retail exposures (QRRE) sub-class to exposures that are (a) to individuals and (b) revolving, UNSECURED, and — to the extent they are not drawn — immediately and unconditionally cancellable; (c) is the per-individual GBP 90,000 (B31) / EUR 100,000 (CRR) aggregate-nominal limit. engine/stages/classify/subtypes.py tested only the revolving flag and the (c) aggregate limit, so a secured revolving retail facility, a non-cancellable undrawn commitment, or an SME-derived / non-natural-person RETAIL_OTHER row could wrongly become QRRE — an RWA understatement, because QRRE's fixed 0.04 asset correlation is below the retail-other correlation at the low PDs typical of performing revolving retail (the two cross at PD ≈ 7.3%). is_qrre_candidate now carries three added gates: (a) individuals — the shared natural_person_expr() predicate (is_natural_person flag OR the individual / natural_person / retail entity-type aliases; a null flag AND non-natural type → NOT an individual → not QRRE, the same conservative signal P1.243 introduced); (b) unsecured — a new nullable facility-level attestation is_secured (FACILITY_SCHEMA), coupled through the existing _FACILITY_QRRE_COUPLED_COLUMNS machinery so it reaches both the drawn loan exposures and the synthesised facility_undrawn rows, consumed as ~is_secured (qrre_unsecured_expr); (b) cancellable — the row's undrawn commitment must carry the CCF unconditionally-cancellable (LR / low-risk) risk_type (qrre_undrawn_cancellable_expr, reusing the CCF machinery's UC signal rather than minting a duplicate flag), while a fully-drawn row (no undrawn commitment) satisfies the limb trivially. Design decisions & direction of error: the classifier runs before CRMProcessor, so general (non-property) collateral is not yet allocated — an attestation was chosen over a classify-time pledge-presence join, which would have had to replicate CRM's multi-level (direct/facility/counterparty) beneficiary cascade (recorded deferral). is_secured defaults False (unsecured) — the backward-compatible direction consistent with the pipeline's treatment of absent collateral everywhere else and with revolving retail credit being unsecured by nature; the Art. 147(5A) second-sub-paragraph wage-account derogation (a wage-account-linked collateralised facility is treated as unsecured) is applied via input semantics (set is_secured=False), a documented deferral for an explicit flag. The cancellability limb reuses the CCF null convention with no divergence: a null/non-LR risk_type on an undrawn row resolves to not-cancellable (the CCF path likewise resolves a null risk_type to the MR-equivalent CCF, never the LR benefit) → not QRRE. The same conditions apply under both regimes (CRR Art. 154(4) is identical; only the (c) limit value differs, from the pack), so the gates are not regime-Featured — no config.is_crr branch. A new CLS010 classification warning is raised once per run when a would-be QRRE row (RETAIL_OTHER, regulatory-retail, revolving) is denied by an (a)/(b) gate (limit-only demotions are not flagged — that is the long-standing (c) behaviour). Demoted rows land in RETAIL_OTHER (never mortgage, never expelled from retail). Worked case — a £50,000 individual revolving line at PD 2% / LGD 45% routed to retail A-IRB: as QRRE (R = 0.04) RWA = £15,329 (CRR) / £14,461 (B31); demoted to RETAIL_OTHER (R = 0.0946) RWA = £30,733 (CRR) / £28,993 (B31) — +100.5%, confirming the demotion is the conservative direction; a genuine unsecured, LR-cancellable individual control is unchanged (stays QRRE). Number-neutral across every existing portfolio: the full unit + acceptance + integration + contracts + oracle suite is green with no golden, oracle or acceptance figure moved — no existing fixture carried a secured or non-cancellable revolving retail line that classified as QRRE. The only fixture change is the P1.191 QRRE control facility, whose risk_type is set from MR to LR — it is a genuinely unconditionally-cancellable revolving line, so this is a correct attestation (its intent is unchanged QRRE). The is_secured end-to-end facility→hierarchy→classify coupling is exercised by the raw-bundle tests. Pinned by tests/unit/classifier/test_p1_244_qrre_gates.py (unsecured, cancellable and individuals gates + controls under CRR/B31, CLS010 emission and its all-pass negative control, the fully-drawn trivial-cancellability case) and the CRR/Basel 3.1 acceptance twins tests/acceptance/basel31/test_p1_244_qrre_gates.py (all three gates end-to-end through the full pipeline). Ref: CRR Art. 154(4)(a)-(c); PRA PS1/26 Art. 147(5A)(a)-(c); CRR Art. 111(1) / PS1/26 Table A1 Row 7 (LR = unconditionally cancellable).
  • A natural person owing more than the retail cap now stays in the IRB retail class instead of being expelled to corporate — the EUR 1,000,000 / GBP 880,000 monetary cap conditions the SME limb ONLY (P1.243, CRR Art. 147(5)(a); PS1/26 Art. 147(5)(a)). CRR Art. 147(5)(a) and PS1/26 Art. 147(5)(a) admit an exposure to the IRB retail exposure class when it is either (i) an exposure to one or more natural persons — with no amount cap — or (ii) an exposure to an SME, provided the total amount owed (excluding residential-property-secured exposures) does not exceed EUR 1,000,000 (CRR) / GBP 880,000 (PS1/26). The engine applied the aggregate-owed threshold via qualifies_as_retail to every row and reclassified any RETAIL_OTHER row that failed it — including natural persons — to CORPORATE (engine/stages/classify/subtypes.py), so a large-borrowing individual in an IRB retail book was expelled from retail IRB into corporate: an RWA overstatement (corporate correlation/LGD instead of the retail formula) and a mandatory-classification error, not an election. The qualifies_as_retail flag is the SA regulatory-retail test (CRR Art. 123 / PS1/26 Art. 123A — a separate rule that legitimately caps natural persons for the 75% SA regulatory-retail treatment, and additionally, under B31, applies the Art. 123A(1)(b)(ii) 0.2% granularity limb); it is left completely untouched. The IRB divergence lives only in exposure_class_irb: sync_irb_exposure_class now restores a natural person expelled to CORPORATE back to the IRB retail class, because neither the monetary cap (Art. 147(5)(a)(ii), SME limb only) nor the granularity limb (absent from Art. 147(5)) exists in the IRB retail class — provided the Art. 147(5)(c) management-basis condition holds (is_managed_as_retail not explicitly False; a null flag defaults to True, matching the existing Art. 123A(1)(b)(iii) KEEP). Because the IRB calculator reads exposure_class (not exposure_class_irb) for correlation/LGD/floor selection, _align_irb_exposure_class (engine/stages/classify/approach.py) — previously scoped to rgla/pse rows — now also propagates the restored retail class to exposure_class for the IRB-routed natural person, gated on exposure_class_irb != exposure_class so QRRE/mortgage/SME subtyping is never reverted. The natural-person signal is the explicit is_natural_person flag OR one of the documented natural-person entity_type aliases (individual / natural_person / retail, a new NATURAL_PERSON_ENTITY_TYPES input-domain constant in data/schemas.py); a null flag AND a non-natural entity type resolve to NOT a natural person (conservative direction of error — the monetary cap keeps binding). The split is regime-invariant (both texts share the structure; only the cap value differs, and it comes from the pack), so no config.is_crr branch is introduced. The SME limb is unchanged: an SME owing more than the cap stays corporate_sme. Worked case — a natural person owing £2,000,000 (> both caps) at PD 2% / own-LGD 45%, with retail A-IRB permission: exposure_class_irb → retail_other, routed to A-IRB, retail-other risk weight 0.6147 under CRR (K ≈ 0.04644 via retail correlation R ≈ 0.0946, maturity adjustment 1, × the 1.06 scaling factor) and 0.5799 under Basel 3.1 (no 1.06), materially below the corporate weight the buggy engine assigned; the SA qualifies_as_retail flag stays False (SA regulatory-retail correctly still expels the over-cap individual). Controls: an SME owing £2,000,000 stays corporate_sme; a natural person owing £500,000 (≤ cap) stays retail_other. Number-neutral across every existing portfolio: the full unit + acceptance + integration + contracts + oracle suite is green with no golden, oracle or acceptance figure moved — no existing fixture carried a natural person above the retail cap. Pinned by tests/unit/classifier/test_p1_243_natural_person_irb_retail_cap.py (CRR/B31 class + approach + SA-flag-untouched twins, SME and under-cap controls) and the CRR/Basel 3.1 acceptance twins tests/acceptance/basel31/test_p1_243_natural_person_irb_retail_cap.py (retail A-IRB routing + risk-weight pin, SME-stays-corporate control). Ref: CRR Art. 147(5)(a)(i)/(ii); PRA PS1/26 Art. 147(5)(a)(i)/(ii); Art. 123 / 123A (SA regulatory retail, untouched).
  • The 1.25× IRB correlation multiplier for large financial sector entities is now DERIVED from entity type + total assets, not just a user flag — a mandatory treatment the engine can no longer miss (P1.242 / P1.246, CRR Art. 142(1)(4) / Art. 153(2); PS1/26 IRB Part glossary + Art. 153(2)). CRR Art. 153(2) and PS1/26 Art. 153(2) both say the asset-value correlation R shall be multiplied by 1.25 for exposures to large financial sector entities (LFSEs) — a mandatory treatment, not an input election. The engine drove requires_fi_scalar solely from the user-supplied apply_fi_scalar flag (engine/stages/classify/subtypes.py, docstring "user flag is authoritative … no entity-type gate"), so a firm that supplied the entity's FSE flag and total assets but forgot the election silently escaped the multiplier — an RWA understatement. The pack already declared the size test (lfse_total_assets_threshold) but nothing read it. classify_exposure_subtypes now derives requires_fi_scalar = apply_fi_scalar OR (is_financial_sector_entity AND total_assets ≥ threshold), reading the threshold through the FX seam: CRR EUR 70bn (Art. 142(1)(4), individual/consolidated basis, most recent audited accounts) × the run's EUR/GBP rate → GBP; Basel 3.1 GBP 79bn native (PS1/26 IRB Part glossary "large financial sector entity", at the highest level of consolidation — the pack value was corrected from 0, and the CRR inline citation from the stale "Art. 4(1)(146)" to Art. 142(1)(4)). total_assets is a GBP figure, mirroring the SME balance-sheet gate. The user apply_fi_scalar is retained as an authoritative True-override (a firm may know an entity is a large or unregulated FSE even when the data says otherwise) that can never suppress a derived True. Null total_assets on a flagged FSE (direction-of-error: potential under-application, never silent): largeness is undetermined, so the multiplier is NOT applied — the LFSE base rate among FSEs is low (only the largest banks/insurers clear EUR 70bn / GBP 79bn), so defaulting unknown-size FSEs to "large" would over-state the whole FSE population — and a new CLS009 classification warning flags the data gap in both regimes (suppressed when an explicit election already resolves the treatment). The unregulated-FSE limb (Art. 142(1)(5) / 153(2), size-independent) is deferred: the schema carries no regulated-status signal, adding one is a schema-enablement change of its own, and apply_fi_scalar is the interim override for known unregulated FSEs. Worked case — a £1m senior IRB corporate loan to an FSE at PD 0.5% / LGD 45% / M 2.5: R rises 0.213456 → 0.266820 (×1.25), lifting RWA £737,884 → £965,199 (+30.8%) under CRR and £696,117 → £910,565 (+30.8%) under Basel 3.1; a £65bn FSE clears the CRR threshold but not the GBP 79bn PS1/26 one, so the multiplier applies under CRR only. Number-neutral across every existing portfolio: no fixture carries an FSE at or above the threshold, so no golden, oracle or acceptance figure moved (the only test change is the B31 threshold pin 0 → GBP 79bn). Pinned by tests/unit/classifier/test_p1_242_lfse_fi_scalar.py (CRR/B31 derivation twins, the regime-threshold divergence at £65bn, sub-threshold/non-FSE/null-assets no-scalar, CLS009 emission, the authoritative override, and the exact-1.25× correlation hand-calc). Ref: CRR Art. 142(1)(4)/(5), Art. 153(2); PRA PS1/26 IRB Part glossary, Art. 153(2).
  • A pledged life-insurance policy now takes the Art. 233(3) 8% FX reduction on a currency mismatch and can be recognised when pledged at facility or counterparty level, not only directly against one exposure (P1.275, Art. 232(3) with Art. 233(3)). The SA risk-weight-mapping path for pledged life-insurance policies (engine/crm/life_insurance.py::compute_life_insurance_columns) had two gaps against Art. 232(3) (the secured portion of the exposure takes a risk weight mapped from the insurer's SA RW) read with Art. 233(3) (the protection value — the current surrender value — is reduced by the 8% FX volatility haircut when the policy is denominated in a different currency from the exposure). First, the value channel summed the raw surrender value with no currency comparison, so a USD policy securing a GBP exposure was recognised in full — an anti-conservative understatement (the comprehensive/LGD path already applied the 8% Hfx, but the RW-mapping side channel did not). Second, the pledge join matched only beneficiary_reference = exposure_reference, silently dropping any policy pledged at facility or counterparty level — a conservative loss of benefit. Both are fixed. The Art. 233(3) 8% reduction (the existing regime-invariant fx_haircut scalar in the common rulepack pack, cited CRR Art. 224 / retained unchanged by PS1/26) is applied per policy when its denomination — original_currency pre-FX-conversion, else currency (the P1.135-safe pair, matching engine/crm/haircuts.py) — differs from the exposure denomination; the mapped RW is unchanged, only the secured value shrinks. The pledge is now resolved at whichever level its beneficiary_reference names via three chained left-joins onto the single exposures base (keeping the exposures plan single-referenced — no plan-node explosion): the direct level (weight 1.0), the facility level (parent_facility_reference) and the counterparty level (counterparty_reference), a facility/counterparty pledge shared pro-rata by EAD across that key's exposures (Art. 230-231 pooling). Reference namespaces are disjoint, so a key fires at exactly one level and a direct pledge still benefits only its own exposure; a life-insurance row still passes through the P1.270 own-issue gate. Null-currency decision (CONSERVATIVE): when the collateral carries a currency column but leaves it null, the match cannot be proven, so the 8% reduction is applied and one CRM020 data-quality warning is raised (the anti-conservative full-benefit treatment is disallowed); when the collateral carries no currency column at all the FX dimension is simply absent and no reduction applies — so every currency-matched or currency-agnostic fixture is number-neutral. Art. 232(3) and Art. 233(3) are retained unchanged under PS1/26 (the 8% Hfx is regime-invariant), so CRR and Basel 3.1 give identical results. Worked cases on a £1m unrated SA corporate loan (100% RW) secured by a £500k policy at insurer RW 20% → mapped secured RW 20%: currency-matched → secured 0.5 → blended 0.5×0.20 + 0.5×1.00 = 0.60 → RWA £600k; USD-denominated → 8% cut → effective £460k → blended 0.632 → RWA £632k; null currency → same £632k + one CRM020; a £1m GBP policy pledged at counterparty level over two loans (£600k + £400k) → shared pro-rata → each fully secured at 20% → total RWA £200k (vs £1,000k unprotected). The pack citation for the secured-RW map was corrected from Art. 232(1) to Art. 232(3). Pinned by tests/unit/crm/test_life_insurance.py (FX cut/match/null, original-currency-wins, facility + counterparty pro-rata, direct-pledge precedence, regime parity, CRM020) and the CRR/Basel 3.1 acceptance twins test_p1_275_art_232_life_insurance.py (matched baseline, FX 8% cut, null-currency CRM020, counterparty pro-rata). Ref: CRR/PS1-26 Art. 232(3); Art. 233(3); Art. 224 (Hfx); Art. 230-231 (pooling); Art. 122.
  • A credit-linked note is now treated as cash collateral only when it is attested issued by the lending institution itself — a third-party CLN no longer gets full own-issue cash treatment (P1.274, Art. 218). CRR Art. 218 (retained verbatim by PS1/26 — regime-identical) grants cash-collateral treatment (0% haircut, full EAD/LGD* offset) to investments in credit-linked notes issued by the lending institution (the note's cash proceeds fund the protection), provided the embedded credit default swap qualifies as eligible unfunded protection. engine/crm/haircuts.py::_normalize_collateral_type_expr mapped every credit_linked_note row to the cash bucket with no issuer check, so a third-party CLN — whose value is materially correlated with its reference entity (typically the obligor, the Art. 194(4) wrong-way-risk case) — received full own-issue cash treatment (an anti-conservative understatement). A new nullable COLLATERAL_SCHEMA field is_own_issued_cln gates the treatment: a CLN that is not attested own-issued (is_own_issued_cln False or null) is ruled ineligible funded protection — its value_after_haircut is zeroed, is_eligible_financial_collateral is cleared, and one CRM019 data-quality warning is raised. Because the F-IRB effectively_secured amount derives from the zeroed value, the gate removes the row from both the SA E* reduction and the F-IRB LGD* input in one place. Null is_own_issued_cln is CONSERVATIVE = own-issuance unattested → ineligible (absence of attestation must not fabricate cash treatment; no Boolean default, mirroring the is_main_index/is_listed idiom). The gate is eligibility, not valuation — a single shared predicate (engine/crm/haircuts.py::credit_linked_note_ineligible_expr) drives both the value zeroing in apply_haircuts and the CRM019 emission in apply_collateral, so the two cannot drift, exactly as the P1.271 equity gate does; the 0% cash haircut the row would otherwise take is left intact. Fallback decision: a non-own-issued CLN degrades to ineligible (not to a debt-security-of-its-issuer treatment) — a CLN carries no clean issuer-bond eligibility path in _normalize_collateral_type_expr, and recognising it as a plain bond of its issuer would ignore the Art. 194(4) reference-entity correlation, so ineligible is the smallest unambiguously conservative mapping. Worked cases on a £1m unrated SA corporate loan (100% RW) secured by £500k of credit-linked note collateral (identical under both regimes — Art. 218 and the unrated-corporate 100% RW are regime-invariant): is_own_issued_cln=True → cash treatment → EAD/RWA fall to £500k; is_own_issued_cln unattested → ineligible → RWA stays at the £1m gross + one CRM019 warning. Every existing portfolio is number-neutral: no golden or oracle fixture carries credit_linked_note collateral, and the three existing CLN unit tests exercise the type→financial primitives (collateral_category_expr, collateral_lgd_expr, calculate_single_haircut) which remain correct for own-issued notes. Pinned by tests/unit/crm/test_p1_274_credit_linked_note_own_issue.py (own-issued cash treatment, null/False ineligibility, predicate + no-op-when-column-absent) and the CRR/Basel 3.1 acceptance twins test_p1_274_art_218_credit_linked_note.py (own-issued control, third-party no-benefit, CRM019, own-issue-flag-alone-moves-RWA). Ref: CRR/PS1-26 Art. 218; Art. 194(4); Art. 122.
  • Finance-lease exposures can now be treated as collateralised by the leased asset — a lessor F-IRB book gets the leased-asset-secured LGD instead of the flat unsecured supervisory value (P1.273, Art. 199(7) with Art. 211). CRR Art. 199(7) (and PS1/26 Art. 199(7), both retaining Art. 211 verbatim) allow an exposure arising from a leasing transaction to be "treated in the same manner as loans collateralised by the type of property leased" once the Art. 211 conditions are met. No lease-as-collateral path existed: the FIRB Foundation Collateral Method already recognised real-estate / other-physical collateral (gated by the P1.235 is_eligible_irb_collateral attestation), but nothing let a lessor represent the leased asset, so a lessor portfolio received the flat senior unsecured LGD (45% CRR / 40% B31) — a conservative over-statement. The remedy is a documented input convention plus an Art. 211 attestation flag: the leased asset is supplied as an ordinary non-financial collateral row (collateral_type = real_estate for property leases, other_physical for equipment/plant/vehicle leases) pledged to the lease exposure, and the lessor attests the lease-specific Art. 211 conditions via a new nullable COLLATERAL_SCHEMA field is_lease_collateral_attested. That attestation is an independent recognition route through the existing FCM — Art. 211(a) folds in the Art. 208/210 property-eligibility that is_eligible_irb_collateral otherwise attests, so attesting Art. 211 attests a superset — and it is OR-ed into the engine/crm/collateral.py::_apply_collateral_unified eligibility gate: a lessor row is recognised without the general IRB flag, while all existing (non-lease) collateral is untouched. The Art. 211 conditions handled: (a) property-type eligibility (Art. 208/210) — subsumed; (b) robust lessor risk management, (c) legal ownership and timely enforcement, (d) unamortised-amount-vs-market-value gap — attested by the flag. Null is_lease_collateral_attested is CONSERVATIVE = not a lease-collateralised row / no attestation supplied → False (absence must not fabricate a lease CRM benefit; no Boolean default, mirroring the is_main_index/is_listed idiom), so every existing portfolio is number-neutral (goldens and oracle unchanged). Consulted only for non-financial collateral. Worked case (£10m senior corporate F-IRB lease, £10m leased asset, is_eligible_irb_collateral=False): attested other_physical → LGD* = 0.428571 under CRR (Art. 230(2) other-physical FCM haircut 40% → £6m; OC=1.4× → effectively-secured £4.285714m; LGDS 40% blended with LGDU 45% over the secured/unsecured split) and 0.31 under B31 (LGDU 40%), the secured LGD flowing through to rwa_final; unattested → LGD reverts to LGDU 0.45 / 0.40 with one CRM014 warning (the pre-P1.273 behaviour). Pinned by tests/unit/crm/test_p1_273_lease_collateral.py (independent-route recognition, no-warning, real_estate + other_physical, general-IRB-route-unaffected, conservative-when-unattested) and the CRR/Basel 3.1 acceptance twins test_p1_273_art_211_lease_collateral.py (LGD recognition, LGDU revert, attestation-lowers-RWA). Ref: CRR/PS1-26 Art. 199(7); Art. 211; Art. 208/210; Art. 230 (FCM); Art. 161(1)(a)/(aa) (LGDU).
  • On-balance-sheet netting cash collateral now takes the full Art. 237-239 maturity-mismatch treatment — a deposit maturing before the netted loan is scaled or (below 3 months / under a 1-year original term) zeroed (P1.241, Art. 219 with Art. 237-238). CRR/PS1-26 Art. 219 treats a netted deposit as cash collateral, so the funded-protection maturity-mismatch rules (Art. 237-239) apply to it exactly as to any other funded protection. engine/crm/collateral.py::generate_netting_collateral built the synthetic cash collateral carrying the beneficiary loan's maturity_date, a null residual_maturity_years and no original term; apply_maturity_mismatch (engine/crm/haircuts.py) then filled the null residual to 10 years, so the mismatch test coll_maturity < exposure_maturity could never fire for netting collateral — a 6-month deposit netting a 5-year loan was recognised with no adjustment. The synthetic row now carries the deposit's own maturity as maturity_date, its residual t as residual_maturity_years (threaded via the run's reporting_date) and its original term as original_maturity_years (derived from maturity_date − value_date, the same convention as engine/sa/risk_weights.py / hierarchy/enrich.py). The downstream stage then, on a mismatch, applies (t − 0.25)/(T − 0.25) (Art. 238-239) or zeroes the protection when t < 0.25 (Art. 237(1)) OR the original term < 1y (Art. 237(2)(a)) — the same gates real financial collateral already gets. Day-count alignment: the residual t uses the /365.25 basis of the exposure-side T in apply_maturity_mismatch, so a deposit and loan sharing a maturity date net in full with no phantom mismatch (the original term uses the engine's /365 original-maturity convention). Pooling convention (conservative): when several deposits pool into one (agreement, currency, counterparty) row, the pool carries the earliest (minimum) deposit maturity (shortest t → largest haircut) AND the minimum original term (most likely to trip the <1y gate) — the prudent single-value summary. Null handling: a null deposit maturity / no reporting_date leaves the residual null (permissive — absent maturity cannot establish a mismatch, the convention ordinary financial collateral follows; NOT an anti-conservative fill — the 10-year downstream default is unchanged, just no longer fed a null when data is present); a null original term (no value_date) likewise leaves the Art. 237(2)(a) gate permissive. Regime-identical (PS1/26 carries Art. 237/238/239 forward unchanged). Worked cases (SA, unrated corporate 100% RW, £1m loan, £200k deposit, reporting 2026-01-01): a 3-year-original / 6-month-residual deposit (t = 181/365.25 ≈ 0.49555) netting a 7-year loan (T capped at 5) scales the £200k by (0.49555 − 0.25)/(5 − 0.25) ≈ 0.05169 → recognised ≈ £10,339 → RWA ≈ £989,661; the same deposit with a 6-month original term (value_date = reporting) is a mismatch with original < 1y → Art. 237(2)(a) zeroes the protection → no benefit → RWA £1,000,000; a 6-year deposit netting a 5-year loan has no mismatch → full £200k nets → RWA £800,000. Every existing golden/oracle output is number-neutral: the shared loans fixture has no deposits, and the P1.238 fixture gives deposit and loan the same maturity — which now nets in exactly full (the aligned /365.25 day-count removes the ~£50 residual-vs-/365.25 phantom the naive /365 derivation would have introduced), while the stress and model-permissions scenarios carry no netting. The change bites only when a short deposit nets a longer loan. Pinned by tests/unit/crm/test_collateral_submodules.py (maturity/residual/original carry, null-without-reporting-date, earliest-maturity + min-original pooling, Art. 237(1) sub-3-month netting-path zeroing) and the CRR/Basel 3.1 acceptance twins test_p1_241_art_219_netting_maturity_mismatch.py (matched control, partial scaling, short-original zeroed). Ref: CRR/PS1-26 Art. 219; Art. 237(1)/(2)(a); Art. 238-239; Art. 122.
  • Non-main-index equity is now recognised as collateral only when it is attested listed on a recognised exchange — closing the Art. 198(1)(a) eligibility gate deferred by P1.237 (P1.271). CRR/PS1-26 Art. 197(1)(f) makes equities/convertible bonds included in a MAIN index eligible financial collateral under all CRM methods; Art. 198(1)(a) extends eligibility to non-main-index equities only where they are listed on a recognised exchange (and then only under the comprehensive method this calculator uses by default). Until now eligibility was governed solely by the input is_eligible_financial_collateral flag — no expression read the index/listing status — so an unlisted (or listing-unreported) non-main-index equity was recognised at the 25%/30% other-listed haircut it should never have earned. A new nullable COLLATERAL_SCHEMA field is_listed drives the gate: a collateral row of equity type that is neither attested main-index nor attested listed (is_main_index and is_listed both False/unset) is ruled ineligible — its value_after_haircut is zeroed, is_eligible_financial_collateral is cleared, and one CRM018 data-quality warning is raised. Null is_listed is CONSERVATIVE = not listed → ineligible (absence of data must not fabricate eligibility; no Boolean default, mirroring the P1.237 is_main_index idiom). Crucially the gate is eligibility, not valuation: the 25%/30% Art. 224 Table 3/4 supervisory haircut is a valuation parameter and is left intact on the row — a single shared predicate (engine/crm/haircuts.py::non_main_index_equity_ineligible_expr) drives both the value zeroing in apply_haircuts and the CRM018 emission in apply_collateral, so the two cannot drift. Main-index equity (Art. 197(1)(f)) bypasses the listing test entirely. Worked cases on a £1m unrated SA corporate loan (100% RW) secured by £500k of non-main-index equity: attested is_listed=True → recognised at 25% CRR / 30% B31 → RWA £625k / £650k; is_listed unreported → ineligible → RWA stays at the £1m gross + one CRM018 warning. The two shared-fixture other-listed equity rows and the FTSE-100 main-index row are attested is_listed=True (factually listed — intent-preserving), so all goldens and the oracle are unchanged. Scope note: the restriction correctly enforced is the Art. 198(1)(a) listing condition on the default comprehensive-method path — not an SFT/repo-only restriction (neither CRR Art. 198 nor BCBS CRE22.38 conditions non-main-index equity on repo-style transactions; the "only under the comprehensive method" condition is what the audit's "repo-only" shorthand referred to). Excluding listed non-main-index equity from the rarely-elected Financial Collateral Simple Method (Art. 198 additional collateral is comprehensive-method-only) remains a follow-up. Pinned by tests/unit/crm/test_p1_271_non_main_index_equity_eligibility.py (value zeroing, eligibility clearing, haircut-preserved-on-ineligible, main-index bypass) and the CRR/Basel 3.1 acceptance twins test_p1_271_art_198_non_main_index_equity.py (listed control, unlisted no-benefit, CRM018, listing-flag-alone-moves-RWA). Ref: CRR/PS1-26 Art. 197(1)(f)/198(1)(a); Art. 224 Table 3/4; BCBS CRE22.37-38.
  • Cash on deposit with a THIRD-PARTY institution is now treated as a guarantee by that institution — its own risk weight substitutes on the covered part, instead of 0% own-bank cash (P1.239 + P1.240, Art. 200(a)/232(2)). Under CRR/PS1-26 Art. 200(a)/232(2) with Art. 212(1), cash pledged that is held at another institution is "other funded credit protection" treated as a guarantee by the holder — the covered part of the exposure takes the holder institution's own SA risk weight (≥20% for a rated bank), not a 0% haircut with full EAD offset. engine/crm/haircuts.py mapped every cash/deposit row to the 0% cash bucket regardless of where it was held (understating RWA), and COLLATERAL_SCHEMA had no holder field. A new optional held_by_counterparty_reference identifies the holder; NULL is PERMISSIVE = own-bank deposit → the existing 0% cash treatment is preserved (the overwhelmingly common case, so every existing portfolio is number-neutral — goldens and oracle unchanged). When populated, the row is partitioned out of the ordinary collateral frame (engine/crm/third_party_deposit.py::split_third_party_deposits) so it contributes to NO cash-collateral value channel (SA E, F-IRB LGD), and the SA calculator blends the covered part at the holder's institution risk weight (engine/sa/rw_adjustments.py::apply_third_party_deposit_rw_mapping), derived from the deposit row's issuer_cqs via the Art. 120/121 (CRR) / Art. 120A ECRA (Basel 3.1) institution tables — a cash deposit is a claim on the institution holding it, so its issuer IS the holder. The blend is benefit-only-capped (Art. 232 protection can never increase RWA), so a holder RW ≥ the obligor's leaves the exposure unchanged. Worked cases on a £1m unrated corporate loan with a £400k deposit: own-bank → RWA £600k (0% cash, EAD reduced); held at a CQS2 institution → covered part at 50% CRR / 30% B31 ECRA → RWA £800k / £720k (full EAD, RW substituted); held at an unrated institution → £1,000k both regimes (CRR unrated institution = 100%; Basel 3.1 unrated = the CRE20.21 SCRA Grade-C 150% conservative fallback since the deposit carries no SCRA grade — both give no net benefit under the benefit-only cap, so a firm wanting benefit for an unrated-but-Grade-A holder must supply the holder's rating/CQS). The holder RW reuses the shared build_institution_guarantor_rw_expr (single source of truth, correct SCRA fallback). Art. 232(2) applies only to deposits held by an institution: a populated holder whose issuer_type is not an institution is out of scope — no benefit (and still not own-bank 0% cash) + a CRM017 warning. Deferred (F-IRB): the holder-RW substitution has no clean F-IRB analogue this pass, so under F-IRB a third-party deposit is conservatively given NO CRM benefit (excluded from the LGD* collateral input rather than valued at 0% cash) and raises one CRM017 warning recording the pending substitution — a registered follow-up, P1.271-style. Pinned by tests/unit/crm/test_p1_239_third_party_deposit.py (F-IRB exclusion + CRM017) and the CRR/Basel 3.1 acceptance twins test_p1_239_art_232_third_party_deposit.py (own-bank control, rated + unrated holder). Ref: CRR/PS1-26 Art. 200(a)/232(2) with Art. 212(1); Art. 120/120A.
  • On-balance-sheet netting is now limited to a single counterparty — a deposit no longer offsets a loan to a different counterparty under a shared agreement (P1.238, Art. 195). CRR/PS1-26 Art. 195 limits on-B/S netting to "mutual claims" / "reciprocal cash balances between the institution and the counterparty" — one counterparty. engine/crm/collateral.py::generate_netting_collateral keyed its netting pools on (netting_agreement_reference, currency) only and matched beneficiaries on the shared reference alone, so a group-level agreement reference spanning multiple counterparties let a credit balance from counterparty A net a loan to counterparty B (understating RWA) — the docstring even documented this cross-counterparty behaviour as intentional. Pools and the sibling join are now additionally keyed on counterparty_reference: a deposit nets only same-counterparty loans under the agreement (still across facilities — the agreement remains the set-off boundary, the counterparty the Art. 195 eligibility boundary). A netting_agreement_reference that spans more than one counterparty (a deposit and a positive loan for different counterparties) raises one CRM016 data-quality warning naming the agreement, so the disallowed offset is visible rather than silent. counterparty_reference is a core exposure column in production; a test-caller fallback treats its absence as a single counterparty (no-op). No existing golden or oracle case carried a cross-counterparty netting agreement, so all are number-neutral; the change is RWA-increasing only for genuinely cross-counterparty pools. Pinned by tests/unit/crm/test_netting.py::TestNettingByAgreementReference (cross-counterparty disallowed, same-counterparty still nets, CRM016). Ref: CRR/PS1-26 Art. 195; Art. 219.
  • The F-IRB Foundation Collateral Method LGD* now grosses the exposure up by its own volatility haircut — E(1+HE) instead of bare E (P1.272, Art. 230(1) / Art. 228(2)). The Art. 230(1) LGD formula divides by E(1+HE): LGD* = LGDU·EU/(E(1+HE)) + LGDS·ES/(E(1+HE)) with EU = E(1+HE) − ES (CRR Art. 228(2) equivalently embeds HE in E). engine/crm/collateral.py::_apply_collateral_unified computed (LGDS·min(C,E) + LGDU·max(0,E−C))/E on bare ead_for_crm, omitting the gross-up — the exposure volatility haircut HE (Art. 223(5)) was applied only in the SA E branch. HE is non-zero solely for SFT rows that lend out a debt security, so FIRB SFT-style exposures with collateral on the general CRM path got a slightly understated LGD (the unsecured-share denominator was too small). The exposure basis and the collateral cap in lgd_star_expr now use E' = ead_for_crm × (1 + exposure_volatility_haircut) — reusing the same he_factor the SA branch already applies. he_factor == 1 for every non-SFT row (HE = 0), so all lending goldens are bit-stable; the dedicated SFT-FCCM path (which emits E directly) is untouched. Worked case: a £10m FIRB senior corporate SFT lending a CQS1 gilt (HE = 2%×√(5/10) = 0.014142), secured by £6m cash → LGD rises from the pre-fix 0.18 to 0.183765 = 0.45×(10.141m − 6m)/10.141m. Pinned by tests/unit/crm/test_p1_272_fcm_lgd_star_he_grossup.py (SFT gross-up, anti-assert vs bare-E, non-SFT HE=0 control). Ref: PS1/26 Art. 230(1); CRR Art. 228(2) / 223(5).
  • Collateral issued by the obligor (or a member of the obligor's group) is now ineligible funded protection — the first Art. 194(4) connected-issuer gate (P1.270). CRR/PS1-26 Art. 194(4) admits funded protection only where its value is not materially positively correlated with the obligor's credit quality; the canonical ineligible case (BCBS CRE22) is a security ISSUED by the obligor pledged back as collateral — if the obligor defaults the security is worthless exactly when the protection is needed. The COLLATERAL_SCHEMA carried issuer_type/issuer_cqs but no issuer identity, so such collateral was priced normally through the Art. 224 haircut chain and granted full CRM benefit. A new optional issuer_counterparty_reference field identifies the security's issuer; a CRM-stage gate (engine/crm/processor.py::_apply_own_issue_collateral_gate, run before the haircut/allocation chain) resolves it against the obligor (the counterparty of the exposure the collateral secures, at any pledge level) and, via the counterparty hierarchy's ultimate_parent_mappings, the obligor's group. On a match the collateral row is removed (filtered rather than value-zeroed, which also side-steps the pledge-percentage re-resolution that would otherwise revive a zeroed row) and one CRM015 data-quality warning is raised. Null issuer_counterparty_reference is PERMISSIVE (issuer unknown / not an issued security, e.g. cash on deposit) — the gate never fires, so every existing portfolio is number-neutral (goldens and oracle unchanged). Worked case: a £10m unrated SA corporate loan secured by a £10m own-issued CQS1 corporate bond keeps its full £10m RWA (no benefit) instead of the ~£0.8m post-collateral value a third-party bond earns; identical mechanics under CRR and Basel 3.1. The group limb resolves membership through the shared ultimate parent (parent, sibling, or child of the obligor). Pinned by tests/unit/crm/test_p1_270_own_issue_collateral.py (self-issue, group-member, third-party control, null-permissive, CRM015) and the CRR/Basel 3.1 acceptance twins test_p1_270_art_194_own_issue_collateral.py. Ref: CRR/PS1-26 Art. 194(4); BCBS CRE22.
  • Equity collateral of unknown index membership now takes the higher other-listed haircut, not the cheaper main-index one — closing an anti-conservative null default (P1.237 in full; P1.271 partially — see the deferral note below). The Art. 224 equity haircut lookup resolved a null is_main_index to True (engine/crm/haircuts.py), so equity collateral whose index membership was unreported received the 15% (CRR) / 20% (Basel 3.1) main-index haircut instead of 25% / 30% — 10 percentage points of over-recognition, contrary to the project's never-fill-anti-conservative rule and to CRR/PS1-26 Art. 197(1)(f) / 198(1)(a), under which only main-index equities earn the cheaper all-methods treatment (other-listed equities are repo-only at the higher haircut). The null resolution is flipped to False (other-listed): unknown membership must not fabricate main-index eligibility. Only explicitly attested is_main_index=True equity now earns 15%/20%. The COLLATERAL_SCHEMA field keeps no Boolean default (null propagates to the engine, which resolves it conservatively). The dedicated-column path is affected; the legacy fallback (no is_main_index column present, which resolves via is_eligible_financial_collateral) is unchanged. Test fixtures that intend main-index equity now attest it explicitly (the Collateral builder gained an is_main_index field; the shared collateral fixture's FTSE 100 row sets it True); the two other-listed equity rows correctly move to 25%/30%, which nudged one Basel 3.1 F-IRB provisions pool's EL-excess from 606,093.03 to 605,933.93 (an other-listed equity row on a subordinated obligor now takes the correct 30% haircut, raising its EAD and EL — the pre-fix figure encoded the anti-conservative default). Pinned by tests/unit/crm/test_equity_main_index.py (null now → other-listed) and the CRR/Basel 3.1 D3/D11 equity acceptance scenarios. Deferred (P1.271, Art. 198(1)(a)): the repo-only eligibility restriction for non-main-index equity is NOT yet enforced — eligibility remains governed by the input is_eligible_financial_collateral flag, and the audit's prescribed is_listed flag (default False → ineligible) plus its DQ warning require new schema and are tracked as a follow-up in IMPLEMENTATION_PLAN.md, mirroring the P1.235 deferred-conditions disclosure. This entry closes the haircut-band default only. Ref: CRR/PS1-26 Art. 197(1)(f) / 198(1)(a); Art. 224 Table 3/4.
  • Non-financial collateral now only reduces F-IRB LGD where the firm attests it is IRB-eligible, and attested receivables are capped at a 1-year original maturity — the first Art. 199(2)/(5)/(6) Foundation Collateral Method eligibility gate (P1.235). The F-IRB Foundation Collateral Method (Art. 230 LGD substitution) recognised any real-estate / receivables / other-physical collateral posted against the exposure, regardless of whether it met the Art. 199 recognition conditions — so unattested collateral (and receivables of any tenor) wrongly pulled LGD below the supervisory unsecured value. engine/crm/collateral.py::_apply_collateral_unified now gates the effectively_secured amount that feeds the Art. 231 waterfall: non-financial collateral is zeroed unless the institution attests eligibility via the pre-existing is_eligible_irb_collateral flag (Art. 199(2)), and an attested receivable whose original_maturity_years is populated > 1 year is zeroed even so (Art. 199(5) — explicit data contradicting the attestation wins conservatively). Each zeroed row accumulates one CRM014 data-quality WARNING (never raised), distinguishing "not attested" from "receivables > 1 year". The flag is the attestation, so its default of False/unset is treated as ineligible — the P1.10 new-field-permissive precedent deliberately does not apply — but a null* receivables original maturity is PERMISSIVE (the attestation covers the maturity condition; the Boolean > 1y test, not the float column, is filled to False to avoid an anti-conservative float fill). Scope is strictly the F-IRB FCM non-financial recognition path: financial collateral (Art. 197), SA EAD reduction, and exposure classification are untouched, and every existing fixture that attests eligibility (e.g. P1.190) is number-neutral. Worked cases: an unattested £10m RE collateral on a £10m senior corporate reverts LGD to LGDU (0.45 CRR / 0.40 Basel 3.1) instead of the recognised 0.378571 / 0.2800; the attestation flag alone flips the outcome end-to-end in both regimes. The deferred Art. 199 sub-conditions (affiliated-party / sub-participation / credit-derivative-linked / securitisation-linked receivables exclusions; RE material-dependence pair; other-physical liquid-market / public-price / 70%-10% realisation attestations) each require new input fields and are tracked as a follow-up. Pinned by tests/unit/crm/test_p1_235_firb_fcm_eligibility_gate.py (attestation, receivables-maturity, CRM014) and the CRR/Basel 3.1 acceptance twins test_p1_235_art_199_firb_fcm_eligibility.py. Ref: CRR/PS1-26 Art. 199(2)/(5)/(6); Art. 230 (FCM); Art. 161(1)(a)/(aa) (LGDU); BCBS CRE32.
  • Securitisation positions are now recognised as eligible financial collateral under Art. 197(1)(h), with the Art. 224 Table 1 securitisation supervisory haircut (P1.234). Previously a securitisation debt security posted as collateral had no branch — it fell to the flat 40% "other physical" haircut with no eligibility gate, over-stating the haircut on eligible positions and (worse) silently granting collateral benefit to resecuritisations and >100%-RW positions that Art. 197(1)(h) makes ineligible. Added: a securitisation collateral-normalisation branch in engine/crm/haircuts.py (keyed on collateral_type/issuer_type); securitisation haircut rows in both rulebook packs pinned to the printed Art. 224 Table 1 securitisation column (CRR 3-band CQS1 2/8/16%, CQS2-3 4/12/24%; Basel 3.1 5-band CQS1 2/8/8/16/16%, CQS2-3 4/12/12/24/24% — note the Basel 3.1 figures are the table's own printed values, NOT double the corporate column); and an eligibility gate that zeroes value + is_eligible_financial_collateral when the position is a resecuritisation, its risk weight exceeds 100%, or its CQS is outside 1-3. Two new nullable COLLATERAL_SCHEMA fields drive the gate: is_resecuritisation (default False) and securitisation_position_risk_weight (null is CONSERVATIVE — the RW<=100% test cannot be confirmed, so the position is treated as ineligible). Identical mechanics under CRR and Basel 3.1.
  • The Art. 114(4)/(7) 0% domestic-CGCB extension now requires the exposure to be BOTH denominated AND funded in the guarantor's domestic currency (P1.229, Art. 235(3)). The engine tested only the guarantee currency against the guarantor's country, so a loan funded in a foreign currency but guaranteed in the sovereign's domestic currency (e.g. a USD-funded, EUR-guaranteed loan from an EU sovereign) wrongly received 0% on the covered part instead of the sovereign's own CQS risk weight (a CQS-3 sovereign should give 50%, not 0%). A nullable funding_currency input column was added to the loan/facility/contingent schemas and plumbed through the hierarchy-exit edge to the SA, IRB and CRM guarantee paths; the new funding_currency_expr helper ANDs the funding limb into build_domestic_cgcb_guarantor_expr. Null funding currency is PERMISSIVE — it falls back to the exposure's denomination currency, preserving existing datasets' 0% treatment (mirrors the Art. 237(2)(a) null fallback). Identical mechanics under CRR and Basel 3.1.
  • Guarantee maturity-mismatch now enforces the Art. 237(1) and Art. 237(2)(b) eligibility gates, not just the Art. 239(3) scaling (P1.231). _apply_maturity_mismatch_to_guarantees previously only scaled coverage by (t-0.25)/(T-0.25); it now ZEROES coverage — mirroring the collateral sibling in engine/crm/haircuts.py — when (a) the guarantee's raw residual maturity is < 3 months AND shorter than the exposure (Art. 237(1); tested on the pre-floor residuals so a short exposure whose T also floors to 0.25 no longer masks the mismatch), or (b) the exposure is subject to the Art. 162(3) one-day IRB maturity floor (daily-margined repos/SFTs) and any maturity mismatch exists (Art. 237(2)(b)). The has_one_day_maturity_floor exposure flag is joined onto each guarantee row; null/absent is PERMISSIVE (treated as no floor). A null / join-miss exposure maturity is CONSERVATIVELY defaulted to a 5y exposure (aligning with the collateral twin) so both gates and the scaling still bind rather than silently keeping full coverage; the gate mismatch test compares the raw residuals so protection that outlives a sub-3-month exposure stays recognised. The Art. 239(3) scaling formula is otherwise unchanged. Identical under CRR and Basel 3.1.
  • The Art. 237(2)(a) >=1yr original-maturity guarantee test now binds only WHERE a maturity mismatch exists, not as an unconditional pre-filter (P1.232). Previously any guarantee with original_maturity_years < 1 year was dropped before the pipeline ever checked for a maturity mismatch, so a matched (or protection-outlives-exposure) short-dated guarantee — common in trade finance — was discarded and the exposure over-stated at the borrower's own risk weight. The test is relocated from the unconditional pre-filter in _prepare_guarantees into _apply_maturity_mismatch_to_guarantees and conditioned on the same raw-residual mismatch as the Art. 237(1)/(2)(b) gates (mirroring the collateral sibling in haircuts.py): a <1yr-original guarantee is zeroed only when the protection is shorter than the exposure; a matched or longer guarantee is now recognised (guarantor RW on the covered part). Null original maturity stays PERMISSIVE (>= 1yr; P1.10), and the residual-vs-original distinction (P1.219) is preserved — the gate reads the original term, the scaling reads the residual. Direction: reduces RWA for matched short-dated guaranteed exposures. Identical under CRR and Basel 3.1.
  • The Art. 140(2)(b) 100% floor now binds on exposures that received their short-term CQS from the Art. 120(3)(c) obligor spillover (P1.225 co-fire fix). The spillover overwrites cqs/has_short_term_ecai on an unrated leg, so the contamination predicate (cqs null AND no ST ECAI) skipped exactly the legs the floor must catch — an obligor with a 50% short-term facility left its spilled sibling at 50% instead of ≥100% under both regimes (anti-conservative). A new per-exposure has_own_short_term_ecai flag distinguishes the directly-rated source (never a target) from spilled legs; the target predicate is now ~own & (cqs null | spilled). Pinned by tests/acceptance/{crr,basel31}/test_p1_225_cofire_spillover_floor.py (LN-B 0.50 → 1.00, RWA 1.0M → 2.0M).
  • The ccr_modelled_lgd netting-set collapse is now deterministic and conservative (P1.215 follow-up). Both the SA-CCR pipeline adapter and the FCCM/SFT producer collapsed the own-estimate LGD carrier first-trade-wins over an unordered grouping, so a multi-trade netting set with heterogeneous modelled LGDs carried an order-nondeterministic value; both now take the maximum (highest LGD = largest capital requirement), pinned by an order-parametrized contract test.
  • Unrated corporate guarantors are no longer recognised as credit protection (P1.227). Art. 201(1)(g) admits corporates as eligible unfunded-protection providers only with an ECAI rating — or, under Art. 201(2), an internal rating where the protected exposure is itself on the IRB approach — but the engine recognised any corporate guarantor, pricing an unrated one at the 100% unrated-corporate default and granting substitution benefit (a CQS5 borrower's 150% covered leg wrongly dropped to 100%). The guarantor-approach assignment now gates the corporate limb: no ECAI rating and no (IRB-beneficiary) internal rating → the guarantee leg takes the established no-substitution path, the covered exposure reverts to its borrower basis, and a CRM013 warning names the ineligible provider. The Art. 201(2) internal-rating limb is beneficiary-scoped — the same internally-rated-only guarantor is eligible on an F-IRB exposure and ineligible on an SA one (both pinned); slotting beneficiaries are deliberately outside the internal limb (conservative, documented). Non-corporate providers keep their existing unconditional paths, and a fixture-estate sweep confirmed no existing scenario carried an unrated corporate guarantor, so the change is purely additive. A retail-class guarantor probe confirmed such providers already receive zero benefit via the guarantor risk-weight lookup's null-guard (documented as an accidental but real safety net). Ref: CRR/PS1-26 Art. 201(1)(g)/(2) w/ Art. 194(5); BCBS CRE22.73-76.
  • Short-term ECAI ratings mis-scoped onto ineligible obligor classes are now ignored and flagged (P1.264). Art. 140(1) confines short-term assessments to institution and corporate obligors, but the override applied to any scope-matched exposure regardless of class — a short-term rating wrongly attached to a sovereign exposure silently overwrote its CQS and short-term flag. Worse, once the Art. 140(2) contamination landed, such a mis-scope could force a genuinely unrated sovereign sibling to 150% (a pinned £500k RWA overstatement per £1M in the worked scenario). The override now gates on the obligor's entity type (pack-sourced institution/corporate set; SMEs carry corporate entity types), leaves the counterparty-level CQS untouched on rejection, and emits a DQ009 warning per mis-scoped exposure — including for obligors with null or unknown entity types, where a Polars Kleene-null would otherwise have swallowed the warning (guarded and pinned). The Art. 120(3)(c) spillover and Art. 140(2) contamination both run after the gate and inherit it. Correctly-scoped institution/corporate short-term ratings are untouched. Ref: CRR/PS1-26 Art. 140(1); BCBS CRE21.16.
  • A 150% short-term rating on any facility now contaminates the obligor's other unrated unsecured exposures, and a 50% short-term rating floors its unrated short-term exposures at 100% (P1.225). Art. 140(2) (CRR and PS1/26, CRE21.17-18) requires obligor-level spillover from adverse short-term issue assessments, but no such mechanism existed — an obligor with an ST-CQS4 (150%) facility kept its unrated long-term siblings at the 100% class default, and a 50% ST facility left unrated short-term claims at the 20% preferential. Two obligor-level flags are now built in the hierarchy stage (beside the Art. 120(3)(c) spillover, which remains independent — contamination forces the risk weight and never rewrites CQS) and consumed by a shared post-ladder override in the SA calculator under both regimes. Guarantee-substituted legs keep their guarantor risk weight ("unsecured" excludes them); collateralised-but-unguaranteed rows are contaminated (SA collateral acts on EAD, not RW); the Art. 127 defaulted handler runs last, so provision-based defaulted risk weights survive contamination (load-bearing ordering now commented). Worked deltas pinned in both regimes: unrated long-term corporate 100%→150%; unrated short-term institution 20%→100% (Basel 3.1 exercised via an SCRA-A base). Ref: CRR/PS1-26 Art. 140(2); BCBS CRE21.17-18.
  • A-IRB firms' repo-style and derivative counterparty-credit-risk exposures can now route to A-IRB end-to-end (P1.215). The classifier's A-IRB gate requires a row-level own-modelled LGD, which the FCCM SFT and SA-CCR producers never emitted — so an A-IRB-permissioned repo silently fell back to F-IRB (supervisory LGD, M=0.5y) or SA, missing the Art. 162(2) effective-maturity carve-outs the engine already supports (e.g. the 5-business-day repo floor M=5/365). A new nullable ccr_modelled_lgd input on the SFT and derivative trade schemas — a dedicated carrier mirroring ccr_effective_maturity, never the lending lgd column — flows through the CCR-only edges, widens the classifier gate, and feeds the IRB K formula on A-IRB CCR rows. The change is opt-in by data: with the input absent every row routes exactly as before (all lending goldens bit-stable), and it cannot bypass model permissions or the Basel 3.1 A-IRB restrictions for institutions and large corporates. The five strict-xfail acceptance anchors flipped green, with the flagship 5BD repo pinning K, maturity adjustment, LGD and M independently. Ref: CRR Art. 143/153 (A-IRB own-estimate LGD), Art. 162(2) (effective maturity).
  • The CRR Art. 164(4) portfolio-level A-IRB retail real-estate LGD-floor backstop now exists (P1.183). CRR (as amended by CRR2) requires the EAD-weighted-average own-estimate LGD across all A-IRB retail exposures secured by immovable property to stay at or above 10% (residential) / 15% (commercial), excluding exposures benefiting from central-government guarantees — a portfolio-level monitoring backstop, not a per-exposure input floor, and previously entirely unenforced under CRR (the Basel 3.1 per-exposure floors are a different mechanism and gate off under CRR). The aggregator now computes the EW-average per sub-class after aggregation and emits an IRB007 warning carrying the computed average, the floor, and the population size when breached; RWA and LGD are never adjusted (pinned bit-stable). The check is CRR-only via the cited pack Feature crr_retail_re_portfolio_lgd_floor; the central-government-guarantee carve-out correctly excludes the guaranteed leg of a split exposure while keeping the retained leg in the average; a null-LGD row dilutes the average downward by design (conservative over-flagging, documented). Ref: CRR Art. 164(4)/(5); spec docs/specifications/crr/airb-calculation.md.
  • Unfunded credit protection whose provider can unilaterally cancel it is no longer recognised — the first Art. 213(1)(c)(i) eligibility gate (P1.10, re-scoped). Neither regime validated the Art. 213(1)(c)(i) protection-contract conditions: a guarantee (or credit derivative) the provider could unilaterally cancel — or, under Basel 3.1's new "or change" words, unilaterally change — still produced full substitution benefit. Two nullable Boolean inputs (is_unilaterally_cancellable, is_unilaterally_changeable) now feed an eligibility gate in _prepare_guarantees that drops flagged protection before multi-level expansion, reverting the covered exposure to its borrower basis and emitting a CRM012 warning per dropped guarantee. The change arm is gated by the cited pack Feature ucp_unilateral_change_ineligible (Basel 3.1 on / CRR off — no regime branch in engine code). Nulls are permissive (null = no known defect), mirroring the Art. 237(2)(a) precedent, so every existing portfolio is number-neutral until a firm actually flags a defective contract. The Rule 4.11 transitional (P1.143) will later disapply only the change limb for pre-2027 contracts. Pinned by ten acceptance tests across both regimes (eligible 200k / ineligible 1M on the worked fixture; exact CRM012 counts as leak controls). Ref: CRR Art. 213(1)(c)(i) / PS1/26 Art. 213(1)(c)(i); CRE22.68.
  • The Art. 159 IRB expected-loss vs provisions comparison now nets at the default-status pool level, correcting a simultaneous CET1 over-deduction and T2 over-credit (P1.221). compute_el_portfolio_summary summed the per-row max(0, EL_i − pool_b_i) shortfall/excess columns, so one exposure's provision excess never offset a sibling exposure's EL shortfall inside the same pool — Art. 159(3) (CRE35.4) compares the pool totals. The summary now derives the non-defaulted/defaulted shortfall and excess from the aggregate pool sums (max(0, ΣEL − Σpool_b)); the per-row columns remain as audit trail, the Art. 159(3) two-branch selector and the Art. 62(d) 0.6% T2 cap are unchanged, and frames without expected-loss data keep the prior per-row-sum behaviour. On the CRR-G acceptance book the netted figures are 1,250 shortfall / 861,274.40 excess (previously 729,016.50 / 1,576,721.43 — both directions over-stated at once). Pinned by the TestP1221PoolAggregateNetting worked examples plus four inverted mixed-sign unit pins; the P1.127 pool-separation regression guard stays green, and the OF-ADJ unit helper was made internally consistent so its EL/provision inputs genuinely carry the shortfall it asserts. Ref: CRR Art. 159 / PS1/26 Art. 159 (CRE35.4); CRR Art. 36(1)(d), 62(d).
  • COREP and Pillar 3 were mis-scoping counterparty credit risk: SA-CCR derivatives were missing from C 07.00 (and, under Basel 3.1, dropped from it entirely), C 02.00 did not foot, OF 02.01 double-counted U-TREA, and CMS1 silently lost the whole CCR charge. Four templates move; C 09.01 inherits the C 07.00 population and moves with it. Found by an operator using the new report-cell drill-down: the C 07.00 lineage panel printed the scope note "SA-CCR derivatives are excluded — they report under C 34", they asked why, and the note turned out to be false. It was never a scope decision — it rationalised a defect. C 07.00 and C 34 are not alternatives: Annex II rows 0070/0080 say verbatim that "exposures that are subject to counterparty credit risk shall be reported in rows 0090 – 0130, and therefore shall not be reported in this row", C 34 analyses CCR by CCR approach while C 07.00 risk-weights those same exposures under the SA, and a derivative belongs in both (no roll-up sums the two — C 02.00, OF 02.01 and OV1 each read the ledger directly, so the wider population moves nothing twice). Four fixes, one workstream:
  • COREP C 09.01 and C 02.00 now assign defaulted SA exposures to the "Exposures in default" row, as the Annex II instructions require (recorded decision 2026-07-12). The geographical breakdown's primary columns (original exposure, exposure value, RWEA) are defined "same as the CR SA template" — the Art. 112(j) ladder, under which a defaulted exposure moves to row 0100 exactly as C 07.00 assigns it. The engine had preserved the retired raw-class keying, leaving the defaulted exposure's amounts in its origination class row. Column 0020 "Defaulted exposures" is — per the instruction's own words — a memorandum reported "where the obligors would have been reported if those exposures were not assigned to the exposure classes 'exposures in default'", so it stays on the original class row: C 09.01 is now a two-basis template (applied-ladder primaries, original-class memo), and a class row whose only exposures defaulted keeps its memo while its primaries sit in row 0100. C 02.00's class rows make the same move (its SA breakdown must tie to C 07.00's applied-basis totals; values identical for IRB rows). On the reference portfolio the golden movers are exactly the defaulted corporate: C 09.01 row 0070 → row 0100 (1.0M exposure / 1.5M RWEA under CRR, memo unchanged), C 02.00 row 0130 → row 0160; totals unchanged. C 09.02 was confirmed correct as-is (the IRB template deliberately has no default row). A LedgerShimCorepGenerator (mirroring the Pillar 3 shim) mirrors the sealed reporting projection onto synthetic unit frames. Ref: Reg (EU) 2021/451 Annex II C 09.1 (cols 0010/0020/0040, row 0100); PRA PS1/26 Annex II OF 09.01 (near-verbatim identical); CRR/PS1-26 Art. 112(1)(j).
  • Guarantees on slotting exposures finally produce RWA relief — closing the confirmed zero-benefit gap (recorded 2026-07-06; fixed via RWSM under both regimes by operator decision 2026-07-12). A guaranteed slotting exposure was physically split into its covered/retained legs at the CRM stage, but the slotting calculator had no guarantee-substitution step at all: a 10M project-finance Strong loan (70% RW) fully guaranteed by a CQS 1 sovereign kept RWA of 7.0M — with and without the guarantee — under both frameworks, and the covered leg's Art. 158(6) expected loss (40k) double-counted into the Art. 159 shortfall pool. The slotting branch now runs the exact same shared substitution step as the SA branch (Risk-Weight Substitution Method, PS1/26 Art. 235(1)): the covered leg takes the guarantor's SA risk weight when beneficial — with the identical beneficial-only gate (a weak guarantor can never increase RWA), multi-guarantor redistribution, and audit columns — and the covered leg's slotting EL is zeroed (Art. 235(1A); mirrors the existing IRB SA-guarantor precedent). The behaviour is gated by the new cited pack Feature slotting_guarantee_substitution, enabled in both packs: the Basel 3.1 side is mandated by the PS1/26 Part 3 CRM decision tree; the CRR side is applied by analogy with the black-letter uncertainty recorded on the Feature's citation (Art. 235 is textually SA-scoped; COREP Annex II para 43 expects substitution flows on slotting rows) and reverts with a one-line flag flip. This also fixes a latent Basel 3.1 defect the fix's scouts uncovered: the output-floor SA-equivalent pass leaks SA-basis guarantee columns onto slotting rows, so the recently-added guarantee_rwa_benefit would have reported a wrong benefit (computed off the SA-default 100% base) on guaranteed slotting legs — the branch substitution now overwrites the leak with the true slotting-basis relief (10M × (0.70 − 0.00) = 7.0M in the hand-calc scenario), pinned by the Basel 3.1 acceptance twin. Hand-calc acceptance coverage: full cover (RWA 7.0M → 0; EL 40k → 0), 60% partial cover (RWA → 2.8M; EL → 16k on the retained leg), and the non-beneficial unrated-corporate guarantor (nothing changes). No existing fixture combines a guarantee with a slotting exposure, so every existing golden and test is untouched. Ref: PRA PS1/26 Art. 235(1)/(1A); CRR Art. 213-217; CRR Art. 158(6)/159.
  • The API/UI stat cards and the framework-comparison page now report on the same post-substitution basis as COREP and Pillar 3 — closing the operator-flagged UI/recon basis gap (Phase 7 Sn, decisions F5 + invariant 8). The SA/IRB/Slotting card totals (SummaryStatistics.total_ead_sa/irb/slotting and the RWA twins, surfaced on the results-page stat cards and the REST comparison deltas) previously bucketed rows by the raw pre-guarantee approach_applied against string-alias unions; they now read the sealed reporting_method (STD/FIRB+AIRB/SLOTTING), so a cross-approach guaranteed leg — an IRB obligor with an SA guarantor — reports on its guarantor's card, exactly as COREP C 07.00 does. The comparison estate (analysis/comparison.py) makes the same move: per-exposure deltas carry the post-substitution reporting_class/reporting_approach under name-stable aliases, the methodology label is read from the sealed reporting_method instead of being re-derived from the raw approach, and the capital-impact waterfall's IRB gate (scaling-factor and output-floor drivers) keys the post-substitution approach — which is the approach the engine actually scaled. This retargets the comparison page's by-class chart and table (the one remaining raw-class UI surface; the single-run results page moved at S4). Defaulted, SME-managed-as-retail and guaranteed rows change class/approach attribution in those views; portfolio totals are unchanged. No committed golden pinned the retargeted cells; the mock unit fixtures now populate the reporting_* columns the seal previously null-injected. Ref: CRR Art. 235 (substitution), Art. 112 (applied class).
  • The by-class / by-approach / by-method summaries now tie exactly to the portfolio RWA total — closing a CRR overstatement equal to the supporting-factor relief — and defaulted / SME-managed-as-retail exposures finally appear under their applied class (Phase 7 S4). The three persisted summaries (summary_by_class, summary_by_approach, summary_by_class_method) were built from an internal "post-CRM detailed" view that re-split every guaranteed exposure a second time and reconstructed total_rwa as reporting_ead × reporting_rw — a basis that (a) excluded the Art. 501/501a supporting factors, so CRR summary totals exceeded the true portfolio rwa_final by exactly the SME/infrastructure relief (measured +2.70M, ~1.9%, on the reference reporting portfolio — the UI's by-class chart disagreed with its own headline total); (b) priced IRB-guaranteed legs at the flat SA guarantor_rw instead of the leg's actual Art. 161 parameter-substituted risk weight; (c) grouped non-guaranteed rows by raw origination class, so a defaulted SA exposure hid inside its origination class (no defaulted bucket existed) while COREP C 07.00 already reported it under defaulted; and (d) counted a phantom zero-EAD "unguaranteed portion" row per guaranteed leg. The summaries are now pure group-bys of the sealed per-leg reporting ledger (reporting_class/reporting_approach/reporting_method, Phase 7 S2) summing the sealed post-floor rwa_final — all six frames tie exactly to the portfolio total under both regimes (pinned), the defaulted bucket appears, and counts reflect physical legs. The reconciliation's class/method allocation reads the same sealed columns (its values are unchanged — they alias the columns it already used), and all 14 multi-candidate our_columns fallback ladders in the recon registry collapsed to single sealed names, killing the fictional rungs (sa_cqs, ccf_applied, irb_expected_loss, …) that unit fixtures had been pinning. The internal pre_crm_summary/post_crm_detailed/post_crm_summary bundle fields (dropped at the API boundary; audit-cache-only) were deleted with their generator module. Ref: CRR Art. 235/161 (substitution), Art. 112/127 (applied class), Art. 501/501a (supporting factors), PRA PS1/26 Art. 92(2A) (post-floor totals, P1.130 preserved).
  • A less-favourable short-term ECAI assessment now spills across all of an obligor's unrated short-term claims, not just the directly-rated exposure — closing an understatement on obligors with a poor short-term issue rating. CRR Art. 120(3)(c) (with the Art. 131 Table 7 short-term ladder) requires that when an obligor carries a short-term issue-specific ECAI assessment mapping to a less favourable (higher) risk weight than that obligor's general preferential short-term treatment, the preferential treatment is disapplied and all of that obligor's unrated short-term claims take the short-term assessment's CQS. engine/stages/hierarchy/enrich.py applied the short-term override strictly per-exposure, so a sibling unrated short-term claim on the same obligor kept the 20% Table 4 preferential weight even though a directly-rated short-term exposure carried a CQS-3 (100%) Table 7 assessment. The new _apply_obligor_short_term_spillover derives, per obligor, the worst short-term-assessment CQS within the short-term maturity window (≤3m, ≤6m for self-liquidating trade LCs) and — when the CQS-band "less favourable" test fires (general CQS 1-3 vs assessment ≥2; general CQS 4-5 vs assessment ≥3) — spills that CQS onto the obligor's unrated short-term claims only (long-term claims and other obligors are untouched). The gate is regime-independent (both short-term tables are identical across CRR and Basel 3.1 over this CQS range). Worked case: an unrated short-term claim moves from RW 0.20 to 1.00. Pinned by tests/acceptance/basel31/test_p1_223_art_120_3c_obligor_st_spillover.py (CRR + Basel 3.1 twins). Ref: CRR/PS1-26 Art. 120(3)(c); CRR Art. 131 (Table 7).
  • IRB double-default treatment is now reachable, so a guaranteed exposure whose double-default risk weight beats substitution finally receives it. Under CRR Art. 217 / 153(3) the double-default risk weight rw_dd should be compared, unfloored, against the substitution risk weight, applying whichever is lower. But engine/irb/guarantee.py::_apply_double_default first floored rw_dd at max(rw_dd, guarantor_rw) and then gated on the strict rw_dd < guarantor_rw — a condition the floor makes permanently False, so guarantee_status could never take the value DOUBLE_DEFAULT and every eligible exposure silently fell back to plain risk-weight substitution (an overstatement of RWA relative to the intended DD treatment). The spurious max(...) floor is removed; the gates now compare the unfloored rw_dd against guarantor_rw and emit it. CRR-only; the Basel 3.1 path (no DD) is untouched. Pinned by tests/unit/test_irb_double_default.py::TestDoubleDefaultReachable. Ref: CRR Art. 217 / 153(3).
  • Corporate / PSE / institution bond collateral is now routed to the corp_bond haircut class, so the Art. 197 CQS-eligibility gate fires instead of a flat 40% catch-all — closing an over-recognition of low-rated debt collateral. CRR/PS1-26 Art. 197(1)(c)/(d) (with the Art. 224 Table 1 supervisory haircuts) makes debt-security collateral eligible only through its CQS band, zeroing recognition below the eligibility floor. But engine/crm/haircuts.py::_normalize_collateral_type_expr normalised a bond collateral row to other_physical unless the issuer was sovereign, so a corporate or PSE bond bypassed the Art. 197 CQS gate entirely — a CQS-5 corporate bond was recognised at 60% of market value (a flat 40% haircut) rather than being zeroed, and eligible bonds missed the Table 1 1-12%/20% band structure. A .when((collateral_type == "bond") & issuer_type ∈ {corporate, pse, institution}) branch now routes these to corp_bond ahead of the other_physical fallback; the sovereign passthrough is unchanged. This closes the merge-cluster P1.233 ≈ P1.236 in a single change. Pinned by tests/acceptance/crr/test_p1_233_art_197_corp_bond_collateral.py. Ref: CRR/PS1-26 Art. 197(1)(c)/(d); CRR Art. 224 (Table 1).
  • CRR undrawn "other commitments" (risk_type=OC) now take the 50%/20% CCF split on original maturity, not residual — closing a final-year understatement on seasoned revolvers. CRR Annex I items 2(b)/3(b) set the Medium-Risk 50% CCF when an undrawn facility's original maturity exceeds one year and the Medium/Low-Risk 20% only when original maturity ≤ 1yr, but the OC override keyed on maturity_date − reporting_date (residual), so every seasoned >1yr revolver silently dropped to 20% CCF in its final year — a systematic capital understatement. engine/ccf.py::_apply_oc_original_maturity_ccf now derives original maturity from a new nullable original_maturity_years input field (else maturity_date − value_date, else a conservative 50% Medium-Risk default), comparing against the existing 365-day pack threshold. Long-term/no-maturity behaviour and the Basel 3.1 flat-40% OC arm are unchanged. Pinned by tests/unit/test_ccf.py::TestOtherCommitCCF::test_sa_pipeline_oc_50_percent_crr_seasoned_original_maturity. Ref: CRR Art. 111(1) / Annex I items 2(b),3(b).
  • Guarantee coverage on off-balance-sheet commitments is now measured on the CCF=100% exposure basis, closing a ~47% RWA understatement on undrawn guaranteed commitments. The covered part Eg = min(GA, E) must measure E at 100% of an off-balance-sheet item's value, with the CCF re-applied to the covered/uncovered split afterwards (CRR Art. 235(1)/236(3); PS1/26 Art. 235(1)(a)/236(1)(a)) — but engine/crm/guarantees.py pro-rated coverage against the post-CCF ead_after_collateral, shrinking the coverage denominator and over-recognising cover on undrawn commitments. The guarantee split now derives the coverage ratio against _crm_basis (= ead_for_crm, the CCF=100% basis, falling back to ead_after_collateral when absent so pure on-balance-sheet rows are unaffected), leaving total EAD invariant and only re-splitting RWA (worked case: total RWA 180,000 → 340,000). Pinned by tests/acceptance/crr/test_p1_218_guarantee_coverage_ccf_basis.py. Ref: CRR Art. 235(1)/236(3); PS1/26 Art. 235(1)(a)/236(1)(a).
  • Under Basel 3.1, institution-typed regional governments / local authorities / PSEs are now forced onto the Standardised Approach (quasi-sovereign class), closing an F-IRB understatement. PS1/26 Art. 147(3)(c)-(e) assigns regional governments, local authorities and public sector entities to the quasi-sovereign exposure class unconditionally — the "risk weight of 0%" qualifier in Art. 147(3) binds only point (g) (international organisations), not points (c)-(e) — and Art. 147A(1)(a) makes that class Standardised-Approach only. But rgla_institution/pse_institution were excluded from B31_SOVEREIGN_LIKE_ENTITY_TYPES, so an internally-rated one holding an institution F-IRB permission received a modelled risk weight below the mandatory SA weight (worked case ≈26% vs 50%). Both entity types are now in the SA-only set, so b31_sa_only fires for them under the approach_restrictions_b31_applicable Feature (CRR path untouched). The premise was verified directly against the primary PS1/26 PDF (ps126app1.pdf p.89) after an initial drop recommendation based on the repo's own basel31 skill/specs, which mis-state this rule — a follow-on docs correction is warranted. Pinned by tests/acceptance/basel31/test_p1_220_pse_institution_sa_only.py. Ref: PS1/26 Art. 147(3)(c)-(e) w/ Art. 147A(1)(a).
  • Guarantee maturity-mismatch scaling now uses the residual protection maturity, not the original contract term — closing an understatement on seasoned guarantees. In GA = G·(t−0.25)/(T−0.25), t is the years remaining to the protection's maturity (CRR Art. 238(1) with 239(3); PS1/26 Art. 239(3)), but engine/crm/guarantees.py::_apply_maturity_mismatch_to_guarantees preferred the original_maturity_years input over the residual derived from maturity_date. Any dataset that populated the original term (needed for the separate Art. 237(2)(a) ≥1-year eligibility gate) therefore silently over-recognised a long-dated-but-nearly-matured guarantee, treating a seasoned guarantee as if no mismatch applied. The preference is now inverted — the residual from maturity_date drives t, with original_maturity_years used only as a fallback when no maturity_date is supplied — while the Art. 237(2)(a) eligibility gate (which correctly reads the original* term) is untouched. Worked case: a 1yr-residual / 5yr-original guarantee on a 4yr exposure now scales GA to 200,000 (total RWA 840,000) instead of full recognition (RWA 200,000). Pinned by tests/acceptance/crr/test_p1_219_guarantee_maturity_mismatch_residual.py; the companion test_p1_109 / test_p1_200 fixtures and expectations were re-pinned onto the residual basis. Ref: CRR Art. 238(1)/239(3); PS1/26 Art. 239(3).
  • The flat 20% domestic-currency risk weight for regional governments / local authorities / PSEs is now restricted to UK-sterling exposures, closing an understatement on EU-domestic-currency RGLAs. CRR / PS1/26 Art. 115(5) scopes the flat 20% to UK RGLAs denominated and funded in sterling, but engine/sa/risk_weights.py reused the composite is_domestic_currency = is_uk_domestic | is_eu_domestic flag for the RGLA branch, so every EU-domestic-currency RGLA short-circuited to 20% ahead of the rating tables — an unrated Italian municipality (sovereign CQS3) received 20% instead of the Table 1A sovereign-derived 100%. The RGLA 20% branch on both regime override chains now gates on a dedicated is_uk_domestic (GB & GBP) flag; the Art. 114(4)/(7) central-government 0% branch legitimately keeps the composite flag (EU-domestic-currency central-government exposures still qualify for 0%). Unrated EU RGLAs now route to Table 1A sovereign-derived and rated EU RGLAs to Table 1B own-rating; UK/GBP RGLAs still get 20%. Pinned by tests/acceptance/crr/test_p1_222_rgla_uk_gbp_flat_20.py (identical under CRR and Basel 3.1) and the inverted tests/unit/test_rgla_risk_weights.py::test_eu_domestic_currency_rgla. Ref: CRR Art. 115(5); PS1/26 Art. 115(5).
  • The A-IRB credit-conversion-factor fallback now uses the F-IRB supervisory CCF instead of the Standardised CCF, and suppresses modelled CCFs on out-of-scope items — closing an EAD understatement. Under CRR Art. 166(8)-(10) an A-IRB exposure with no own-estimate CCF takes the F-IRB supervisory CCF (75% for Art. 166(8)(d) credit lines / NIFs / RUFs), and own-estimate CCFs are admissible only across the Art. 166(8)(a)-(d) product scope (Art. 166(9)) — not for issued full-risk substitutes (FR/FRC), which take the Art. 166(10) supervisory value. But the CRR A-IRB branch of engine/ccf.py::_compute_ccf filled a null ccf_modelled from the SA CCF (_sa_ccf_from_risk_type, 50%/20%) and honoured any modelled CCF regardless of risk type. It now fills from _firb_ccf_from_risk_type and gates own-estimates behind in_166_8_scope = (is_obs_commitment | is_short_term_trade_lc) & risk_type ∉ {FR, FRC}, so a null-modelled MR commitment takes 75% (EAD 750,000 not 500,000 on a 1,000,000 nominal) and an issued FR item's spuriously low modelled CCF is suppressed to the supervisory 100%. The Basel 3.1 branch is unchanged. Pinned by tests/unit/test_ccf.py::TestArt16689CCFFallbackScope; three stale sibling tests that encoded the old SA-fallback behaviour were re-pinned. Ref: CRR Art. 166(8)/(9)/(10).

[0.3.13] - 2026-07-10

Added

  • The reconciliation tab's asset-class allocation now splits by method, because each asset class is reported per method. COREP reports every asset class under the methodology it was calculated with (SA on C 07.00, IRB on C 08.0x), so a class-only allocation could hide a real difference: a class the two engines allocate to different approaches nets to zero at class level and reads as a clean match. Mapping the new [components.approach] entry in the reconciliation TOML gives the legacy side a method to split on, and the engine produces a second, two-sided allocation frame class_allocation_by_method (analysis/reconciliation.py::_class_allocation_by_method, sharing one _build_class_allocation body with the existing class_allocation so the two can never drift on normalisation, one-sided fills, column order or the non-finite guard). The method dimension is a pure partition of the class one — summing a class's methods reproduces its combined row exactly — so the existing tie-out is preserved while the per-method rows expose the disagreement as offsetting deltas. Unlike the our-side-only summary_by_class_method (which is at the collapsed borrower grain, for break attribution), this frame is genuinely two-sided. The UI (reconciliation.html) renders one grouped-bar section per method — Legacy vs Ours RWA by class, all sharing one bar scale via the same ui/views/method_split.py the results and comparison tabs use — plus an EAD & RWA by risk class & method table ordered by the presentation METHOD_ORDER; when approach is unmapped both are empty and the page falls back to today's combined chart + table, and the class×method break segment tells the analyst how to turn the split on. Exposed as collect_class_allocation_by_method() on ReconciliationResponse and as a Class Alloc by Method Excel sheet / reconciliation_class_allocation_by_method.csv. Covered by tests/unit/engine/test_reconciliation.py::TestClassAllocationByMethod, tests/unit/ui/test_views_reconciliation.py and tests/integration/test_ui_reconciliation.py.
  • A new per-exposure approach_post_crm output column — the post-guarantee twin of approach_applied, mirroring what exposure_class_post_crm did for the class in 0.3.12. The by-method allocation above needs the post-substitution method, and the raw approach_applied is not it: a guaranteed exposure's __G_ leg inherits the obligor's approach because the guarantee split (engine/crm/guarantees.py::_build_guarantor_sub_rows) copies the parent row and never rewrites approach, so the leg is routed into the obligor's branch and each calculator sets approach_applied = approach. Only guarantor_approach ('sa'/'irb'/'') records the guarantor's treatment. Deriving the method from approach_applied would therefore file an AIRB corporate guaranteed by an SA sovereign under (central_government, AIRB) while a post-substitution legacy extract reports it under (central_government, STD) — a phantom break in exactly the substitution case the view exists to reconcile. The aggregator now derives approach_post_crm (engine/aggregator/aggregator.py::_add_post_crm_reporting_approach, CRR Art. 235): an SA-guaranteed leg reports as standardised (Art. 235 risk-weight substitution treats the protected portion as a direct SA exposure to the guarantor), an IRB-guaranteed leg keeps the obligor's IRB approach (Art. 161 / CRE22.70-85 parameter substitution), and retained/unguaranteed rows keep approach_applied. The rule itself is a single source (engine/aggregator/_crm_reporting.py::post_crm_approach_expr) now shared with the post-CRM detailed view, which previously re-derived it inline. The column is sealed on AGGREGATOR_EXIT_EDGE and so persists to the results parquet. Ref: CRR Art. 235, Art. 161.
  • REC007 — a new non-fatal reconciliation warning when the mapped legacy approach values do not resolve to a methodology. The gate for the by-method allocation is that approach is mapped, not that its values resolve: a legacy column coded "STA" / "1" / "Internal" passes straight through method_label_expr to its own upper-cased label, which can never share a (class, method) key with our STD/FIRB/AIRB rows — the split renders, but every row is one-sided and the deltas are meaningless. One small diagnostic collect now names the offending values and tells the analyst to add a [components.approach] value_map, turning a silently-misleading split into an actionable warning. The message quotes the raw cells from the analyst's file rather than the labels they folded to, and a null approach is called out separately as an unpopulated column — method_label_expr maps null to the synthetic label OTHER, and telling someone to value-map OTHER (a value they never supplied) would be useless. The diagnostic reads one projected, de-duplicated column off the legacy extract rather than the grouped allocation frame, so it never re-executes the by-method aggregation. The engine's methodology vocabulary is exported once as METHOD_LABELS (engine/aggregator/_summaries.py) rather than re-listed.

Fixed

  • A single exposure with a non-finite (NaN/inf) RWA no longer blanks the reconciliation tab's totals and their deltas — the tables now show the finite rows, and the bad rows stay surfaced as the existing REC006 warning. Every reconciliation total is a float .sum(), and Polars sums a NaN summand to NaN (unlike a null, which it skips), so one bad exposure — most often an IRB maturity-adjustment blow-up at very low PD — poisoned that component's or class's whole total; the UI's fill_nan(None) then rendered the poisoned total as an empty cell, and every column derived from it (delta, delta_pct, delta_rwa, delta_rwa_pct) went blank alongside it, hiding the dozens of exposures in that bucket that reconciled perfectly well. The headline tie-out stat cards showed the same NaN as a literal nan rather than a blank, because headline_stats coerces via _f (which maps None → 0.0 but passes NaN through untouched). The four total-producing builders in analysis/reconciliation.py_totals_tie_out, _class_allocation (via _sum_or_null), _summary_by_component and _summary_by_group — now route each summand through a new _drop_nonfinite helper that maps NaN/inf to 0 while leaving nulls untouched, so sum keeps skipping a genuinely absent one-sided value (a null means "no row on that side"; a NaN means "the calculation broke") and a one-sided class still reports its full allocation delta rather than a null. The REC006 ERROR_RECON_NON_FINITE_VALUE warning is unaffected: its detector runs on the raw per-key frame before any summary builder, so the analyst still gets the banner naming the affected component and row count, and is still pointed upstream at the offending exposures — sanitising the sums makes the totals readable without ever hiding the condition. This mirrors the layering already established for the results page in 0.3.8 (display boundary excludes non-finite rows; AGG001/AGG002 surface them), and the same deliberate line is held here: the reconciliation is an analysis surface, so its screen and CSV/Excel export agree, while the regulatory COREP / Pillar III outputs remain unsanitised. Covered by new tests in tests/unit/engine/test_reconciliation.py (a NaN rwa_final still yields a finite tie-out total/delta/pct and still raises REC006; a finite per-component sum_abs_delta; a finite per-class our_rwa/delta_rwa with EAD unaffected).

[0.3.12] - 2026-07-08

Added

  • A new per-exposure exposure_class_applied output column reports the class that matches the applied risk weight — so SME-managed-as-retail and defaulted SA exposures are finally bucketed correctly in reconciliation and COREP. The routing exposure_class records origination and guarantee substitution but omitted two SA-only applied-treatment movements, so the class dimension (not the RWA — the numbers were always right) was mis-attributed for those rows: (1) an SME managed as part of a retail pool that took the 75% retail risk weight (CRR Art. 123 / PS1/26 Art. 123A) stayed corporate_sme, even though Art. 122 corporate has no 75% band — a 75%-weighted SME logically entails the retail class; and (2) a defaulted SA exposure kept its origination class instead of routing to "Exposures in default" (CRR Art. 112(1)(j) / Art. 127), which wins over origination per PS1/26 Table A2 (high-risk, Art. 128, still outranks it). The aggregator now derives exposure_class_applied (engine/aggregator/aggregator.py::_add_exposure_class_applied) with a predicate that mirrors the SA risk-weight branch exactly, so the reported class always tracks the applied RW. Only SA rows (approach_applied == "standardised") are re-mapped — IRB already reclassifies corporate→retail on exposure_class itself and reports default via a PD override rather than a class, slotting keeps specialised_lending, and equity keeps equity — so every non-SA approach carries exposure_class unchanged. The column is sealed on AGGREGATOR_EXIT_EDGE (contracts/edges.py) and therefore persists to the results parquet. The reconciliation exposure_class component now prefers the post-guarantee class (see next entry) then this one (analysis/recon_registry.py, with the origination class surfaced as rationale), and COREP C 07.00 buckets on it (reporting/corep/generator.py) so defaulted SA rows land in the "Exposures in default" sheet (row 0100) and SME-managed-as-retail rows in the Retail sheet — both fall back to the origination exposure_class when the column is absent (frames produced before this release). Covered by tests/unit/test_exposure_class_applied.py (the applied-class predicate incl. priority ordering and per-approach preservation, the recon wiring, and the COREP C 07.00 re-bucketing). Ref: CRR Art. 112(1)(j), Art. 123, Art. 127; PS1/26 Art. 123A, Table A2.
  • A companion exposure_class_post_crm column lets the reconciliation tie out by asset class on a post-guarantee basis. Parallel-run reconciliation compares our per-class allocation against a legacy extract that is produced after guarantee substitution, so the guaranteed slice of an exposure must be reported under the guarantor's class, not the borrower's. A guaranteed exposure is physically split into a __G_ guaranteed leg and a __REM retained leg (engine/crm/guarantees.py); both carry the obligor's origination exposure_class, with the guarantor's class held in post_crm_exposure_class_guaranteed. The aggregator now derives exposure_class_post_crm (engine/aggregator/aggregator.py::_add_post_crm_reporting_class, CRR Art. 235): the guaranteed leg takes the guarantor's class, everything else keeps the pre-substitution exposure_class_applied. It is sealed on AGGREGATOR_EXIT_EDGE, and the reconciliation's class_allocation by-class money tie-out (analysis/reconciliation.py) buckets on it, so a guaranteed exposure's guaranteed portion is totalled under the guarantor's class and its retained portion under the obligor's — mirroring the post-substitution reporting. The two by-class reconciliation views are now cleanly separated: the allocation (money, ours vs legacy) is post-guarantee as above, while the break concentration (summary_by_exposure_class, and the per-key class label in the explorer) uses the obligor's applied class (exposure_class_applied, which is uniform across a guaranteed exposure's __G_/__REM legs) so a break is attributed deterministically to its borrower class rather than an arbitrary leg — and the two are labelled distinctly in the thin UI (reconciliation.html). The per-exposure money tie-out is unchanged (EAD/RWA sum the same regardless of class), and a partially-guaranteed exposure is flagged recon_grain_heterogeneous on collapse so the split across classes is visible. exposure_class_applied remains the pre-substitution class that COREP C 07.00 keys its sheet + substitution flows on. Covered by tests/unit/test_exposure_class_applied.py (guaranteed leg → guarantor class, retained/unguaranteed → applied class, unresolved-guarantor fallback) and tests/unit/engine/test_reconciliation.py (post-guarantee class_allocation tie-out). Ref: CRR Art. 235.

Fixed

  • The COREP "of which: defaulted" filter now reads the pipeline's own is_defaulted flag, so the SA memo populates. _filter_defaulted (reporting/corep/generator.py) previously keyed on default_status / exposure_class == "defaulted" / pd_floored >= 1.0 — none of which the live SA output carries — so the "of which: defaulted" rows were empty for standardised exposures even when the portfolio held defaults. It now checks the row-level is_defaulted flag first (the same signal the C 08.05 IRB count path already reads, and which also captures row-level defaults on an otherwise-performing counterparty), falling back to the legacy columns for hand-rolled frames. Ref: CRR Art. 178.
  • CRR short-term-rated institution and corporate paper is now risk-weighted through Art. 131 Table 7, closing a systematic RWA understatement. When an exposure carried a dedicated issue-specific short-term ECAI assessment (has_short_term_ecai=True), only the Basel 3.1 Table 4A/6A branches read the flag — the CRR risk-weight ladder had no Table 7 branch at all, so short-term-rated institution paper fell through to the Art. 120(2) Table 4 general short-term weight (CQS 3 = 20% instead of Table 7's 100%) and short-term-rated corporates fell through to the Art. 122 base join (CQS 4 = 100% instead of Table 7's 150%). Both are understatements of regulatory capital. The fix adds the cited pack table crr_short_term_ecai_risk_weights (CRR Art. 131 Table 7 = 20/50/100/150/150/150% for CQS 1-6; rulebook/packs/crr.py, read back via the engine/sa/crr_risk_weight_tables.py shim — no regulatory scalar hard-coded in engine code) and two has_short_term_ecai-gated CRR branches in engine/sa/risk_weights.py: a Table 7 branch prepended ahead of the Table 4 gate for rated institutions, and a new SME-excluded Table 7 branch for rated corporates. Long-term-rated exposures (is_short_term=False) still route through Table 4 (institutions) / Table 5-6 (corporates) unchanged, and Basel 3.1 behaviour is untouched. This closes the merge-cluster P1.216 / P1.224 / P1.226 from the 2026-07-07 Art. 111–241 compliance audit in a single change. Pinned by tests/acceptance/crr/test_p1_216_art_131_table_7_short_term_ecai.py (institution CQS 3 → RW 100% / RWA 1,000,000; corporate CQS 4 → RW 150% / RWA 1,500,000, both on a GBP 1,000,000 drawn loan). Ref: CRR Art. 131 (Table 7).

[0.3.11] - 2026-07-07

Added

  • The reconciliation review can now hide zero-gross-exposure rows from the breakdown counts, so the real differences stop being buried in immaterial noise. Our engine emits legitimate our-only lines that collapse to a key with zero EAD — a fully-provisioned exposure, a zero-undrawn (but committed) facility row, a guarantee-remainder sub-row — and each is a correct missing_right that adds nothing to any money total yet still counts in every breakdown (summary_by_bucket, summary_by_component, and the by-class / by-approach / by-class×method segment tables), inflating the missing_right tallies and hiding the material omissions among them. An opt-in "Hide zero-gross-exposure rows" toggle (off by default — an audit tool shows everything unless asked) now removes those rows from the overview breakdown counts, the per-key explorer, and the biggest-breaks worklist; the money tie-out and Σ|Δ| charts are untouched (a zero-gross row contributes nothing). Materiality is defined once in the engine (analysis/reconciliation.py): a new _materiality_columns tags every key with gross_exposure = max(|our EAD|, |legacy EAD|) (falling back to RWA when EAD is unmapped) and an is_immaterial flag (gross_exposure ≤ 1e-9), added to the wide component_reconciliation frame before any summary/breaks builder runs. The toggled breakdowns are re-derived by a new public material_summaries(recon) that reuses the very same private summary builders the bundle is assembled from — over the frame filtered to ~is_immaterial — so the material view can never drift from the all-rows view (_active_components_from_frame rebuilds the active-component list from the frame's columns, needing no engine-internal state on the response). breaks_detail now carries is_immaterial so the worklist can drop the rare zero-gross break. Plumbed through api/models.py (memoised ReconciliationResponse.collect_material_summaries()), ui/views/reconciliation.py (hide_immaterial on segment_tables / summary_by_component_table / biggest_breaks / breaks_signoff_progress, a new ForensicFilters.hide_immaterial applied in _apply_forensic_filters), ui/app/main.py (the hide_immaterial query param on the overview + explorer routes, threaded into the context, drill links and preserved across paging/sorting/sign-off), and the reconciliation.html / recon_explorer.html / _recon_table.html templates (an overview callout with a hidden-row count, an explorer checkbox, and a drill_table extra_qs argument that carries the toggle into segment drills). Covered by new tests in tests/unit/engine/test_reconciliation.py (flagging + material_summaries count reduction, money-total invariance, RWA fallback, no-measure no-op), tests/unit/ui/test_views_reconciliation.py (segment / component / explorer / worklist honour the toggle), tests/integration/test_ui_reconciliation.py (the overview callout + hidden count and the explorer checkbox + preserved query, via the real routes), and tests/acceptance/reconciliation/test_reconcile_end_to_end.py (a zero-gross phantom dropped from missing_left through the full CreditRiskCalc.reconcile() path).

[0.3.10] - 2026-07-07

Fixed

  • Opening a single-loan forensic (clicking a row's key) from the reconciliation explorer no longer 500s with polars ComputeError: parquet: File out of specification: The page header reported the wrong page size. Every reconciliation view — the overview, the segment tables, and the per-key explorer — renders from an eager DataFrame that is collected once (warmed on the worker thread, or memoised on first drill via ReconciliationResponse.collect_*), but loan_detail (ui/views/reconciliation.py) was the one drill path that instead re-executed the reconcile plan against disk on every click (scan_component_reconciliation().filter(_recon_key==key).collect() and scan_breaks_detail().filter().collect()). That fresh scan_parquet re-read of the run's last_results.parquet — with an equality filter that enables predicate/projection pushdown and its statistics/page-index-driven seeking read path — is materially more fragile than the full sequential collect the explorer does, so it alone tripped on a results parquet whose on-disk page offsets didn't line up (a streaming-sink_parquet write, or a torn/half-written file). loan_detail now filters the already-collected collect_component_reconciliation() / collect_breaks_detail() snapshots in memory — exactly like forensic_page, recon_fingerprint and every other view — so it never goes back to disk; as a bonus its numbers now match the explorer exactly (no second, independently-summed collect, which Polars group-by float sums make non-deterministic). The reconciliation CSV/Excel export buttons (api/export.py::_reconciliation_frames) had the same latent defect — they collected the raw lazy bundle frames directly, bypassing the memoised cache — and now read through the same collect_* accessors. As defence-in-depth, ResultsCache (api/results_cache.py) now writes every parquet atomically (sibling temp file + os.replace) so a concurrent scan_parquet reader — the results-page pagination, a reconcile re-scan, an export — can never observe a half-written last_results.parquet in the first place.

[0.3.9] - 2026-07-03

Added

  • The reconciliation overview now carries a "By exposure class × method" segment — completing the methodology split across every UI surface that splits by exposure class. The Tier-2 "Segment" section already broke down where breaks concentrate by exposure class and by approach as two separate axes; it now also crosses them, showing how our breaks distribute across methodology (STD / FIRB / AIRB / SLOTTING / EQUITY) within each exposure class. A new engine summary ReconciliationBundle.summary_by_class_method groups the per-key reconciliation frame by (our_exposure_class, method) — reusing the exact bucket-count/sum_abs_delta_rwa aggregations as the existing by-class summary (_summary_by_group generalised to multiple group columns), so summing n_total over methods within a class ties, cell-for-cell, to summary_by_exposure_class (method is a pure partition). The methodology label comes from the same shared mapping the results/comparison tabs use (method_label_expr), applied to our_approach, so a raw advanced_irb shows as AIRB consistently. To make the segment drillable, method is now a first-class column on the wide component_reconciliation frame and a first-class explorer filter (ForensicFilters.method, _FILTER_COLUMNS, the /reconciliation/{id}/rows route, and a Method dropdown in the explorer form), so a class×method cell links straight into the per-key explorer pre-narrowed on both dimensions (a new drill_table2 template macro builds the two-param link). This is deliberately an our-side view: approach is an our-engine attribute the legacy extract has no counterpart for, so the two-sided asset-class allocation tie-out above it stays un-split (a method-split allocation would need the legacy file to map an approach column) — the UI copy says so explicitly. Plumbed through contracts/bundles.py (bundle field + create_empty_reconciliation_bundle), api/models.py (collect_summary_by_class_method), ui/views/reconciliation.py (segment_tables, forensic_filter_options), ui/app/main.py, and the reconciliation.html / recon_explorer.html / _recon_table.html templates. Covered by new tests in tests/unit/ui/test_views_reconciliation.py (class-method partition, the by-class reconciliation invariant, method filter options, and single- and two-dimension explorer filtering), tests/unit/api/test_models.py (the cached accessor), and tests/integration/test_ui_reconciliation.py (the segment section + the explorer Method filter render).
  • The methodology split (STD / FIRB / AIRB / SLOTTING / EQUITY) now extends beyond the results-tab "RWA by exposure class" chart to every other place the UI splits by exposure class: the results-tab "EAD by exposure class" panel and the CRR-vs-Basel-3.1 comparison "by exposure class" view. Previously only the results RWA-by-class panel was split by method; the EAD panel next to it and the comparison's by-class chart showed a single undifferentiated bar per class, so a reviewer could not see, say, how much of the corporate EAD (or of the corporate CRR→B31 RWA delta) was standardised vs IRB. The section-building logic is now a single shared helper (ui/views/method_split.pysingle_series_sections for one value column, grouped_series_sections for a two-series CRR-vs-B31 chart), so the three tabs render one consistently-ordered chart section per methodology present and cannot drift; the methodology label stays sourced from the one engine mapping (engine/aggregator/_summaries.py, now exposed as the public method_label_expr) rather than re-derived per surface. The results EAD split reuses the existing summary_by_class_method frame (which already carries total_ead), so it needs no engine/cache change. The comparison split is derived from the same exposure_deltas frame that backs the by-class chart — exposure_deltas now carries a method column (analysis/comparison.py), and ui/views/comparison.py::summary_by_class_method groups it by (exposure_class, method) — so the per-method bars sum cell-for-cell to the by-class totals shown alongside them (verified against the multi-methodology reporting portfolio: max reconciliation diff 0.0 on both CRR and B31, and equity surfaces with real RWA, nothing falls into an OTHER bucket). Chart sections in a set now also share one bar scale (an optional max_value on ui/views/charts.py's horizontal_bar_svg / grouped_bar_svg), so a small method reads as genuinely small next to a large one instead of each section rescaling to its own max. Covered by new unit tests in tests/unit/ui/views/test_method_split.py (ordering, empty tolerance, RWA+EAD from one frame, shared-scale shrink), tests/unit/ui/views/test_comparison.py (class-method partition + the by-class reconciliation invariant), tests/unit/ui/views/test_charts.py (the shared-scale param), and extended tests/integration/test_ui_app.py assertions (both results panels split by method; the comparison class×method panel renders).
  • Parallel-run reconciliation now links our results to legacy/old data on the original, pre-concatenation exposure reference — so guarantor-split and facility-undrawn rows stop breaking the join. Our per-exposure output deliberately mutates exposure_reference at several sites — guarantee splits append __G_<guarantor> / __REM…, real-estate splits append _rre/_cre/_res…, facility undrawn synthesises <facility>_UNDRAWN[_<sub>|_RESIDUAL], and synthetic derivative/SFT rows are prefixed ccr__/ft__/dfc__ — but a legacy calculator's extract keys each exposure on its original reference, so those suffixed/synthetic references matched nothing and the reconciliation silently dropped them (undrawn especially: it had no recovery path at all, and the built-in collapse only ever un-split guarantee and RE rows). A new always-present, always-non-null source_exposure_reference column now carries the pre-concatenation base on every result row: loans/contingents set it to their own reference (engine/stages/hierarchy/unify.py) and guarantee / RE-split sub-rows inherit it unchanged (they only mutate exposure_reference), so it recovers the original loan reference on collapse; facility-undrawn rows — including every MOF waterfall / residual sub-row — set it to the facility reference (engine/stages/hierarchy/facility_undrawn.py), the grain a legacy file keys undrawn commitments on; the equity path (engine/aggregator/_equity_prep.py) and the synthetic CCR/SFT builders (engine/ccr/pipeline_adapter.py, engine/sft/fccm.py, engine/stages/ccr.py) populate it too. It is declared on the sealed edge contracts beside source_facility_reference (contracts/edges.py_hierarchy_resolved_columns and _calc_output_common_columns), so it survives the full pipeline to the results frame and the exported parquet/CSV. The reconciliation collapse now coalesces it first (data/schemas.py::RECON_PARENT_KEY_COLUMNS), so on the default exposure_reference key guarantee/RE/undrawn sub-rows are automatically rewritten to their base reference before the join — guarantee and RE recovery is byte-for-byte unchanged (their base equals the parent-link column already used), undrawn now links to the legacy facility line, and parent_exposure_reference/split_parent_id are retained as defensive fallbacks for result parquets written before this column existed. Synthetic ccr__/ft__/dfc__ rows deliberately keep their namespace in the base (a bare netting-set / trade / contribution id could otherwise collide with an unrelated loan reference and silently sum EAD on a base-grain key), so they correctly remain our-only lines unless the legacy file reports those aggregates too. The base is also usable as an explicit reconciliation key (our_keys = ["source_exposure_reference"], documented in the UI mapping editor). Covered by new unit tests in tests/unit/engine/test_collapse.py (undrawn → facility, MOF multi-sub → one line, guarantee recovery unchanged, null fall-through, namespaced-CCR no-merge) and an integration contract in tests/integration/test_source_exposure_reference.py (non-null on 100% of a real CRR run's rows; undrawn strips _UNDRAWN; guarantee splits strip their suffix). Note: this changes the default-key reconciliation grain for portfolios containing facility-undrawn rows (they now collapse to the facility base line instead of surfacing as unmatched <fac>_UNDRAWN our-only lines); a loan whose reference literally equals a facility reference would collapse with that facility's undrawn line (the heterogeneity flag surfaces mixed class/approach in such a group).

[0.3.8] - 2026-06-30

Added

  • The results page now splits "RWA by exposure class" by methodology — STD / FIRB / AIRB (advanced IRB, retail folded in), plus slotting / equity where present — so a reviewer can see how much RWA each methodology contributes within each exposure class. Previously the panel showed a single bar chart of total RWA per class with no way to tell, say, how much of the corporate RWA was standardised vs IRB. A new aggregator summary frame summary_by_class_method (engine/aggregator/_summaries.py::generate_summary_by_class_method) groups the same post-CRM reporting rows by (exposure_class, method), reusing the identical reporting columns and aggregation expressions as the by-class summary — so summing total_rwa over methods within a class reconciles exactly with summary_by_class (guarantee splits and the output-floor add-on already folded in). The method label is derived from the calculation approach (standardised → STD, foundation_irb → FIRB, advanced_irb → AIRB with retail A-IRB included, slotting → SLOTTING, equity → EQUITY); there is no separate "retail IRB" approach, so retail A-IRB folds into AIRB. The frame is plumbed through the bundle (AggregatedResultBundle.summary_by_class_method), the parquet cache (ResultsCache.sink_results / scan_summary_by_class_method), and the response (CalculationResponse.scan_summary_by_class_method); the results page renders one sub-titled "RWA by exposure class" chart per methodology present (ui/app/main.py::_class_method_sectionstemplates/results.html), falling back to the single combined chart when the split is unavailable. Covered by new tests in tests/unit/test_aggregator.py (shape, method labels, by-class reconciliation, retail-A-IRB→AIRB), tests/unit/api/test_results_cache.py (round-trip), tests/unit/api/test_formatters.py (path populated), and tests/integration/test_ui_app.py (the STD subsection renders).

Fixed

  • Parallel-run reconciliation can finally compare PD, LGD and guarantee on a real IRB run — previously every such mapping was silently skipped with a REC001 "no column for component …" warning. The reconciliation registry (analysis/recon_registry.py) looked up each component's value on our results frame (scan_results()) by a list of candidate column names, but the candidates for pd, lgd and guarantee were authored against the documentary, never-enforced CALCULATION_OUTPUT_SCHEMA (data/schemas.py) rather than the engine's actual output: it tried irb_pd_floored/irb_pd and irb_lgd_floored/irb_lgd (and guarantee_benefit), none of which the engine ever emits. The IRB calculator writes the un-prefixed names pd_floored/pd, lgd_floored/lgd_input/lgd (engine/irb/{formulas,transforms}.py), and the only persisted, additive guarantee figure on the combined output is guaranteed_portion (the guarantee_benefit/guarantee_benefit_rw RWA-reduction columns never reach the per-exposure output) — so _first_present(...) returned None and the component was dropped, leaving the analyst's PD/LGD/guarantee comparison blank even though the engine had computed them. (maturity was unaffected because the engine genuinely renames maturity → irb_maturity_m, which is a registry candidate.) The fix repoints the three components to the real output names: pd → (pd_floored, pd), lgd → (lgd_floored, lgd_input, lgd) (mirroring the reporting layer's _pick(cols, "lgd_floored", "lgd_input")), and guarantee → (guaranteed_portion,) reconciling the additive guaranteed EAD portion (its explain/input drivers and the ead component's de-dup driver list were corrected to real columns to match, and the UI's default-mapping hint now points at a guaranteed-amount legacy column). The fictional irb_-prefixed names are also annotated as non-enforced in CALCULATION_OUTPUT_SCHEMA / IRB_RESULT_SCHEMA and docs/data-model/output-schemas.md, and the stale analysis/reconciliation.py module docstring now points at the sealed AGGREGATOR_EXIT_EDGE as the real output contract. The root cause was a test gap — no test ran reconcile() against a real IRB portfolio mapping PD/LGD (the unit suite fed SA-shaped frames and even codified the skip as expected behaviour) — so the fix adds tests/integration/test_reconciliation_output_contract.py, which runs the real IRB pipeline once and asserts every key component's our_columns resolves against the live output schema (catching any future engine column rename), plus a positive resolution test in tests/unit/engine/test_reconciliation.py. UI-view tests that built ours frames keyed on guarantee_benefit were updated to guaranteed_portion.
  • A single IRB exposure with a non-finite (NaN/inf) RWA no longer blanks the entire results page — the totals, per-approach card and charts now render the real numbers for the unaffected rows, and the bad rows are surfaced as an explicit error. A genuine NaN in a per-row rwa_final / ead_final / risk_weight (most often from a NaN PD/LGD reaching the IRB formula, or a zero-EAD guaranteed row dividing 0/0) propagated straight through Polars' float .sum() — which, unlike a null, does not skip a NaN — turning total_rwa, total_rwa_irb and the average risk weight into Decimal('NaN') (rendered literally as "NaN", and Decimal('NaN') > 0 even raised, masquerading as a whole-page failure) and collapsing both SVG charts to 1px slivers (a NaN poisoned the bar-scale max_value). The fix is layered: (1) the IRB PD/LGD floors now fill_nan(None) before flooring, so a NaN PD/LGD is treated like a null and raised to its regulatory floor — conservative and finite (engine/irb/{formulas,transforms}.py; max_horizontal/clip/fill_null do not scrub NaN, only fill_nan does); (2) the IRB guarantee-blend now guards the EAD divisor so a zero-EAD guaranteed row yields a finite 0 instead of 0/0 → NaN / x/0 → inf (engine/irb/guarantee.py); (3) the output aggregator detects any remaining non-finite output and records a non-critical AGG001 CalculationError naming a sample of the offending exposures, so the gap is a visible coded issue rather than a silent blank (engine/aggregator/aggregator.py); and (4) the display boundary excludes non-finite rows from the card sums (api/formatters.py) and the chart item builders / bar-scale (ui/app/main.py, ui/views/charts.py), so the screen stays readable while the error explains the exclusion. The summary parquet / COREP / Pillar III exports are deliberately not sanitised — silently zeroing a regulatory output there would be worse than surfacing it. The detector additionally (5) raises a non-blocking AGG002 warning when a raw IRB input (pd/lgd) was non-finite and got raised to the regulatory floor — so the conservative scrub in (1) is visible rather than absorbed silently — and (6) the AGG001 scan also covers the post-CRM reporting_rw/reporting_ead columns the by-class/by-approach charts aggregate (de-duplicated against the output scan so the same exposure is never reported twice). Separately, the results cache now clears a stale summary parquet when a later run writes none, so an error run can no longer resurrect a previous run's by-class / by-approach / by-method summary. Covered by new tests in tests/unit/test_aggregator.py (AGG001 detection, AGG002 input warning, reporting-column de-dup), tests/unit/api/test_formatters.py (NaN excluded from totals, no InvalidOperation), tests/unit/api/test_results_cache.py (stale-summary clearing), tests/unit/irb/test_irb_formulas.py (NaN PD/LGD → floor, finite RWA, on both the formula and production paths), tests/unit/irb/test_irb_parameter_substitution.py (zero-EAD guarantee stays finite), and tests/unit/ui/views/test_charts.py (non-finite values dropped/zeroed, scale not poisoned).

[0.3.7] - 2026-06-28

Added

  • The COREP and Pillar III Excel exports now carry a readable column-name banner above the regulatory ref codes, so a reader no longer has to decode bare 0010/a/b headers. Every template column is defined with both a regulatory ref and a human-readable name (COREPColumn.name / P3Column.name in reporting/{corep,pillar3}/templates.py), but only the ref reached the workbook — each sheet's header row was row_ref, row_name, 0010, 0020, … (COREP) or …, a, b, c, … (Pillar III), even though the rows already carried a readable row_name. Each exported sheet now writes a two-row header band: row 1 (top) is the readable column-name banner ("Row code"/"Row name" over the two structural columns, then each column's name, e.g. Original exposure pre conversion factors over 0010), row 2 is the unchanged regulatory ref codes, and the data follows below; the top two rows are frozen so both stay visible while scrolling the (often very wide) templates. The banner is written as Excel cells only — the underlying template DataFrame schema is untouched (still keyed by ref), so the ndjson golden comparisons and any code-stable downstream consumer are unaffected; a column with no static name (none on the Excel path today) falls back to showing its ref. Implemented as one shared writer reporting/kernel/excel.py::write_template_sheet (it also absorbs the former per-generator _finite_only non-finite→blank guard and sheet-name sanitisation, removing the duplicated copies), threaded the per-template {ref → name} map (column_name_map) from each generator's export_to_excel. Covered by new banner-layout regression tests in tests/unit/test_corep.py and tests/unit/test_pillar3.py (row 0 = readable names, row 1 = refs), with the existing non-finite-cell tests updated for the one-row data offset.
  • Pillar III disclosure templates can now be exported from the UI and REST API, alongside COREP. The engine already produced the full Pillar III quantitative credit-risk disclosure suite — OV1; SA CR4/CR5; IRB CR6/CR6-A/CR7/CR7-A/CR8/CR9/CR9.1; slotting CR10; output-floor CMS1/CMS2; counterparty CCR1/CCR2/CCR3/CCR8 (UK-prefixed sheets under CRR, UKB-prefixed under Basel 3.1) — but it was reachable only from Python via ResultExporter().export_to_pillar3(...): there was no CalculationResponse.to_pillar3(), no REST endpoint, and no UI button, so a user could download COREP but not Pillar III. Pillar III is now wired through every export surface that COREP already had: a CalculationResponse.to_pillar3(path) convenience method (api/models.py); the REST GET /api/export/{fmt}?run_id=… endpoint accepts pillar3 and streams the workbook (api/rest.py); the results page shows a Pillar III download button next to COREP, and pillar3 is an option in the calculator's at-run-time and the results page's Save to folder format pickers (it writes rwa_pillar3.xlsx into the run-stamped subfolder via output_writer). Like Excel/COREP it needs xlsxwriter and is greyed out / reported per-format when that is missing, and the user-supplied run_id never reaches a filesystem path (fresh temp dir + fixed literal filename). Covered by TestExportToPillar3 (tests/unit/api/test_export.py), the REST export parametrisation, tests/unit/ui/test_output_writer.py, and an extended results-page render assertion in tests/integration/test_ui_app.py.
  • The CRR vs Basel 3.1 comparison page can now export its results, like the calculator and reconciliation pages. Previously the comparison ran both frameworks, rendered the executive summary / waterfall / by-class table, and then threw the result away — the ComparisonBundle + CapitalImpactBundle were locals in _compute_comparison, never cached, so there was no way to take the numbers off the screen (the page had no download UI and nothing was registered to export). The comparison is now cached under a fresh comparison_id (a new _COMPARISONS registry in api/rest.py, peer to _RUNS / _RECON_RUNS) the moment it renders, and the page shows CSV / Parquet / Excel download buttons wired to a new GET /api/comparison/export/{fmt}?comparison_id=… endpoint. The export carries exactly what the comparison is for — the executive-summary headline, the by-class and by-approach delta summaries, the capital-impact driver waterfall, and the per-exposure deltas / driver attribution — as one file per dataset (CSV and Parquet stream as a zip; Excel as a single multi-sheet workbook with friendly sheet titles). This mirrors the reconciliation export precedent rather than the calculator's parquet-path-backed /api/export, because the comparison holds in-memory lazy bundle frames, not a sunk-to-parquet CalculationResponse; a new ComparisonExportResponse (api/models.py) collects each frame once into an eager DataFrame at registration (not the lazy two-pipeline bundle graph), so a registry entry stays light. The Excel button greys out when xlsxwriter is absent (matching the results page), and as with the other export endpoints the user-supplied id never reaches a filesystem path (fresh temp dir + fixed literal filenames). The comparison page renders inline (no background job), so the id and download URLs ride along in the same POST response — no redirect. Covered by tests/unit/api/test_export.py::TestComparisonExport (one file per frame, Parquet round-trip, Excel sheet titles, missing-xlsxwriter raise), tests/integration/test_rest_api.py (CSV/Parquet/Excel stream + unknown-id 404), and an extended tests/integration/test_ui_app.py comparison render assertion.

Changed

  • The calculator progress stepper now shows a "Create exports" step when a run writes export files, so the end-of-run wait is labelled instead of looking like a hang. When an output folder + formats are chosen, the calculator writes the selected exports (Parquet/CSV/Excel/COREP/Pillar III) after the pipeline finishes — a step that has no registry stage, so the stepper's spinner previously parked on the last pipeline stage (or appeared to "circle" with everything ticked) while the workbook(s) wrote, with no indication of what was happening. The stepper now appends a synthetic Create exports step (mirroring the reconciliation stepper's "Reconcile & summarise" tail): the spinner parks on it honestly while the files write, and it ticks off when they are done. The step is shown only when the run actually writes exports (Job.writes_exports); a plain run with no output folder ends at the aggregator as before. The reported total_stages (the registry pipeline count) is unchanged — the export step is a UI-only tail. Covered by extended tests/integration/test_ui_app.py assertions (the step renders and ticks for an export-writing run, and is absent otherwise).

Fixed

  • The COREP export no longer crashes when a template cell computes a non-finite value. Like the Pillar III export, a COREP template ratio can be NaN or ±Inf on real data — e.g. a ratio over a zero denominator in an empty exposure-class or geography segment — and xlsxwriter rejects those in write_number() (NAN/INF not supported … without 'nan_inf_to_errors'), aborting the whole workbook. The three COREP workbook writers (reporting/corep/generator.py) now replace non-finite floats with null at the write boundary, so an undefined cell is shown blank rather than #NUM! or a crash; existing nulls and non-float columns are untouched. Covered by a new TestExcelExport regression test.
  • The CRR vs Basel 3.1 comparison charts no longer clip long category labels off the left edge of the card. The server-rendered SVG chart builders (ui/views/charts.py) drew every category label right-aligned inside a fixed ~142-unit left gutter (text-anchor="end" at x = _LABEL_W - 8), so any label wider than ~20 monospace characters overran the viewBox left edge and was clipped — most visibly the capital-impact waterfall driver names ("Methodology & parameter changes", "Supporting factor removal (SME/infrastructure)"), which showed only their tail (e.g. just "& parameter changes"). The three builders (horizontal_bar_svg, grouped_bar_svg, waterfall_svg) now detect, per chart, when any label is too wide for the gutter and switch that chart to a label-above-bar layout: the label gets the full chart width on its own line, left-aligned, with the bar beneath it, so the full text always renders. Charts whose labels all fit (the results page's exposure-class/approach charts, the reconciliation charts) are unchanged — the compact gutter layout is byte-identical, so nothing regresses. Pure server-side SVG (no CSS or template change; the existing --oah-* theme classes are reused). Covered by new tests/unit/ui/views/test_charts.py (both layouts pinned: short labels stay compact, a long label stacks and renders in full, taller viewBox, value text in both, empty placeholder).
  • The Pillar III export no longer crashes when a disclosure cell computes a non-finite value. On real portfolios a template ratio can be NaN or ±Inf — e.g. an average PD or a risk-weight ratio over a zero denominator in an empty segment — and xlsxwriter rejects those in write_number() (NAN/INF not supported … without 'nan_inf_to_errors'), which aborted the whole workbook. The Pillar III workbook writer (reporting/pillar3/generator.py) now replaces non-finite floats with null at the write boundary, so an undefined disclosure value is shown as a blank cell rather than #NUM! or a crash; existing nulls and non-float columns are untouched. Covered by a new TestExcelExport regression test.

[0.3.6] - 2026-06-27

Added

  • The reconciliation analyst can now sign off each difference — Accept (acceptable) or Reject (genuine error) with a free-text reason — and the worklist filters those decisions out, so the Open list burns down to just what still needs investigating. Previously the reconciliation surface was entirely read-only: an analyst could drill tie-out → segments → break worklist → single-loan forensic, but had nowhere to record that they had checked a difference or why it is (un)acceptable, so on a several-hundred-break portfolio there was no way to tell reviewed from un-reviewed. A new per-user, per-dataset sign-off store (ui/app/recon_signoff.py, mirroring recon_state.py) persists one decision per exposure row, keyed by the deterministic _recon_key, under a stable workspace id (a hash of the resolved data path + mapping join-keys) — so decisions survive an app restart and a re-run of the same dataset (re-run after a source fix and your prior sign-offs carry over; differences you resolved simply drop off). Writes are atomic (temp file + os.replace) and every IO path is error-swallowing, so a sign-off click can never 500 and a corrupt store loads as empty. The decision control lives on the single-loan forensic page (recon_loan.html): a reason <textarea> plus Accept / Reject / Reopen submit buttons posting to a new POST /reconciliation/{recon_id}/signoff route (guarded by the existing require_same_origin CSRF belt, 303-redirecting back so the actioned row leaves the Open list); a reason is required to reject (the page re-renders with a 400 + error callout otherwise) and optional to accept. The per-key explorer (recon_explorer.html) now defaults to a new status filter (Open / Accepted / Rejected / All, Open first), renders each row's status as a colour-coded badge with its reason, and offers a one-click inline Accept per row for fast triage; the explorer and overview both show an "X of Y breaks reviewed — Z open" burndown, and the overview worklist (biggest_breaks) and ranked break list now exclude already-reviewed keys. The inline Accept is a progressive enhancement (static/recon-signoff.js): on the Open worklist it POSTs in the background (X-Requested-With: fetch → a small JSON burndown payload instead of the 303) and drops just that row, so the analyst keeps their scroll position and can run down the list ticking items off without the page jumping to the top; with JS disabled (or on any non-Open view) it falls back to the normal full-reload form post.
  • Re-running the same dataset now re-flags any sign-off whose difference has moved since it was signed off, so a regression cannot be waved through under a stale approval; and analysts can clear all sign-offs at once. Each decision stores a fingerprint of the difference at sign-off time (Decision.fingerprint): the row bucket plus, per still-breaking component, a name:bucket:our~legacy token where numbers are banded to 4 significant figures (_value_token / _delta_band, in canonical scientific notation) and categoricals are normalised. On a re-run the fingerprint is recomputed (current_fingerprints, off the cached wide frame) and compared: if it differs while the row is still a material difference (not exact/within-tolerance), the decision is stale — the row returns to the Open worklist with a changed badge / overview "changed since sign-off" count and, on the loan forensic, a re-review warning showing the prior disposition; re-signing re-stamps the fingerprint. Because the token bands both sides' values (not just abs_delta, which is null for categoricals and one-sided breaks), a legacy reclassification (e.g. retail → sovereign) on an accepted class break, or a break that moves to a different component, is now caught — not just a same-component magnitude change. The fingerprint is float-noise-robust (4 sig figs absorbs Polars' non-deterministic group-by float sums; canonical notation avoids a false break at decade boundaries), so an identical re-run never false-flags. A new Clear all sign-offs control (overview + explorer, confirm-guarded) wipes every decision for the dataset via POST /reconciliation/{id}/signoff/clear-all (same-origin guarded); the unguarded GET /reconciliation/reset side-effect was also brought under the same-origin guard. Covered by added unit tests (moved-categorical and moved-to-a-different-component staleness, fixed-row-shows-matched, within-tolerance-resolution, decade-boundary band, empty/failed-response safety) and integration tests (stale-on-rerun, re-accept clears, overview changed state, clear-all from overview/explorer). The change was hardened against a 5-lens adversarial review. Implemented by annotate_signoff / breaks_signoff_progress and a status dimension on ForensicFilters / _FILTER_COLUMNS in ui/views/reconciliation.py; a ReconWorkspace registry in api/rest.py binds each run's recon_id to its sign-off workspace (the UI recon worker computes it from the parsed mapping). The signoff status derives as accepted/rejected (a stored decision), matched (an exact-match row — never a difference, never in the Open list) or open (an un-actioned difference). No engine, REST-export, or loader change. Covered by tests/unit/ui/test_recon_signoff.py (store round-trip / workspace isolation / workspace_id determinism / atomic write / resilience), tests/unit/ui/test_views_reconciliation_signoff.py (annotation buckets, default-Open filtering, worklist burndown, progress counts) and tests/integration/test_ui_reconciliation.py (accept clears Open and returns under Accepted, reject-needs-reason 400, reopen, same-origin guard, unknown-recon 404, and persistence across a re-run of the same dataset).
  • The reconciliation single-loan forensic now reads as an ordered RWA-driver chain with legacy beside ours at each step, and collateral / guarantee / CQS are first-class comparable components — so an analyst can see why a loan's RWA differs, not just that it differs. Previously the loan view (ui/views/reconciliation.py::loan_detailtemplates/recon_loan.html) rendered a flat By component panel grid plus an our-side-only, unordered Input & explain drivers key=value dump. loan_detail now also returns an ordered steps chain (exposure class → approach → CQS → PD → LGD → maturity → CCF → collateral → guarantee → EAD → risk weight → RWA, module-level _CHAIN_ORDER); each step shows legacy | ours | absΔ | relΔ | status for the mapped component and nests that component's explain/input columns beneath it as our-side driver rows, each tagged "legacy not provided" so a one-sided field is never mistaken for a zero (new _driver_chain helper; the template renders the chain, and recon_loan.html keeps the pinned Loan forensic / By component headings). Three new RECONCILABLE_COMPONENTS (recon_registry.py) make the previously buried CRM drivers genuinely comparable: collateral (collateral_adjusted_value, additive) and guarantee (guarantee_benefit, additive) sum across split sub-rows to the key grain like EAD/RWA, and cqs (sa_cqs/external_cqs, exact-int) — so once a legacy column is mapped they surface everywhere (the single-loan chain, the per-key explorer grid, the totals tie-out, the asset-class allocation, and the CSV/Excel export) with no loader, export, or REST change (the loader maps any registered component by name; the export dumps the whole frame). PD/LGD/CCF/maturity/risk-weight/exposure-class were already comparable — they just need a legacy column mapped; the page's DEFAULT_MAPPING_TOML now ships commented [components.pd|lgd|cqs|collateral|guarantee] examples. Numeric component deltas are now cast to Float64 so an integer-valued component (CQS) no longer breaks the per-component summary concat. Behaviour note: mapping collateral/guarantee adds them to the Tier-1 totals tie-out (intended — total CRM benefit ties out). Rating grades (vs PD/CQS) are deferred — they would need a new CALCULATION_OUTPUT_SCHEMA column. Covered by new tests in tests/unit/ui/test_views_reconciliation.py (chain order, driver grouping/our-side-only, the three new components' bucketing) with tests/integration/test_ui_reconciliation.py green. Phase 5 of docs/plans/reconciliation-ux-redesign.md.
  • The calculator UI can now write results straight to a folder you choose, instead of leaving them only in the in-memory registry reachable through a hand-typed REST URL. Three complementary paths land together: (1) an optional Output folder + Output format(s) (Parquet/CSV/Excel/COREP) on the /calculator form — when set, the background worker writes the selected formats to disk after calculate() returns (outside STAGE_SEQUENCE, so the live stepper and the stage count are unchanged) and the results page confirms exactly what was written; (2) a Save to folder form on the results page (POST /results/{run_id}/save) that re-exports an already-computed run to any folder without recomputing, looked up by its unguessable run_id; and (3) real Download buttons on the results page wired to the existing GET /api/export/{fmt} endpoint (replacing the inert "use the REST API" text). All disk writing flows through one shared helper (ui/app/output_writer.py::write_selected_formats) that normalises the directory-vs-file asymmetry of the export wrappers and isolates every save in a run-stamped rwa_export_<run_id> subfolder, so a re-export can never silently clobber a different run's files and two concurrent writes cannot race on the same fixed filenames. A missing xlsxwriter (Excel/COREP) is reported per-format on the page rather than raising — the route never 500s on an export failure. The chosen folder/formats are remembered between sessions via a new calculator_state last-run file (mirroring the reconciliation form), and the calculator's framework / permission-mode / data-format selects now survive a validation bounce. A new non-raising validate_output_path (sibling to validate_data_path) requires an absolute, resolvable path whose immediate parent exists (so a typo cannot create a deep tree), rejects Windows reserved device names, and treats os.access as advisory. Covered by new tests in tests/unit/ui/test_output_writer.py, tests/unit/ui/test_calculator_state.py, tests/unit/api/test_api_validation.py (the validate_output_path matrix) and tests/integration/test_ui_app.py (calc-time write, the save route, graceful per-format failure, the form prefill and the non-destructive validation bounce). Plan: docs/plans/ui-output-folder.md.

Fixed

  • CSV export no longer produces a blank results.csv (and no longer silently skips the summary CSVs) when the result set contains nested columns. The full exposure-level frame carries a few nested columns (ancestor_facilitiesList, securitisation_pool_allocationsList[Struct], addon_by_asset_classStruct); ResultExporter.export_to_csv wrote them straight to write_csv, which has no nested types — Polars created results.csv, then raised ComputeError: CSV format does not support nested data, leaving a 0-byte file and aborting before the summary_by_class.csv / summary_by_approach.csv writes. (Parquet was unaffected — it supports nested types — which is why selecting Parquet and CSV gave a populated Parquet but a blank CSV, and why the REST /api/export/csv test had quietly excluded CSV.) The CSV writers now JSON-encode any nested column first, so every value is preserved as a JSON string (the cell round-trips through any CSV reader), the summary CSVs are written, and GET /api/export/csv + the results-page CSV download + the new save-to-folder CSV all carry data. Covered by tests/unit/api/test_export.py::TestCsvNestedColumns, the REST export test now parametrizes csv, and the UI save tests assert a non-empty results.csv.

Changed

  • The server-rendered UI app now enforces a loopback-only network posture, because letting a user-supplied string become a real filesystem write target reverses the deliberate "no user input reaches an FS path" stance of the REST export endpoints. create_app() adds TrustedHostMiddleware(allowed_hosts=["localhost", "127.0.0.1"]) (DNS-rebinding defence; the bind stays on 127.0.0.1), and the disk-writing routes (POST /calculate, POST /results/{run_id}/save) require a same-origin request (require_same_origin checks Origin / Sec-Fetch-Site) so a web page on another site cannot drive a write via a cross-origin form POST. The standalone REST app (create_api_app, used for tests/embedding) is unchanged — in the served UI the API routes run under the same middleware. This is safe only on the loopback single-user model the UI ships as; do not expose the app off 127.0.0.1.

Documentation

  • The blog was audited against the live codebase and brought current under a "snapshot-and-extend" policy, and a new scripts/blog_counts.py makes its headline figures reproducible. An audit found the eight-post series had drifted: headline counts were stale (the suite was cited at ~5,300 tests when it is now ~7,450; "eight architectural checks" when there are 17; "four agents" when there are 7; "eight pipeline stages" when there are 10), and the entire post-April epic — SA-CCR/CCR, SFT/FCCM, BA-CVA, securitisation allocation, the rulebook-as-data migration, and the FastAPI web UI/reconciliation — was unmentioned. The four published posts (2026-04-28 → 2026-06-23) are kept as honest dated snapshots: corrections land only in their dated Update notes, never by rewriting the body, preserving the pinned-commit narratives. The three future-dated unpublished drafts (2026-07-07, 2026-07-21, 2026-08-04) were brought current in place — including removing a false "BDD suite under tests/bdd/" claim (that directory is empty) and reworking the season-one finale's now-stale open-backlog scaffolding — and the finale is reframed as the end of "season one" rather than the end of the series. Four season-two posts were added (Counterparty Credit Risk / SA-CCR; the rulebook-as-data migration; the workbench-to-web-app reconciliation story; and a reviewer-gated-worktree follow-up to the agent-swarm post), with the series index (docs/blog/index.md) and nav restructured into season one / season two. scripts/blog_counts.py emits the canonical project counts (test functions per pyramid layer, source/test file counts, arch-check count, role-agent count, pipeline-stage count) as a table, JSON, or Markdown, so future prose cites one reproducible source instead of hand-copied figures.

[0.3.5] - 2026-06-26

Added

  • The reconciliation report is now a progressive-disclosure explorer that scales to millions of per-key rows instead of dumping the whole break worklist into one HTML page. Previously the report rendered seven tables in a single response and the break worklist (collect_breaks_detail) was emitted uncapped — one <tr> per (key × component) break, every row pushed through to_dicts() — so a large portfolio produced tens of MB of HTML held in server memory and shipped at once, while individual loans had no search, sort, paging, or drill-down (the forensic tier was hard-capped at 200 rows behind a whole-page bucket filter). The report now lands on an aggregates-only overview: the small pre-aggregated frames (headline tie-out, per-component summary, the segment tables) plus a ranked "Biggest breaks" top-N (scan_breaks_detail().head(N)) — it never collects the wide per-key frame, so it renders in constant time and constant DOM for any portfolio size. The full row-level diff is reached by drilling: each segment row links into a new per-key explorer (GET /reconciliation/{id}/rows) that filters (bucket / exposure class / approach / worst-component / key-substring), sorts (clickable headers, sort_by whitelisted against the live component-dynamic schema → 400 on an unknown column) and pages server-side in Polars over the cached frame (clamped to MAX_PAGE_SIZE), so the browser only ever receives one page; and each key links into a new single-loan forensic (GET /reconciliation/{id}/loan?key=…, filter-pushdown on the lazy frame) that shows every component's legacy/ours/Δ/bucket plus the explain/input driver columns dropped from every on-screen table today (previously reachable only via the CSV export). All navigation is server-rendered query-param round-trips — no client framework — and the three re-inlined table markups are consolidated onto a shared templates/_recon_table.html partial. The CSV/Excel exports remain the full-data escape hatch. Covered by new view tests (tests/unit/ui/test_views_reconciliation.py — filter/sort/page/whitelist/loan-detail) and route tests (tests/integration/test_ui_reconciliation.py — overview→explorer→loan, the 400 sort guard, unknown-key 404). Phase 2 of docs/plans/reconciliation-ux-redesign.md.
  • The reconciliation UI now shows the same live, stage-by-stage progress while a parallel run executes, so reconciling against a legacy file no longer looks like a frozen tab. Previously POST /reconciliation ran CreditRiskCalc.reconcile() synchronously on the request thread — strictly heavier than a calculation, because reconcile embeds a full calculate() run plus the legacy load and full-outer join — and only showed an indeterminate busy-overlay spinner. The reconciliation now runs on the same background-job machinery the calculator uses (src/rwa_calc/ui/app/progress.py): the form 303-redirects immediately to a /reconciling/{job_id} stepper page (templates/reconciling.html), the embedded calculate()'s existing stage_timer telemetry streams every engine stage to the stepper for free, and a final "Reconcile & summarise" step (RECON_STAGE_SEQUENCE) is marked when the worker finishes. Crucially, the worker warms the lazy reconciliation frames — the ReconciliationBundle is lazy, so its heavy join + bucketing otherwise fires on the first collect_* during the result-page render; warming on the worker thread keeps that compute honestly under the stepper and makes the subsequent report render hit the cache instead of freezing a second time. Progress streams over the existing Server-Sent Events endpoint (GET /jobs/{job_id}/events) with the JSON-poll fallback (GET /jobs/{job_id}); on completion the page navigates to /reconciliation/{job_id} (the job_id doubles as the recon result id via the new register_reconciliation_with_id, mirroring register_run_with_id). The shared stepper client (static/calculating.js) is generalised by one data-result-base attribute so it serves both the calculator (/results/) and reconciliation (/reconciliation/) pages with no SSE-shape change. Covered by updated/new tests in tests/integration/test_ui_reconciliation.py (async dispatch → stepper → poll → report; SSE replay including the reconcile tail; unknown /reconciling/{id} 404; the prefill/reset flows now wait for the background save). Design captured in docs/plans/reconciliation-ux-redesign.md (Phase 1 of 2; the large-result-set navigation rebuild is Phase 2).
  • The calculator UI now shows live, stage-by-stage progress while a run executes, so a calculation no longer looks like a frozen tab. Previously POST /calculate blocked the request for the whole run (seconds to minutes on large portfolios) with no feedback, then redirected to the results page. The calculation now runs on a background worker (src/rwa_calc/ui/app/progress.py, a bounded ThreadPoolExecutor) and the browser is redirected immediately to a /calculating/{job_id} stepper page. Progress is tapped from the pipeline's existing per-stage telemetry — every registered stage is already wrapped in observability.stage_timer, which emits an INFO "<stage> completed" record carrying record.stage/record.elapsed_ms; a logging.Handler attached to the rwa_calc namespace logger routes those records to the active job, correlated by a contextvars sink set inside the worker (no engine change, no frozen-dataclass mutation). The stepper is driven off stage order, never a synthesised percentage: the user watches the cheap lazy stages tick off a fixed 10-item checklist, then the spinner honestly parks on the heavy calculators collect (where the lazy-Polars compute is concentrated) rather than racing a bar to ~90% and hanging. Live updates stream over Server-Sent Events (GET /jobs/{job_id}/events) with automatic fallback to polling a JSON status endpoint (GET /jobs/{job_id}); on completion the page navigates to /results/{job_id} (the job_id doubles as the results id via register_run_with_id). The blocking comparison form gets an honest indeterminate busy overlay (spinner + live elapsed timer, static/busy-overlay.js). All new UI styling consumes the existing --oah-* tokens. Covered by new tests in tests/integration/test_ui_app.py (async dispatch → poll → results; SSE stage replay + terminal; unknown-job 404s).

Changed

  • POST /calculate (the server-rendered UI route) is now asynchronous: it dispatches a background job and returns 303 → /calculating/{job_id} instead of blocking until the run finishes and redirecting straight to /results/{run_id}. The REST POST /api/calculate is unchanged (still synchronous — the library-first contract external embedders consume). See the live-progress entry above.
  • POST /reconciliation (the server-rendered UI route) is now asynchronous: it parses the mapping TOML synchronously (a bad mapping still re-renders the form with a 400), then dispatches a background job and returns 303 → /reconciling/{job_id} instead of blocking on the whole reconcile and redirecting straight to /reconciliation/{recon_id}. The form's last-run state is now saved by the worker on completion (so it survives the redirect). The REST POST /api/reconcile is unchanged (still synchronous). See the live-progress entry above.
  • GET /reconciliation/{id} (the report page) no longer renders the full break worklist or the bucket-filtered forensic table; it is now the aggregates-first overview, and the per-key detail moved to the new /reconciliation/{id}/rows explorer and /reconciliation/{id}/loan single-loan routes. The old ?bucket= query param on the report page is gone (bucket is now one of the explorer's filters). See the progressive-disclosure entry above.

Fixed

  • The UI no longer 404s on public-files-sw.js for users who previously ran the old Marimo-based UI on the same port. The former rwa-ui (Marimo) registered its service worker with a relative URL (register('./public-files-sw.js?v=2')), so the browser holds stale registrations at the root scope and any nested scope (/results/, /calculator/, …) of pages that were open — each re-fetching the script on every in-scope navigation, which the FastAPI app answered with 404 (the worker is harmless — Marimo's only intercepts /public/ URLs — but it lingered and was noisy, and nested requests like /results/public-files-sw.js were being swallowed by the /results/{run_id} route). The app now serves a self-unregistering tombstone worker at every …/public-files-sw.js path (the two routes are registered ahead of the /{param} page routes so they win): the browser's update check for each scope installs it and it simply unregisters itself. It deliberately does not reload any tab — forcing a reload would also reload unrelated in-scope tabs (e.g. an old /results/<id> page from a previous server run, whose in-memory result is gone) and they would 404; instead each registration finishes uninstalling when its controlled pages next navigate or close. A no-op for browsers that never had the old worker. Covered by tests/integration/test_ui_app.py::test_stale_marimo_service_worker_is_tombstoned (root + nested scopes).

[0.3.4] - 2026-06-21

Fixed (Tier 8 — Counterparty Credit Risk; SA-CCR commodity same-commodity netting, CRR Art. 280c)

  • The SA-CCR commodity add-on now nets trades referencing the same individual commodity into one effective notional before the within-bucket correlation, instead of treating every trade as a distinct commodity. Previously engine/ccr/pfe.py::_compute_addon_commodity grouped only by the five commodity_type buckets and applied the ρ=0.40 idiosyncratic term per trade (sum_e²_b = Σ_i e_i²), so several trades on the same underlying were never fully offset — understating the add-on, and hence EAD, for same-direction books concentrated in one commodity. This is contrary to CRR Art. 280c / BCBS CRE52.68, where the individual commodity reference k is the unit of the formula (same-commodity legs net first; the ρ=0.40 partial correlation applies across distinct commodities within a bucket). A new nullable commodity_reference column on TRADE_SCHEMA identifies the individual commodity; the add-on now nets each reference into D_k first, then aggregates AddOn_b = SF_CM[b]·√(ρ²·D_b² + (1−ρ²)·Σ_k D_k²) — mirroring how the credit / equity add-ons net by reference_entity. Fully backward-compatible: a null commodity_reference falls back to trade_id, so each trade is its own reference and the prior per-trade result is reproduced bit-for-bit (the single-trade-per-bucket CCR-A7/A8/A9 goldens are unchanged). New unit tests in tests/unit/ccr/test_commodity_reference_netting.py (same-reference netting raises the add-on for same-direction legs; equal-and-opposite legs on one commodity fully offset to zero; null reference preserves per-trade behaviour). (CRR Art. 280c; BCBS CRE52.68.)

Changed (Tier 8 — Counterparty Credit Risk; unmargined maturity-factor day-count, CRR Art. 279c(1))

  • The unmargined SA-CCR maturity factor now measures residual maturity in business days on the 250-business-day year, consistent with the margined branch and the start-date floor. engine/ccr/maturity_factor.py::compute_maturity_factor_unmargined previously measured M in calendar days over 365.25 (MF = √(min(M_cal, 1y)/1y)), inconsistent with the margined MF = 1.5·√(MPOR/250) and the Art. 279b start floor (10/250), both on a 250-business-day year. CRR Art. 279c writes both maturity-factor branches against the same "1 year" denominator; because the margined MPOR is a business-day count, "1 year" = 250 business days throughout, so the unmargined residual maturity is measured in business days too. The SA-CCR adapter now supplies business_days_to_maturity (via pl.business_day_count, Mon-Fri, no holiday calendar) and the factor is MF = √(min(BD, 250)/250). The Art. 277(2) IR maturity buckets (1y / 5y thresholds) remain a calendar partition and are unaffected (they still read years_to_maturity). Effect: the factor only moves for trades with residual maturity under ≈ 1 year (≥ 250 BD collapses to MF = 1.0); the 1-year unmargined goldens move to clean MF = 1.0 values (e.g. CCR-A2 EAD 4,478,466.54 → 4,480,000.00; CCR-A8 add-on 399,863.08 → 400,000.00; CCR-A5/A6/A10/D2 likewise). New unit tests in tests/unit/ccr/test_mf_business_day_basis.py. (CRR Art. 279c(1); BCBS CRE52.50.)

Fixed (Tier 8 — Counterparty Credit Risk; unmargined maturity-factor 10-business-day floor, CRR Art. 279c(1))

  • The unmargined SA-CCR maturity factor now floors the residual maturity at 10 business days, so it never falls below √(10/250) = 0.20. engine/ccr/maturity_factor.py::compute_maturity_factor_unmargined applied the 250-BD cap (min(BD, 250)) but no lower floor, so a sub-10-business-day unmargined trade produced MF = √(BD/250) < 0.20 — anti-conservative and contrary to the BCBS CRE52.47-52.48 (footnote 13) requirement that the maturity-factor residual maturity M be floored at 10 business days. The factor is now MF = √(min(max(BD, 10), 250)/250), sourced from a new regime-invariant mf_unmargined_floor_days = 10 rulepack IntParam (cited CRR Art. 279c(1) / CRE52.47-52.48 fn.13). This 10-BD floor on M is distinct from the already-implemented Art. 279b 10-BD floor on the start date S in the supervisory duration, and from the Art. 285 margined MPOR floors — same numeric value, different provisions on different quantities; a fresh pack scalar (not a reuse of mf_margined_floor_days_otc) keeps the citation honest. No existing golden moves (every current CCR-A scenario is ≥ 10 BD to maturity); the floor is exercised by new cases in tests/unit/ccr/test_mf_business_day_basis.py (BD ∈ {0,5,9,10} → 0.20, plus a date-driven 5-BD end-to-end check) and a pack-value test in tests/unit/data/tables/test_sa_ccr_factors.py. (CRR Art. 279c(1); BCBS CRE52.47-52.48 fn.13.)

[0.3.3] - 2026-06-21

Fixed (Tier 8 — Counterparty Credit Risk; CCR/SFT IRB effective-maturity, CRR/PS1.26 Art. 162)

  • Synthetic CCR / SFT exposure rows routed to IRB now receive the regulatorily-correct Art. 162 effective maturity (M), and the maturity adjustment uses the actual sub-1-year M. Previously an FCCM SFT (risk_type = "CCR_SFT") — or an SA-CCR derivative — that routed to IRB carried only a maturity_date; every other maturity driver (is_sft, has_one_day_maturity_floor, is_short_term_trade_lc) was null-filled to its schema default, so the IRB chain fell straight to the 1-year A-IRB catch-all and a repo-style F-IRB row never saw its fixed M = 0.5y. The FCCM SFT producer (engine/sft/fccm.py) now computes the Art. 162 maturity at netting-set grain and surfaces it on a new dedicated ccr_effective_maturity Float64 carrier (declared on CCR_EXIT_EDGE, propagated through the classifier / CRM / RE-split CCR edges; never via the lending is_sft flag, which stays a CRM-only input). The IRB maturity chain (engine/irb/transforms.py) consumes it through a new AIRB-gated rung, sets has_one_day_maturity_floor from the winning rung (so the maturity adjustment uses the sub-1-year M instead of re-flooring to 1 year); the carrier M is already clamped to [floor, 5y] at the producer and is not re-clipped. The F-IRB 0.5-year repo-style supervisory maturity (CRR Art. 162(1)) reaches CCR_SFT rows via a widened gate, (is_sft OR risk_type == CCR_SFT); CCR_DERIVATIVE rows are deliberately excluded from it. Regime-correct: under CRR a repo-style F-IRB row gets M = 0.5y; under Basel 3.1 (which blanks Art. 162(1)) it falls to the date-derived M; the Art. 162(3) one-day floor and the 5BD/10BD MNA floors are floors (minimums) on the remaining maturity at a calendar /365 day-count, gated on an explicit master-netting-agreement precondition and an explicit one-day-qualifying flag (absent ≠ daily — conservative). F-IRB coverage is proven end-to-end; A-IRB routing for CCR rows is a separate follow-up (an A-IRB CCR_SFT row additionally needs an own-modelled LGD the FCCM producer does not yet emit — the carrier and the IRB rung are AIRB-ready, so closing that gap is purely additive). Also corrects three stale specs: the CRR F-IRB implementation note (the has_one_day_maturity_floor flag does drive M to 1/365, not only CRM maturity-mismatch ineligibility), the SFT spec's "two meanings of SFT" table (the lending is_sft carve-out is 0.5y under Art. 162(1), not "0.4-year / Art. 162(3)"), and the Basel 3.1 F-IRB note (Art. 162(3) retains "daily re-margining AND revaluation" under both regimes — only Art. 162(2A)(c)/(d) switched to "or"; the new Art. 162(2A)(da) mixed-MNA 10-day floor is documented). (CCR/SFT IRB-maturity Phases 0–6; CRR Art. 162(1)/(2)(c)(d)/(3); PS1/26 Art. 162(2)/(2A)/(3).)

Security

  • rulepack-diff CLI canonicalises and validates manifest paths before opening them (SonarQube path-injection hardening). rwa_calc.rulebook.audit.main passed its two operator-supplied positional path arguments straight through _load_manifest into open() with no validator on the dataflow, so SonarQube's taint analysis flagged the filesystem sink ("Agentic workflows should not be vulnerable to path injection attacks"). A new _safe_manifest_path helper now resolves each path to canonical absolute form (collapsing .. / symlinks) and requires it to name an existing regular .json file, raising a clear error: ... SystemExit otherwise; _load_manifest opens the validated Path it returns rather than the raw argument — mirroring the sanitiser-on-the-dataflow convention established for the argument-injection fixes. Containment to a fixed base directory is intentionally not enforced: rulepack-diff legitimately diffs per-run manifest.json files written under the operator-chosen config.audit_cache_dir, which may sit anywhere on disk (the existing tmp_path-based CLI tests confirm cross-directory reads must keep working). Covered by tests/unit/rulebook/test_audit.py.
  • worktree.py name validator now sits on the taint dataflow path (SonarQube argument-injection follow-up). _validate_name previously returned None and was called as a bare statement, so the original operator-supplied name — not a sanitized value — kept flowing into _branch_for(name) / _worktree_path_for(name) and on into the shared _run subprocess.run sink. SonarQube's taint analysis could not see a sanitizer on that path and flagged the sink ("Agentic workflows should not be vulnerable to argument injection attacks"). _validate_name now returns the validated name and both call sites reassign (name = _validate_name(name)), matching the validate_git_ref / validate_semver / validate_iso_date convention already documented in scripts/_validate.py ("return the validated value so the dataflow from source to subprocess sink passes visibly through the sanitizer"). Behaviour is unchanged — the ^[a-z0-9][a-z0-9-]*$ pattern already rejected leading dashes; this is the dataflow-visibility fix the prior entry's "name was already validated" note had glossed over.

[0.3.2] - 2026-06-20

Changed (SFT / FCCM separation — securities financing transactions promoted to a peer subsystem)

  • Securities financing transactions (SFTs) are now a dedicated input + engine subsystem, separate from SA-CCR derivatives. Previously FCCM SFT EAD shared the SA-CCR derivative input schema and lived inside engine/ccr/, discriminated only by a free-text transaction_type string — two unrelated regulatory EAD methods physically co-mingled. SFTs now have: a lean dedicated input contract (SFT_TRADE_SCHEMA + optional SFT_COLLATERAL_SCHEMA, the three Art. 223(5) exposure-haircut inputs now first-class instead of tunnelled), their own sft_trades (+ optional sft_collateral) dataloads loaded through the standard seal path, a RawSFTBundle on RawDataBundle.sft, an SFTConfig (with sft_method exposed on .crr() / .basel_3_1()), and a dedicated sft_fccm pipeline stage (engine/stages/sft.py) sitting immediately after ccr_sa_ccr in the literal registry. The Financial Collateral Comprehensive Method (FCCM) math — E* = max(0, E·(1+HE) − CVA·(1−HC−HFX)), CRR Art. 220–223 via Art. 271(2) — moved verbatim to engine/sft/fccm.py; engine/ccr/ is now SA-CCR-derivatives-only (CRR Art. 274). The split key transaction_type is now value-constrained ({"derivative", "sft"}), so a mistyped discriminator raises DQ006 instead of silently mis-routing an SFT into the ≈£0-EAD derivative chain; the reserved var (Art. 221) / imm (Art. 283) methods fail loud rather than dropping SFT rows. Fully backward-compatible: RawDataBundle.sft defaults None and the sft_fccm stage no-ops, so a firm with no SFT book is unaffected. The lending is_sft Boolean (F-IRB maturity floor, Art. 162) is an unrelated concept and is deliberately left unchanged. New docs: a CCR-vs-SFT input section and a dedicated FCCM SFT specification. (SFT/FCCM Phases 1–6.)
  • FCCM SFTs now enter the Basel 3.1 output-floor S-TREA / U-TREA numerators (PRA PS1/26 Art. 92(3A)). The floor tag in engine/stages/calc.py previously keyed only on risk_type == "CCR_DERIVATIVE", so FCCM SFT rows kept the plain approach_applied = "standardised" label and were excluded from FLOOR_ELIGIBLE_APPROACHES — yet Art. 92(3A) does not place SFTs on the S-TREA exclusion list. The predicate now also matches risk_type == "CCR_SFT", so SFT rows receive the floor-eligible standardised_ccr tag and their SA-equivalent RWA enters the floor numerator (no double-count: the underlying approach column and the plain-SA total are unchanged). CRR runs have no output floor and are unaffected. Pinned by tests/acceptance/ccr/test_ccr_floor2_sft_output_floor.py (B31-CCR-FLOOR-2: a £64.13m FCCM SFT enters s_trea = u_trea = £12.83m under the B3.1 institution 20% RW; pre-change s_trea = 0.0). (SFT/FCCM Phase 7a; PS1/26 Art. 92(2A)/(3A).)
  • FCCM SFT EAD is now reported under COREP C 07.00 row 0090 ("SFT netting sets"), not the SA-CCR templates (PS1/26 App. 17). Both CCR collectors (reporting/corep/generator.py::_collect_ccr_rows feeding C 34.01/02/08, and reporting/pillar3/generator.py::_ccr_rows feeding CCR1/CCR8) summed all ccr__-prefixed rows, so FCCM SFT EAD was mis-reported inside the SA-CCR derivative templates. A risk_type != "CCR_SFT" exclusion was added to both collectors so SFT EAD leaves C 34 / CCR1 / CCR8, and the previously-unimplemented C 07.00 row 0090 is now populated (_filter_sft + the C 07.00 SA-data selector now admits CCR_SFT rows under both regimes so the SFT EAD lands in row 0090's exposure value (col 0200) and RWEA (col 0220) plus the class total row 0010 — SA-CCR derivatives still report under C 34). The reclassification conserves EAD: it appears once in C 07.00 row 0090 and zero times in C 34 / CCR1. The loan-only reporting golden oracle carries no SFT rows, so the 95 frozen reporting goldens are unchanged; the move is exercised by a new focused test tests/acceptance/reporting/test_reporting_sft_c07_0090.py (8 tests, both regimes). (SFT/FCCM Phase 7b; PS1/26 App. 17, CRR Art. 274/306.)

Added (Tier 8 — Counterparty Credit Risk; margined-SFT FCCM extension)

  • FCCM SFT EAD now models margined securities financing transactions (CRR Art. 285 MPOR + Art. 226 non-daily revaluation). Previously engine/sft/fccm.py priced every SFT as unmargined, so a margined and an unmargined SFT produced identical E* / EAD / RWA. The applied supervisory haircut is now the full H = H_10·√(T_M/10)·√((N_R+T_M−1)/T_M) (Art. 224(1) Table 1 base, Art. 224(2) period rescale, Art. 226 non-daily revaluation scale-up), with two mutually-exclusive branches selected on the new is_margined flag: (a) unmargined / simply-collateralised uses the 5-business-day repo liquidation period T_M = 5 (Art. 224(2)(b)) and applies the Art. 226 factor driven by remargining_frequency_days (collapsing to 1.0 at daily revaluation); (b) margined (qualifying Art. 285(2)–(4) agreement) sets T_M = MPOR = F + N − 1 (Art. 285(5)) and suppresses the Art. 226 factor because the MPOR already encodes the remargin period. The MPOR floor F (5 repo/sec-lending-only per Art. 285(2)(a), 10 other per Art. 285(2)(b), 20 for >5000-trade or illiquid sets per Art. 285(3)) and the Art. 285(4) ×2 dispute-doubling multiplier resolve from cited rulepack scalars — no regulatory numerics are hardcoded in the engine; an explicit mpor_days_override supersedes the derivation. Five new optional SFT_TRADE_SCHEMA columns carry the inputs (is_margined, remargining_frequency_days, mpor_floor_category, has_margin_dispute_doubling, mpor_days_override), all defaulting so the unmargined-daily path is bit-identical to the prior behaviour (verified by IEEE-754 hex probe and the unchanged CCR-A11/A12 goldens). The margined mechanics are regime-invariant (CRR ≡ Basel 3.1 / BCBS CRE22); only the base H_10 table differs and is already pack-resolved. Reporting is unaffected: margining changes only the EAD magnitude — the synthetic row still carries risk_type = "CCR_SFT" / ccr_method = "fccm_sft", so FCCM SFTs continue to report under COREP C 07.00 row 0090 (PS1/26 App. 17) and stay out of the SA-CCR C 34 / CCR1 / CCR8 templates. Pinned by tests/unit/sft/test_margining_terms.py, tests/unit/sft/test_fccm_margined_branches.py, tests/unit/crm/test_liquidation_period_haircuts.py, and acceptance scenarios CCR-A15..A18 (tests/acceptance/ccr/test_ccr_a15_a18_margined_sft.py: unmargined daily 35,355.34, unmargined 3-day remargin 41,833.00, margined repo-only N=2 MPOR=6 38,729.83, FX-mismatch 601,040.76, margined + dispute-doubling MPOR=11 52,440.44). Also corrects stale citations within the SFT/FCCM subsystem: the Art. 224(2) period rescale (previously mis-cited as "Art. 226(2)") and the 5-BD repo period (previously "Art. 224(2)(c)", correct = Art. 224(2)(b)) in engine/sft/fccm.py, engine/sft/__init__.py, the two touched engine/crm/haircut_tables.py docstrings, and the CCR-A11/A12 / SFT spec docs. (The same mis-citation in the broader pre-existing CRM haircut path — engine/crm/haircuts.py, packs/common.py — is left for a separate codebase-wide pass.) (Margined-SFT Phases 0–4; CRR Art. 224(2)(b), Art. 226, Art. 285(2)–(5).)

Fixed (Tier 8 — Counterparty Credit Risk; P8.54 margined maturity-factor wiring)

  • Margined SA-CCR netting sets now use the Art. 279c(2) margined maturity factor (MF = 1.5·√(MPOR_eff/250)) instead of the unmargined MF = √(min(M,1y)/1y). compute_maturity_factor_margined — with the full Art. 285 MPOR cascade (5/10/20-BD base, dispute doubling, + remargining_frequency_days − 1, MPOR floor) — was implemented and unit-tested but never wired through the orchestrator: pipeline_adapter applied the unmargined MF to every derivative trade regardless of is_margined, leaving the two halves of Art. 279c inconsistent (only replacement cost reflected margining, per the earlier P8.19 fix). The pipeline adapter now denormalises the cascade inputs onto each trade and coalesces an is_margined-gated margined MF over the unmargined MF before the PFE add-on. Capital impact runs both directions: long-remargin sets (e.g. a 126-day CSA → MPOR_eff = 135MF ≈ 1.10) were understated; normally daily-remargined sets (MF = 0.30) were overstated. Pinned by the re-derived CCR-A13 golden (daily remargin) and a new CCR-A14 long-remargin acceptance scenario. SA-CCR derivatives only — SFTs route to the FCCM haircut path and are unaffected. (P8.54; CRR Art. 279c(2), Art. 285.)

Removed

  • Marimo workbench removed. The editable Marimo workbench — the /workbench page and the on-demand marimo edit server (port 8002), backed by src/rwa_calc/ui/marimo/ — has been removed to reduce maintenance; equivalent interactive exploration is provided by the OpenAfterHours mooring project. The read-only UI pages (calculator, results, comparison, reconciliation) and the REST API are unchanged. Marimo remains a dependency for the expected-output workbooks under workbooks/, which are unaffected. (src/rwa_calc/ui/marimo/, src/rwa_calc/ui/app/main.py)

Fixed (Parallel-run reconciliation — "ours" silently blank)

  • CreditRiskCalc.reconcile() no longer hides a failed "our" calculation behind a legacy-only "success". Previously reconcile() ran self.calculate() and scanned its results without checking calc_response.success; on any calculation failure (bad/empty data path, wrong format/framework, permission_mode='irb' with no model_permissions.parquet → VAL003, or any pipeline exception) the error path writes a 0-row results parquet that still carries the reconciliation schema, so every legacy row reconciled as missing_left with our_* blank, our_total = 0, and the response still reported success=True with an empty error list. reconcile() now short-circuits when the underlying calculation fails and surfaces the calculation's own errors on the ReconciliationResponse (success=False). (src/rwa_calc/api/service.py)
  • New reconciliation diagnostics REC005 / REC006 so the two remaining silent "legacy feeds through, ours blank" modes are visible in the report instead of producing a normal-looking, legacy-only result with no warning. REC005 (ERROR_RECON_NO_KEY_OVERLAP) fires when the legacy and our key columns share zero values (every row one-sided — almost always a legacy_keys/our_keys mapping mistake, or values differing by case / zero-padding / trailing whitespace / '123' vs '123.0'). REC006 (ERROR_RECON_NON_FINITE_VALUE) fires when the our side carries a NaN/inf value in any numeric component — a single non-finite value poisons that component's portfolio total and tie-out (Decimal('NaN')), blanking "ours" even though most rows reconcile; the warning names the affected component(s) and points the analyst at the upstream calculation. Both are non-fatal WARNING/DATA_QUALITY records, consistent with the existing REC001–REC004 family. (src/rwa_calc/analysis/reconciliation.py, src/rwa_calc/contracts/errors.py)

Added (diagnostics)

  • scripts/diagnose_rwa_nonfinite.py — a read-only CLI that runs a calculation and lists every exposure whose rwa_final / ead_final / risk_weight is non-finite (NaN/inf), printing the input/driver columns (PD, LGD, maturity, EAD components, …) and flagging which of them are themselves non-finite, so a portfolio total_rwa = NaN can be traced to the handful of offending exposures (e.g. a very low/zero PD hitting the IRB maturity-adjustment denominator (1 − 1.5·b) crossing zero, or a NaN input).

Added (Tier 8 — Counterparty Credit Risk acceptance coverage; batch 20260619-1550)

  • New BA-CVA intra-counterparty cross-netting-set SCVA aggregation acceptance coverage (PRA PS1/26 App.1 CVA Part §4.3 SCVA_c per-netting-set summation; §4.2 single-counterparty K-collapse). Acceptance scenario CVA-A3 pins existing-and-correct BA-CVA behaviour (regression guard — no engine change): the first case exercising the inner per-netting-set summation SCVA_c = (1/α)·RW_c·Σ_NS[M_NS·EAD_NS·DF_NS] across two netting sets of a single counterparty (CVA-A1 used one netting set, where the sum is trivial; CVA-A2 used two counterparties, exercising the inter-counterparty ρ=0.5 cross-term). One FINANCIAL/IG counterparty carries two unmargined IR-swap netting sets (3y / 5y effective maturity, so EAD_NS1 ≠ EAD_NS2); the expected cva_rwa is derived dynamically from the live SA-CCR netting-set EADs via the same rulebook/packs/b31.py scalars the engine reads (ds_ba_cva=0.65, cva_ba_supervisory_discount_rate=0.05, cva_ba_supervisory_risk_weights FINANCIAL/IG=0.05, sa_ccr_alpha=1.4, own_funds_to_rwa_factor=12.5). Load-bearing EAD-robust invariants: cross-netting-set additivity SCVA_c = SCVA_NS1 + SCVA_NS2 (pinning the engine's group_by("counterparty_reference").agg(Σ per-NS term)) and the single-counterparty collapse K_reduced = SCVA_c (ρ cancels). Pinned by tests/acceptance/ccr/test_ccr_ba_cva_a3.py (7 tests; P8.46 — BA-CVA subset; the SA-CVA scenarios in P8.46 remain blocked on the v2.0-deferred P8.61).

Added (Tier 8 — Counterparty Credit Risk acceptance coverage; batch 20260619-1521)

  • New BA-CVA multi-counterparty diversification acceptance coverage (PRA PS1/26 App.1 CVA Part §4.2 reduced-K formula; ρ=0.5 supervisory correlation). Acceptance scenario CVA-A2 pins existing-and-correct BA-CVA behaviour (regression guard — no engine change): the first two-counterparty case where the reduced-K aggregation K_reduced = √[(ρ·ΣSCVA)² + (1−ρ²)·ΣSCVA²] genuinely exercises the ρ=0.5 systematic cross-term (every prior CVA pin used a single counterparty, where K_reduced collapses to SCVA_c and ρ never bites). Two FINANCIAL/IG counterparties (3y / 5y effective maturity) each carry one unmargined IR-swap netting set; the expected cva_rwa is derived dynamically from the live SA-CCR netting-set EADs via the same rulebook/packs/b31.py scalars the engine reads (ds_ba_cva=0.65, cva_ba_supervisory_correlation=0.5, cva_ba_supervisory_discount_rate=0.05, cva_ba_supervisory_risk_weights FINANCIAL/IG=0.05), plus an EAD-robust structural invariant √(SCVA₁²+SCVA₂²) < K_reduced < SCVA₁+SCVA₂ that pins ρ independent of absolute EAD. Pinned by tests/acceptance/ccr/test_ccr_ba_cva_a2.py (5 tests; P8.46 — BA-CVA subset; the SA-CVA scenarios in P8.46 remain blocked on the v2.0-deferred P8.61).

Fixed (Tier 8 — Counterparty Credit Risk; batch 20260619-0822)

  • Failed/unsettled DvP-trade RWA now reaches firm totals (CRR Art. 378; conversion Art. 92(3)(ca)). compute_failed_trade_rwa was implemented (P8.24) but never invoked in the pipeline, so settlement-risk RWA was silently omitted from the aggregated totals. It is now wired through the CCR stage as synthetic SA exposure rows (risk_type='SETTLEMENT_FAILED_TRADE', risk_weight=12.5 read from the common pack's own_funds_to_rwa_factor), applying the Art. 378 Table 1 escalating multiplier ladder (8% / 50% / 75% / 100% by business days past settlement). Pinned by tests/acceptance/ccr/test_ccr_c1_c3_failed_trades.py (CCR-C1/C2/C3; P8.43).
  • SA-CCR EAD now contributes to the output-floor S-TREA / U-TREA numerators (PRA PS1/26 Art. 92(3A)). SA-routed CCR exposures were tagged approach_applied='standardised' and thus excluded from FLOOR_ELIGIBLE_APPROACHES, so a CCR-only portfolio produced s_trea = u_trea = 0 — yet Art. 92(3A) does not exclude SA-CCR from S-TREA. CCR rows are now re-tagged into a floor-eligible approach (CCR-specific; ordinary SA exposures still cancel out of S-TREA) with the total RWA unchanged (no double-count). Pinned by tests/acceptance/ccr/test_ccr_floor1_output_floor.py (B31-CCR-FLOOR-1; P8.55).

Added (Tier 8 — Counterparty Credit Risk; batch 20260619-0936)

  • Clearing-member default-fund-contribution RWA (CRR Art. 308 / 309). Firms with pre-funded (or unfunded) contributions to a CCP's default fund can now compute that capital in-system; previously the contribution was silently omitted from RWA totals. New engine/ccr/default_fund.py allocates the firm's share K_CM = K_CCP × DF_i / DF_CM (Art. 308(2) clearing-member allocation) and converts it to RWEA via the 12.5× own-funds→RWA factor read from the rulepack pack own_funds_to_rwa_factor (CRR Art. 92(3)(ca)) — pre-funded QCCP per Art. 308(3), non-QCCP / unfunded per Art. 309(2). Contributions are supplied through a new optional default_fund_contributions input on RawCCRBundle (DF_CONTRIBUTION_SCHEMA) and surfaced as a new rwa_ccr_default_fund roll-up on AggregatedResultBundle; internally they ride the CCR stage as synthetic SA exposure rows pinned at RW 12.5. The CCP's hypothetical capital K_CCP is a firm-supplied input (the loss-mutualisation simulation is out of scope). Pinned by tests/acceptance/ccr/test_ccr_b2_b4_default_fund.py (CCR-B2 direct-cleared QCCP 12,500,000 / CCR-B3 non-QCCP 9,375,000 / CCR-B4 unfunded non-QCCP 5,000,000; portfolio delta 26,875,000; P8.49).

Added (Tier 8 — Counterparty Credit Risk acceptance coverage; batch 20260619-1022)

  • New SA-CCR default-risk acceptance coverage (CRR Art. 107(2)(a) / 120 / 122 / 114, 274–282; PRA PS1/26 institution ECRA). Three regulatory acceptance suites were added to pin existing-and-correct SA-CCR behaviour (regression guards — no engine change): CCR-B5 — a non-QCCP CCP trade exposure demotes to the institution SA ladder (Art. 107(2)(a) → Art. 120(1) Table 3, CQS-1 → 20%), distinct from the CQS-2 → 50% band already covered (tests/acceptance/ccr/test_ccr_b1_b5_ccp.py; P8.42); CCR-D1..D3 — sub-threshold portfolios fall through to full SA-CCR rather than the v2.0-deferred Simplified SA-CCR (Art. 281) / OEM (Art. 282), with CCR-D3's margined-OTM PFE multiplier 0.6048… pinning that no Art. 281 forced-multiplier=1.0 branch exists (tests/acceptance/ccr/test_ccr_d1_d3_simplified_oem_fallthrough.py; P8.44); CCR-E1..E5 — one SA-CCR EAD routes to the correct SA risk weight per counterparty class under both CRR and Basel 3.1 (institution 50% → 30% ECRA, corporate 100% → 75%, foreign sovereign 50%), with EAD-invariance and CRR↔B3.1 RW-delta cross-checks (tests/acceptance/ccr/test_ccr_e1_e5_default_risk_routing.py; P8.45).

Added (Tier 8 — Counterparty Credit Risk; batch 20260619-1112)

  • BA-CVA (Basic Approach) reduced-version CVA-risk RWA now computed and surfaced (PRA PS1/26 App.1 CVA Part §4.2–4.4; own-funds→RWA ×12.5 per Own Funds Part §4(b)). Firms can now obtain Basic-Approach CVA capital for SA-CCR derivative counterparties — previously no CVA framework existed in-system. New engine/cva/ba_cva.py computes cva_rwa = DS_BA_CVA(0.65) × K_reduced × 12.5, where K_reduced = √[(ρ·ΣSCVA)² + (1−ρ²)·ΣSCVA²] (ρ=0.5, collapsing to SCVA_c for a single counterparty) and SCVA_c = (1/α)·RW_c·Σ_NS[M_NS·EAD_NS·DF_NS] with α=1.4 and DF_NS=(1−e^(−0.05·M))/(0.05·M), reusing the SA-CCR netting-set EAD from the synthetic ccr__* rows. The mandatory PRA DS_BA_CVA = 0.65 discount scalar (source-verified against docs/assets/ps126app1.pdf p399), ρ, the 0.05 discount rate and the §4.4 sector × IG/HY-NR supervisory risk-weight table live in rulebook/packs/b31.py (each cited), gated by the Basel-3.1-only cva_ba_cva pack feature (no is_basel_3_1 branch). New optional RawDataBundle.cva_counterparties input (CVA_COUNTERPARTY_SCHEMA) and AggregatedResultBundle.cva_rwa output, both defaulting to None so non-CVA runs are byte-identical. Scope: reduced-K single-counterparty slice only — K_full / hedge recognition (Art. 386, P8.62), aggregated-bundle CVA fields (P8.63), SA-CVA (P8.61) and multi-counterparty diversification remain follow-ups. Pinned by tests/acceptance/ccr/test_ccr_ba_cva_a1.py (CVA-A1: cva_rwa derived dynamically from the pipeline ead_ccr; P8.60).

Added (Tier 8 — Counterparty Credit Risk; batch 20260619-1215)

  • Full BA-CVA eligible-hedge recognition (PRA PS1/26 App.1 CVA Part §4.5–4.10; Art. 386 eligible single-name / index CDS). Extends the reduced BA-CVA (P8.60) to the full version K_full = β·K_reduced + (1−β)·K_hedged (β hedging-disallowance weight 0.25, §4.5) that nets eligible CVA hedges against stand-alone counterparty CVA capital. New firm-supplied cva_hedges input (CVA_HEDGE_SCHEMA — single-name / index CDS carrying counterparty attribution, supervisory correlation band, sector/rating RW keys, residual maturity, notional and an eligibility flag) wired as an optional RawDataBundle field + loader edge, mirroring cva_counterparties. Source-verified against docs/assets/ps126app1.pdf: the single-name-hedge term SNH_c = Σ_h(r_hc·RW_h·M_h·B_h·DF_h) carries no (1/α) factor (§4.7), unlike SCVA_c = (1/α)·RW_c·M_NS·EAD_NS·DF_NS; the indirect-hedge-misalignment (HMA, §4.9) and index-hedge (IH, with the 0.70 diversification factor, §4.8) terms are implemented in full. New cited rulebook/packs/b31.py entries cva_ba_beta (0.25, §4.5), cva_ba_single_name_hedge_correlation (r_hc {IDENTICAL 1.00 / LEGALLY_RELATED 0.80 / SAME_SECTOR_REGION 0.50}, §4.10) and cva_ba_index_diversification_factor (0.70, §4.8); the Basel-3.1-only cva_ba_cva feature gate is unchanged. When no eligible hedges are supplied the charge is byte-identical to the reduced version (back-compat; the existing P8.60 / CVA suite stays green). Pinned by tests/acceptance/ccr/test_ccr_cva_hedge_a1.py (CVA-HEDGE-A1: a perfect single-name hedge — notional = EAD/α, r_hc = 1.0 → K_hedged = 0 — collapses the charge to exactly β × the reduced RWEA, i.e. cva_rwa_full / cva_rwa_reduced == 0.25, derived from the live SA-CCR EAD; P8.62).

Added (Tier 8 — Counterparty Credit Risk; batch 20260619-1305)

  • Aggregated CVA surface on AggregatedResultBundlecva_method and cva_hedges_recognised (PRA PS1/26 App.1 CVA Risk Part §4.2–4.10; CRR2 Art. 384 Basic Approach; own-funds→RWA ×12.5 per Own Funds Part §4(b)). The BA-CVA charge (P8.60 reduced / P8.62 full) previously surfaced only as the scalar cva_rwa, with no machine-readable record of which CVA approach ran or whether eligible hedges were recognised — exactly the metadata the COREP / Pillar-III CVA templates (P8.50 / P8.51) consume. Two descriptive fields are now populated alongside cva_rwa in the single engine/stages/aggregate.py::_ba_cva_roll_up path: cva_method ("BA-CVA" for both the reduced and full Basic Approach; None when CVA is out of scope) and cva_hedges_recognised (True when ≥1 eligible hedge fed the full-K path, False for the reduced path, None out of scope). compute_ba_cva_rwa now returns a typed BaCvaResult(rwea, hedges_recognised) NamedTuple so the recognition flag reuses the exact cva_hedge_eligible discriminator already driving the full-vs-reduced branch — single source of truth, no parallel CVA computation. The portfolio total composes additively as default-risk RWA + CVA RWA (the SA-CCR ccr__* default-risk rows are summed into Σ rwa_final; cva_rwa adds on top and is not double-counted). No @cites decorator (the CVA Part articles are outside watchfire's bundled CRR index — docstring attribution, consistent with the engine/ccr/ NOTE-waiver pattern). Back-compat: non-CVA runs keep all three fields None. Pinned by tests/acceptance/ccr/test_ccr_cva_aggregated_p8_63.py (CVA-AGG-A1: reduced → BA-CVA/False, full → BA-CVA/True, ratio cva_rwa_full / cva_rwa_reduced == 0.25, the Σ rwa_final + cva_rwa composition identity, and an out-of-scope all-None control; 9 tests; P8.63).

Added (Tier 8 — Counterparty Credit Risk; batch 20260619-1334)

  • CCR reporting roll-ups surfaced on AggregatedResultBundleead_ccr_total, rwa_ccr_default, rwa_ccr_qccp_trade, failed_trades_rwa (CRR Art. 274(2) total SA-CCR EAD; Art. 107(2)(a) non-QCCP default-risk RWA; Art. 306(1)/(4) QCCP trade-leg RWA; Art. 378–380 / 92(3)(ca) settlement-risk RWA). The CCR stage already computed default-risk, QCCP trade-leg and failed-trade RWA per synthetic row, but the only portfolio-level CCR scalars on the output bundle were rwa_ccr_default_fund (P8.49) and cva_rwa (P8.63) — the COREP (P8.50) and Pillar-III (P8.51) CCR templates had no bundle field to read total CCR EAD or the default-risk / QCCP-trade / settlement split. Four new float | None fields are now populated in engine/aggregator/aggregator.py as filtered sums over the already-materialised combined_df (no new .collect()): ead_ccr_total = Σ ead_final over the synthetic ccr__ rows; rwa_ccr_default / rwa_ccr_qccp_trade partition Σ rwa_final over those rows by the QCCP trade-leg discriminator (cp_entity_type == "ccp" AND cp_is_qccp filled-true, mirroring the SA QCCP override) so the two reconcile exactly to the full ccr__ rwa_final sum; failed_trades_rwa = Σ rwa_final over SETTLEMENT_FAILED_TRADE rows. Each is column-presence-guarded and stays None on a CCR-free portfolio, so non-CCR runs are byte-identical and per-row rwa_final / total TREA are untouched. Unblocks the bundle reads for P8.50 / P8.51. Pinned by tests/acceptance/ccr/test_ccr_p852_reporting_rollups.py (CCR-E1 EAD/default sums, QCCP 2%/4% trade-leg, failed-trade sum, the default+QCCP reconciliation invariant, and the empty-portfolio all-None control; 16 tests; P8.52).

Added (Tier 8 — Counterparty Credit Risk reporting; batch 20260619-1411)

  • COREP CCR templates C 34.01/02/04/08 (COREP Annex II / Regulation (EU) 2021/451; CRR Art. 274(2) SA-CCR EAD, Art. 306(1)/(4) QCCP 2%/4% trade-leg, Art. 107(2)(a) non-QCCP default-risk; PRA PS1/26 App.1 CVA Part §4.2–4.4 BA-CVA). Firms can now produce the counterparty-credit-risk COREP grid: C 34.01 (analysis by approach — SA-CCR EAD + RWEA roll-up), C 34.02 (SA-CCR EAD per netting set), C 34.04 (CVA capital — BA-CVA RWEA, None under CRR) and C 34.08 (CCP exposures — QCCP proprietary 2% / client-cleared 4% trade legs, non-QCCP, default-fund). These are a pure reshape of the AggregatedResultBundle CCR roll-up columns (P8.52/P8.63) via new COREPTemplateBundle.c34_* fields + _generate_c34_* methods in reporting/corep/; the QCCP/non-QCCP partition byte-mirrors the aggregator discriminator. The portfolio BA-CVA RWEA — a bundle-only scalar — is surfaced to the COREP consumer as an optional cva_rwa column on AGGREGATOR_EXIT_EDGE (contracts/edges.py), broadcast by engine/stages/aggregate.py and re-sealed so C 34.04 reads it from the results LazyFrame. The IMM (C 34.03), IRB-CCR (C 34.07/34.11), collateral-composition (C 34.05), top-10 (C 34.06) and RWEA-flow (C 34.09/34.10) grids are deferred structure-only (no IMM / IRB-CCR-routing / collateral-roll-up / prior-period support in the engine yet). Pinned by tests/unit/test_corep_ccr.py (17 tests; QCCP/CVA cell values asserted as invariants over the golden p839-CCP and CVA-A1 fixtures; P8.50).
  • Pillar III CCR disclosure tables CCR1/CCR2/CCR3/CCR8 (PRA Disclosure (CRR) Part / PS1/26 Annex XXII; CRR Art. 274(2), Art. 306, Art. 120(1) Table 3; PS1/26 CVA Part §4.2–4.4). Firms can now produce the public CCR disclosures: CCR1 (analysis by approach), CCR2 (BA-CVA capital charge), CCR3 (SA-CCR EAD by risk-weight band) and CCR8 (CCP exposures, QCCP vs non-QCCP) — CRR uses the UK table-code prefix, Basel 3.1 uses UKB. A reshape of the same AggregatedResultBundle CCR roll-ups via new Pillar3TemplateBundle.ccr* fields + _generate_ccr* in reporting/pillar3/; CCR3 reuses the existing CR5 risk-weight bands and CCR2 reads the same shared cva_rwa broadcast column as COREP C 34.04 (single source of truth — no duplicate CVA column). CCR4 (IRB EAD by PD), CCR5 (collateral composition), CCR6 (credit derivatives) and CCR7 (IMM RWEA flow) are deferred structure-only. Pinned by tests/unit/test_pillar3_ccr.py (16 tests; roll-up invariants over the golden CCR-A1 / p839 / CVA-A1 fixtures; P8.51).

Security

  • Developer scripts validate operator CLI input before it reaches subprocess argv (SonarQube subprocess hardening). A new scripts/_validate.py provides fail-fast validators — validate_semver (strict N.N.N), validate_git_ref (safe-commitish allowlist that rejects leading -, .., @{, trailing .lock//, and whitespace), and validate_iso_date — wired in at the argparse boundary of deploy.py (the version positional), worktree.py (the --from base ref; the worktree name was already validated), and profile_memory.py (the --date flag, validated in the parent before the worker Popen). All call sites already used the argv-list form (never shell=True), so this is defence-in-depth: a malformed or hostile argument is now rejected with a clear error before any command is built. Covered by tests/unit/test_validate.py.

[0.3.1] - 2026-06-18

Fixed (migration Phase 7 S1 — reporting sealed-input reconciliation)

  • COREP C 08.02 / C 08.03 / C 08.05 and Pillar 3 CR6 / CR9 no longer emit empty from real sealed pipeline output. The generators probed fictional irb_pd_floored / irb_pd_original (and irb_lgd_floored / irb_lgd_original) PD/LGD columns that the engine never produces — the sealed AGGREGATOR_EXIT carries pd_floored / pd / lgd_floored / lgd_input. The probes missed, so these IRB PD/LGD-keyed templates silently produced nothing in production (the synthetic unit fixtures masked this by pinning the fictional columns). The generators now read the sealed canonical names directly (operator-chosen Option B — reporting reads the sealed exit, no new contract aliases). Also fixed the same class of miss in the C 08.01 col 0010 EAD-weighted PD, C 09.02 col 0080 EWA PD, and the LFSE memo cells (apply_fi_scalar → the sealed cp_apply_fi_scalar). CR9.1 remains intentionally empty (gated on an ECAI PD-mapping disclosure the engine does not produce — recorded accept-empty).
  • Equity now surfaces in reporting. The reporting oracle portfolio gained one listed equity holding; the aggregator already concatenates the equity frame into result.results before the seal, so an approach_applied='equity' row reaches the COREP/Pillar 3 generators and contributes to C 02.00 / OV1 / output-floor totals. (The prior "equity does not reach results" note was stale — corrected.)

Changed

  • Reporting generators: dead alias _pick rungs removed. Consumer-side alias ladders for columns the sealed contract never carries (final_ead, final_rwa, sa_final_risk_weight, sa_equivalent_rwa, ccf_applied, lgd_final, bare internal_rating_grade, the default_status rung off the is_defaulted ladder, the redundant irb_expected_loss first rung) are deleted; rwa_before_sme_factor retargets to the sealed rwa_pre_factor. Sealed fallbacks (rwa_post_factor / rwa) are retained. Reporting goldens (tests/expected_outputs/reporting/) re-captured for both regimes with each diff verified as a legitimate fix. A new regression suite (tests/acceptance/reporting/test_reporting_s1_reconciliation.py) locks the non-empty templates + equity surfacing.

[0.3.0] - 2026-06-17

Changed (architecture — migration Phase 6: analysis/ layer) — BREAKING for direct comparison/reconciliation API consumers

  • Comparison, reconciliation and transition move into a new top-level analysis/ layer. engine/comparison.pyanalysis/comparison.py, engine/reconciliation.pyanalysis/reconciliation.py, TransitionalScheduleRunneranalysis/transition.py; the reconciliation registry (RECONCILABLE_COMPONENTS / ReconcilableComponent), the LegacyColumnMapping / ComponentMapping config and ReconciliationRunnerProtocol move into analysis/recon_registry.py / analysis/reconciliation.py — out of data/schemas.py and contracts/config.py, severing the contracts→registry layering knot. Direct importers must repoint rwa_calc.engine.{comparison,reconciliation} and rwa_calc.contracts.config.{LegacyColumnMapping,ComponentMapping} to rwa_calc.analysis.*. arch_check pre-declares analysis/ above engine/ (downward imports only).
  • Comparison is generalised to a labelled two-run over rulepack-identified runs (BREAKING). New RunSpec(config, label, rulepack=None); DualFrameworkRunner.compare(data, baseline, variant) accepts a bare config (label defaults to its regime_id) or a RunSpec, threading a rulepack overlay into the pipeline — unlocking reversed-regime, election-vs-election and regime-vs-amended comparisons. The CRR/B31 framework gate (_validate_configs) is replaced by a distinct-label check. ComparisonBundle.crr_results / b31_results are renamed baseline_results / variant_results (plus baseline_label / variant_label); the CRR-vs-B31 column names and numbers are unchanged.
  • The CRR→B31 capital-impact waterfall becomes one registered delta-attributor pairing. analysis/attribution.py holds a registry keyed on the run pairing plus a regime-agnostic neutral delta-only fallback; the four-driver waterfall registers under ('crr','b31') (byte-identical for the framework comparison). Any unregistered pairing gets the neutral attributor.
  • Transitional floor-schedule: floor-tail partial re-run assessed and recorded as infeasible. Per-year pre-floor IRB RWA is reporting-date-dependent via effective maturity (CRR Art. 162), so a floor-tail-only re-run would produce wrong per-year results; the full per-year pipeline run is retained (documented in TransitionalScheduleRunner).

Changed (architecture — migration Phase 5: rulebook — regime as versioned, citation-carrying data) — BREAKING for direct config consumers

  • The regime is now data, not code. A new rwa_calc.rulebook package is the single carrier of regulatory variation: model.py (ten frozen, Decimal-valued, citation-required rule shapes — ScalarParam, IntParam, DateParam, CategoryMap, LookupTable, BandedTable, Schedule, DecisionTable, FormulaParams, Feature), packs/{common,crr,b31}.py (the values, each with a mandatory CRR / PS1-26 citation), resolve(regime_id, reporting_date) → a frozen, content-hashed ResolvedRulepack, and compile.py (pack → Polars expressions, the only Decimal→float boundary). Regime-divergent behaviour is selected by a cited pack Feature (pack.feature(...)); transitional / effective-date logic resolves through Schedule entries at resolve() time, so the engine never compares reporting_date to a regulatory date to pick behaviour.
  • data/tables/ is deleted. Every regulatory value (SA risk weights, CRM collateral haircuts, PD/LGD floors, supervisory LGDs, CCFs, slotting/equity tables, SA-CCR factors, LTV bands, the CRR Art. 153(1) 1.06 scaling factor, …) now lives in the rulepack packs and is read back via resolve. The three relocated table-builder modules survive as thin pack-binding shims under engine/ (engine/sa/crr_risk_weight_tables.py, engine/sa/b31_risk_weight_tables.py, engine/crm/haircut_tables.py); src/rwa_calc/data/ now holds only column_spec.py + schemas.py (input-domain validation enums / category maps stay in schemas.py).
  • Regulatory values are removed from the config object (BREAKING). The per-run config now carries firm inputs + elections + a regime_id (str) and zero regulatory values. The scaling_factor, pd_floors, lgd_floors, supporting_factors and thresholds fields — and the PDFloors / LGDFloors / SupportingFactors / RegulatoryThresholds dataclasses — are gone; those values resolve from the pack (monetary thresholds via engine/thresholds.py, which applies EUR base × eur_gbp_rate at read time, with eur_gbp_rate kept on the config as a market input). RunConfig is introduced as the canonical per-run name (currently a transparent alias, RunConfig = CalculationConfig; the class is retained for back-compat). .crr() / .basel_3_1() remain as named constructors, now setting regime_id; framework / is_crr / is_basel_3_1 survive as derived read-only properties for non-engine consumers.
  • The engine no longer branches on the framework. The ~62 config.is_crr / config.is_basel_3_1 reads across the SA / IRB / slotting / equity calculators, classifier, CRM, CCF, RE-split and CCR stages are replaced by cited pack Features, and the two regime-state constructor classes (CRMProcessor, HaircutCalculator) lost their is_basel_3_1 flag. New / tightened arch_check gates make this permanent: check 17 bans config.is_crr / config.is_basel_3_1 reads in engine/**; check 12 is now a zero-tolerance hard ban on engine/** importing rwa_calc.data.tables; and a new check_no_numeric_tables_in_engine guards against module-level float-rate tables re-entering the engine.
  • Auditability is structural. The run manifest records the rulepack id + content hash + the full serialised resolved parameter set with citations; a new rulepack diff CLI (rwa_calc/rulebook/audit.py) materialises the regulatory delta between two regimes as a reviewable artifact; watchfire @cites coverage now extends to pack data (validated by arch_check).
  • Duplicates single-sourced; a latent trap removed. The 1.06 scaling factor (×4 sites), the Art. 161 supervisory LGDs (two formats collapsed to one canonical DecisionTable), the collateral-haircut dict↔DecisionTable duplication, and the life-insurance Art. 232 RW map (a dead {float: float} dict that had drifted from the production expression) each collapse to a single cited pack home; the COVERED_BOND_UNRATED_DERIVATION unsuffixed-alias trap is deleted (it was a latent risk, not a live bug — no CRR number ever borrowed the B31 value).
  • Parity: every slice (S1–S13) was gated byte-identical across all four 10k stress configs (crr_sa / crr_irb / b31_sa / b31_irb) vs the pre-phase baseline (../rwa_phase5_parity/before); full suite 7506 passed / 2 skipped at close. The slice-group narrative and every recorded preserve-or-fix decision are in docs/plans/target-architecture-migration.md (Phase 5, §6 decisions S1–S13). Deliberately deferred (recorded): the ccr/sft_fccm.py regime-insensitive SFT haircut (number-changing) and the regime-invariant formula-embedded constants kept inline per the S5d precedent.

Changed (architecture — migration Phase 4: uniform stage model)

  • The pipeline is now a fold over a literal stage registry. engine/registry.py holds the single ordered, literal stage list (nine StageSpec entries — one screen, no conditionals); engine/orchestrator.py provides the pure fold run_stages that threads an immutable PipelineContext (contracts/context.py: typed ArtifactKey[T] artifact map) through the stages under per-stage stage_timers, with declared per-stage failure policies (verbatim ports of the pre-fold behaviour). Each stage is one run(ctx, rulepack, run_config) -> ctx adapter module under engine/stages/ wrapping today's class-shaped component. PipelineOrchestrator (engine/pipeline.py, 1,194 → ~520 LOC) survives as the PipelineProtocol facade owning the run lifecycle (run_id, edge capture, FX-rate sync, error merge, audit persistence) — zero churn for the ~90 test files that drive run_with_data. Parity: byte-identical across all four 10k stress configs.
  • The final stage signature is frozen: Stage(ctx, rulepack, run_config). rwa_calc.rulebook lands with RulepackV0 — a frozen facade over today's CalculationConfig (regime id, is_crr/is_basel_3_1, the canonical CRR Art. 153(1) scaling_factor) built once per run after the EUR/GBP FX-rate sync finalises the effective config. Phase 5 swaps the implementation, not the signature. arch_check check 12 gains the rulebook layer (imports contracts/data/domain; never engine/api/ui/reporting/analysis).
  • Orchestrator scratch state is gone. The cross-stage self._securitisation_resolved / self._errors / self._ccr_errors attributes are replaced by typed context artifacts (SECURITISATION_RESOLVED, PIPELINE_ERRORS, CCR_ERRORS, BRANCH_ERRORS); the four error channels keep their exact pre-fold merge order and codes (unification is the dedicated error-channel slice, P2.21).
  • Components are built per run, never cached. build_components(config, **overrides) constructs framework-fresh defaults each run (injected overrides honoured), so the stale-CRMProcessor failure mode — a framework switch on a reused orchestrator silently keeping the wrong haircut table, the reason for comparison.py's two-orchestrator workaround — is structurally unrepresentable.
  • Context-era test surface: sanctioned builder tests/fixtures/context.py (make_context), PipelineContext( added to the builder-conformance hard lint, fold/registry/context pins in tests/unit/test_orchestrator_fold.py + tests/unit/contracts/test_pipeline_context.py; the stage-execution tests in tests/unit/test_pipeline.py now drive the stage adapters through built contexts.
  • engine/hierarchy.py (3,363 LOC) split into engine/stages/hierarchy/ per the mandatory stage anatomy: graph (parent/ultimate-parent/facility-graph resolution + the four cp_lookup_* seals), ratings (dual best-rating inheritance), facility_undrawn (synthetic undrawn rows incl. the MOF waterfall; the SA-RW preview stays here until its dedicated slice), unify (loans/contingents/facility-undrawn concat + facility metadata), enrich (QRRE propagation, rating attach, short-term override, property coverage, LTV, lending group), with a thin resolver.py keeping HierarchyResolver (verbatim resolve() recipe + delegating private methods) and stage.py the fold adapter. engine/hierarchy.py survives as a 28-line back-compat shim, so the 23+ test files importing HierarchyResolver from it are untouched. Function bodies moved verbatim — the defensive-surface ratchet metrics are byte-identical (fill_null 446, presence guards 374, collect_schema probes 166) and the parity gate stays byte-identical; max_engine_module_loc banks 3,364 → 2,252.
  • engine/classifier.py (2,227 LOC) split into engine/stages/classify/ per the mandatory stage anatomy: attributes (counterparty/SL joins, independent flags, shared SME size-test expr — keeps the _pt_upper/_sa_class scratch-column builders co-located with derive_independent_flags), subtypes (SME/retail/QRRE class mutation, Art. 147(5) reclassification, IRB-class sync, B31 subclass), re_split_flags (the 6-function RE loan-split candidate block + _SECURED_TARGET_*, isolated so the Slice-4 re_split co-location never moves it again), permissions (model-permission resolution, permission exprs, CLS006 diagnostics), approach (decision ladder + B31 Art. 147A restrictions + _B31_SLOTTING_ONLY_SL_TYPES), audit (audit trail, CLS008/DQ008 warnings), with a thin classifier.py keeping ExposureClassifier (verbatim classify() recipe — materialise-before-diagnostics, raw seal() after, CCR brand probe — plus _build_bundle) and stage.py the fold adapter. engine/classifier.py survives as a 24-line back-compat shim, so the 30 test files importing ExposureClassifier from it are untouched; the stale ENTITY_TYPE_TO_* re-export comment is deleted (all consumers import from data.tables.entity_class_mapping directly). Function bodies moved verbatim — ratchet metrics unchanged; the 8 @cites decorators moved with their functions and the citation snapshot/matrix re-keyed to the new module paths.
  • FX conversion and the RE-split each get their stage package (Slice 4). engine/stages/fx/ lands the FX code seam: converter.py (the stateless FXConverter five-method kernel + create_fx_converter, moved verbatim from engine/fx_converter.py) and conversion.py (convert_resolved_frames — the five-converter block extracted verbatim from HierarchyResolver.resolve, still invoked at the same unify → FX → enrich seam because LTV / property-coverage / lending-group totals and the classifier's GBP thresholds assume reporting-currency amounts). Registry promotion of FX to a standalone stage is deferred — the code seam landed here, the EUR/GBP config-mutation hoist landed in Slice 1; engine/fx_rate_sync.py stays with the pipeline facade. engine/stages/re_split/ co-locates the whole RE loan-split: splitter.py (RealEstateSplitter + the 19 split/allocation helpers moved verbatim from engine/re_splitter.py, producer seal and RE001 keep-alive included), flagging.py (the candidate-flagging brain moved verbatim from stages/classify/re_split_flags.py — still invoked from classify() at the same point), and stage.py (the Slice-1 fold adapter, previously stages/re_split.py). Split parameters stay in the data layer (data/tables/re_split_parameters.py, arch_check check 5). engine/fx_converter.py and engine/re_splitter.py survive as thin back-compat shims, so the test files importing FXConverter / RealEstateSplitter from them are untouched. Function bodies moved verbatim — no behaviour change (the recorded null-currency FX-haircut findings and the Art. 123B post-FX currency comparison are deliberately left as-is); ratchet metrics unchanged; the 2 moved @cites functions re-keyed in the citation snapshot/matrix.
  • The multi-level direct/facility/counterparty allocator is written once: engine/kernels/allocation.py (Slice 6). The drifting copies of the "classify item by beneficiary level → aggregate per beneficiary → join at three keys → pro-rata by basis / level total → additive combine" skeleton now parameterise one kernel — allocate_multi_level (annotate direction), expand_items_pro_rata (expand direction), the level-lookup builders (direct_level_lookup/grouped_level_lookup + ancestor_membership_expr/explode_facility_membership — the [parent]-fallback expression previously duplicated 4×), the 3-join + beneficiary_type-switched coalesce (join_items_to_level_lookups/switch_by_beneficiary_level), and the attribute-precedence sibling (level_attribute_lookup/coalesce_attribute_levels). Converted copies, each a thin parameterisation keeping its exact semantics: provisions (crm/provisions.py — pre-CCF synthetic basis, ancestor cascade, null/unknown beneficiary dropped), property coverage (stages/hierarchy/enrich.py — drawn-only Art. 147 basis, immediate-parent .over() window weights via partition_by_nullable, unknown→direct), guarantees (crm/guarantees.pyead_after_collateral basis, expand direction, inner-join stranding and the beneficiary_type="loan" rewrite preserved), the CRM collateral lookup builders (crm/processor.py — ancestor-subtree, AIRB-pool-aware aggregates stay caller-side), the LTV metadata lookup (add_collateral_ltv — direct→facility→cp coalesce, contingent-exclusion and order-dependent unique(keep="first") tie-break preserved and documented), and the collateral-link demand pooling (crm/link_allocation.py). Every drift axis (basis, cascade vs immediate parent, unknown-type handling, window vs join weight mechanics — with each copy's float associativity tied to its mechanics) is a kernel parameter documented in the module docstring; crm/expressions.beneficiary_level_expr delegates to the kernel classifier. Documented residue: FCSM (crm/simple_method.py) stays unconverted — it is level-blind (one aggregate joined under three keys, double-count-prone) and converting it through the level-aware kernel would change results for colliding reference namespaces; the collateral-core ancestor_facilities column materialisation (crm/collateral.py) keeps its 3-way null-list fallback; crm/look_through.py contains no allocator (row-wise re-anchoring only). Zero behaviour change — full suite green at pre-slice counts; ratchet banks fill_null 439→431, presence guards 374→372.
  • Polars namespace retirement begins: the ccr namespace is deleted (Slice 7). engine/ccr/namespace.py — a pure delegation shim over the rwa_calc.engine.ccr free functions with zero accessor call sites in src or tests (the production path has always called the free functions directly via pipeline_adapter) — is removed outright, all 8 delegate methods with it, along with the registration import in engine/ccr/__init__.py. The scaffold contract (tests/contracts/test_ccr_engine_scaffold.py) now pins the package's public free-function surface (__all__ + callability) instead of the lf.ccr registration. The slotting namespace is converted to plain typed functions. engine/slotting/namespace.py (SlottingLazyFrame + SlottingExpr, 469 LOC) becomes engine/slotting/transforms.py: every method is now a module-level function fn(lf, config, ...) -> LazyFrame (or fn(expr, *, ...) -> Expr for lookup_rw/lookup_el_rate), bodies moved verbatim, public names unchanged; SlottingCalculator.calculate_branch composes them via .pipe(fn, config). The _SHORT_MATURITY_THRESHOLD_YEARS scalar moved with its consumer (arch_check allowlist re-keyed), ~107 test accessor call sites across 5 files rewired to direct function calls, tests/unit/crr/test_slotting_namespace.py renamed to test_slotting_transforms.py, and the @cites("CRR Art. 153(5)") key re-homed to transforms::apply_slotting_weights in the citation snapshot. The sa namespace — the biggest — is converted to plain typed functions across three focused modules. engine/sa/namespace.py (SALazyFrame, 2,153 LOC) splits by cohesion into engine/sa/risk_weights.py (base RW assignment: apply_risk_weights + the CRR/B31 override chains, sovereign/ECA/covered-bond helpers, SA_INPUT_CONTRACT and the three _SA_*_RW scalar dicts), engine/sa/rw_adjustments.py (the five post-base modifiers: FCSM, life-insurance mapping, guarantee substitution, Art. 123B currency mismatch — recorded findings moved verbatim — and Art. 110A due diligence, plus the guarantee helpers) and engine/sa/factors_output.py (calculate_rwa, apply_supporting_factors, build_audit); bodies verbatim, public names unchanged, no module near the LOC ratchet. SACalculator.calculate_unified/calculate_branch compose them via .pipe; the CRM link-ranking preview (crm/processor._annotate_link_rank_metric) now defers-imports risk_weights.apply_risk_weights directly (the namespace-registration lazy import is gone; the sa↔crm coupling stays lazy in both directions). The dead prepare_columns method (zero call sites anywhere) is deleted with the shim. ~76 test accessor call sites across 11 files rewired; the citation-hygiene path pins (test_crr_art114_citation_paragraph.py, test_crr_art123_payroll_citation.py) re-point to the new module homes, and 20 @cites keys re-home in the citation snapshot. The irb namespace — the last registration — is converted to plain typed functions, and the Polars-namespace pattern is extinct. engine/irb/namespace.py (IRBLazyFrame + IRBExpr, 984 LOC) becomes engine/irb/transforms.py: all 17 LazyFrame methods and 3 Expr methods are module-level functions (bodies verbatim — including the two 1.06 if config.is_crr else 1.0 scaling-factor reconstructions, deliberately NOT rewritten to config.scaling_factor until Phase 5 rulepack threading); IRBCalculator._run_irb_chain composes them via .pipe; the IRBExpr/IRBLazyFrame re-exports leave engine/irb/__init__; ~370 test accessor call sites across 24 files rewired (tests/unit/crr/test_irb_namespace.py renamed to test_irb_transforms.py), and 5 @cites keys re-home in the citation snapshot. With the namespace pattern extinct, the 6 ty rules disabled for it (unresolved-attribute, invalid-argument-type, invalid-assignment, invalid-parameter-default, no-matching-overload, not-subscriptable) are re-enabled and the surfaced backlog burnt down to zero with typing-only fixes: ColumnSpec.dtype/EdgeColumn.dtype widened to Polars' PolarsDataType (killed ~1,050 of the 1,366 diagnostics at the two declaration sites), the SA/equity when-then chain appenders re-annotated Then | ChainedThen -> ChainedThen, has_required_columns upgraded to a TypeGuard[LazyFrame] (narrows every guarded optional-frame site), brand made generic over LazyFrame/DataFrame, the COREP/Pillar-3 workbook: object params typed xlsxwriter.Workbook, plus localized Mapping/Sequence covariance fixes and casts on heterogeneous report-row dicts. Nine per-line ty: ignore[unresolved-attribute] comments remain, all one class: config.irb_permissions.<attr> where the field is annotated IRBPermissions | None but is always derived non-None in CalculationConfig.__post_init__ (justification comments in situ). The dev-tool lock bumps ty 0.0.26 → 0.0.49, which resolves polars' decorated collect()/collect_all() overloads correctly — eliminating the ~200 InProcessQuery | DataFrame union false positives that were the remaining blocker. All four namespace retirements (ccr → slotting → sa → irb) complete; ty check src/ is clean with zero globally disabled rules. Zero behaviour change.
  • The error channel is unified — stage data-quality errors now reach the result with their ORIGINAL codes (Slice 8, closes P2.21). This CHANGES observable error codes: the lossy rewriting of bundle-attached CalculationErrors into dynamically-minted PIPELINE_<STAGE> codes is deleted. Hierarchy (HIE*, DQ004/DQ005), classification (CLS*, DQ008), CRM (CRM*), RE-split (RE*, upstream-dedup preserved) and equity errors now arrive on AggregatedResultBundle.errors verbatim via the new STAGE_ERRORS artifact channel (engine/orchestrator.py, append_stage_errors) — code, severity, category, and all six reference fields (exposure_reference/counterparty_reference/regulatory_reference/field_name/expected_value/actual_value) are preserved instead of destroyed (previously: code → PIPELINE_HIERARCHY_RESOLVER/PIPELINE_CLASSIFIER/PIPELINE_CRM_PROCESSOR/PIPELINE_RE_SPLITTER/PIPELINE_EQUITY_CALCULATOR, severity force-upgraded to ERROR, category forced to CALCULATION, every reference field dropped — so downstream consumers now see WARNINGs where they previously saw ERRORs). The CCR_ERRORS side channel, which existed only to dodge the rewrite, folds into STAGE_ERRORS (CCR001/CCR010/CCR011 unchanged on the result); the securitisation allocator keeps its loader-channel append (SEC codes were already verbatim, and moving them would reorder the final list). Stage crashes keep PipelineErrorconvert_pipeline_errorPIPELINE_<STAGE> (a crash has no original code), as does the IRB-mode missing-model-permissions warning. Final merge order in the facade: result.errors (incl. branch-calculator warnings) + loader/securitisation bundle errors + STAGE_ERRORS + converted crash errors. Pinned by four new verbatim-survival tests (tests/unit/test_pipeline.py::TestStageErrorChannel — hierarchy/classify/CRM/equity sentinels assert frozen-dataclass equality on result.errors and the absence of the corresponding PIPELINE_* code); the crash-channel pins (test_convert_pipeline_error, test_stage_error_returns_error_result, fold tests) pass unchanged.
  • The Phase-4 shape is now gated: arch_check checks 14-16 land, and the standing instructions flip with them (Slice 9, closes Phase 4). Check 14 bans Polars namespace registrations (register_(lazyframe|dataframe|expr|series)_namespace) anywhere under src/rwa_calc — no allowlist, the pattern stays extinct. Check 15 pins engine/registry.py as a literal stage list: module body is the docstring, imports, the module logger, and assignments whose value is a literal tuple of StageSpec(...) calls with literal/name/attribute arguments (conditionals, loops, comprehensions, function defs all violate). Check 16 pins the stage anatomy: every StageSpec.fn is <engine/stages/ module>.run resolved from the registry's rwa_calc.engine.stages imports, stage modules bind a top-level run, and every package under engine/stages/ exposes run from its __init__ unless pinned in the shrink-only STAGE_PACKAGES_WITHOUT_RUN set (fx — registry promotion deferred; stale entries are violations). All three are mirrored in tests/contracts/test_arch_migration_gates.py; the module docstring's numbered check list now also documents the previously-undocumented check 13. The ~600-LOC engine-module ceiling is deliberately NOT a new failing check: the existing max_engine_module_loc ratchet (banked at 1,499, monotone decreasing) is the mechanism, now documented at the ratchet config and in the check-11 docstring as the Phase-4 target the bank must keep falling toward. Per the do-not-do register, the agent-facing instructions flip in the same change: CLAUDE.md's "Polars custom namespaces" design pattern and "Namespace extensions" Polars convention are replaced by the plain-typed-functions + .pipe(fn, config) convention, the Architecture section documents the fold (registry / orchestrator / stages / PipelineContext / RulepackV0), and engine/registry.py + engine/orchestrator.py join the shared-engine-file single-stream lists in CLAUDE.md, /next-items and /sonar-clean (the engine-implementer charter gains the new invariants). Stale docs refreshed mechanically: docs/specifications/observability.md (stage stage_timer records come from rwa_calc.engine.orchestrator; run-level records stay on rwa_calc.engine.pipeline), docs/architecture/pipeline-collect-barriers.md (edge inventory re-pointed at the stage adapter modules — the facade fires no edges), and docs/development/module-dependencies.md regenerated (194 modules; the retired *.namespace nodes are gone). The ty rule re-enablement was already recorded in the Slice-7 entry above.

Fixed (Phase 4 — recorded regulatory decision)

  • The hierarchy SA-RW preview leaves the hierarchy package and adopts the shared entity RW expression (slice 5c). The facility-share riskiest-counterparty selection (engine/stages/hierarchy/facility_undrawn.py::_derive_facility_share_counterparty) now compiles build_entity_rw_expr from data/tables/guarantor_rw.py instead of the package-local _preview_sa_rw_expr (deleted, with its nested _cqs_lookup and seven RW-table imports). The builder keeps the preview's sovereign / institution / corporate+covered-bond / retail / high-risk branches value-identical (corporate stays on the CRR Art. 122 Table 5 dict under both frameworks, preserving preview parity) and closes the branches the old preview lacked: PSE Table 2A (Art. 116(2)), RGLA Table 1B (Art. 115(1)(b)) with the GB→20%/else→100% unrated approximation (sourced from the lookup's country_code), international organisations 0% (Art. 118), named MDBs 0% (Art. 117(2)) — these entity types previously fell to the flat conservative 1.0 default, which stays in place for genuinely unmatched types (equity / other items). Non-binding for risk weights, but the preview selects which counterparty receives a multi-counterparty facility's undrawn EAD, so selections involving PSE/RGLA/IO/MDB candidates can flip (PSE/RGLA/IO/MDB candidates now rank lower than before). Pinned at the expression level by tests/unit/test_entity_rw_preview.py (8 hand-derived pins); zero pre-existing tests changed outcome (no fixture pins a multi-CP share with those entity types).
  • IRB exposures guaranteed by PSEs, RGLAs, international organisations and MDBs now receive the guarantor's preferential SA risk weight (CRR Art. 235 RWSM). The IRB guarantor chain (engine/irb/guarantee.py::_compute_guarantor_rw_sa) handled CGCB/CCP/institution/corporate guarantors only — PSE, RGLA, IO and MDB classes fell to .otherwise(null), making is_guarantee_beneficial false and silently discarding the guarantee under a misleading GUARANTEE_NOT_APPLIED_NON_BENEFICIAL audit label (with null risk weights leaking into post-CRM guaranteed-portion reporting). The chain now compiles the new shared guarantor RW expression (data/tables/guarantor_rw.py — branch chain mirrored from the SA-side reference implementation: domestic CGCB 0% → CGCB CQS table → CCP 2%/4% → IO 0% (Art. 118) → named MDB 0% (Art. 117(2)) → MDB Table 2B (Art. 117(1), previously misrouted to institution Table 3) → institution ECRA/SCRA → PSE Table 2A (Art. 116(2)) → RGLA Table 1B (Art. 115(1)(b)) → corporate), deleting the IRB chain's inline CGCB literals. RWA-decreasing for affected guarantees; unrated PSE/RGLA guarantors keep the documented GB→20%/else→100% approximation (recorded decision — no guarantor sovereign CQS join exists). Pinned by 8 acceptance tests (CRR + B31 arms, hand-calculated expectations, verified failing pre-fix) + 4 IO/MDB unit pins; zero pre-existing tests changed outcome; the 10k parity set stays byte-identical (it contains no such guarantors — the P5.11 acceptance hole this partially fills). The SA path (slice 5b) and the hierarchy SA-RW preview (slice 5c, below) have since adopted the shared expression.

Changed (architecture — migration Phase 1: eager stage edges)

  • Stages now exchange materialised frames; laziness is strictly intra-stage. engine/materialise.py is rewritten around materialise_edge(lf, config, label), called at every stage exit (hierarchy_exit, ccr_exit when a derivatives book is present, classifier_exit, crm_exit, re_split_exit, and the three calculator branches). The hand-placed barrier inventory — classifier_output, pipeline_pre_branch, crm_post_ead_unified/_fanout, crm_no_guarantee — is deleted; Two benchmark-justified intra-stage checkpoints survive inside CRM: crm_post_ead (a controlled A/B showed removing it costs 35–52% on the full-pipeline benchmarks — the collateral lookups re-execute the provisions→CCF→EAD chain without it) and crm_pre_guarantee_unified (empirically irreducible on Polars 1.37). Bundle fields stay LazyFrame-typed (cheap .lazy() wrap) until the Phase 3 producer seal. Byte-identical outputs; the inter-stage plan-depth SIGSEGV class is now unrepresentable. See the rewritten Stage-Edge Materialisation page.
  • One execution semantics; spill failures are loud. The cpu/streaming dual mode collapses into: in-memory edges by default, opt-in spill-to-parquet via the new config.spill_edges; a sink failure raises SpillError instead of the previous silent in-memory fallback (which defeated the only purpose of spill mode). collect_engine="streaming" is deprecated (accept-and-warn, one release). The module-global spill registry and atexit hook are replaced by a run-scoped capture whose cleanup lives in the orchestrator's finally.
  • Every run now records a materialisation map. Each edge collect emits an EdgeEvent (label, rows, columns, estimated bytes, wall ms, spill mode); the map is logged at INFO at run end and written into the audit-cache manifest.json (materialisation_map).
  • Plan-node ceilings replace barrier folklore. tests/integration/test_stage_edges.py asserts the edge inventory in pipeline order and pins per-edge unoptimised plan-node ceilings (measured 2026-06-11 on Polars 1.37: hierarchy 1,586; CRM ~1,840; everything else ≤100 — ceilings at ~2×, SIGSEGV threshold ~25,000), plus a Polars version pin that forces recalibration on upgrade (RWA_PRINT_EDGE_NODES=1). arch_check gains an engine_eager_collect_sites ratchet metric (47 at baseline) so the small-lookup collect census cannot grow.
  • Post-aggregation views are computed once, not per accessor. OutputAggregator.aggregate() now collects its summary views in two dependency-safe batches (pre-floor and post-floor) and exposes them as eager-backed LazyFrame wraps, so downstream consumers' .collect() calls are near-free instead of re-executing the concat→multiplier→floor→group_by plan each time; ReconciliationResponse caches collected frames, and the /api/reconcile endpoint executes each view once per response (previously ~7 plan executions per request). Byte-identical outputs; field types unchanged.

Changed (architecture — migration Phase 3: producer-sealed edge contracts) — IN PROGRESS, BREAKING for direct bundle construction

  • Edge contracts land in contracts/edges.py. EdgeColumn/EdgeContract declare per-edge column contracts (dtype, required/optional, producer-owned default, Boolean-only null fill, null-semantics annotation, regulatory citation); conform() raises EdgeContractViolation on missing required columns or dtype drift (programming-error channel), injects defaults for absent optional columns, strips undeclared scratch and emits canonical column order. seal() = conform + brand; the brand is deliberately lost on any frame transformation, so only the exact object that went through the seal carries it. The Boolean-only fill conservatism gate is enforced when a contract is declared (a Float/String fill_null_default is a ValueError).
  • The loader is a producer-enforced boundary. Every input table is sealed at load against RAW_TABLE_EDGES (one edge per RawDataBundle frame field, seeded from the ColumnSpec schemas): missing required columns now produce DQ001 errors plus typed-null injection — implementing the ColumnSpec.required contract that was previously documentary only; dtype mismatches cast (strict=False); undeclared input columns are stripped; tables arrive schema-complete, canonically ordered and branded. Input alias translation happens in the loader exactly once (node_typechild_type on facility mappings).
  • RawDataBundle demands sealed frames. All 18 frame fields are registered in contracts/bundles.SEALED_FRAME_FIELDS; __post_init__ raises unless each non-None frame carries its loader-edge brand. Tests construct bundles via the contract-derived builder tests/fixtures/raw_bundle.py (make_raw_bundle / seal_raw_table) — same keyword surface as RawDataBundle, frames sealed exactly as the loader seals them, so test bundles are shape-identical to production-loaded ones (~95 test files migrated).
  • Schema gaps surfaced by the strip are now declared contract: LOAN_SCHEMA gains ltv, property_type (null defaults — never 0.0/""), has_income_cover (Boolean False per CRR Art. 126(2), mirrored on CONTINGENTS_SCHEMA), ava_amount and other_own_funds_reductions (CRR Art. 159 Pool B (c)/(d), null when unreported). COLLATERAL_SCHEMA.is_main_index loses its False default: null means "index membership unreported" and the haircut engine resolves null → main-index (CRR Art. 224 Table 4) — a load-time False fill silently re-rated unreported equity to the higher other-listed haircut.
  • engine/hierarchy.py sheds its column-presence guards. With every RawDataBundle frame sealed at the loader edge, the resolver's if "X" in <table>_cols branches on declared input columns were dead: the loans/contingents coercion blocks, facility-undrawn select, MOF expansion, facility-share derivation, QRRE propagation, property-coverage/LTV collateral joins, and short-term-rating lookup now read sealed columns directly (engine ratchet: −57 presence guards, −15 collect_schema probes, −20 fill_null sites). Dead Boolean fills whose value equals the schema default (already filled at load) are removed; all Float/String fills and all null-VALUE semantics (null child_type, null is_qualifying_re) are preserved. _normalise_facility_mappings is deleted outright — the loader translates node_typechild_type exactly once and sealed tables always carry child_type. Unit tests that call hierarchy private helpers directly now seal their hand-rolled frames via tests/fixtures/raw_bundle.seal_raw_table, mirroring production input shape.
  • engine/classifier.py sheds its column-presence guards; CLS005 / CLS007 retired. With the exposures frame sealed against hierarchy_exit, the four CounterpartyLookup frames sealed against the cp_lookup_* edges, and model_permissions sealed at the loader edge, the classifier's presence machinery was dead: the 15-branch conditional cp_ attribute select collapses to one unconditional select, the model-permissions country_codes/excluded_book_codes/ppu_reason injections and the model_id early-return go, the RE-split _re_split_null_defaults early-return and capped-column fallback are deleted, and the QRRE / is_defaulted / beel / internal_pd / has_income_cover / cp_is_managed_as_retail presence gates collapse to direct reads (engine ratchet: −49 presence guards, −3 fill_null sites, −3 collect_schema probes; classifier.py −202 LOC). The CLS005 ("is_managed_as_retail column missing") and CLS007* ("is_financial_sector_entity column missing") warnings are deleted — the absent-column states they detected are unrepresentable on sealed input (the seal injects declared-but-absent columns as typed nulls); their null-VALUE semantics (fill_null(True) pool-management default per Art. 123A, fill_null(False) FSE gate per Art. 147A(1)(e)) are preserved verbatim, as are all optional-table is None gates (specialised lending, model permissions) and the CLS006/CLS008 null-VALUE diagnostics. Sealed-invariant tests replace the absence-warning tests (tests/unit/classifier/test_p1_125_fse_column_warning.py, tests/unit/test_art123a_retail_criteria.py), mirroring the CLS004 pattern.
  • Every pipeline stage edge now carries a producer seal. The full chain — loader → hierarchy (hierarchy_resolved/hierarchy_exit/ccr_exit) → CounterpartyLookup (four cp_lookup_* contracts) → classifier (classifier_exit/_ccr, brand-selected by input) → CRM (crm_exit/_ccr) → RE-split (re_split_exit/_ccr) → calculator branches (sa_branch/irb_branch/slotting_branch, conformed before the shared collect and branded as DataFrames) → aggregator (aggregator_exit, 297 columns — the reporting input contract). Bundle __post_init__ validates brands via the SEALED_FRAME_FIELDS registry (tuples for multi-producer fields); transformed frames lose their brand by design.
  • Conditional columns (EdgeColumn(inject=False)). Path-dependent columns (guarantee substitution, provisions, FCSM, SA-CCR provenance, regime-gated floor/currency columns) are declared-if-present: dtype-validated and never stripped when the producing sub-step ran, never injected when absent. The parity gate forced this design — blanket null-injection made the presence-gated SA/IRB guarantee-substitution machinery execute on null data and moved IRB risk weights. Each group flips to injection alongside its consumer's guard deletion with verified null-path equivalence.
  • Wave-2 guarantor rework — the path-dependent CRM columns now inject as typed nulls. 18 of the 19 conditional crm_exit columns (the provision split provision_on_drawn/provision_on_nominal, the guarantee metadata and guarantor-attribute set, and the FCSM pair fcsm_collateral_value/fcsm_collateral_rw) flip to injection on the crm_exit, calculator-branch and aggregator_exit contracts, so per-row outputs carry a uniform shape whether or not the producing CRM sub-step ran (all-null = sub-step skipped). Consumers verified null-path-equivalent: on the 10k parity set the per-row frames gain exactly the 18 all-null columns with zero value drift in every shared column. guarantor_entity_type stays conditional as the run-level presence sentinel that keeps the SA/IRB guarantee-substitution machinery — and its derived audit columns (guarantor_rw, guarantee_status, pre_crm_risk_weight, …) — off unguaranteed runs (lazy column addition is row-independent, so a value gate cannot prevent the shape divergence). The _inst_guarantor_short_term scratch column is dropped at source in the SA guarantee substitution and removed from the branch/aggregator contracts; the FCSM presence early-exit and two always-ensured seniority/FSE presence branches in the IRB EL adjustment are deleted (presence-guard ratchet 377→374, collect_schema probes 168→166).
  • Guard retirement banked by the ratchet: presence guards 549→377 (−172), fill_null sites 469→446, collect_schema probes 191→166; hierarchy.py −413 LOC, classifier.py −202 LOC. Dead absent-column warning families (CLS005, CLS007, QRRE CLS004) deleted with their unrepresentable-state tests, replaced by sealed-frame invariant pins.
  • Unknown-flag conservatism fixes (recorded decisions): qualifies_as_retail unknown → non-qualifying 100% (CRR Art. 123 — the 75% weight is preferential); has_default_definition_info unknown → the Art. 155(3) 1.5× scaling applies; is_main_index unknown → preserve the engine's null→main-index resolution (Art. 224 Table 4). Zero goldens changed; pipeline paths unaffected (the classifier emits non-null in-pipeline).
  • Contract-derived test builders are the sanctioned construction path at every grain: make_raw_bundle/seal_raw_table, make_resolved_bundle/make_counterparty_lookup/make_classified_bundle/make_crm_bundle/make_aggregated_bundle, plus tests/fixtures/contract_columns.py pads for hand-rolled branch frames. ~150 test files migrated across the phase; recurring lesson: tests that omitted columns production always carries now receive typed nulls and must supply production-realistic values — never changed expected numbers.
  • Producer-contract robustness fixes: the CRM001 unusable-collateral skip path now emits the full CRM contract (runs the collateral step on an empty schema-valid table); an equity-stage failure no longer nukes non-equity results via a self-inflicted aggregator seal violation (equity_type optional-with-injection).

Changed (architecture — migration Phase 2: dead-path deletion and protocol diet) — BREAKING for direct API consumers

  • The legacy dual CRM orchestration is deleted. CRMProcessor.apply_crm() / get_crm_adjusted_bundle() (and their crm_post_audit_fanout edge) are gone; get_crm_unified_bundle() is the single CRM entry point. The misdirected-AIRB collateral diagnostic (CRM006, CRR Art. 181 / B3.1 Art. 169A) — which only the dead path ever emitted — is migrated into the unified path, so production now surfaces it for the first time.
  • One branch entry point per calculator. SACalculator.calculate/get_sa_result_bundle, IRBCalculator.calculate/get_irb_result_bundle/calculate_expected_loss, SlottingCalculator.get_slotting_result_bundle, and EquityCalculator.calculate are deleted — none were invoked by the orchestrator. Each calculator keeps calculate_branch() (plus SA's calculate_unified() for the Basel 3.1 output floor); equity keeps get_equity_result_bundle().
  • Branch-path error accumulation is restored. calculate_branch() (and SA calculate_unified()) now take an optional errors= accumulator wired by the orchestrator, so SA004 (Art. 110A due diligence), SA005 (equity in main table, Art. 133), SF001 (Art. 501 SME group aggregation) and EL diagnostics reach AggregatedResultBundle.errors with their original codes — previously these production warnings were silently discarded (only the dead bundle paths collected them). Pinned per calculator by tests/unit/test_branch_error_accumulation.py and end-to-end by tests/integration/test_branch_error_accumulation.py.
  • Orphaned contracts deleted. LazyFrameResult, SAResultBundle/IRBResultBundle/SlottingResultBundle, SACalculationError, the approach-split fields (sa_exposures/irb_exposures/slotting_exposures) and the always-None crm_audit field on ClassifiedExposuresBundle/CRMAdjustedBundle (consumers filter the unified frame on approach; the CRM audit projection ships via the audit cache), the never-called validate_classified_bundle/validate_crm_adjusted_bundle, and the zero-implementation CCRCalculator/SchemaValidatorProtocol/DataQualityCheckerProtocol protocols.
  • Protocol conformance is asserted on real implementations. tests/contracts/test_protocols.py now checks every pipeline component class (17 protocol/implementation pairs) via runtime isinstance + typed assignment, replacing the stub-based tests that could pass while the real component drifted.
  • Empty-LazyFrame sentinels are gone from the engine; optional frames are None. RawDataBundle.lending_mappings is optional (loader returns None when the file is absent; the hierarchy resolver treats None as group-of-one per CRR Art. 4(1)(39), identical to an empty table), and CollateralLinkAllocation.collateral stays None for absent collateral. New arch_check check 13 (+ contracts mirror) bans bare pl.LazyFrame() construction in engine/**.
  • Parity gate: scripts/parity_gate.py captures/compares the full AggregatedResultBundle over the deterministic 10k stress set (4 framework/permission configs). Result vs the pre-Phase-2 baseline: every per-exposure frame byte-identical; group-by sum aggregates equal to float-reassociation tolerance (Polars parallel Float64 summation is not deterministic across processes — verified on identical code); error growth = exactly one SA004 warning per Basel 3.1 run (the restored due-diligence diagnostic).

Fixed

  • Financial Collateral Simple Method (CRR Art. 222) election was silently ignored on the production pipeline path. compute_fcsm_columns / undo_sa_ead_reduction were only invoked by the legacy get_crm_adjusted_bundle entry point; the orchestrator's get_crm_unified_bundle never ran them, so a firm electing crm_collateral_method=SIMPLE got Comprehensive Method treatment instead — EAD reduced by financial collateral with no Art. 222 risk-weight substitution. The FCSM steps are now ported into the unified path behind the same election guard (mirroring the legacy ordering). SIMPLE-electing runs will show changed SA RWA: EAD is no longer reduced by financial collateral, and the secured portion takes the collateral risk weight (20% floor / carve-outs per Art. 222). COMPREHENSIVE (default) runs are byte-identical. Recorded as a FIX decision in docs/plans/target-architecture-migration.md §6. Pinned by tests/acceptance/crr/test_art_222_fcsm_unified_pipeline.py (6 orchestrator-path tests, full + partial coverage).
  • COREP and Pillar 3 no longer disagree about on/off-balance-sheet filtering when the indicator columns are missing. The copy-pasted _filter_on_bs helpers had drifted: COREP returned an empty frame when neither bs_type nor exposure_type exists, Pillar 3 returned all rows — double-counting the full population across on-BS and off-BS cells. Both generators now share reporting/kernel/ (column resolution, approach/BS filters, safe sums, null rows), unified on return empty (a missing balance-sheet indicator must not silently pass rows through; decision recorded in docs/plans/target-architecture-migration.md §6). Pipeline output always carries exposure_type, so this only affects synthetic/minimal inputs. Genuinely divergent semantics (safe_sum 0.0 vs None, col_sum empty-frame handling, approach-column candidates) are preserved per-caller via explicit parameters. Pinned by tests/unit/reporting/kernel/test_kernel.py (23 tests).
  • Master CI is green again. The Lint & Format job was failing on 22 ruff errors (F401/I001/SIM102) plus 8 masked ruff format failures across tests/{fixtures,acceptance}/ccr/ introduced by recent CCR batches; all fixed (the intentional re-export module now uses explicit X as X re-export syntax).

Added

  • Target architecture & migration plan committed to the repo. docs/plans/target-architecture-migration.md distils the 2026-06-11 multi-agent architecture review (49 evidenced findings) into the rulepack target architecture and a phased (0–8) strangler migration, with quick wins, a binding do-not-do register, and a decision log. The two investigation plans previously held only in agent session memory are committed alongside: engine-defensiveness-boundary-hardening.md (folded into Phase 3; preserves the ~130/189 KEEP-guard triage) and single-lazy-plan-refactor.md (SUPERSEDED by Phase 1; preserves the Polars 1.37 plan-depth SIGSEGV evidence). IMPLEMENTATION_PLAN.md cross-links the phases.
  • Architecture-debt ratchet and import-direction gates (arch_check checks 11–12). Check 11 measures the engine defensive surface (.fill_null( sites, string-literal column-presence guards, .collect_schema( probes, max engine module LOC) against a committed baseline (scripts/arch_metrics.json) and fails any increase; the watchfire @cites count may never decrease. Improvements are banked via python scripts/arch_check.py --update-baseline. Check 12 enforces downward-only imports (contracts ↛ api/ui/reporting/engine/analysis; engine ↛ api/ui/reporting/analysis; reporting ↛ api/ui; data/domain ↛ anything above), with known legacy inversions allowlisted against the migration phase that retires them. Mirrored as contract tests in tests/contracts/test_arch_migration_gates.py.
  • A real pre-commit gate. .pre-commit-config.yaml (local hooks: arch_check, ruff check, ruff format --check over src/tests/scripts) so non-agent commits are gated the same way as agent commits.
  • Dedicated CI benchmarks job. Benchmark bodies are excluded from the default dev loop and the CI tests job (27 test bodies, ~112s of dead worker time per run, executed despite --benchmark-disable); a new additive CI job runs tests/benchmarks and uploads benchmark-results.json as the stored baseline artifact. scale_10k/scale_100k markers registered; --strict-markers enforced.
  • Citation coverage snapshot replaces the hand-maintained whitelist. tests/contracts/test_watchfire_coverage.py's 84-row manual WHITELIST is replaced by a generated, committed snapshot (tests/contracts/data/citation_snapshot.json, 131 functions — the whitelist had silently missed 47) diffed bidirectionally against the live @cites state; regenerate via uv run python scripts/generate_citation_matrix.py when a change is intentional.

Changed

  • ExportResult moved from rwa_calc.api.export to rwa_calc.contracts.results (re-exported from the old location for backwards compatibility). This clears the contracts→api and reporting→api layering inversions now enforced by arch_check check 12.
  • Audit-cache writer relocated to the observability layer. sink_audit / prune_audit_cache moved verbatim from engine/materialise.py to rwa_calc.observability.audit_cache (the "sink_parquet only in materialise.py" invariant they were co-located for was never implemented in arch_check — a verified phantom rule). Import sites updated; behaviour identical.
  • Stale load-bearing prose corrected. engine/materialise.py's ">500-node optimizer segfault" comment now states the verified mechanism (recursive plan-tree depth, ≈25,000-node measured threshold on Polars 1.37, barriers also bound plan-construction time); docs/architecture/pipeline-collect-barriers.md refreshed (cpu — not streaming — is the default collect engine; all barrier line references re-verified; the undocumented classifier_output barrier added).
  • Verified-dead code deleted: tests/bdd/ (empty scaffold), config/fx_rates.py, engine/utils.is_valid_optional_data, and the contracts/validation.py duplicate risk-type validators (canonical source: data/schemas.py VALID_RISK_TYPES_INPUT via COLUMN_VALUE_CONSTRAINTS).

[0.2.26] - 2026-06-10

Added

  • Reconciliation now gives comfort on the asset-class allocation, and can reconcile each class portion line-by-line. A new class_allocation view totals EAD/RWA by risk class on each side — using the raw (un-collapsed) results so a split exposure's portions each count in their own class — and full-joins on the canonical class to show our_* / legacy_* / delta_* per class; a class our engine allocates differently to the legacy one then stands out as offsetting deltas. It surfaces as a new tier-2 table + grouped-bar chart on /reconciliation, a Class Allocation sheet/CSV in the export, and ReconciliationResponse.collect_class_allocation() / ReconciliationBundle.class_allocation. Separately, the join key can now carry the risk class: putting the class in both our_keys/legacy_keys reconciles at the (exposure × class) grain, with the class key normalised and value_map-translated on the way into the join (legacy RRE ↔ our residential_mortgage) so a portion in a class on only one side shows as missing_left / missing_right — the precise "this exposure moved to a different risk class" signal. The default mapping TOML now maps exposure_class and documents the recipe. Purely additive analysis — no change to any RWA calculation. Pinned by new cases in tests/unit/engine/test_reconciliation.py (TestClassAllocation, TestExposureClassGrain), tests/unit/ui/test_views_reconciliation.py, tests/integration/test_ui_reconciliation.py, and tests/acceptance/reconciliation/test_reconcile_end_to_end.py.
  • P8.53 — the SA-CCR wrong-way-risk gate (apply_wwr_gate) is now wired into the pipeline orchestrator. apply_wwr_gate was implemented and unit-tested (P8.27, 13 tests in tests/unit/ccr/test_wwr.py) but never invoked by pipeline.py::_run_ccr_stage, so no end-to-end SA-CCR run routed through the Art. 291(4)-(5) treatment: any trade flagged is_specific_wwr=True was treated as a non-WWR trade. The CCR stage now runs apply_wwr_gate(apply_legal_enforceability_gate(data.ccr)), so a specific-WWR trade is broken out into its own <ns>__wwr__<trade> synthetic single-trade netting set tagged wwr_lgd_override = 1.0 per PRA Rulebook CCR (CRR) Part / CRR Art. 291(5)(c), while the non-WWR trade stays in the parent netting set; the override is surfaced onto the synthetic CCR exposure row (pipeline_adapter.py) for audit/COREP reconciliation. The gate's CCR010 (specific-WWR) / CCR011 (general-WWR) diagnostics now reach result.errors as raw CalculationErrors through a new CCR-error channel — which also surfaces the legal-enforceability gate's CalculationErrors to the result for the first time. Pinned by tests/acceptance/ccr/test_ccr_wwr1_orchestrator_gate.py (scenario CCR-WWR-1: one specific-WWR + one normal trade through PipelineOrchestrator). Scope note: this lands the partition + override tag; the downstream IRB LGD = 100% consumption of the override is deferred (blocked on P8.31 CCR→IRB routing — CCR rows route through SA today, where Art. 291(5)(d) "unsecured transaction" treatment already holds). No EAD/RWA arithmetic changed; no new regulatory scalar (CCR_WWR_SPECIFIC_LGD_OVERRIDE = 1.0 already in data/tables/sa_ccr_factors.py). Ref: PRA Rulebook CCR (CRR) Part Art. 291(5)(c); CRR Art. 291(5)(c).
  • P8.39 — QCCP central-counterparty trade-exposure risk weights (2% / 4%) are now correct end-to-end through the orchestrator. The SA calculator already pinned a ccp entity_type to the Art. 306(1) weights, but two bugs survived: (a) the trade-level is_client_cleared flag never reached the synthetic CCR exposure row, so a client-cleared QCCP trade was risk-weighted at the proprietary 2% instead of 4% per PRA Rulebook CCR (CRR) Part / CRR Art. 306(1)(c); and (b) the pin keyed on entity_type alone, with no qualifying-CCP gate, so a non-QCCP CCP was wrongly pinned at 2% instead of being treated as an ordinary institution. The fix threads the client-clearing flag (collapsed to netting-set grain via any()) onto the CCR row as cp_is_ccp_client_cleared (pipeline_adapter.py) and surfaces the QCCP flag as cp_is_qccp (classifier.py, new cp_is_qccp column in schemas.py); the SA QCCP branch (engine/sa/namespace.py) now gates on cp_is_qccp (an absent flag is treated as qualifying, preserving legacy ccp rows), and a demoted non-QCCP CCP lifts its cp_institution_cqs into cqs so it resolves to the Art. 120 Table 3 institution ladder (e.g. CQS 2 → 50%) per Art. 107(2)(a) rather than the unrated-100% fallback. Net effect: proprietary QCCP → 2% (Art. 306(1)(a)), client-cleared QCCP → 4% (Art. 306(1)(c)), non-QCCP CCP → institution ladder. EAD is unchanged (the SA-CCR Art. 274 EAD is invariant to the risk-weight branch). Pinned by tests/acceptance/ccr/test_ccr_ccp_orchestrator_pin.py (scenarios CCR-CCP-1 proprietary / CCR-CCP-2 client-cleared + a 2-counterparty keyed-join fan-out guard; 12 tests). Ref: PRA Rulebook CCR (CRR) Part Art. 306(1)(a)/(c), Art. 107(2)(a); CRR Art. 306(1), Art. 120, Art. 272 Def (88).
  • P8.28 — SA-CCR supervisory-alpha carve-out (α = 1.0) for non-financial and pension-scheme counterparties is now applied per netting set. Previously the engine applied the default α = 1.4 uniformly to every netting set, so a derivative with a non-financial counterparty (EMIR Art. 2(9)), a pension scheme arrangement (EMIR Art. 2(10)) or a pension-scheme default-fund-contribution position had its EAD over-stated by a factor of 1.4 (EAD = α·(RC + PFE)) — the correct value is ~28.6% lower per CRR Art. 274(2) second sub-paragraph. A new counterparty_type column on the counterparty schema (default "financial" → α = 1.4; non_financial/pension_scheme/pension_default_comp → α = 1.0) is joined onto the netting-set frame and reduced to a per-netting-set alpha_applied scalar (surfaced on the synthetic CCR exposure row for COREP/audit reconciliation EAD = alpha_applied · (RC + PFE)); compute_pfe/compute_ead honour the per-row value when present and fall back to the scalar α otherwise, so every existing CCR scenario (which never sets the column) is unchanged at α = 1.4. The α scalars SA_CCR_ALPHA = 1.4 / SA_CCR_ALPHA_CARVE_OUT = 1.0 live in data/tables/sa_ccr_factors.py. Pinned by tests/acceptance/ccr/test_ccr_alpha_carveout.py (scenarios CCR-ALPHA-1 non-financial / CCR-ALPHA-2 pension-scheme / CCR-ALPHA-3 financial control + a 2-counterparty keyed-join fan-out guard; 14 tests). Ref: PRA PS1/26 / CRR Art. 274(2); EMIR Art. 2(9)-(10); BCBS CRE52.1.
  • P8.23 — long-settlement transactions confirmed to take standard SA-CCR treatment (no special margin-period-of-risk floor); regression-pinned. A prior plan item assumed CRR Art. 271 mandated a "bespoke MPOR floor" for long-settlement transactions. Primary-source verification (CRR Art. 271, 272(2)/(9), 285) confirms otherwise: Art. 271 merely permits long-settlement transactions to be calculated under the SA-CCR chapter, and neither Art. 272 nor Art. 285 prescribes any long-settlement-specific maturity-factor or MPOR treatment — the Art. 285 floors (5/10/20 business days) key off the netting set's margining type. Under SA-CCR a long-settlement trade is therefore an ordinary trade whose maturity factor follows Art. 279c, so the is_long_settlement flag has no effect on EAD/RWA. No calculation change was made; the behaviour is pinned by tests/acceptance/ccr/test_ccr_ls_long_settlement_inert.py (a long-settlement trade routes through SA-CCR and yields EAD/RWA identical to an economically-identical control — guarding against any future spurious is_long_settlement branch). Ref: CRR Art. 271, Art. 272(2)/(9), Art. 285.
  • P8.29 — Basel 3.1 transitional SA-CCR alpha add-on (PRA PS1/26 Art. 274(2A)–(2B)) is now applied per netting set. For derivative trades entered into before 1 January 2027 with a CVA-exempt non-financial / pension-scheme counterparty (CVA Risk Part 7.1(1)(a)/(b)), Art. 274(2A) phases an "alpha add-on" — EAD(α=1.4) − EAD(α=1) = 0.4·(RC+PFE) — back onto the netting-set exposure value at 60% (2027) → 40% (2028) → 20% (2029) → 0% (2030+), so the EAD steps down from ~1.24× to 1.00× of the α=1 carve-out value over the transition. A new firm-supplied is_legacy_cva_exempt flag on the trade schema gates the add-on (collapsed to netting-set grain via any()); it fires only under the Basel 3.1 framework at a 2027–2029 reporting date and only where the counterparty already qualifies for the α=1 carve-out (alpha_applied == 1.0) — so an α=1.4 financial counterparty receives nothing, and every CRR run / 2030+ date / non-legacy trade is byte-identical to before. The phased uplift is folded into the SA-CCR EAD and surfaced as a transitional_add_on audit column. The phase schedule lives in data/tables/sa_ccr_factors.py (SA_CCR_TRANSITIONAL_ADDON_PHASE). Art. 274(2B) (exclude the add-on from the leverage-ratio EAD) is not yet applicable — the engine exposes no leverage-ratio EAD path. Pinned by tests/acceptance/ccr/test_ccr_alpha_addon_transitional.py (the four-year phasing plus non-legacy / legacy-financial / CRR-framework controls and a 2-netting-set fan-out guard; 20 tests). Builds on the P8.28 carve-out. Ref: PRA PS1/26 Art. 274(2A)–(2B); CRR Art. 274(2).
  • P8.31 — SA-CCR derivative exposures now route through IRB (F-IRB / A-IRB) for IRB-permissioned counterparties, instead of always falling back to the Standardised Approach. A synthetic CCR exposure row reaches the classifier with model_id = null (the rating-inheritance attach that renames internal_model_id → model_id only runs over hierarchy-resolved lending rows, before the CCR rows are appended), so even a counterparty holding an IRB model permission had its derivative counterparty-default-risk RWA computed on the SA ladder. The classifier now surfaces the counterparty's resolved internal_model_id as cp_internal_model_id and coalesces it into model_id at the start of model-permission resolution, so an IRB-permissioned counterparty's CCR derivative exposure resolves its permission and routes through the existing PD/LGD/M machinery — using the SA-CCR EAD (α·(RC+PFE)) as the IRB EAD input (replacing the drawn-amount/CCF flow), per CRR Art. 153(1) (corporate IRB risk-weight formula). The effective maturity M for the single-trade netting set follows CRR Art. 162(2)(b) (derivatives under a master netting agreement: residual maturity, 1-year floor / 5-year cap) via the existing maturity_date-clipped derivation — note this corrects the plan bullet's citation, which wrongly referenced Art. 162(2)(g)-(i) (those are the IMM / CVA-internal-model maturity paths, out of scope for an SA-CCR firm). The coalesce is a strict no-op for lending rows whose model_id is already populated (verified across 1,600+ tests; CCR-A1 still routes through SA, the QCCP 2%/4% and α-carve-out pins are intact). Pinned by tests/acceptance/ccr/test_ccr_irb1_routing.py (scenario CCR-IRB-1: a corporate F-IRB counterparty with one 5y GBP IR swap → SA-CCR EAD → approach_applied = "foundation_irb", ead_final == ead_ccr, F-IRB risk weight ≈ 145.9%; 6 tests) with golden tests/expected_outputs/ccr/CCR-IRB-1.json. Scope note: this lands the SA-CCR-EAD→IRB routing; the downstream WWR LGD = 100% consumption (P8.53 Change 2b) and the CCR-specific 0.05% PD floor (P8.32) remain deferred and are explicitly not asserted here. Ref: CRR Art. 153(1), Art. 162(2)(b), Art. 161(1)(a), Art. 163.
  • The reconciliation page (/reconciliation) now remembers your last completed run and re-opens pre-filled, so you no longer re-type every input each time. After a reconciliation succeeds, all six form fields — data path, framework, permission mode, data format, reporting date, and the (comment-preserving) legacy-mapping TOML — are saved verbatim as JSON to a per-user state file, and the next visit to the form silently restores them (the three dropdowns included). New module src/rwa_calc/ui/app/recon_state.py holds a frozen ReconciliationFormState plus save_last_run / load_last_run; the state file is ~/.rwa_calc/reconciliation_last_run.json, overridable via the RWA_STATE_DIR env var (the test seam and the packaged-app override). Saving never raises (a save failure just means the next form isn't pre-filled) and a missing/corrupt/partial file falls back to the built-in defaults, so a fresh install behaves exactly as before. Field precedence is explicit-override (a failed submit re-renders with what you chose) > last run > default; the failure path now also preserves the submitted framework/mode/format, which it previously dropped. The saved values are only editable defaults — a stale data path or legacy file still surfaces the usual clear error on the next run. A "Reset to defaults" button (a ghost-styled link shown beside "Run reconciliation" only when a saved run exists) clears the saved state via GET /reconciliation/reset and returns the form to its built-in defaults. UI convenience only — no calculation impact. Pinned by tests/unit/ui/test_recon_state.py and tests/integration/test_ui_reconciliation.py (test_reconciliation_prefills_from_last_run, test_reset_restores_defaults_and_clears_saved_run).

Changed

  • Reconciliation: the legacy side is now aggregated to the key grain instead of silently keeping the first row (REC002). When a legacy exposure spans several lines — a collateralised portion in one risk class, the residual in another, or guaranteed/unguaranteed portions — the engine previously kept only the first row of each key and dropped the rest, understating the legacy EAD/RWA totals and producing false breaks. The legacy side is now collapsed symmetrically with our side: additive components (EAD, RWA, expected loss) are summed and the risk-weight ratio is recomputed from the summed numerator/denominator, so the totals tie out. The REC002 warning is reworded to reflect aggregation (not row-dropping), and REC004 now also fires when a legacy key's rows disagree on class/approach (an exposure split across classes), surfacing exactly the case that motivated the per-(exposure × class) grain. The change is in engine/reconciliation.py (_prepare_legacy_side + _aggregate_legacy_to_key_grain); no other RWA calculation is affected.
  • The landing-page polar-bear constellation now plays a one-shot "tows the text in" intro on phones instead of running off-screen. On the 16:9 slice SVG the bear's rest anchors (CX_STAND=128, CX_QUAD=114) sat outside the narrow, height-cropped visible band a portrait phone leaves (≈ viewBox x [59,101]), so the bear stood off-screen and you only ever caught it mid-run; and because the bear is vertically centred it would otherwise sit right behind the full-width hero copy. bear-constellation.js now parametrises the lifecycle on an anchors object and, at/below the existing 880px mobile breakpoint, plays a single intro — the bear stands centred (always fully visible), then bolts off the right edge — and then hides, leaving only the twinkling starfield. In the same beat the hero copy is towed in from the left by a pure-CSS auto-play animation (@keyframes bear-tow-in on .hero-body in homepage.css), whose animation-fill-mode: both hides the text from the first painted frame (no flash, no JS arming — text still shows normally without JS) and whose timing is kept in lockstep with the script via the INTRO_T0/INTRO_END contract. prefers-reduced-motion shows the copy immediately with no bear. The intro is framed by centring each pose's visual extent (and scaling the bear to the measured visible band) so the head no longer clips as it drops to all fours, and it is driven by an IntersectionObserver so the run-off plays the moment the hero is genuinely on-screen — not lost in the initial mobile paint — and replays whenever the hero re-enters view. As the bear bolts off, the whole hero gives a subtle footfall-synced "ground shake" (a GPU transform on .landing-hero, ramped by the gallop and faded out as the bear leaves frame; the landing body is darkened to --oah-slate-900 so the shake never reveals an edge), also off under prefers-reduced-motion. Desktop is unchanged — width > 880px keeps the existing endless STAND→CROUCH→RUN→WALK-IN→RISE walk in the right third. Edits are confined to the two drift-guarded asset pairs (bear-constellation.js, homepage.css under src/rwa_calc/ui/app/static/ and docs/assets/), kept byte-identical by tests/unit/ui/test_tokens_drift.py. UI presentation only — no calculation impact.

[0.2.25] - 2026-06-07

Added

  • Parallel-run reconciliation is now a first-class page in the app (/reconciliation) and a REST endpoint, replacing the Marimo workbook. The reconciliation feature (run this calculator alongside a firm's legacy engine and compare component-by-component, triaging each break to a data vs. engine fix) is now part of the server-rendered FastAPI/Jinja app: a form takes the data path and an editable TOML mapping, and the result renders across the four drill-down tiers — headline tie-out + per-component summary (with two inline-SVG charts: legacy-vs-ours per component and Σ|Δ| by component), the by-bucket / by-class / by-approach segmentation, the break worklist ranked by materiality, and a per-key forensic table whose bucket filter (?bucket=…) re-reads a cached result without recomputing. The wide forensic frame is projected to a readable column set on screen; the full per-key detail (explain + input drivers) is available via CSV/Excel download. A matching library-first HTTP contract is exposed: POST /api/reconcile (returns a recon_id + each tier as {columns, rows}) and GET /api/reconcile/export/{csv|excel}. New module src/rwa_calc/ui/views/reconciliation.py (framework-agnostic view helpers) sits over the unchanged CreditRiskCalc.reconcile() API — no calculation impact. Pinned by tests/unit/ui/test_views_reconciliation.py, tests/integration/test_ui_reconciliation.py, and new reconcile cases in tests/integration/test_rest_api.py.

Changed

  • The reconciliation UI moved from a Marimo workbook to the native app; src/rwa_calc/ui/marimo/reconciliation_app.py is removed. The native /reconciliation page (above) is now the single standard surface, so the standalone Marimo reconciliation app — and its stale references to a marimo/server.py that no longer exists — are deleted. The reconciliation guide (docs/reconciliation/index.md) and the Interactive UI guide are updated to describe the native page and the new REST endpoints. The editable Marimo workbench (/workbench) is unaffected. Docs/UI only — no calculation impact.
  • httpx is now in the default-synced [dependency-groups].dev so the REST/UI test gate runs on a clean checkout (tooling fix). The FastAPI TestClient (used by test_rest_api.py, test_ui_app.py and the new test_ui_reconciliation.py) imports httpx, but it was declared only in the [project.optional-dependencies].dev extra — which uv sync does not install by default — so a plain uv sync left those integration tests erroring at import. Adding httpx>=0.27.0 to [dependency-groups].dev mirrors the existing pytest-xdist (0.2.22) and watchfire (0.2.24) fixes. Build/tooling only — no calculation impact.
  • Docs now show uv add rwa-calc as the primary install command, fixing a wrong PyPI package name. The docs landing hero (docs/overrides/main.html) advertised pip install rwa-calculator — the wrong package name (the project publishes as rwa-calc, not rwa-calculator) — so the copy-to-clipboard command would have failed. It now reads uv add rwa-calc. The getting-started, quickstart and interactive-UI guides are realigned to lead with uv add rwa-calc (pip shown as the secondary option), reflecting uv as the recommended package manager. Docs only — no calculation impact.
  • Removed the pip install rwa-calculator box from the app landing page (src/rwa_calc/ui/app/templates/landing.html). Anyone viewing the app's landing page is already running the app locally and has therefore already installed it, so the install command was redundant. The box remains on the docs landing page (docs/overrides/main.html), where visitors may not yet have installed the package. UI presentation only — no calculation impact.

[0.2.24] - 2026-06-07

Added

  • New server-rendered read-only UI (rwa-ui) on a real REST API. The read-only surface (landing, calculator, results explorer, CRR vs Basel 3.1 comparison) is now a pure-Python FastAPI + Jinja application (src/rwa_calc/ui/app/) rendered with the shared --oah-* brand tokens so it matches the Zensical docs, with charts drawn as inline SVG (ui/views/charts.py) — no JavaScript build step, no vendored JS blob, so it bundles cleanly via moonlit. It is backed by a new REST API (src/rwa_calc/api/rest.py, exported as create_api_app/api_router): POST /api/calculate, POST /api/validate, GET /api/results, GET /api/results/summary/{class|approach}, POST /api/comparison, and GET /api/export/{parquet|csv|excel|corep} over the existing CreditRiskCalc — the library-first contract the UI itself consumes and that external callers can embed. The CRR↔Basel waterfall/transform logic is now a framework-agnostic module (ui/views/comparison.py) shared by the docs, the app, and Marimo. The editable Marimo workbench is retained and launched on demand from the app. UI/API only — no calculation impact. Pinned by tests/integration/test_rest_api.py, tests/integration/test_ui_app.py, and tests/unit/ui/.
  • Parallel-run reconciliation — compare this calculator's output against a legacy calculator, component by component (migration tooling). Firms adopting the calculator can now reconcile its per-exposure output against their existing engine's output to build migration confidence. A new canonical component registry (data/schemas.RECONCILABLE_COMPONENTS: exposure class, approach, PD, LGD, maturity, CCF, EAD, risk weight, supporting factor, expected loss, RWA — each with our value column, explain columns and raw input drivers) drives engine/reconciliation.ReconciliationRunner, which collapses our guarantee/RE sub-rows to the reconciliation grain (engine/aggregator/_collapse.aggregate_to_key_grain, on a default exposure_reference key or a composite/custom key e.g. counterparty + facility), full-outer joins the mapped legacy output, and buckets every mapped component as exact_match / within_tolerance / break / missing_left / missing_right (per-component tolerances default to the acceptance-suite values, overridable). The ReconciliationBundle (contracts/bundles.py) is layered headline → forensic: totals_tie_out + summary_by_component, then summary_by_bucket / _by_exposure_class / _by_approach, then a ranked breaks_detail worklist, then a per-key component_reconciliation carrying legacy-vs-ours, our reason and our input drivers so a break can be triaged to a data fix vs an engine fix. Analyst entry points: a TOML mapping config (stdlib tomllib — no new dependency) via CreditRiskCalc.reconcile("reconciliation.toml") / api.load_reconciliation_config, a ReconciliationResponse with collect_* accessors + to_csv / to_excel (multi-sheet) export, and a new Marimo workbook (ui/marimo/reconciliation_app.py, served at /reconciliation) with a live-editable mapping and the four drill-down tiers. Legacy column mapping handles unit scaling (scale, e.g. millions), unit = "percent" ratios, and categorical value_map synonyms; non-fatal data-quality issues accumulate as REC001REC004 warnings rather than aborting. Purely additive — no change to any RWA calculation. Pinned by tests/unit/engine/test_collapse.py, tests/unit/engine/test_reconciliation.py, tests/contracts/test_reconciliation_contract.py, and tests/acceptance/reconciliation/test_reconcile_end_to_end.py.
  • New "Parallel-Run Reconciliation" docs guide (docs/reconciliation/index.md), surfaced as a top-level nav section. A prominent new-user guide framing reconciliation as the way to gain comfort the calculator produces the right numbers before migrating: a migration-confidence narrative (linked to the parallel-run-discipline blog post), the component/bucket model, how to read the four drill-down tiers (tie-out → by-component → break worklist → per-key forensic), and the full how-to (TOML mapping, CreditRiskCalc.reconcile(), Marimo /reconciliation app). Threaded into the Overview (Key Features bullet + "Migrate with Confidence" nav card), Getting Started, the Features index, and a cross-link distinguishing it from the CRR↔Basel-3.1 comparison. Docs only — no calculation impact.
  • New generated "Module Dependencies" docs page (docs/development/module-dependencies.md). Driven by the new curfew dev dependency, scripts/generate_dependency_graph.py builds the live import graph of src/rwa_calc and renders two Mermaid charts: a readable package-level overview (top-level subpackage edges collapsed from the module graph) and the full 144-module graph in a collapsible block. The generator is wired into scripts/deploy.py so the page refreshes on each release, mirroring the Citation Coverage Matrix. Docs/tooling only — no calculation impact.

Changed

  • The rwa-ui console script now launches the new server-rendered app (rwa_calc.ui.app.main:main), not the Marimo multi-app gateway. Brand design tokens were extracted into a single source of truth, docs/assets/stylesheets/tokens.css (the --oah-* palette, font stack and easing, previously inlined in homepage.css and hand-mirrored in the Marimo theme.css), loaded first via zensical.toml extra_css and vendored into the app under src/rwa_calc/ui/app/static/tokens.css (kept in lockstep by the drift-guard test tests/unit/ui/test_tokens_drift.py). New runtime deps: jinja2, python-multipart; new dev dep: httpx (FastAPI TestClient). UI/packaging only — no calculation impact. After upgrading, re-run uv sync to regenerate the console script.
  • watchfire is now in the default-synced [dependency-groups].dev so the arch_check pre-commit gate works on a clean checkout (tooling fix). scripts/arch_check.py invokes watchfire check as its final step, but watchfire==0.3.1 was declared only in the [project.optional-dependencies].dev extra — which uv sync does not install by default — so a plain uv sync left watchfire uninstalled and arch_check failed with watchfire not importable: No module named 'watchfire', blocking commits. Adding watchfire==0.3.1 to [dependency-groups].dev (the group uv sync installs by default) makes the citation validator part of the default dev environment, mirroring the existing pytest-xdist fix in 0.2.22. Build/tooling only — no calculation impact.
  • Renamed the UI console script rwa-calc-uirwa-ui. The [project.scripts] entry point in pyproject.toml (and its references in the README, quickstart, interactive-UI guide, workbooks guide, and interface spec) now uses the shorter rwa-ui command to launch the Marimo web server (rwa_calc.ui.marimo.server:main). Packaging/UX only — no calculation impact. After upgrading, re-run uv sync (or reinstall) to regenerate the console script; the old rwa-calc-ui name is removed.
  • Animated "URSA POLARIS" polar-bear constellation now backs both landing pages. The docs landing hero's static, slowly-rotating generic star-cluster background is replaced by a polar bear drawn as a constellation (stars + bone-lines) that walks across the night sky — STAND → CROUCH → RUN off the right edge → WALK-IN from the left → RISE, over a deterministic twinkling starfield (a nod to "computed at the speed of polars"). It is a single dependency-free vanilla-JS module, docs/assets/javascripts/bear-constellation.js, that hydrates the first .constellation-bg element via requestAnimationFrame and no-ops everywhere that element is absent (so it loads safely site-wide via zensical.toml extra_javascript); it disables itself under prefers-reduced-motion, rendering one static standing pose. The same effect now also backs the app landing page (src/rwa_calc/ui/app/templates/landing.html), which is reworked into the full-screen constellation hero matching the docs (the four nav cards collapse into the hero nav + CTAs). The landing design system (homepage.css) and the script are vendored into src/rwa_calc/ui/app/static/ and held in lockstep with their docs/ sources by the drift-guard test tests/unit/ui/test_tokens_drift.py (now parametrised over tokens.css, homepage.css and bear-constellation.js); base.html gains overridable styles/topnav/scripts blocks so the landing page can own the screen without changing the other app pages. UI/docs presentation only — no calculation impact. Pinned by tests/integration/test_ui_app.py::test_landing_hosts_bear_constellation.

[0.2.23] - 2026-06-03

Fixed

  • Facility-level provisions and guarantees now also cascade down nested facility hierarchies (CRR Art. 111(2), 213-217). The same single-level limitation fixed for collateral existed in engine/crm/provisions.py::_resolve_provisions_multi_level (facility provisions joined on the immediate parent_facility_reference) and engine/crm/guarantees.py::_resolve_guarantees_multi_level (facility guarantees _allocate_guarantees_pro_rata on parent_facility_reference), so a provision or guarantee pledged at a grandparent facility was silently allocated zero to exposures under an intermediate child facility (confirmed: a facility provision at a grandparent allocated 0.0 to both descendant loans). Both now explode the exposure's ancestor_facilities set so a provision/guarantee at any ancestor facility is allocated pro-rata (by EAD-equivalent weight / ead_after_collateral) across the whole descendant subtree, stacking across levels, with the same [parent] fallback that keeps single-level behaviour byte-for-byte unchanged. Pinned by tests/unit/crm/test_provisions.py::TestFacilityLevelProvision::test_grandparent_facility_provision_cascades and tests/unit/crm/test_multi_level_guarantees.py::TestFacilityLevelGuarantee::test_grandparent_facility_guarantee_cascades. Ref: CRR Art. 111(2), 213-217; Art. 230-231.
  • Facility-level collateral now cascades down nested facility hierarchies (CRR Art. 230-231). Collateral pledged at a facility (beneficiary_type="facility") was allocated only to exposures whose immediate parent_facility_reference matched the pledged facility: engine/crm/processor.py::_build_facility_lookup grouped exposure EAD by the immediate parent, and engine/crm/collateral.py::_apply_collateral_unified joined facility collateral on parent_facility_reference == beneficiary_reference. So when collateral sat on a grandparent facility (FAC_1 → FAC_2 → loans/contingents), a pledge_percentage resolved against FAC_1's zero direct exposures (→ resolved amount 0) and the allocation join matched nothing → zero collateral allocated, with IRB lgd_post_crm reverting to the unsecured supervisory value. The failure was independent of how the pledge was sized (percentage or explicit market_value) and of collateral type (cash, real estate, …). The HierarchyResolver already built the facility transitive closure for undrawn-limit aggregation but the CRM stage never consumed it for collateral. Fix: HierarchyResolver now emits an ancestor_facilities list column (parent + every ancestor up to root, incl. self) via a new _build_facility_ancestor_closure / _resolve_ancestors_eager; _build_facility_lookup and a new _cascade_facility_collateral consume it so a pledge at any ancestor facility flows pro-rata (by ead_for_crm) to its whole descendant subtree, pool-aware (unflagged collateral stays in the non-AIRB subtree pool per CRR Art. 181) and stacking when pledged at multiple levels. Reduces exactly to the legacy single-level allocation when no facility hierarchy is present (ancestor_facilities falls back to [parent]), so existing single-level facility tests are byte-for-byte unchanged. Pinned by tests/unit/crm/test_multi_level_sa_collateral.py::TestNestedFacilityCollateralCascade (5 SA/FIRB cases incl. grandparent percentage + amount, subtree isolation, stacked pledges), tests/unit/test_hierarchy.py::TestBuildFacilityAncestorClosure (4), and tests/integration/test_nested_facility_collateral.py (2 end-to-end through hierarchy → classifier → CRM). Ref: CRR Art. 223(4), 230-231; Art. 181.

[0.2.22] - 2026-06-02

Changed

  • Collateral-link allocation is now joint and residual-demand-aware across competing collateral items (CRR Art. 230-231). When two or more collateral_links items were pledged to overlapping beneficiaries, CollateralLinkAllocator._allocate_slices (engine/crm/link_allocation.py) split each item independently (a per-collateral_reference cumulative-cap cum_sum), with no shared state for demand already absorbed by other items — so every item piled onto the same highest-RWA-density beneficiary, over-allocating it (the downstream Art. 231 waterfall silently caps secured ≤ EAD, wasting the surplus) while starving the others. Example: two £100m cash items each linked to F1 (RW 37%) and F2 (RW 40%) both dumped £100m on F2 → F2 covered 200/100 (£100m wasted), F1 received £0, total recognised benefit £100m. The split is now a single global edge-ordered walk (_residual_fill) that fills each link against the residual of both endpoints — item finite value (supply) and beneficiary demand — so once F2 is filled by one item the other spills to F1 (both fully covered, £0 wasted, benefit £200m). Edge order is unchanged where it mattered before (explicit priority first, then descending RWA density, then lexical tie-break) with a new most-constrained-item tie-break (an item linked to fewer beneficiaries draws first) so restricted-eligibility supply is not stranded by a more flexible item. A deterministic greedy heuristic, not a global LP optimum; reduces exactly to the legacy cumulative-cap split for any single-item link set, so the existing 7 allocator tests and all 3 integration scenarios pass byte-for-byte unchanged. The per-edge supply cap preserves the Σ slices ≤ value guarantee; max_pledge_amount and priority semantics are unchanged. Gated, as before, by CalculationConfig.enable_collateral_link_splitting (default True); a corpus with no collateral_links table is unaffected. Pinned by 3 new tests in tests/unit/crm/test_collateral_link_allocation.py (test_two_items_two_facilities_joint_fill, test_constrained_item_not_stranded, test_duplicate_link_no_double_spend). Ref: CRR Art. 230-231.
  • pytest-xdist moved into the default-synced [dependency-groups].dev so the configured addopts parse on a clean checkout (release/tooling fix). [tool.pytest.ini_options].addopts unconditionally passes -n auto --dist=loadfile, but pytest-xdist was declared only in the [project.optional-dependencies].dev extra — which uv sync does not install by default — so a fresh uv sync produced an environment where every bare uv run pytest (including scripts/deploy.py's uv run pytest -x -q release gate) aborted with error: unrecognized arguments: -n --dist=loadfile. Adding pytest-xdist>=3.5.0 to [dependency-groups].dev (the group uv sync installs by default) makes the parallel-test plugin part of the default dev/release environment, matching the existing optional-extra declaration. Tooling/build only — no calculation impact.

[0.2.21] - 2026-06-01

Added

  • collateral_links is now auto-discovered from the standard data layout via the data-source registry. The optional collateral_links input (the M:N collateral-to-beneficiary mapping shipped in v0.2.20, COLLATERAL_LINK_SCHEMA) was fully wired through DataSourceConfig.from_registry() and _build_bundle in engine/loader.py, but had no entry in the DATA_SOURCES registry (config/data_sources.py) — so get_p("collateral_links") always resolved to None and the table was never picked up from the conventional layout; a caller had to set collateral_links_file by hand. A new DataSourceFile(id="collateral_links", relative_path=Path("collateral/collateral_links"), OPTIONAL) entry (mirroring the sibling collateral source) now lets DataSourceConfig.from_registry() resolve collateral_links_file to collateral/collateral_links.parquet (or .csv) automatically, exactly like every other optional input. Purely additive and behaviour-preserving — firms with no collateral_links table still take the single-beneficiary path (loader returns None gracefully when the file is absent). Pinned by tests/unit/config/test_data_sources_collateral_links.py (7 tests: registry entry presence, relative path, OPTIONAL requirement, parquet appears in get_optional, from_registry parquet/csv population, description). Ref: CRR Art. 230-231.

[0.2.20] - 2026-05-31

Added

  • Collateral M:N allocation — one finite collateral item split across multiple beneficiaries (CRR Art. 230-231). Collateral could previously attach to a single beneficiary_reference only; a real-world pledge backing several facilities/loans had no representation. A new optional collateral_links input table (COLLATERAL_LINK_SCHEMA in data/schemas.py: collateral_reference, beneficiary_type, beneficiary_reference, optional max_pledge_amount sub-limit and priority override) maps one collateral item to many beneficiaries. A new CollateralLinkAllocator (engine/crm/link_allocation.py, implementing CollateralLinkAllocatorProtocol) splits each item's finite value across its linked beneficiaries for the most beneficial RWA impact — a greedy fill of the highest pre-CRM RWA-density beneficiary first (SA-equivalent risk weight computed by a ranking pre-pass inside the CRM stage), honouring any max_pledge_amount cap, and never over-claiming (Σ slices ≤ value) via the same Art. 231 cumulative-cap trick the waterfall uses (slice_i = min(cum_i, V) − min(prev_i, V)). The allocator expands the links into per-beneficiary collateral rows of the normal COLLATERAL_SCHEMA shape, so the existing apply_collateral waterfall, EAD/LGD reduction, and SA/IRB consumers are unchanged. All five beneficiary types resolve (loan/contingent/exposure direct, facility/counterparty pooled). Threaded additively through RawDataBundle/ResolvedHierarchyBundle/ClassifiedExposuresBundle and surfaced on CRMAdjustedBundle.collateral_link_allocation (per-link audit). Referential integrity (validate_collateral_links in contracts/validation.py: unknown collateral CRM009, unknown beneficiary CRM010, duplicate link CRM011). Gated by CalculationConfig.enable_collateral_link_splitting (default True; an A/B kill-switch). Purely additive — a corpus with no collateral_links table behaves exactly as the single-beneficiary path (full suite 7178 green). Pinned by tests/unit/crm/test_collateral_link_allocation.py (7 allocator tests: finite-value split, RWA-minimising order, no-overclaim, max_pledge_amount cap, priority override, passthrough, mixed beneficiary types), tests/unit/contracts/test_validation_collateral_links.py (6), tests/unit/data/test_collateral_link_schema.py (6), and tests/integration/test_collateral_links_pipeline.py (3 end-to-end: one £1m cash item linked to a 0% sovereign loan and a 100% corporate loan lands entirely on the corporate loan, proving the RWA-ranking drives the split rather than the lexical tie-break). Ref: CRR Art. 193/194/207, Art. 230-231.

[0.2.19] - 2026-05-30

Added

  • P4.20 (v0.2.45) — COREP C 08.02 now keys IRB rows by the firm's own internal rating grade when an internal_rating_grade (RATINGS_SCHEMA) / cp_internal_rating_grade (HIERARCHY_OUTPUT_SCHEMA) column is supplied, with graceful fallback to the fixed PD buckets when absent/all-null. Per COREP Annex II §C 08.02 (one row per obligor grade); reporting-granularity only, no RWA impact. Pinned by tests/unit/test_p4_20_c0802_internal_grades.py (19 tests; existing fixed-bucket output byte-identical — tests/unit/test_corep.py 711 passed).
  • P2.25(b) (v0.2.45) — Pillar III CR5 now reports a regulatory-RE not-materially-dependent loan split at the 55% LTV boundary: new Basel-3.1 "of which" sub-rows 9f (secured ≤55% LTV @20%) / 9g (above-55% residual @counterparty RW), partitioned by re_split_role. Per PRA PS1/26 Annex XX §CR5 (Art. 124F/124L); CRR CR5 byte-identical, sub-rows not double-counted into the grand total. Pinned by tests/unit/reporting/pillar3/test_p2_25_cr5_re_55ltv_split.py (11 tests). Sub-item (c) equity-transitional end-state RW deferred (needs an engine equity_rw_end_state column; overlaps P3.6b).
  • P1.188 — Stale PS9/24 regulatory-instrument citations replaced with the final PS1/26. The post-model-adjustment docstrings/comments in contracts/config.py, data/schemas.py, engine/irb/adjustments.py and the pyproject.toml package description still cited PS9/24 (the superseded 2024 PRA consultation paper), whose numbering was reassigned to PS1/26 on publication of the final policy statement (effective 1 Jan 2027). Five in-scope source occurrences plus the package description were corrected; article numbers (Art. 153(5A)/154(4A)/158(6A)) and surrounding text are unchanged. Cosmetic/metadata only — no calculation impact. Pinned by tests/contracts/test_ps126_citation_currency.py (6 parametrized: PS9/24 absent and PS1/26 present per source file). Ref: PRA PS1/26 (final); PS9/24 (consultation — superseded).
  • P6.21 — _compute_portfolio_waterfall is now fully lazy (LazyFrame-first contract). The capital-impact waterfall builder collected its six aggregate sums mid-pipeline (engine/comparison.py), materialising a frame before the output boundary in violation of the LazyFrame-first convention. The .collect() was removed and the 4-row waterfall re-expressed with a pl.LazyFrame scaffold + cross-join + when/then so the function returns a genuine LazyFrame (no .collect().lazy() shape — arch_check check-3 clean). Values byte-identical. Pinned by tests/unit/test_comparison_waterfall_lazy.py (no-eager-collect + return-type + value-invariance), with tests/unit/test_capital_impact.py as the invariance net. Ref: CLAUDE.md Polars Conventions (LazyFrame-first).
  • P6.33 — compute_pfe delegates unmargined replacement cost to the canonical compute_rc_unmargined (CRR Art. 275(1)). The SA-CCR PFE builder (engine/ccr/pfe.py) inlined max(V_net − C_net, 0) as a duplicate of the canonical compute_rc_unmargined in engine/ccr/rc.py, so a future change to the RC kernel would have to be applied twice. compute_pfe now calls compute_rc_unmargined before composing the multiplier/EAD, preserving the has_unified_rc/rc_for_ead coalesce; the @cites("CRR Art. 278") decorator is unchanged. EAD/PFE values invariant — no calculation impact. Pinned by tests/unit/ccr/test_pfe_rc_delegation.py (delegation contract + NS-P6.33-01 golden), with tests/unit/ccr/test_pfe_multiplier.py as the invariance net. Ref: CRR Art. 275(1).
  • P2.38 — CRR Art. 155(2) non-trading-book short-position netting for the IRB Simple equity method (CRR-only). Long and short positions in the same issuer were not netted before applying the simple equity risk weight, so a short position was treated as a standalone long and equity RWA was overstated. New signed position_value plus issuer_reference and is_explicitly_hedged columns on EQUITY_EXPOSURE_SCHEMA drive a new helper engine/equity/calculator.py::_net_short_positions (@cites("CRR Art. 155(2)")): per issuer the netted long = max(0, Σ position_value) carries the netted EAD and the absorbed short → EAD/RWA 0, gated on a non-null issuer key + is_explicitly_hedged (≥1-year explicit hedge). CRR-only — Basel 3.1 routes equity to SA (PS1/26 Art. 147A), so the B31 arm never reaches the netting branch; column-absent frames are unchanged. The ≥1-year hedge-tenor floor scalar CRR_EQUITY_NETTING_MIN_HEDGE_YEARS lives in data/tables/crr_equity_rw.py (no new engine-scope scalar). Pinned by tests/acceptance/crr/test_p2_38_art_155_2_short_position_netting.py (8 tests; worked case: long £1,000,000 + short £400,000 in ISSUER-A → netted EAD £600,000 × 290% = RWA £1,740,000, vs the un-netted £4,060,000 the pre-fix engine produced). Ref: CRR Art. 155(1)-(2), Art. 165.
  • P2.46 — CRR Art. 150(1) PPU provenance enum so COREP C 07.00 can distinguish the three SA-routing reasons. Column 0050 ("Standardised Approach — of which permanent partial use") was indistinguishable from Art. 148 sequential roll-out (row 0060) and from no-permission SA, because the model_permissions input carried no provenance. New PpuReason StrEnum (art_150_1_a..j + art_148_rollout, domain/enums.py); a new ppu_reason column on MODEL_PERMISSIONS_SCHEMA and CLASSIFIER_OUTPUT_SCHEMA threaded through engine/classifier.py::_resolve_model_permissions onto the surviving SA-precedence row; reporting/corep/generator.py routes C 07.00 row 0050 (ppu_reason ∈ art_150_1_*) and row 0060 (art_148_rollout) with a graceful fallback that preserves the prior null behaviour when the column is absent (so the ~700 existing COREP tests stay green). Also adds the required "standardised" value to VALID_MODEL_PERMISSION_APPROACHES (the classifier already tested mp_approach == ApproachType.SA.value, but the constraint set rejected it). Art. 150(2) firm-level equity materiality is an explicit non-goal. Provenance-only — RWA/EAD-conservation-neutral. Pinned by tests/acceptance/crr/test_p2_46_ppu_provenance_corep.py (8 tests: three corporate SA exposures → C 07.00 rows 0050/0060/residual = £1m/£1m/£1m of total SA £3m). Ref: CRR Art. 150(1)(a)-(j), Art. 148; COREP Annex II §C 07.00.
  • P2.48 — Pillar III CR8 RWEA-flow opening and residual rows now populated (PRA PS1/26 Annex XXII §11 / CRR Art. 438(h)). The CR8 flow statement only populated the closing row; rows 1 (opening) and 2-8 (flow drivers) emitted None for lack of prior-period data. A new optional previous_period_results: pl.LazyFrame | None parameter on Pillar3Generator.generate / generate_from_lazyframe (following the P2.29 output_floor_summary precedent — no contracts/bundles.py change) lets _generate_cr8 derive row 1 (opening = prior-period IRB-non-slotting rwa_final sum, via the same _filter_irb_non_slotting + _col_sum as the closing row, so the two snapshots reconcile like-for-like) and row 8 (Other = closing − opening, a signed Float64 — increases positive, decreases negative); rows 2-7 remain None (per-driver decomposition needs exposure-level lineage not derivable from two snapshots, and is out of scope). Backwards-compatible: omitting the parameter leaves rows 1-8 None. Pinned by tests/unit/reporting/pillar3/test_p2_48_cr8_rwea_flow.py (9 tests: opening £1,000,000, residual +£150,000, closing £1,150,000; −£150,000 decrease control; reconciliation row_1 + row_8 == row_9). Ref: PRA PS1/26 Annex XXII §11; CRR Art. 438(h).
  • P2.15 — Basel 3.1 equity transitional irrevocable opt-out election (PRA PS1/26 Rules 4.9-4.10). EquityTransitionalConfig had no flag for a firm that has irrevocably elected to leave the equity transitional schedule, so such a firm could not be modelled at end-state risk weights during the 2027-2029 phase-in. A new opt_out: bool = False field (contracts/config.py) now gates both transitional gates per Rule 4.9's joint election: engine/equity/calculator.py::_equity_holding_higher_of_rw returns None (a CIU look-through equity underlying reverts from the 370% legacy Art. 155(2) higher-of to the 100% default holding RW) and _apply_transitional_floor early-returns unchanged (direct equity keeps its end-state assigned RW), @cites("PS1/26, paragraph 4.9"). The CIU higher-of mechanic the opt-out suppresses shipped earlier under P1.139. Behaviourally inert when opt_out=False (the default), so existing runs are unchanged. Pinned by tests/acceptance/basel31/test_p2_15_equity_transitional_optout.py (8 cases: CIU look-through RW 3.70 / RWA £3,700,000 at opt_out=False → RW 1.00 / RWA £1,000,000 at opt_out=True; direct LISTED control RW 2.50 / RWA £2,500,000 invariant under both). Ref: PRA PS1/26 Rules 4.9-4.10; Art. 133, CRR Art. 155(2).
  • P2.41 — exposure_subclass derivation for the Art. 147A(1)(e)/(f) COREP corporate split. The COREP C 02.00 corporate sub-rows (0295 financial/large, 0296 SME, 0297 other non-SME) were split on a is_fse = apply_fi_scalar OR cp_is_financial_sector_entity heuristic that misrouted large corporates by revenue (the Art. 153(2) FI-scalar population is threshold-gated LFSEs, which is the wrong population for the Art. 147A(1)(e) subclass). A new ExposureSubclass StrEnum (domain/enums.py) and classifier-derived exposure_subclass column on CLASSIFIER_OUTPUT_SCHEMA now label corporate exposures: engine/classifier.py::_derive_exposure_subclass (Basel-3.1-only, CRR → null; @cites("PS1/26, paragraph 147A.1")) routes FSE or cp_annual_revenue > config.thresholds.large_corporate_revenue_threshold (GBP 440m) to corporate_financial_large (Art. 147A(1)(e)), SME to corporate_sme, else corporate_other (Art. 147A(1)(f)). reporting/corep/generator.py::_c02_00_irb_sub_agg consumes the new label (with a graceful fallback to the prior heuristic when the column is absent). Reporting-granularity only — RWA-conservation-neutral (full tests/unit/test_corep.py stays 706 green). Pinned by tests/acceptance/basel31/test_p2_41_exposure_subclass_corep.py (7 tests: row 0295 == RWA(FSE) + RWA(large-corp-by-revenue) and strictly > RWA(FSE) alone, row 0297 residual 0, F-IRB conservation). Ref: PRA PS1/26 Art. 147A(1)(e)/(f), Art. 147(2)(c)(ii)/(iii); COREP Annex II §C 02.00.
  • P2.47 — Art. 137 OECD MEIP direct sovereign risk weight now applies on the Basel 3.1 arm (capital-overstatement fix). The Art. 137 Table 9 ECA/MEIP-score → risk-weight path was implemented on the CRR arm (P1.100) but engine/sa/namespace.py::_apply_b31_risk_weight_overrides lacked the cp_eca_score branch, so an unrated Basel-3.1 sovereign carrying an ECA/MEIP score fell through to the Art. 114 unrated 100% default — e.g. a score-2 sovereign was risk-weighted 100% instead of 20%, a 5× overstatement — even though PS1/26 leaves sovereign treatment unchanged from CRR. The B31 override chain now carries the same cp_eca_score branch as the CRR sibling (ordered below the Art. 114(3)/(4) domestic-currency 0% override and above the unrated fallback), reusing the existing ECA_MEIP_RISK_WEIGHTS data table via _eca_meip_rw_expr() — no new scalar, no schema change — @cites("CRR Art. 137"). Additive: a sovereign with no MEIP score keeps its current behaviour. Pinned by tests/acceptance/basel31/test_p2_47_art137_meip_b31.py (unrated B31 sovereign, MEIP score 2, USD £5,000,000 → RW 0.20 / RWA £1,000,000; anti-confound RW ≠ 1.00). Art. 136 grade-string→CQS, Art. 138 second-best, and Art. 121 sovereign-derived institution propagation remain open follow-ons. Ref: CRR Art. 137(1)-(2) Table 9; PRA PS1/26 Art. 114.
  • P2.30 — CCF Annex I Row 3 / Row 4 are now distinguishable for COREP disclosure. domain/enums.py exposed only six RiskType values, collapsing CRR Annex I Row 3 (other issued off-balance-sheet items, medium-risk) and Row 4 (NIFs/RUFs) onto the single MR value — so the C 07.00 off-balance-sheet-by-CCF section, which buckets purely on the numeric ccf_applied, could not separate the two rows for Annex I-faithful disclosure. A new RiskType.MR_ISSUED = "medium_risk_issued" (Row 3) is now distinct from MR (Row 4) but resolves to an identical 50% SA CCF (and matching F-IRB behaviour), so RWA/EAD are provably unchanged — the change is identifier-separability only. Threaded through data/schemas.py (VALID_RISK_TYPES_INPUT + RISK_TYPE_SYNONYMS mr_issued/medium_risk_issuedMR_ISSUED) and data/tables/ccf.py (explicit MR_ISSUED: 0.50 in SA_CCF_CRR/SA_CCF_B31/FIRB_OBS_FALLBACK and is_mr_or_oc membership so the F-IRB issued/commitment split mirrors MR exactly). The concrete-product → risk_type derivation table (P2.31) and the optional C 07.00 "of which" sub-row remain out of scope. Pinned by tests/unit/test_corep_annex1_row_discrimination.py (11 tests: enum/VALID_RISK_TYPES_INPUT/synonym existence RED→GREEN + CCF 0.50 / EAD £500,000 invariance for both rows; the pre-existing test_ccr_schemas_contract.py count and test_ccf_tables.py exact-table assertions were widened for the additive MR_ISSUED entry). Ref: CRR Annex I, Art. 111.
  • P3.5 — Pillar III CR9.1 (ECAI-based PD back-testing) is now callable end-to-end (Basel 3.1 only). The CR9_1_COLUMNS template existed but had no generator method, so the template was defined-but-not-callable (P3.2 was marked complete prematurely). reporting/pillar3/generator.py gains _generate_cr9_1 + _generate_cr9_1_for_class + _cr9_1_schema and a new cr9_1: dict[str, pl.DataFrame] field on the reporting-side Pillar3TemplateBundle (not the core contracts/bundles.py), wired into generate_from_lazyframe. CR9.1 is Basel-3.1-only (CRR → {}): it filters ECAI-mapped obligor rows (ecai_pd_mapping, Art. 180(1)(f)), groups by external_rating_equivalent, and reuses the existing _compute_cr9_values for the back-testing columns c–h (obligor counts, observed default rate, EAD-weighted and arithmetic average PD, historical default rate). Multi-ECAI column fan-out and Excel-sheet export remain out of scope. Pinned by tests/unit/reporting/pillar3/test_p3_5_cr9_1_ecai_backtesting.py (25 tests; seeded results fixture tests/fixtures/p3_5/; key "advanced_irb - corporate", height 3, ECAI grades "A"/"BBB", CRR framework-gate, and a CR9 regression guard). Ref: PRA PS1/26 Art. 452(h), Art. 180(1)(f), Annex XXII §15.
  • P6.25 — CalculationConfig.crr() now exposes the airb_collateral_method knob, matching basel_3_1() (config-API symmetry). The A-IRB collateral-method selector existed as a field on CalculationConfig and was settable via CalculationConfig.basel_3_1(), but the crr() factory neither accepted nor set it, so crr().airb_collateral_method silently returned None instead of a framework-appropriate default — an asymmetry that could surprise a caller constructing a CRR config and reading the field. crr() now accepts airb_collateral_method: AIRBCollateralMethod = AIRBCollateralMethod.LGD_MODELLING and threads it into the returned config (CRR A-IRB recognises collateral inside the firm's own modelled LGD per CRR Art. 161/181 — the own-LGD treatment PS1/26 Art. 169A names LGD_MODELLING; FOUNDATION is the F-IRB supervisory-LGD substitution and is the wrong default for A-IRB). The change is behaviourally inert on the CRR calculation path: every read of airb_collateral_method in engine/crm/collateral.py is is_basel_3_1-gated, so RWA/EAD/LGD are unchanged; only the reported config default moves from None to LGD_MODELLING. The dataclass field default (= None) is left untouched so raw CalculationConfig(...) construction keeps its "not-set" semantics. Pinned by tests/unit/crm/test_art169_lgd_modelling.py::TestConfigAndEnum (3 tests: crr() default == LGD_MODELLING, explicit override pass-through == FOUNDATION, and a basel_3_1() symmetry regression-lock; the stale test_crr_config_no_airb_method is None assertion was flipped). Ref: CRR Art. 161/181; PRA PS1/26 Art. 169A.
  • P1.141 — Basel 3.1 mixed real-estate exposures now enforce the Art. 124(4) all-or-nothing qualifying gate. The pro-rata-by-collateral-value split of a mixed RRE/CRE exposure was already implemented, but the second limb of Art. 124(4) — the preferential Art. 124F–124I real-estate tables apply only if both the residential and the commercial component separately qualify under Art. 124A, otherwise both fall to Art. 124J (no partial preference) — was not enforced, so a mixed exposure whose commercial component failed Art. 124A still received the residential 20% preferential band (capital understatement). engine/hierarchy.py now aggregates a per-exposure re_collateral_non_qualifying flag and preserves uncapped residential/commercial collateral values before the retail-threshold cap (which was distorting the split shares when total collateral exceeded the exposure); engine/classifier.py emits a Basel-3.1-only re_split_force_other_re = is_mixed & re_collateral_non_qualifying (@cites("PS1/26, paragraph 124.4"); the CRR path stays pl.lit(False), unchanged); engine/re_splitter.py then routes both secured rows through Art. 124J (b31_other_re_rw_expr: RESI → counterparty RW, CRE → max(60%, counterparty RW)) at full pro-rata EAD, dropping the 0.55×value cap so the residual is zero. No new regulatory scalar (reuses B31_OTHER_RE_CRE_FLOOR_RW). Worked golden: an unrated-corporate £2,000,000 mixed exposure (£1.5m qualifying residential + £1.0m non-qualifying commercial collateral) now risk-weights both legs at 100% → RWA £2,000,000 (vs the pre-fix £1,340,000 that retained the residential 20% band). Pinned by tests/acceptance/basel31/test_p1_141_art_124_4_all_or_nothing_gate.py (9 tests). Ref: PRA PS1/26 Art. 124(4), Art. 124A, Art. 124J.
  • P2.32 — Basel 3.1 CCF for undrawn purchased-receivables purchase commitments (Art. 166E(5)). engine/ccf.py had no purchased-receivables branch, so an undrawn revolving purchase commitment fell through to the generic risk-type CCF ladder (e.g. a medium-risk commitment took 50% instead of the regulatory 40%). A new is_purchased_receivable_commitment boolean on FACILITY_SCHEMA / CONTINGENTS_SCHEMA (threaded through engine/hierarchy.py mirroring is_uk_residential_mortgage_commitment) drives a Basel-3.1-gated override in _compute_ccf (_apply_purchased_receivable_ccf, @cites("PS1/26, paragraph 166.5")): a revolving purchase commitment converts at 40% (Table A1 Row 5 "other commitments") or 10% where it meets the Row 7 unconditionally-cancellable criteria, reusing SA_CCF_B31["OC"] / ["LR"] (no new scalar). The override is a no-op under CRR (which has no dedicated purchased-receivables undrawn-commitment CCF). The dilution/default split and the Art. 166A(5) EAD netting remain separate, out-of-scope mechanisms. Pinned by tests/acceptance/basel31/test_p2_32_purchased_receivable_ccf.py (7 tests; load-bearing: a medium-risk-tagged revolving purchase commitment of £1,000,000 → CCF 40% / EAD £400,000, overriding the generic 50% / £500,000; CRR control unchanged). Ref: CRR Art. 166(5), PRA PS1/26 Art. 166E(5).
  • P5.15 — Basel 3.1 retail qualification now enforces the Art. 123A(1)(b)(ii) 0.2% granularity sub-condition. The regulatory-retail check previously enforced only the GBP 880k aggregate-exposure limb; the second limb of the same sub-paragraph — no single obligor may exceed 0.2% of the total retail portfolio — was unimplemented, so a concentrated natural-person exposure was risk-weighted at the 75% retail RW when it should fall to the 100% corporate RW (capital understatement). New B31_RETAIL_GRANULARITY_LIMIT = Decimal("0.002") in data/tables/b31_risk_weights.py; engine/classifier.py::_build_qualifies_as_retail_expr gains a Basel-3.1-only granularity limb after the SME auto-qualify and GBP 880k threshold branches — a candidate-retail obligor whose per-obligor aggregate (lending_group_adjusted_exposure) exceeds 0.2% of the total candidate-retail portfolio (denominator de-duplicated to one contribution per obligor via pl.len().over("counterparty_reference") wrapped in partition_by_nullable, guarded against a zero portfolio total) fails qualifies_as_retail and re-routes to CORPORATE. The limb is gated on a new CalculationConfig.enforce_retail_granularity flag (default True; settable via basel_3_1(enforce_retail_granularity=...)) so it can be suppressed where a firm assesses portfolio granularity by another method under CRE20.66's national-discretion clause — and so isolated single-obligor tests of the other Art. 123A limbs (pool management, GBP 880k threshold) are not spuriously re-classed (a single obligor is trivially 100% > 0.2%). The CRR Art. 123 branch stays threshold-only. Pinned by tests/acceptance/basel31/test_p5_15_art_123a_granularity.py (19 tests; breach obligor → CORPORATE RW 1.00 / RWA £2,000 with the load-bearing anti-confound that its aggregate is below GBP 880k so only the granularity limb re-classed it; control-pass obligor stays retail RW 0.75 / RWA £750). Ref: PRA PS1/26 Art. 123A(1)(b)(ii); BCBS CRE20.66.
  • P2.29 — Pillar III OV1 equity sub-approach and output-floor rows are now populated (PRA PS1/26 Annex XX §OV1). B31 OV1 rows 11-14 (equity under IRB Transitional / look-through / mandate-based / fall-back) and rows 26/27 (output-floor multiplier / OF-ADJ) were hard-coded null despite the pipeline carrying the source columns, so the disclosure could not reproduce the Annex XX template. reporting/pillar3/generator.py replaces the catch-all _OV1_EXPLICIT_NULL_REFS with _OV1_FLOOR_NO_SHIM_REFS={"26","27"} plus an _OV1_EQUITY_SUBAPPROACH_REFS discriminator map: rows 11-14 sum rwa_final over approach_applied="equity" and the row's equity_transitional_approach/ciu_approach discriminator (own-funds column c = 8% × column a); row 26 reads the first non-null output_floor_pct; row 27 reads OutputFloorSummary.of_adj via a new optional generate_from_lazyframe(output_floor_summary=...) parameter (default None, so the parquet-backed generate() path and all existing callers are unaffected; rows 26/27 are excluded from the column-c shim). Rows 11-14 are memo "of which" sub-rows of equity already counted in row 2 and are deliberately not folded into the row-29 grand total. Pinned by tests/unit/reporting/pillar3/test_p2_29_ov1_equity_subapproach.py (15 tests, Python-only seeded-results fixture + OutputFloorSummary). Ref: PRA PS1/26 Annex XX §OV1.
  • P1.153 — CRR Art. 155(3) PD/LGD equity approach (CRR-only; removed under Basel 3.1). The IRB PD/LGD method for equity exposures was entirely absent — equity routed only to SA (Art. 133) or IRB Simple (Art. 155(2)). New EquityApproach.PD_LGD enum (domain/enums.py) and a crr_equity_pd_lgd.py data table carrying the Art. 165 supervisory parameters: PD floors 0.09%/0.09%/0.40%/1.25% (Art. 165(1)(a)-(d)), LGD 90% (65% for diversified private equity, Art. 165(2)), M=5y (Art. 165(3)), and the Art. 155(3) 1.5× scaling applied when the firm lacks Art. 178 default-definition data. A new equity_pd_lgd: bool flag on CalculationConfig selects the approach (gated to CRR with IRB permissions; ignored under Basel 3.1, where IRB equity is removed by PS1/26 Art. 147A), and a new has_default_definition_info column on EQUITY_EXPOSURE_SCHEMA drives the 1.5× branch. engine/equity/calculator.py::_apply_equity_weights_pd_lgd reuses the shared corporate IRB primitives (engine/irb/formulas.py) for correlation/K/maturity-adjustment, then applies RWEA = K×12.5×1.06×MA×EAD, the per-exposure cap min(RWEA, EAD×12.5 − EL×12.5), and bypasses the Simple-approach transitional floor. Pinned by tests/acceptance/crr/test_p1_153_art_155_3_pdlgd_equity.py (14 tests; worked exchange-traded exposure of EAD £1,000,000 → risk weight 1.918731, RWA £1,918,731, K 0.0736714, cap non-binding). Ref: CRR Art. 155(3), Art. 165.
  • P1.30(e) — Art. 234 partial-protection (mezzanine) tranching of credit protection. Previously every guarantee attached to first loss [0,G), leaving a single senior remainder; protection covering only a middle loss band [a,d) (with the borrower retaining both a first-loss tranche [0,a) and a senior tranche [d,EAD] at its own risk weight) was not modelled. New optional attachment_amount/detachment_amount columns on GUARANTEE_SCHEMA let protection attach to a mezzanine band; engine/crm/guarantees.py::_build_remainder_sub_rows (now @cites("CRR Art. 234")) emits a three-row split (first-loss __REM_FL + guarantor-substituted mezzanine + senior __REM_SEN) when attachment_amount > 0, composing after the existing FX/restructuring/maturity-mismatch haircuts; a null attachment preserves the legacy single-__REM first-loss behaviour byte-for-behaviour. The redistribute_non_beneficial remainder predicate was loosened (ends_withcontains("__REM")) so both retained tranches are recognised. Pinned by tests/acceptance/crr/test_p1_30e_art_234_partial_protection_tranching.py (11 tests: three-row structure, per-tranche EAD 200k/400k/400k, ΣEAD conservation = £1,000,000, exactly-one-guarantor-row, blended RW 0.80). Ref: CRR Art. 234, PRA PS1/26 Art. 234.
  • P1.94(e) — Basel 3.1 currency-mismatch 1.5× multiplier is now suppressed for pre-2027 reporting dates (PRA PS1/26 Art. 123B(3) transitional). The Art. 123B 1.5× multiplier is a Basel-3.1 measure commencing 1 January 2027, but the engine applied it whenever is_basel_3_1 was set, so a Basel-3.1-configured run dated before commencement over-weighted unhedged FX-mismatched retail/RE exposures. A new B31_EFFECTIVE_DATE = date(2027, 1, 1) in data/tables/b31_risk_weights.py plus a strict if config.reporting_date < B31_EFFECTIVE_DATE short-circuit at the head of engine/sa/namespace.py::apply_currency_mismatch_multiplier (after the existing is_basel_3_1 guard) now returns the frame unchanged — emitting currency_mismatch_multiplier_applied = False so downstream reporting always sees the flag — for reporting dates strictly before 1 Jan 2027 (the boundary date itself is in scope). @cites("PS1/26, paragraph 123B.3"). Pinned by tests/acceptance/basel31/test_p1_94e_pre_2027_fallback.py (7 tests: Run A 2026-12-31 → RW 0.75 / RWA £75,000, multiplier suppressed; Run B 2027-01-01 → RW 1.125 / RWA £112,500, multiplier fires). Ref: PRA PS1/26 Art. 123B(3).
  • P2.31 — Annex I concrete-product → risk_type mapping table (eliminates manual OBS-item classification). The CCF engine mapped abstract Annex I risk_type buckets to conversion factors but had no table translating concrete off-balance-sheet product descriptions (acceptances, performance bonds, warranties, tender bonds, documentary credits) into those buckets, leaving users to hand-classify every contingent — a silent-misclassification risk. A new input-domain obs_product column on FACILITY_SCHEMA/CONTINGENTS_SCHEMA (data/schemas.py, with VALID_OBS_PRODUCTS + OBS_PRODUCT_SYNONYMS normalisation) feeds a single, framework-invariant ANNEX1_PRODUCT_RISK_TYPE dict + build_product_to_risk_type_expr in data/tables/ccf.py (ACCEPTANCE → FR/100%; performance/tender/bid bonds, warranties, documentary/trade credits → MLR/20% — all framework-invariant under SA Table A1, so the framework split stays solely in the untouched SA_CCF_CRR/SA_CCF_B31). engine/ccf.py::_compute_ccf fills risk_type from obs_product only when no explicit risk_type is supplied (explicit always wins), @cites("CRR Art. 111"). Regulatory note: under SA Annex I, performance bonds are MLR/20%; the 50% sometimes cited is the F-IRB Art. 166(10)(b) treatment, not the SA bucket. Pinned by tests/acceptance/crr/test_p2_31_annex1_mapping.py (10 tests: ACCEPTANCE → CCF 1.00 / EAD £2,000,000; PERFORMANCE_BOND and DOCUMENTARY_CREDIT → CCF 0.20 / EAD £400,000; explicit-LR override preserved). Ref: CRR Annex I, Art. 111.
  • P2.49 — Pillar III CR9 column-a taxonomy extended to the full PRA PS1/26 Annex XXII leaf set. The CR9 (IRB PD back-testing) disclosure collapsed the regulatory column-a breakdown — F-IRB lacked the financial/large-corporate and other-corporate (non-SME) sub-classes, and A-IRB collapsed seven retail/corporate sub-classes into retail_mortgage/retail_qrre/retail_other. CR9_FIRB_CLASSES now carries 5 leaves (added corporate_financial_large per Art. 147(2)(c)(ii) F-IRB-only and corporate_other_non_sme) and CR9_AIRB_CLASSES 10 (retail RRE/CRE × SME/non-SME, QRRE, Other × SME/non-SME, plus the corporate splits). To respect the data/engine boundary the row definition became a 3-tuple (row_key, label, CR9ClassSpec) where CR9ClassSpec is a plain frozen dataclass — reporting/pillar3/templates.py stays import-clean (no Polars; arch_check-enforced) — and the descriptor→pl.Expr resolution (_cr9_class_predicate, discriminating on exposure_class/is_sme/property_type/cp_is_financial_sector_entity with graceful degradation when a discriminator is absent) lives in reporting/pillar3/generator.py, applied to both _generate_all_cr9 and _generate_cr9_1. @cites("PS1/26, paragraph 147.2"). Supersedes P2.28. Pinned by tests/unit/reporting/pillar3/test_p2_49_cr9_taxonomy.py (13 tests: leaf counts 5/10, 15 expected keys, collapsed-parent absence, discriminator routing); the existing test_pillar3.py CR9 count/key/value tests and the _make_cr9_irb_data fixture were updated for the new taxonomy (full pillar3 surface: 256 tests green). Ref: PRA PS1/26 Annex XXII pp.19-20, Art. 147(2).

Changed

  • P1.94(d) — Basel 3.1 currency-mismatch 1.5× multiplier now applies the Art. 123B(2A) revolving-instalment rule. The Art. 123B(2) 90%-hedge-coverage waiver previously measured coverage against a revolving facility's current drawn balance, so a facility 90%-hedged on a small drawing escaped the multiplier even with large undrawn headroom. Per Art. 123B(2A), engine/sa/namespace.py::apply_currency_mismatch_multiplier now rescales the coverage test against the fully-drawn base (max(drawn_amount, facility_limit)) for is_revolving rows — effective coverage = hedge_coverage_ratio × drawn / full_draw_base — so the waiver correctly fails once measured against the committed limit (e.g. a 0.95-hedged-on-drawing revolving QRRE with £100k drawn / £400k limit → effective 0.2375 < 0.90 → multiplier fires → RW 0.75→1.125). Non-revolving exposures are unchanged; the change reuses the existing B31_CURRENCY_MISMATCH_HEDGE_COVERAGE_FLOOR (no new floor literal) and defaults defensively when the revolving columns are absent (production frames unaffected). Pinned by tests/acceptance/basel31/test_p1_94d_art_123b_2a_revolving_instalment_rule.py (11 tests: revolving in-scope RW 1.125 / RWA £112,500; non-revolving and fully-drawn-revolving controls hold the waiver at RW 0.75). Ref: PRA PS1/26 Art. 123B(2A); BCBS CRE20.88.

Fixed

  • P1.175 — CRR Art. 114 / Art. 123 citation comments corrected (cosmetic, no calc impact). In-source comments cited "Art. 114(3)/(4)" for the domestic-currency sovereign 0% RW (correct refs: Art. 114(4) UK / Art. 114(7) EU) and "Art. 123(3)(a-b)" for the 35% retail payroll/pension RW (correct ref: Art. 123(4)). 20 citation strings corrected across engine/sa/namespace.py, engine/classifier.py, engine/irb/guarantee.py, data/tables/eu_sovereign.py, data/schemas.py, data/tables/b31_risk_weights.py; the legitimate Art. 123(3)(c) 100% non-regulatory-retail citation is preserved and no numeric scalar changed. Pinned by tests/contracts/test_crr_art114_citation_paragraph.py + tests/contracts/test_crr_art123_payroll_citation.py. Ref: CRR Art. 114(4)/(7), Art. 123(4).
  • P6.32 — two inline A-IRB 0.5 floor multipliers hoisted out of engine/ccf.py into new data/tables/airb_floors.py (arch-cleanup, no calc impact). The CRE32.27 own-estimate-CCF floor and the Art. 166D(5)(b) off-balance-sheet EAD floor were bare 0.5 literals carrying # TODO markers; both are now Decimal("0.5") constants (AIRB_REVOLVING_CCF_FLOOR_MULTIPLIER, AIRB_OBS_FLOOR_B_MULTIPLIER) in a new data-table module, coerced Decimal->float at the call site via the house pattern. float(Decimal("0.5")) == 0.5, so values are byte-identical (tests/unit/test_ccf.py 156 passed unchanged). Pinned by tests/unit/test_p6_32_airb_floors_hoisted.py (AST scan of the two function bodies + constant-value checks). Ref: PRA PS1/26 Art. 166D(5)(b); BCBS CRE32.27.
  • P6.35 — apply_ccp_risk_weight QCCP trade-exposure RW citation corrected from CRR Art. 307 to Art. 306(1) (metadata-only; updates the watchfire citation matrix). The 4% client-cleared trade-exposure RW carried a stray @cites("CRR Art. 307") (Art. 307 defines the trade-exposure value, not the RW) stacked beside the correct @cites("CRR Art. 306"); the redundant decorator is removed and the docstring relabelled so both the 2% and 4% RWs cite Art. 306(1). Runtime unchanged (0.02 / 0.04). Pinned by tests/unit/ccr/test_ccp.py::test_apply_ccp_risk_weight_cites_art_306_not_307. Ref: CRR Art. 306(1); PRA PS1/26 Annex R §9.
  • P6.23 — transitional output-floor reporting dates corrected from mid-year to 1 January (PRA PS1/26 Art. 92(5)). engine/comparison.py::_TRANSITIONAL_REPORTING_DATES was hardcoded to date(YYYY, 6, 30) for the transitional-schedule comparison runner, contradicting Art. 92(5), which keys each transitional output-floor rate to 1 January (60% from 1 Jan 2027, 65% from 1 Jan 2028, 70% from 1 Jan 2029) with the 72.5% steady-state applying from 1 Jan 2030 under Art. 92(2A). The four literals are now date(YYYY, 1, 1) and two stale "mid-year" docstrings were corrected; the canonical OutputFloorConfig.basel_3_1() schedule was already on 1-Jan dates, so only the comparison-runner constant was out of step (the mid-year dates happened to resolve to the same percentage within each calendar band, so this surfaced as a wrong reporting-boundary date rather than a wrong floor percentage). Pinned by tests/unit/test_transitional_schedule.py (TestTransitionalDates::test_dates_are_first_of_january, inverted from the prior mid-year assertion, plus a new default-path timeline reporting_date behavioural test; 21 tests green). Ref: PRA PS1/26 Art. 92(5)/(2A).
  • P2.26 — COREP "(-)"-labelled deduction columns now reported with the correct negative sign (Annex II §1.3). reporting/corep/generator.py emitted positive sums for the C 07.00 / OF 07.00 deduction columns 0030/0035/0050/0060/0070/0080/0090/0130/0140 and the C 08.01/02 memorandum column 0290, all of which COREP Annex II §1.3 declares as "(-)" (negative-signed) values — so PRA DPM validation would reject the return. A _negate_deduction_cols(values, negative_cols) pass keyed on module-level _C07_NEGATIVE_COLS / _C08_NEGATIVE_COLS now negates those columns at the emit boundary, applied after the reconciliation columns (0040 net-of-adjustments, 0110, 0150, IRB 0090) have consumed the positive magnitudes — so the net-exposure arithmetic is byte-unchanged and non-"(-)" columns stay positive; negative-zero is normalised to 0.0 and nulls preserved (@cites("PS1/26, paragraph 1.3")). Pinned by tests/unit/test_corep.py::TestSignConvention (15 tests, including over-negation invariants that assert col 0040 stays +850), with 17 pre-existing COREP tests updated to the corrected sign (13 direct sign flips + 4 reconciliation re-derivations replaced by direct engine-value assertions). Ref: COREP Annex II §1.3.
  • P1.130 — aggregator class/approach summaries now reflect post-floor RWA when the output floor binds (PRA PS1/26 Art. 92(2A)). summary_by_class / summary_by_approach (and post_crm_detailed / post_crm_summary) on AggregatedResultBundle were generated in engine/aggregator/aggregator.py from the pre-floor combined frame, before the portfolio-level output-floor block reassigns combined to the floored frame. Because engine/aggregator/_summaries.py computes total_rwa as (reporting_ead × reporting_rw).sum() and the floor never recomputes reporting_rw, the reported per-class and per-approach RWA understated the floored regulatory total (Art. 92(2A): TREA = max{U-TREA; x·S-TREA + OF-ADJ}) on every portfolio where the floor bound — e.g. a binding-floor portfolio summarised 109.6M instead of the floored 193.75M. Fix (two files): (a) aggregator.py moves post_crm_detailed/post_crm_summary/summary_by_class/summary_by_approach generation to after the floor block so they build from the floored combined (return-arg order unchanged; pre_crm_summary left in place); (b) _summaries.py adds a private _floor_addon_expr(cols, ead_col) that folds the per-row floor_impact_rwa add-on (allocated by reporting_ead / ead_final share so guarantee-split rows don't double-count; no-op pl.lit(0.0) when the floor didn't run/bind) into the reporting-path total_rwa for both the by-class and by-approach aggregations. The Art. 62(d) EL/T2-credit-cap carve-out (which intentionally uses un-floored IRB RWA) is left byte-for-byte identical, and the floor-not-binding path is unchanged. Pinned by tests/acceptance/basel31/test_p1_130_summaries_reflect_post_floor.py (6 tests; assertions are relationship-framed — summary totals reconcile to output_floor_summary.total_rwa_post_floor and results.rwa_final — so they are robust to IRB-K drift). Ref: PRA PS1/26 Art. 92(2A); CRR Art. 62(d) (carve-out, unchanged).

[0.2.18] - 2026-05-29

Changed

  • On-balance-sheet netting (CRR Art. 195/219) is now driven solely by a netting_agreement_reference, not facility hierarchy (breaking input-schema change). Previously generate_netting_collateral (src/rwa_calc/engine/crm/collateral.py) pooled negative-drawn deposits and positive-drawn sibling loans by coalesce(netting_facility_reference, root_facility_reference, parent_facility_reference) — so netting followed the facility tree and silently crossed counterparties whenever they shared a facility node, while the explicit netting_facility_reference override never even reached netting in the production loader path (it was dropped by the _coerce_loans_to_unified curated select). Netting now keys exclusively on a new netting_agreement_reference (String, optional) column: a deposit (drawn_amount < 0 with a non-null reference) and the loans it offsets net together iff they carry the same reference, across different facilities, roots, or counterparties — reflecting the legal right of set-off, which is defined by the agreement rather than the facility structure. The deposit filter drops the old has_netting_agreement == True and parent_facility_reference is not null requirements; the sibling match is a single equality join on netting_agreement_reference; the pool is grouped by (netting_agreement_reference, currency) and allocated pro-rata by on_bs_for_ead (Art. 219 drawn-on-drawn scope unchanged — contingents and facility_undrawn rows remain excluded). The hierarchy negative-balance survival guard (hierarchy.py::_aggregate_loan_drawn_per_facility and the MOF sub-facility helper) now preserves a negative drawn balance when netting_agreement_reference IS NOT NULL instead of when has_netting_agreement is set, and the new column is threaded through _coerce_loans_to_unified so it survives to the netting stage (closing the latent override bug). Breaking: the has_netting_agreement (Boolean) and netting_facility_reference (String) columns are removed from LOAN_SCHEMA — callers must supply netting_agreement_reference instead; portfolios that previously netted via shared facility/root, or relied on the has_netting_agreement flag, will stop netting until the participating rows are given a matching reference (this can increase RWA for affected portfolios — intended). @cites("CRR Art. 219") added to generate_netting_collateral (watchfire coverage tuple updated). processor._join_netting_amounts and COREP C 08.01/02 column 0035 (on_bs_netting_amount) are unchanged. Rewritten tests/unit/crm/test_netting.py pins the new contract — including test_cross_counterparty_cross_facility_netting (a deposit for counterparty A under facility FAC_A nets a loan to counterparty B under facility FAC_B sharing AGR1) and test_only_matching_reference_nets_in_same_facility (same facility, different references ⇒ no netting). Docs updated: docs/data-model/input-schemas.md, docs/architecture/data-flow.md, docs/user-guide/methodology/crm.md. Ref: CRR Art. 195, Art. 219, Art. 223; PRA PS1/26 Art. 195/219.

[0.2.17] - 2026-05-29

Added

  • SA-CCR per-article spec pages (D2.77 / D2.78 / D2.79). Three more pages under docs/specifications/crr/ccr/ close the next set of discoverability gaps against shipped src/rwa_calc/engine/ccr/ modules: legal-enforceability.md documents Art. 272(4) contractual-netting definition, Art. 295 framework for recognising netting (three eligible agreement types + cross-entity-group exclusion), Art. 296(2)(a)–(d) four legal-opinion obligations including the four-jurisdiction sweep, Art. 297 ongoing-monitoring duty plus supervisory power to disregard non-enforceable netting, the engine break-out at sa_ccr.py:83-210 (each trade in a non-enforceable set becomes its own one-trade synthetic netting set), and a worked two-ITM / three-OTM example showing higher EAD when netting is non-enforceable; wrong-way-risk.md documents Art. 291(1)(a) general WWR and 291(1)(b) specific WWR definitions, the 291(2)–(3) identification and stress-testing process, the 291(5) carve-out plus LGD = 100% override mechanic, the 291(6) senior-management reporting hook, and the SA-CCR ↔ IMM demarcation (Art. 284(9) IMM α re-estimation, Art. 274(2) SA-CCR α = 1.4 unchanged) for general WWR — engine wwr.py documented including the unwired-into-pipeline_adapter status; ccp-exposures.md documents Art. 306(1)(a) 2% QCCP trade-exposure RW, Art. 306(1)(b)–(c) client-cleared 2% / 4% split with the Art. 305(2)(a)–(c) segregation / portability / operational-requirement condition list, Art. 306(4) RWA aggregation, Art. 307 / 308(3) / 309(2) default-fund-contribution 12.5× multiplier, and the non-QCCP fallback through the standard SA-CCR + SA institution ladder with four worked examples (direct-cleared, client-cleared both branches, non-QCCP). Ref: PRA Rulebook (CRR) Part Art. 272(4), 291, 295–297, 305, 306–309 (Basel 3.1 inherits unchanged per PS1/26 Appendix 1 p. 396, 457).
  • SA-CCR per-article spec pages (D2.74 / D2.75 / D2.76). Three more pages under docs/specifications/crr/ccr/ close the next set of discoverability gaps against shipped src/rwa_calc/engine/ccr/ modules: rc-calculation.md documents Art. 275 — unmargined RC = max(V − C, 0), margined RC = max(V − C, TH + MTA − NICA, 0), the Art. 271(7) NICA composition (independent collateral + segregated IM held − non-segregated IM pledged), and four worked NS_A/B/C/D examples covering the threshold-floor binding behaviour; pfe-multiplier.md documents Art. 278 — the asset-class add-on aggregation as a plain linear sum across IR + FX + credit + equity + commodity (fill_null(0.0) for placeholder asset classes), the canonical multiplier min(1, F + (1−F)·exp((V−C)/(2·(1−F)·AddOn))) with F = 0.05, the four-regime behaviour table, and a CCR-A1 cross-check pinned to the live golden values; ead-composition.md documents Art. 274(2) — EAD = α·(RC + PFE) with α = 1.4, BCBS CRE52.1 calibration rationale, the Art. 274(2) α = 1 carve-out for non-financial counterparties and pension scheme arrangements, the Art. 274(2A)–(2B) transitional alpha-add-on phase-in for legacy CVA-exempt trades, downstream routing into the SA/IRB exposure ladder via pipeline_adapter.ccr_rows_to_exposures, and the Art. 92(2A) S-TREA consumer for the output floor. Ref: PRA PS1/26 Art. 274, 275, 278, 271(7); BCBS CRE52.1, CRE52.10–11, CRE52.20–23.
  • SA-CCR per-article spec pages (D2.71 / D2.72 / D2.73). Three new pages under docs/specifications/crr/ccr/ close the discoverability gap between the shipped src/rwa_calc/engine/ccr/ modules and the spec set: supervisory-delta.md documents Art. 279a (linear ±1, Black-Scholes Φ(d1) option delta with the long/short × call/put sign rule, and the Art. 279a(3) CDO-tranche attachment/detachment formula); maturity-factor.md documents Art. 279c unmargined √(min(M,1y)/1y) with the 10 BD floor and Art. 285 margined 1.5·√(MPOR_eff/250) plus the MPOR cascade (5 BD SFT / 10 BD OTC base, 20 BD large-or-illiquid upgrade, dispute doubling, remargining-frequency adjustment); hedging-sets.md documents the Art. 277 per-asset-class partition (IR three maturity buckets per currency, FX per currency pair, credit single name, equity single issuer, commodity five buckets) and the Art. 277a inter-bucket correlation parameters. IR + FX worked examples land now; credit / equity / commodity worked examples placeholder-flagged on engine batch P8.35–P8.38. The crr/ccr/index.md status table flipped three rows from "Pending" to "Live". Ref: PRA PS1/26 Art. 277, 277a, 279a, 279c, 285.

Fixed

  • P8.19 — SA-CCR margined replacement cost (Art. 275(2)) now wired through the pipeline adapter. compute_rc_margined was implemented and unit-tested (P8.11) but never invoked by the orchestrator: pipeline_adapter.py::ccr_rows_to_exposures only called compute_pfe, which inlined the unmargined RC = max(V − C, 0) for every netting set — so margined sets that should bind the threshold floor max(V − C, TH + MTA − NICA, 0) were understated, and the synthetic exposure row carried only rc_unmargined. _derivative_rows_to_exposures now calls compute_rc_unmargined + compute_rc_margined on the netting-set frame and coalesce(rc_margined, rc_unmargined) into a unified rc column; compute_pfe consumes that unified rc when present (EAD = α·(rc + PFE)) and falls back to rc_unmargined otherwise, keeping the lazy plan single-pass and the call backward-compatible. rc_margined and rc are surfaced on the synthetic row for the COREP-reconciliation surface. Worked golden (CCR-A13): a margined institution NS with V = −4,000,000, TH = 2,000,000, MTA = 500,000, NICA = 250,000 now takes rc_margined = 2,250,000 (TH + MTA − NICA arm) → EAD = 6,464,360.39 / RWA = 3,232,180.20 at CQS-2 institution 50%, vs the buggy rc = 0 / RWA = 1,657,180.20. Margined maturity factor (P8.14) remains out of scope. Pinned by tests/acceptance/ccr/test_ccr_a13_margined_rc.py (6 tests). Ref: PRA PS1/26 Art. 275(2); CRR Art. 275(2); Art. 285.
  • P1.139 — Basel 3.1 CIU equity transitional Rule 4.7-4.8 higher-of now applied to look-through / mandate-based equity underlyings (PRA PS1/26 Rules 4.7-4.8; CRR Art. 155(2)). _apply_transitional_floor excludes CIU look-through / mandate-based exposures from the equity transitional floor (correct for the wrapper, which is not itself floored per the Rule 4.7 derogation to Art. 132A) — but the exclusion also meant the underlyings never received the Rule 4.8 higher-of, and an EQUITY-class underlying with no CQS fell back to the 100% CQS-miss default (_DEFAULT_HOLDING_RW), understating capital for transitional years 2027-2029. Fix lands in engine/equity/calculator.py::_resolve_look_through_rw (not _apply_transitional_floor, which is unchanged): an EQUITY-class look-through / mandate underlying whose CQS join misses now takes max(legacy Art. 155(2) "other equity" simple RW = 370%, Rule 4.2/4.3 transitional SA RW) whenever the B3.1 equity-transitional regime is active — gated on the existing equity_transitional.enabled + reporting_date surface (no new config fields; equity_transitional.enabled is the IRB-permission proxy since the regime only applies to firms that held IRB equity permission on 31 Dec 2026). New helper _equity_holding_higher_of_rw reuses IRB_SIMPLE_EQUITY_RISK_WEIGHTS (no new engine-scope scalar). Worked golden: a £1m look-through CIU with a £600k EQUITY underlying (→ max(370%, 160%)=370%) and a £400k CORPORATE CQS-3 underlying (→ B3.1 75%) re-aggregates to ciu_look_through_rw = 2.52, RWA = 2,520,000 vs the buggy 900,000; the wrapper stays unfloored. Mandate-based 1.2× variant, the Rule 4.9-4.10 opt-out election (P2.15), and the no-IRB-permission negative control remain out of scope. Pinned by tests/acceptance/basel31/test_p1_139_ciu_transitional_higher_of.py (3 tests). Ref: PRA PS1/26 Rules 4.7-4.8; CRR Art. 155(2).
  • P1.142 — Art. 124E three-property limit now auto-derives income-dependent RE routing for natural persons (PRA PS1/26 Art. 124E(2)). A natural-person obligor collateralised by more than three qualifying residential properties is "materially dependent on cash flows generated by the property" and must take Art. 124G income-producing risk weights — but the engine had no derivation, so a 4+-property buy-to-let obligor was silently risk-weighted on the Art. 124F loan-split track unless the caller manually set the income flag, understating capital. New qualifying_property_count: ColumnSpec(pl.Int32, required=False) on COUNTERPARTY_SCHEMA (data/schemas.py) and B31_RRE_THREE_PROPERTY_LIMIT = 3 in data/tables/b31_risk_weights.py; engine/classifier.py derives materially_dependent = cp_is_natural_person AND (cp_qualifying_property_count > 3) (strict >3) with coalesce precedence so an explicit caller-supplied income flag still wins, Basel-3.1-only guard (CRR routing untouched). The derived flag overrides has_income_cover, routing the breach obligor onto Art. 124G (70-80% LTV → RW 0.50, RWA 100,000) while a 3-property control obligor stays on the Art. 124F loan-split (RW 0.34667, RWA 69,333.33). Cross-lender aggregation and housing-unit counting (Art. 124E(4)) remain the caller's responsibility (out of scope). Pinned by tests/acceptance/basel31/test_p1_142_three_property_income_dependent.py (6 tests, incl. the strict->3 boundary). Ref: PRA PS1/26 Art. 124E(2), Art. 124G.
  • P8.38 — SA-CCR SFT EAD now routes through CRR Art. 271(2) + Art. 220-223 FCCM branch. Previously, booking an SFT (transaction_type="sft") through the trades table picked up the derivative α·(RC+PFE) path in engine/ccr/sa_ccr.py::compute_ead and produced an EAD on the order of £4M from the small per-asset-class supervisory-factor add-on — instead of the regulatorily correct E* = max(0, E·(1+HE) − CVA·(1−HC−HFX)) of ~£65M for a £60.7M uncollateralised IG ≥5y debt-security SFT (operator-surfaced via empirical mis-pricing). New src/rwa_calc/engine/ccr/sft_fccm.py implements the FCCM branch reusing the supervisory haircut table (data/tables/haircuts.py) at the 5-business-day SFT liquidation period per Art. 224(2)(c) and Art. 226(2) scaling — no new regulatory scalars. pipeline_adapter.py::ccr_rows_to_exposures now partitions RawCCRBundle on trades.transaction_type and concatenates derivative and SFT outputs via diagonal_relaxed. Synthetic SFT exposure rows emit risk_type="CCR_SFT" (placeholder at domain/enums.py:452 promoted to live) and ccr_method="fccm_sft" (new third literal alongside "sa_ccr"); SA-CCR-only columns (rc_unmargined, pfe_addon, addon_aggregate, pfe_multiplier) are null on FCCM rows. New CCRConfig.sft_method: Literal["fccm","var","imm"] = "fccm" field; VaR (Art. 221) and IMM (Art. 283) deferred. Pinned by 18-test acceptance pair tests/acceptance/ccr/test_ccr_a11_a12_sft_fccm_ead.py — load-bearing anti-degenerate A11.ead_ccr > 60_000_000 catches the original bug; A11 (uncollateralised) lands at EAD £64,133,710.53 / RWA £32,066,855.26 at institution CQS 2 = 50%; A12 (cash-collateralised £60M) lands at E* £4,133,710.53 / RWA £2,066,855.26. Mixed SFT+derivative netting sets (Art. 271 split) remain out of scope (deferred — both A11 and A12 are 100% SFT NSes). PS1/26 numerically identical for this path. Ref: CRR Art. 271(2), Art. 220(1)(a), Art. 220(3)(a)(i), Art. 223(5), Art. 224 Table 1, Art. 224(2)(c), Art. 226(2), Art. 120 Table 3; PRA PS1/26 CCR (CRR) Part Art. 271/220-223; BCBS CRE22.40-58, CRE52.16-17.
  • P2.19 — Basel 3.1 SA equity higher-risk (400%) test generalised beyond pre-classified PE/VC (PRA PS1/26 Art. 133(4)). The 400% higher-risk gate in engine/equity/calculator.py::_apply_b31_equity_weights_sa only fired for equity_type ∈ {private_equity, private_equity_diversified}, so an unlisted equity with business existing < 5 years (and is_speculative=False) wrongly received the standard 250% (Art. 133(3)) instead of 400% (Art. 133(4)). Generalised the gate to any non-subordinated / non-CIU / non-central-bank / non-government-supported equity that is unlisted (~is_exchange_traded) with evidenced business age < 5y; null/unknown age still resolves to 250% for non-PE types (only PE/VC retains the conservative null = young routing), preserving 18 pre-existing scenarios. Reuses the existing 4.00 scalar (B31_SA_EQUITY_RISK_WEIGHTS[PRIVATE_EQUITY]) — no schema change. Scope correction: the originating plan bullet proposed is_held_for_short_term_resale / is_derived_from_derivative flags; those are BCBS CRE60.20 criteria, not PRA (already corrected in-repo as D1.38/D3.37) and were deliberately NOT added. Pinned by tests/acceptance/basel31/test_p2_19_unlisted_young_equity_higher_risk.py. Ref: PRA PS1/26 Art. 133(3)/(4), Glossary p.5 (higher-risk equity = unlisted AND business < 5y).
  • P2.33 — UK residential-mortgage commitment now receives the 50% CCF (PRA PS1/26 Art. 111 Table A1 Row 4(b)). Absent a flag, a UK residential-mortgage commitment fell through to the 40% "other commitment" (OC) CCF, understating EAD. New is_uk_residential_mortgage_commitment Boolean (FACILITY/CONTINGENTS schema, default False) is threaded through HierarchyResolver to the CCF stage, where engine/ccf.py::_compute_ccf applies a Basel-3.1-gated 50% override (reusing SA_CCF_B31["MR"] — no new scalar) bounded by the Row 4(b) "not subject to a conversion factor of 10% or 100%" carve-out. CRR is a no-op and the unflagged OC path stays 40%. Pinned by tests/acceptance/basel31/test_p2_33_uk_resi_mortgage_commitment_ccf.py. Ref: PRA PS1/26 Art. 111(1) Table A1 Row 4(b).
  • P2.44 — inferred ECAI ratings now disapplied for SA specialised-lending routing (PRA PS1/26 Art. 139(2B)). An IRB firm routing SA SL through Art. 122B(1) must use only directly-applicable (issue-specific) ECAI assessments, not Art. 139(2)/(2A) inferred / issuer-level fallbacks; the engine carried a single external_cqs with no provenance, so an SL exposure whose only rating was inferred picked up the rated-corporate CQS table (e.g. CQS 3 → 75%) instead of the unrated object-finance 100%. New rating_is_issue_specific / rating_is_inferred Booleans (RATINGS_SCHEMA) and external_rating_is_issue_specific (HIERARCHY_OUTPUT_SCHEMA) are threaded through the rating-inheritance chain; engine/sa/namespace.py::_prepare_risk_weight_lookup nulls the CQS for SL exposures (Art. 122B(1)) when the resolved rating is not issue-specific, re-routing to the unrated object-finance 100% RW (reusing B31_SA_SL_RISK_WEIGHTS["object_finance"]). Scoped to SL only and Basel-3.1-gated; schema defaults (issue_specific=True) preserve all existing rated-SL behaviour. Pinned by tests/acceptance/basel31/test_p2_44_sa_sl_inferred_rating_disapplied.py. Ref: PRA PS1/26 Art. 122B(1), 139(2)/(2A)/(2B).

Changed

  • P6.30 — removed the dead compute_pfe_ir_singleton stub from the CCR engine. The single-trade IR PFE singleton at engine/ccr/pfe.py still raised NotImplementedError("…full PFE per Art. 278 is P8.16") even though P8.16 (v0.2.11) shipped the production compute_pfe in the same module — a caller hitting the dead namespace method lf.ccr.pfe_ir_singleton() would have got a misleading not-implemented error. Deleted the function (and its @cites("CRR Art. 278")), the pfe_ir_singleton namespace shim + docstring bullet (engine/ccr/namespace.py), and the import + __all__ entry (engine/ccr/__init__.py), and corrected the stale module scaffold comment that claimed the SA-CCR formula bodies were still stubbed. Watchfire coverage is preserved: production compute_pfe retains its own @cites("CRR Art. 278"). The pre-existing contract test was inverted from asserting NotImplementedError to asserting the symbol is no longer importable. Pinned by tests/contracts/test_ccr_engine_scaffold.py::test_compute_pfe_ir_singleton_removed.

[0.2.16] - 2026-05-27

Fixed

  • P1.190 — Basel 3.1 F-IRB Foundation Collateral Method (Art. 230) now uses the PS1/26 continuous LGD* formula instead of CRR's step function. The engine was inheriting three CRR mechanics that PS1/26 explicitly removes for non-financial collateral: (a) the 30% C* minimum-coverage threshold (engine/crm/collateral.py:858-887 zero-out of _eff_re_a / _eff_op_a when raw collateral < 30% of EAD), (b) the 1.4× / 1.25× overcollateralisation divisor (engine/crm/expressions.py::overcollateralisation_ratio_expr()), and (c) the immovable-property haircut left at 0.00 in BASEL31_COLLATERAL_HAIRCUTS["real_estate"] (data/tables/haircuts.py:134) with a misleading "Handled via LTV" comment that confused the SA Art. 124A-L loan-splitting path with the F-IRB FCM HC term. PS1/26 Art. 230(1) is a continuous formula LGD* = LGDU·(EU/(E·(1+HE))) + LGDS·(ES/(E·(1+HE))) where ES = C·(1−HC−Hfx); Art. 230(2) tabulates HC = 40% for immovable property, receivables, and other physical collateral; there is no C* threshold and no OC divisor. Bug (a) fix wraps the C* block in if not is_basel_3_1:; bug (b) fix extends overcollateralisation_ratio_expr() with an is_basel_3_1 parameter returning 1.0 for non-financial under B3.1; bug (c) fix sets the RE haircut to Decimal("0.40") and rewrites the comment to distinguish the SA LTV path from the F-IRB FCM HC term. Follow-on engine fix in engine/crm/haircuts.py gates Art. 226 liquidation-period scaling on NON_FINANCIAL_COLLATERAL_TYPES — Art. 230 HC values are credit-quality multipliers, not volatility adjustments, so the sqrt(T_m/10) scaling that would have inflated 0.40 to ~0.566 at the 20-day default is now correctly skipped. Capital impact is bidirectional: RWA decreases for B3.1 F-IRB exposures with thin non-financial collateral previously zeroed by the spurious C* gate; RWA changes magnitude on collateralised slices previously divided by 1.4 because B3.1's (1−0.40)=0.60 HC term pairs with a much lower LGDS (20% vs CRR's 35% senior). Pinned by 20 new tests: 4 table-level haircut pins (including CRR/B31 regression guards) + 4 B3.1 acceptance hand-calcs (b31_thin_re LGD*=0.397, b31_full_re 0.280, b31_other_physical 0.315, b31_re_threshold_30pct 0.364) + 2 CRR regression mirrors (crr_thin_re 0.450 unsecured fallback, crr_full_re 0.378571 via 1.4× divisor). 10 pre-existing unit tests in test_collateral_sequential_fill.py and test_art169_lgd_modelling.py whose hand-calcs assumed the old buggy B3.1 step-function behaviour updated to the correct continuous-formula values. Ref: PRA PS1/26 Art. 230(1)-(2); BCBS CRE32.27-32.35; verbatim text at docs/assets/ps126app1.pdf p.209-210.
  • SME supporting factor E* now aggregated across all approach branches (CRR Art. 501): the windowed sum producing the per-row tier threshold input (total_cp_drawn) was previously computed inside each approach branch — SA, IRB, and slotting each called SupportingFactorCalculator.apply_factors on its own filtered LazyFrame after the orchestrator split at engine/pipeline.py:813, so the .sum().over("_sme_group_key") window function only ever saw the rows in its own branch. A lending group containing an SA-treated SME with slotting or IRB siblings therefore had its E* understated; more of the SME's drawn fell into tier 1 (0.7619) and the blended supporting factor was artificially low — bank received a larger Art. 501 discount than the regulation permits. Fix moves the aggregation to the unified frame: new module-level helper compute_e_star_group_drawn in src/rwa_calc/engine/sa/supporting_factors.py mirrors the existing per-row Art. 501 logic (drawn_amount + interest clipped at zero, minus min(residential_collateral_value, drawn), summed over lending_group_reference with fallback to counterparty_reference) and writes the result to a stable e_star_group_drawn column on every row. PipelineOrchestrator._run_calculators_split_once now calls the helper once at engine/pipeline.py:809, immediately after materialise_barrier(..., "pipeline_pre_branch") and before the SA/IRB/slotting split, so all approach rows contribute. SupportingFactorCalculator.apply_factors (lines ~273-320) now reads e_star_group_drawn when present and aliases it to the existing output column total_cp_drawn — downstream consumers and the result schema are unchanged. The legacy per-branch window-sum path remains as a fallback when e_star_group_drawn is absent so existing unit tests that build minimal LazyFrames and call apply_factors directly continue to work byte-identically. Helper is config-gated (config.supporting_factors.enabled → no-op under Basel 3.1 so the column is not added). Pinned by 8 new tests in tests/unit/test_supporting_factors_cross_approach.py — load-bearing assertion is test_blended_factor_uses_pre_computed_group_total: an SA SME with drawn £1m and a slotting sibling with drawn £5m now sees total_cp_drawn = £6m (not £1m) and supporting_factor ≈ 0.8178 (not 0.7619, derived from config to handle the actual GBP threshold of £2,183,000). Full suite green: 6804 passed, 2 skipped. No regulatory scalars introduced — formula and tier factors unchanged. Ref: CRR Art. 501(2)(a) ("total amount owed to the institution … by the obligor client or group of connected clients"); BCBS does not impose a directly equivalent supporting factor under Basel 3.1.
  • On-balance-sheet netting now restricted to drawn loan siblings only (CRR Art. 219): generate_netting_collateral in src/rwa_calc/engine/crm/collateral.py was allocating synthetic cash collateral pro-rata across every positive-EAD sibling under the same netting facility — including off-balance-sheet contingents and synthetic facility_undrawn rows (the per-facility undrawn-headroom rows emitted by hierarchy.py). CRR Art. 219 explicitly limits OBS netting to drawn loans/deposits ("loans to and deposits with the lending institution"); the bank holds no irrevocable lending commitment on an off-BS row that a deposit could net against. Two coupled fixes: (a) the positive_siblings filter now requires exposure_type == "loan" AND on_bs_for_ead > 0, excluding contingents and facility_undrawn; (b) pro-rata basis switched from ead_for_crm (= on_bs_for_ead + nominal_after_provision, the CCF=100% override per Art. 223(4)) to on_bs_for_ead (the drawn portion), so a partly-drawn loan with a large undrawn nominal no longer captures an inflated share of the netting pool. The Art. 223(4) override remains the basis for FCCM E netting against external cash collateral — it is a collateral-valuation* rule, not an OBS-netting allocation rule. Source filter (negative-drawn loans with has_netting_agreement=True) was already correctly drawn-only and unchanged. Downstream collateral pipeline behaviour preserved: synthetic row still flagged beneficiary_type="loan" with beneficiary_reference=exposure_reference, lands at the direct allocation level and does not re-spread across facility/counterparty pools. Pinned by 4 new tests in tests/unit/crm/test_netting.py::TestNettingDrawnOnlyScope — contingent-excluded, facility_undrawn-excluded, pro-rata-uses-drawn-not-ead_for_crm (load-bearing anti-degenerate: deposit 200 with LOAN_A drawn=400 / no off-BS and LOAN_B drawn=100 / off-BS=900; old code split 57.14 / 142.86 by ead_for_crm, new code correctly splits 160 / 40 by drawn portion), and mixed-facility e2e. All 23 netting tests + 5,192 unit + 1,633 acceptance/integration/contract tests pass. Graceful fallback added for direct unit-test callers of _generate_netting_collateral that omit exposure_type / on_bs_for_ead (production always supplies them via the hierarchy + _compute_ead upstream). Docs updated: docs/user-guide/methodology/crm.md On-Balance Sheet Netting section now states drawn-only scope and on_bs_for_ead pro-rata basis with a mixed-facility example; docs/specifications/crr/credit-risk-mitigation.md adds an Art. 219 callout note distinguishing OBS-netting allocation basis from the Art. 223(4) collateral-valuation override. COREP C07 column 0035 (on_bs_netting_amount) semantics unchanged. Ref: CRR Art. 195, 205, 206, 219, 223(4), 224, 230; PRA PS1/26 Art. 219 (unchanged); BCBS CRE22.68-69.

Changed

  • Supporting factors module relocated from engine/sa/ to engine/ top-level (cross-approach stage module): src/rwa_calc/engine/sa/supporting_factors.pysrc/rwa_calc/engine/supporting_factors.py. The file housed both SupportingFactorCalculator (called by all three approach branches — SA at engine/sa/namespace.py:2104, IRB at engine/irb/calculator.py:288, slotting at engine/slotting/calculator.py:166) and the module-level helper compute_e_star_group_drawn (called by the pipeline orchestrator at engine/pipeline.py:810 on the unified post-CRM frame before the SA/IRB/slotting split). Filing it under engine/sa/ was a false-locality signal — engine/irb/ and engine/slotting/ both had to import from engine/sa/, implying SA was a dependency of the other approach branches when in fact supporting factors are cross-cutting. The module now sits as a peer of engine/ccf.py, engine/hierarchy.py, engine/classifier.py, engine/re_splitter.py, and engine/materialise.py — the other cross-cutting stage modules at the same level. Pure relocation: no behavioural change, no regulatory logic touched, no schema or output changes. Move performed via git mv to preserve history. Import paths updated in 5 source files (engine/pipeline.py, engine/sa/namespace.py, engine/irb/calculator.py, engine/slotting/calculator.py, engine/sa/__init__.py) and 4 test files (tests/unit/test_supporting_factors.py, tests/unit/test_supporting_factors_cross_approach.py, tests/unit/crr/test_crr_sa.py, tests/contracts/test_watchfire_coverage.py). The dead re-export in engine/sa/__init__.py (SupportingFactorCalculator, create_supporting_factor_calculator) was dropped since every caller already imported from the full module path. LOGGER_REQUIRED_EXEMPT in scripts/arch_check.py and the --8<-- snippet path in docs/user-guide/methodology/standardised-approach.md updated to the new location. docs/api/engine.md, docs/user-guide/methodology/supporting-factors.md, docs/data-model/output-schemas.md, and docs/specifications/common/default-definition.md references updated; docs/development/citation-matrix.md regenerated via scripts/generate_citation_matrix.py. Historical changelog entries that mention the old path are intentionally left as-is. Full dev-loop suite green: 6804 passed, 2 skipped. Ref: CRR Art. 501 (SME), Art. 501a (infrastructure) — both regulations are themselves approach-agnostic, matching the new module location.

[0.2.15] - 2026-05-26

Changed

  • scripts/deploy.py now promotes [Unreleased] bullets into the new version section (was: dropped them silently): the previous update_changelog looked for the exact placeholder shape ## [Unreleased]\n\n### Added\n- (Next release changes will go here)\n\n### Changed\n- (Next release changes will go here)\n\n--- and fell through to a fallback that inserted a fresh ## [version] block with a hardcoded Version bump for PyPI release bullet above the existing [Unreleased]. Because the team's actual workflow keeps [Unreleased] populated with real bullets, the exact-match branch never fired and every release lost its accumulated changelog content. Promotion logic extracted into scripts/_deploy_changelog.py (pure string transforms promote_unreleased and update_version_table for testability) and deploy.py now delegates. The new helper: (a) extracts the [Unreleased] body up to and including its trailing ---, (b) parses subsections (### Added, ### Changed, ### Fixed, etc.) preserving insertion order, (c) drops bullets matching the literal placeholder line, (d) writes the surviving bullets under ## [{new_version}] - {today}, and (e) resets [Unreleased] to the canonical empty placeholder. Placeholder-only or missing [Unreleased] blocks fall back to the original Version bump for PyPI release stub. Re-running with ## [{new_version}] already present is a no-op. Pinned by 7 new tests in tests/unit/test_deploy_changelog.py (placeholder-only fallback, real-bullet promotion, mixed placeholder/real, re-run idempotency, subsection-order preservation, plus 2 version-table cases). New /release slash command at .claude/commands/release.md previews what will be promoted, confirms with the operator, and invokes scripts/deploy.py.
  • SME classification now falls back to total_assets when annual_revenue is null (CRR Art. 4(1)(128D) / Commission Recommendation 2003/361/EC Art. 2): the total_assets column on COUNTERPARTY_SCHEMA was previously projected onto exposures but read only by the equity calculator; every SME-classification gate keyed off cp_annual_revenue alone, so a counterparty with null turnover and a small balance sheet was silently treated as a large corporate. The classifier's _add_counterparty_attributes now derives a single shared metric sme_size_metric_gbp = coalesce(cp_annual_revenue, cp_total_assets) plus a provenance column sme_size_source ∈ {"turnover", "assets", null}, and a new helper _is_sme_by_size_expr compares the metric to the appropriate threshold per source. The six SME gates (is_corporate_sme, is_retail_sme, is_sl_sme, _reclassify_corporate_to_retail, Art. 123A retail auto-qualification, and the inverse Art. 147A(1)(d) large-corp F-IRB restriction) all read the new helper. The IRB Art. 153(4) third-subparagraph substitution is realised by sourcing turnover_m from the coalesced metric in engine/irb/namespace.py (gated on the classifier's is_sme flag to avoid double-counting counterparties in the EUR 43m-50m equivalent band) — so the SME correlation reduction now picks up assets as S when annual sales are not a meaningful indicator. Art. 501(2)(c) is preserved exactly: the SA supporting factor predicate in engine/sa/supporting_factors.py was tightened from is_sme to is_sme & cp_annual_revenue.is_not_null() & cp_annual_revenue > 0, so a counterparty identified as SME via assets receives the CORPORATE_SME exposure class and the IRB correlation benefit but supporting_factor = 1.0 (no Art. 501 capital relief). New RegulatoryThresholds.sme_balance_sheet_threshold field, derived under both CRR and Basel 3.1 from _CRR_SME_BALANCE_SHEET_EUR = Decimal("43000000") at the configured EUR/GBP rate (PS1/26 does not restate the assets threshold in GBP). CLS008 refined: the conservative-large-corp warning now fires only when annual_revenue is null AND total_assets is either null or ≥ the SME balance-sheet threshold — populated assets below the threshold resolve the size question definitively and suppress the warning. Pinned by 18 new unit tests in tests/unit/classifier/test_sme_assets_fallback.py covering the four observable behaviours (SME-by-assets, large-by-assets, double-null, turnover-only regression) plus a new self-contained fixture module at tests/fixtures/sme_assets_fallback/. The pre-existing P1.126 total_assets filler value was updated to None to preserve the original CLS008 scenario (null revenue with no fallback signal). Full unit (5133) + contract (213) + integration (430) + acceptance (914) suites green. Ref: CRR Art. 4(1)(128D), Art. 153(4) third subparagraph, Art. 501(2)(c); PRA PS1/26 Art. 147A(1)(d), Art. 153(4); Commission Recommendation 2003/361/EC Art. 2.

Added

  • SA-CCR CCR-A10 mixed-asset-class netting set + per-asset-class add-on Struct on synthetic exposure row (P8.41 CCR-A10, batch 20260526-ccr3): closes the P8.41 CCR-A sub-batch with the load-bearing end-to-end regression for cross-asset-class aggregation per CRR Art. 278(2). The scenario stitches one IR swap + one FX forward + one single-name IG credit CDS + one single-name equity TRS + one OIL_GAS commodity forward into a single legally-enforceable netting set (NS_MIX_001 against institution CP_001 at CQS 2 ⇒ 50% RW per CRR Art. 120(1) Table 3). Trade parameters are exact clones of CCR-A1/A2/A3/A5/A7 so per-class add-ons reproduce existing goldens byte-identically — the only novel quantity is the cross-class aggregation step itself: AddOn_aggregate = 3,914,298.228 (IR) + 3,198,904.672 (FX) + 2,016,405.972 (credit) + 15,994,523.295 (equity) + 180,000 (commodity) = 25,304,132.167 GBP (pure linear sum per Art. 278(2) / BCBS CRE52.20-22, no cross-class supervisory correlation). With RC=0 (V=C=0) and the Art. 278(3) multiplier at its ceiling of 1.0, this gives EAD = α × (RC + PFE) = 1.4 × 25,304,132.167 = 35,425,785.034 GBP per Art. 274(2) and RWA = 0.5 × EAD = 17,712,892.517 GBP. The load-bearing anti-degenerate* pins 24M < addon_aggregate < 26M — a naive sqrt-of-sum-of-squares would have yielded ≈ 16.69M (≈ 8.6M understatement, a regulatorily catastrophic regression mode). Engine change is minimal: src/rwa_calc/engine/ccr/pipeline_adapter.py::ccr_rows_to_exposures now surfaces a new addon_by_asset_class: Struct{interest_rate: Float64, fx: Float64, credit: Float64, equity: Float64, commodity: Float64} column on the synthetic CCR exposure row, pivoting the already-computed addon_per_class intermediate (no new collects; LazyFrame-first preserved). Missing asset classes fill_null(0.0) so the Struct schema is stable across every netting set regardless of which asset classes it touches, and the five fields are guaranteed to sum to addon_aggregate (internal-consistency property pinned by the test). This is a pure observability extension — the underlying add-on values are unchanged; the Struct is the audit-trail breakdown needed for downstream COREP C 34.02 (SA-CCR template) and Pillar III CCR3 disclosures. Pinned by 17 tests in tests/acceptance/ccr/test_ccr_a10_mixed_asset_class.py (8 numeric pipeline pins + 1 Struct-existence guard + 5 per-class component assertions + 1 Σ-equals-aggregate consistency check + 2 framework / approach assertions). New fixture at tests/fixtures/ccr/golden_ccr_a10.py and expected outputs at tests/expected_outputs/ccr/CCR-A10.json. All 86 CCR acceptance tests + 451 contract tests remain green. No new regulatory scalars introduced. Ref: CRR Art. 274(2), 275(1), 277(1)-(3), 277a, 278(1)-(3), 279a/b/c, 280-280c, 295, 120(1) Table 3; BCBS CRE52.20-22 (cross-asset-class linear sum), CRE52.41-69; PRA Rulebook CCR (CRR) Part (SA-CCR cross-class aggregation numerically identical to CRR).
  • SA-CCR all-asset-class EAD: credit, equity, commodity asset classes shipped (batch 20260526-ccr2; P8.35/P8.36/P8.37): closes the gap that was the originating concern of the SA-CCR scope expansion — EAD = α × (RC + PFE) is now computed for all five SA-CCR asset classes (interest rate, FX, credit, equity, commodity), not just IR + FX. Three parallel worktree streams landed under a single batch: P8.35 added the credit branch (compute_adjusted_notional_credit per CRR Art. 279b(1)(a) shared-IR supervisory-duration kernel; _compute_addon_credit per Art. 277(2)(c) + 277a + 280a per-entity correlation with SF_SN_IG=0.0046 / SF_SN_HY=0.013 / SF_SN_NR=0.06 / SF_IDX_IG=0.0038 / SF_IDX_HY=0.0106 and ρ=0.50 SN / 0.80 IDX). P8.36 added the equity branch (compute_adjusted_notional_equity per Art. 279b(1)(c) d = abs(market_price × number_of_units); _compute_addon_equity per Art. 277(2)(d) + 277a + 280b with SF_EQ_SN=0.32 / SF_EQ_IDX=0.20 and ρ=0.50 SN / 0.80 IDX, SN/IDX sub-classes summed within one HS). P8.37 added the commodity branch (compute_adjusted_notional_commodity per Art. 279b(1)(c); _compute_addon_commodity per Art. 277(3)(b) 5-bucket partition — ELECTRICITY=0.40 / OIL_GAS/METALS/AGRICULTURAL/OTHER=0.18 — with within-bucket ρ=0.40 per Art. 280c and no cross-bucket correlation per CRE52.69, AddOn_commodity = sqrt(Σ_b AddOn_b²)). P8.35 also extended TRADE_SCHEMA with a required-False credit_quality column ({IG, HY, NON_RATED}) — the CDS reference entity is the underlying, not the counterparty, so the supervisory-factor band cannot be derived from external_cqs and must be supplied explicitly (precedent: P8.33's commodity_type). All five branches in compute_addon_per_asset_class now dispatch correctly; compute_adjusted_notional_* calls in pipeline_adapter.py::ccr_rows_to_exposures chain IR → FX → credit → equity → commodity coalesce-safely. Defensive null-column injection added to credit / equity / commodity branches so IR/FX-only test frames continue to work without schema-update pressure. Pinned by 3 new acceptance scenarios (CCR-A3 single-name IG CDS, CCR-A5 single-name equity TRS, CCR-A7 oil forward, CCR-A8 electricity swap), 6 new unit-test modules at tests/unit/ccr/test_adjusted_notional_{credit,equity,commodity}.py and test_pfe_{credit,equity,commodity}_addon.py (52 new unit tests in total — including the load-bearing electricity-distinct-from-18%-catch-all anti-degenerate at test_ccr_a8_commodity_electricity_swap::test_electricity_sf_is_distinct_from_other_buckets and the two-entity credit correlation anti-degenerate at test_pfe_credit_addon::test_credit_addon_two_entities_same_hs_uses_correlation). The flipped test_credit_asset_class_row_emits_non_null_addon in tests/unit/ccr/test_pfe_fx_addon.py:288 (previously asserted null while credit was deferred) and the P8.33 contract test's _P8_35_EXPECTED_COLUMN_COUNT = 29 (lifted from 28 to absorb credit_quality) close the contract surface. Existing IR / FX / CCR-A1 / CCR-A2 acceptance tests continue to pass byte-identically — no regression. Full dev-loop suite green: 6703 passed, 23 skipped. No new regulatory scalars introduced (all SF and ρ already in data/tables/sa_ccr_factors.py from P8.7). Ref: CRR Art. 274(2), 275(1), 277(2)(c)-(d), 277(3)(b), 277a, 278, 279b(1)(a)/(c), 280, 280a, 280b, 280c; BCBS CRE52.41-48, CRE52.60-69; PRA PS1/26 CCR (CRR) Part (numerically identical for all five asset classes).
  • SA-CCR hedging-set partition extension to credit / equity / commodity (P8.34): extends engine/ccr/hedging_sets.py::assign_hedging_set to emit non-null hedging_set_id for the three asset classes that previously fell through to null. Credit derivatives → "CR-{netting_set_id}" per CRR Art. 277(2)(c) (one hedging set per asset class per netting set; single-name vs index discrimination is deferred to the aggregation step in Art. 277a + 280a/b, P8.35's job). Equity derivatives → "EQ-{netting_set_id}" per Art. 277(2)(d) (same single-set-per-NS pattern). Commodity derivatives → "CO-{netting_set_id}-{commodity_type}" per Art. 277(3)(b), a 5-bucket partition keyed on the upper-case commodity_type column shipped under P8.33 (ELECTRICITY / OIL_GAS / METALS / AGRICULTURAL / OTHER); no cross-bucket netting per CRE52.67. The "CO-" prefix uses the canonical ASSET_CLASS_SHORT_CODE["commodity"] value from data/schemas.py:952 (the plan bullet erroneously said "CM-"; the schema constant is the SSoT). Null commodity_type on a commodity row → null hedging_set_id (no fallback string, no error — matches the IR-no-bucket precedent for malformed inputs). A defensive commodity_type column injection was added so pre-P8.33 test frames (existing FX add-on tests at tests/unit/ccr/test_pfe_fx_addon.py) continue to work without requiring fixture updates. reference_entity and is_index columns are deliberately not consumed at the partition step — single-name vs index discrimination flows through the aggregation step where supervisory factor + correlation differ. Corrects the overstated P8.15 closing claim that this work had already shipped. Pinned by 10 new tests in tests/unit/ccr/test_hedging_sets_extension.py: 4 prefix-format assertions (credit-SN, credit-idx-shares-HS-with-SN, equity, equity-idx-shares-HS-with-SN), 3 commodity assertions (5-bucket-distinct, same-bucket-collapses, null-bucket-null-id), 2 IR/FX regression guards, and 1 mixed-portfolio n_unique == 10 across 12 rows spanning all five asset classes. Pre-existing P8.15 IR partition tests + P8.19 FX add-on tests all remain green. Unblocks P8.35 (credit add-on), P8.36 (equity add-on), P8.37 (commodity add-on). Ref: CRR Art. 277(2)(c)-(d), 277(3)(b), 277a; BCBS CRE52.60, CRE52.65, CRE52.67-69; PRA Rulebook CCR (CRR) Part (numerically identical).
  • SA-CCR TRADE_SCHEMA extension for credit / equity / commodity asset classes (P8.33): adds five nullable columns to TRADE_SCHEMA in src/rwa_calc/data/schemas.pymarket_price: Float64 and number_of_units: Float64 (the two factors of d = market_price × number_of_units per CRR Art. 279b(1)(c) for equity and commodity adjusted notional), reference_entity: String (single-name issuer LEI or index ticker — keys the credit hedging set per Art. 277(2)(c) and the equity hedging set per Art. 277(2)(d)), commodity_type: String (one of the five buckets {ELECTRICITY, OIL_GAS, METALS, AGRICULTURAL, OTHER} per Art. 277(3)(b) — keyed to upper-case to match the existing SA_CCR_SUPERVISORY_FACTORS_COMMODITY table at data/tables/sa_ccr_factors.py:60-66), and is_index: Boolean (single-name vs index discriminator for the credit / equity supervisory-correlation lookup at the per-asset-class add-on stage per Art. 280a / 280b). All five columns are required=False with default=None, so existing CCR-A1 (IR swap) and CCR-A2 (FX forward) fixtures continue to round-trip unchanged. The five-bucket commodity_type enum is also pinned in a new "trades" block of COLUMN_VALUE_CONSTRAINTS (input-domain validation, not a regulatory scalar — lives in schemas.py per the data/engine separation policy). TradeBundle docstring in src/rwa_calc/contracts/bundles.py extended to list the five new columns alongside the previously-shipped FX leg-2 pair. No engine code touched — P8.33 is a foundation-only schema extension that unblocks P8.34 (hedging_sets.py extension), P8.35 / P8.36 / P8.37 (per-asset-class adjusted notional + PFE add-on) and the CCR-A3..A10 acceptance scenarios. Pinned by 7 new contract tests in tests/contracts/test_ccr_schemas_contract.py (one per new column verifying dtype + nullability + required is False + default is None, plus a commodity_type 5-bucket constraint test and a column-count delta-of-5 test). Ref: CRR Art. 277(2)(c)-(d), 277(3)(b), 279b(1)(a), 279b(1)(c), 280a / 280b; BCBS CRE52.41-48, CRE52.60-69; PRA Rulebook CCR (CRR) Part (numerically identical).
  • SA-CCR FX support: leg-2 schema, FX adjusted notional, FX PFE add-on, and CCR-A2 acceptance scenario (P8.8 / P8.9 / P8.19 / P8.41 CCR-A2 slice): closes the FX gap left open by P8.12 (IR-only adjusted notional) and the FX side of P8.15 (hedging-set partition shipped, PFE add-on still IR-hardcoded at the time). Three schema columns and a fixture factory open the input surface: notional_leg2: Float64 and currency_leg2: String (both optional nullable) appended to TRADE_SCHEMA so FX forwards can carry both legs; Trade dataclass + to_dict() mirror the new columns; new make_fx_trade() factory in tests/fixtures/ccr/trade_builder.py produces the canonical 1-year GBP/USD outright-forward defaults (buy USD 100m / sell GBP 80m, MtM=0, delta=1). FX adjusted notional per CRR Art. 279b(1)(b) lands in engine/ccr/adjusted_notional.py::compute_adjusted_notional_fx(trades, base_currency, fx_rates) — joins both leg currencies against an fx_rates lookup (with an identity row so legs already in the reporting currency convert at 1.0), then applies the one-leg-is-base case (i) or both-legs-foreign max case (ii); coalesce-safe against any prior IR-branch output. Sibling fluent method lf.ccr.adjusted_notional_fx(...) added. FX PFE add-on per CRR Art. 277a(2) + BCBS CRE52.55 lands as a refactor of engine/ccr/pfe.py::compute_addon_per_asset_class into a dispatcher that calls byte-equivalent _compute_addon_ir (existing IR three-bucket aggregation, unchanged) and new _compute_addon_fx (D_HS = signed sum per (NS, hedging_set_id); AddOn_HS = SF_FX × |D_HS|; AddOn_FX = simple sum across hedging sets — no cross-HS correlation for FX, unlike equity/commodity Art. 277a(3)). assign_hedging_set in engine/ccr/hedging_sets.py extended to emit hedging_set_id = "FX-{ns}-{min(ccy1,ccy2)}/{max(...)}" for FX rows, with order-independent currency-pair keying so EUR/USD and USD/EUR collapse to one hedging set. Pipeline-adapter wired: ccr_rows_to_exposures now accepts base_currency: str = "GBP" and fx_rates: pl.LazyFrame | None = None (threaded through from config.base_currency and data.fx_rates in engine/pipeline.py), so FX trades flow through end-to-end. CCR-A1 invariant preserved — the existing CCR-A1 expected outputs (RC=0, PFE=3,914,298.228, EAD=5,480,017.519, RWA=2,740,008.759) are byte-identical after the dispatcher refactor. New CCR-A2 acceptance scenario pinned at tests/acceptance/ccr/test_ccr_a2_unmargined_fx_forward.py with six assertions: 1y GBP/USD outright forward, USD 100m / GBP 80m, MtM=0, unmargined, counterparty CP_001 (institution CQS 2) → goldens addon_aggregate=3,198,904.67, pfe_addon=3,198,904.67, ead_final=4,478,466.54, rwa_final=2,239,233.27 (GBP). Goldens recorded in tests/expected_outputs/ccr/CCR-A2.json. New unit-test suites cover the FX paths in isolation: 8 tests for compute_adjusted_notional_fx (one-leg-base / both-foreign-max / negative-notional / non-FX rows / coalesce / missing-rate / LazyFrame return type) and 6 tests for the PFE FX branch (CCR-A2 hand-calc / signed-sum within HS / cross-HS sum / order-independent pair key / mixed IR+FX dispatcher / credit-row null). New specs at docs/specifications/crr/ccr/adjusted-notional.md and docs/specifications/crr/ccr/fx-treatment.md (CCR spec subtree didn't exist before). Full suite green. SA_CCR_SUPERVISORY_FACTOR_FX = 0.04 was already in data/tables/sa_ccr_factors.py:44 from P8.7 — no new regulatory scalars needed. Open follow-ups: orchestrator-level CalculationError emission for missing FX rates (currently produces null adjusted_notional silently); FX options acceptance scenario; cross-rate triangulation for non-major pairs. Ref: CRR Art. 274(2), 275(1), 277(3)(a), 277a(2), 278, 279b(1)(b), 279c(1), 280 Table 1; BCBS CRE52.34 / CRE52.55; PRA Rulebook CCR (CRR) Part Chapter 3 §§3–5.

[0.2.14] - 2026-05-25

Added

  • Audit cache extended to cover the full pipeline (was: CRM-only): the opt-in audit cache shipped in the previous iteration was scoped narrowly to CRM intermediates plus the aggregator's pre/post-CRM summary views — it answered the "is H_fx firing on my collateral" diagnostic but left every other pipeline stage opaque. This pass closes those gaps by sinking the audit-style frames each stage already produces. Eleven new always-present artifacts now land under <audit_cache_dir>/<run_id>/ on every run: early stagesrating_inheritance.parquet (per-CP dual-track best-rating resolution, sunk in _run_hierarchy_resolver), classification_audit.parquet (per-exposure classification reason trail including cp_entity_type, SME/retail gating, defaulted flag, concatenated classification_reason string, sunk in _run_classifier), re_split_audit.parquet (per-parent secured/residual split for property-collateralised SA exposures, sunk in _run_re_splitter — only present when at least one row triggered RE splitting); calculatorsequity_calculation_audit.parquet (CIU mandate / look-through rationale, sunk in _run_equity_calculator), sa_results.parquet / irb_results.parquet / slotting_results.parquet / equity_results.parquet (pre-floor per-approach views from AggregatedResultBundle, sunk in _persist_audit_artifacts; diff against results.parquet to attribute output-floor uplift back to a specific approach branch); conditionalfloor_impact.parquet (per-row floor mechanics under Basel 3.1), supporting_factor_impact.parquet (CRR SME / infrastructure factor impact), securitisation_audit.parquet / securitisation_summary.parquet (only when securitisation_allocations is supplied). All new sinks follow the existing architectural pattern: every call site invokes sink_audit(...) (which lives in engine/materialise.py, the only sanctioned sink_parquet caller per scripts/arch_check.py); no new collects are forced — each frame is sunk at a point where the producing stage already materialises it; failures continue to log WARNING and swallow per the original contract. Two new diagnostic recipes added to docs/specifications/audit-cache.md: "why was this exposure routed to SA / IRB / Slotting?" (pair classification_audit with rating_inheritance) and "did the output floor bind on this exposure?" (filter floor_impact.parquet by is_floor_binding). Pinned by 5 new integration tests in tests/integration/test_audit_cache_pipeline.py (always-present artifact set covering all 19 always-present files, framework-conditional checks for CRR-only supporting_factor_impact and B3.1-only floor_impact, per-row content regression for classification_audit and rating_inheritance, schema-shape check across the four pre-floor per-approach parquets) and 8 new contract tests in tests/contracts/test_audit_cache_contract.py (column-set regression guards for classification_audit, rating_inheritance, equity_calculation_audit, sa/irb/slotting/equity_results, supporting_factor_impact). Default behaviour unchanged — audit_cache_dir=None remains a zero-overhead no-op. Ref: no regulatory change — pure observability / diagnostics extension.
  • Opt-in audit cache — per-run parquet snapshots of CRM intermediate frames: new CalculationConfig.audit_cache_dir: Path | None = None (plus optional audit_cache_max_runs: int | None = None) opts the pipeline into persisting key intermediate frames as parquet under <audit_cache_dir>/<run_id>/. Motivated by a recurring diagnostic ask — "is H_fx firing on my EUR property collateral against a GBP loan?" — that previously required users to re-run HaircutCalculator.apply_haircuts manually on a fixture because no bundle field surfaced the per-collateral fx_haircut / collateral_haircut / value_after_haircut columns. With the cache enabled, the same pipeline run drops collateral_haircuts.parquet (the missing diagnostic), crm_audit.parquet, collateral_allocation.parquet, the aggregator's pre_crm_summary / post_crm_summary / post_crm_detailed / summary_by_class / summary_by_approach / results parquets, and a manifest.json carrying timestamps, framework, config snapshot, artifact list with byte sizes, and the run_id that matches the correlation id on every log line. Default None = feature off, zero overhead, zero new files. Architecture preserved: a new sink_audit(frame, config, name) helper lives in engine/materialise.py (the only sanctioned sink_parquet caller per scripts/arch_check.py); CRMProcessor and PipelineOrchestrator invoke it at points where the frame is already being materialised — no new collects, no streaming-plan disruption. Failures (disk full, permission denied) are logged WARNING and swallowed — audit caching must never break a real run. audit_cache_max_runs triggers an mtime-ordered prune after each run's artifacts commit so the cap is honoured exactly (N runs ⇒ at most N subdirs). Plumbed through CreditRiskCalc.__init__ for the API entry point. Diagnostic recipe documented in docs/specifications/audit-cache.md: open collateral_haircuts.parquet, project collateral_reference, collateral_type, original_currency, exposure_currency, fx_haircutfx_haircut == 0.0 on RE / receivables / other_physical rows confirms the Art. 230 gate is working, anything non-zero on those types points to a collateral_type value not in the recognised synonym list (schemas.py:1034). Pinned by 12 unit tests in tests/unit/observability/test_audit_cache.py (sink/prune semantics, atomic writes, name sanitisation, no-run-id warning path, swallowed failures), 6 integration tests in tests/integration/test_audit_cache_pipeline.py (end-to-end layout, manifest schema, RWA-parity regression vs control, multi-run partitioning, prune-keeps-N-newest), and 3 contract tests in tests/contracts/test_audit_cache_contract.py (column-set regression guards for collateral_haircuts / collateral_allocation / crm_audit). Ref: no regulatory change — pure observability / diagnostics.
  • SA-CCR EAD wired in as synthetic exposure rows between the hierarchy resolver and the classifier (P8.20, batch 20260523-2023): new pipeline stage placed between HierarchyResolver and ExposureClassifier (Option B placement — pre-classifier so the existing counterparty-class lookup naturally routes CCR exposures). For each netting set in RawCCRBundle, the new ccr_rows_to_exposures adapter at src/rwa_calc/engine/ccr/pipeline_adapter.py emits one synthetic exposure row with exposure_reference=f"ccr__{netting_set_id}", risk_type="CCR_DERIVATIVE", drawn_amount=ead_ccr, plus two new provenance columns source_netting_set_id and ccr_method=sa_ccr. With drawn_amount carrying the SA-CCR EAD and undrawn/nominal/interest zeroed, the existing _initialize_ead produces ead_pre_crm = ead_ccr with no CRMProcessor changes required. Schema additions are all nullable and backward-compatible: source_netting_set_id and ccr_method on RAW_EXPOSURE_SCHEMA / RESOLVED_HIERARCHY_SCHEMA / CLASSIFIED_EXPOSURE_SCHEMA / CRM_ADJUSTED_SCHEMA; new RiskType.CCR_DERIVATIVE and RiskType.CCR_SFT enum members (aligning the enum surface with VALID_RISK_TYPES_INPUT which already accepted these strings). Stage wrapped with stage_timer(logger, "ccr_sa_ccr") per the observability contract; the no-op path (data.ccr is None) skips the wrap so no log record is emitted in that branch. Pinned by 7 tests in tests/integration/test_ccr_pipeline_integration.py (3 regression guards for no-CCR / error-accumulation / RWA-total contracts, 4 load-bearing assertions for CCR row emission + provenance + stage_timer + EAD wiring against compute_ead). Ref: CRR Art. 271 (CCR scope), Art. 272(4) (netting set), Art. 274(2) (EAD = α × (RC + PFE)); PRA Rulebook CCR (CRR) Part Art. 274-278.
  • CCR-A1 acceptance scenario + CCR rating-inheritance fix (P8.41, batch 20260524-1421): pins the first CCR vertical-slice acceptance scenario — single 10-year unmargined GBP IR swap, notional 100M, MTM=0, counterparty institution CQS 2 — with five assertions per CRR Art. 274/275/277a/278/279b/280 (RC, PFE add-on, EAD, exposure class, RWA). The new test surfaced an engine bug: CCR synthetic netting-set rows produced by ccr_rows_to_exposures were appended to resolved.exposures AFTER hierarchy._attach_counterparty_rating had already joined cqs / external_cqs / pd / internal_pd onto lending rows, so CCR rows reached the SA Institution lookup with cqs=None and fell through to the 100% unrated bucket (CRR Art. 121(1)) instead of CQS 2 → 50% (CRR Art. 120(1) Table 3). Fix is an additive _enrich_ccr_rows_with_ratings helper in the orchestrator (engine/pipeline.py) that mirrors the hierarchy rating join for CCR rows between ccr_rows_to_exposures and the pl.concat. Expected-output JSON also corrected (values-only) to match the regulatory formula: the prior hand-calc used 3,653 calendar days from 2026-01-15 to 2036-01-15, but the 2036 Feb 29 leap day falls after the Jan 15 maturity so only 2 leap days (2028, 2032) are in scope → 3,652 days, E = 9.998631y not 10.001369y. Scenario invariants preserved: RC=0.0, EAD = 1.4 × PFE, RWA = 0.5 × EAD, exposure_class="institution", risk_weight=0.50. Pinned by tests/acceptance/ccr/test_ccr_a1_unmargined_ir_swap.py and tests/expected_outputs/ccr/CCR-A1.json. Ref: CRR Art. 274, 275, 277a, 278, 279b, 280; PRA Rulebook CCR (CRR) Part.
  • arch_check check 10: enforce module References block on regulatory engine modules: new gate in scripts/arch_check.py that requires every module under engine/ and data/schemas.py to carry a References: block in its module docstring (the CLAUDE.md mandated shape). Reshape / format / IO helpers that carry no per-function regulatory citations are listed in REFERENCES_REQUIRED_EXEMPT. The check is a literal-token grep for References: — strict citation-form enforcement remains the job of watchfire (check 9) on @cites(...) decorators. This keeps protocol-only References blocks (e.g. loader.py-style) valid while preventing regulatory modules from shipping without any citation block. Surfaced and fixed the missing block on engine/crm/processor.py (cites CRR Art. 110, 111, 194, 213-217, 223-224, 230 plus COREP C07). Companion docs pass adds References blocks to 5 previously non-compliant regulatory modules: data/schemas.py (CRR Art. 110, 111, 112-134, 147-153, 153(5), 197-200, 213-217, 223-230, 501/501a), engine/hierarchy.py (lifts CRR Art. 131, 135, 136, 138, 139, 140 from existing internal @cites), engine/ccf.py (CRR Art. 111, 166 and PS1/26 Art. 166D), engine/pipeline.py (CRR Art. 92, 107, 110 plus PS1/26 output floor cross-ref), and engine/aggregator/_crm_reporting.py (CRR Art. 108-111, 213-217, 218-239 plus COREP C07 CRM scope). No runtime code paths touched; line-pinned allowlist in tests/contracts/test_no_raw_over_on_nullable_keys.py bumped {440, 441}{459, 460} to absorb the 19-line docstring prepend on hierarchy.py.
  • data/schemas.py module docstring extended to surface CCR, settlement-risk, CIU look-through, FX, securitisation, and intermediate pipeline-stage schemas: the module docstring listed only the originally-scoped credit-risk inputs and drifted as later work added CCR (SA-CCR), settlement-risk, CIU look-through, FX rates, model permissions, securitisation allocation, and the pipeline-stage intermediate output schemas. The Key Data Inputs index now also lists CIU_holdings and FX_rates, new Counterparty Credit Risk Inputs / Settlement Risk Inputs / Securitisation sections enumerate the relevant tables, Model_permissions is surfaced under Configuration, an Intermediate Pipeline-Stage Schemas section enumerates the resolved / classified / CRM-adjusted bundles, and the References block expands to cite CRR Art. 132, 244-246, 271-279a, 285, 291, 295, 306-307, 378-380. Pure documentation; no runtime impact.

Changed

  • Logging reset helper simplified: _reset() in the four test files that exercise observability now iterates logger.handlers directly instead of via an unnecessary list(...) copy. Behaviour-preserving (logging.Logger.removeHandler does not invalidate iteration over the handler list when each handler is removed in order). Touches tests/integration/test_fx_rate_autosync.py, tests/integration/test_logging_pipeline.py, tests/unit/observability/test_audit_cache.py, tests/unit/observability/test_logging.py.
  • getattr(...) None-checks narrowed per-variable for ty: ty check flagged 8 call-non-callable errors in tests/contracts/test_ccr_bundles_contract.py because the getattr(bundles, "X", None) pattern returns Any | None, and assert all(x is not None for x in [...]) doesn't narrow individual names in the type-checker's view. Replaced with explicit per-variable assert X is not None so each name narrows from Any | None to Any before being called as a constructor. Same runtime guard, same error messages on miss. Companion static check fixes commit cleans up two ruff findings in src/rwa_calc/engine/crm/haircuts.py and src/rwa_calc/engine/sa/supporting_factors.py surfaced by the same gate run.
  • CCR stage_timer caplog test re-enabled: test_stage_timer_emits_ccr_sa_ccr_record previously captured zero records when run on an xdist worker that had previously executed any CreditRiskCalc.calculate()-using test. configure_logging() sets propagate=False on the rwa_calc namespace logger, severing the descendant rwa_calc.engine.pipeline logger from caplog's root-attached handler. The fix temporarily re-enables propagation for the scope of the test and restores the prior value in a finally block — mirrors the documented pattern already applied in tests/unit/test_loader_optional_error_handling.py and tests/unit/test_fx_rate_sync.py.

Fixed

  • SME E* now nets the residential collateral value rather than dropping the entire BTL row (engine/sa/supporting_factors.py): CRR Art. 501 defines E* as the total amount owed "excluding claims or contingent claims secured on residential property collateral". The engine previously implemented this carve-out by dropping entire BTL rows from the group sum, but that diverges from the parallel retail-threshold treatment in engine/hierarchy.py:2444-2447 (CRR Art. 123(c)) which subtracts residential_collateral_value (capped at drawn) per row. This change aligns the SME E* aggregation with the retail interpretation: each row's contribution to E* is now drawn − min(residential_collateral_value, drawn), so a non-BTL SME secured on residential property has the secured portion netted from E*, and a partially-secured BTL row contributes its unsecured residual rather than zero. The is_btl ⇒ factor=1.0 eligibility gate is unchanged; in the typical case where a BTL row's RRE coverage equals its drawn balance, its E* contribution still lands at 0. Behaviour change is CRR-only — supporting factors are removed under PS1/26. Pinned by 5 new tests in TestResidentialCollateralNettedFromEStar (partial coverage, full coverage, cap-at-drawn, lending-group spillover, backward-compat without the column); existing TestBTLExcludedFromSMEFactor tests reframed to set explicit res_coll=drawn on BTL rows so their numerical expectations still hold. Ref: CRR Art. 501; mirrors retail-threshold treatment of CRR Art. 123(c).
  • Brand link in the docs hero updated to the new absolute URL (docs/overrides/main.html): one-line href correction.
  • Skill quick-nav: CRR institution CQS 2 risk weight corrected (.claude/skills/crr/SKILL.md): the Art. 120-121 quick-nav row claimed "UK CQS 2 = 30%", but 30% is the Basel 3.1 / PRA PS1/26 ECRA value. CRR Art. 120(1) Table 3 gives 50% for CQS 2 institutions, and no PRA instrument modifies that under CRR. The misleading hint seeded a wrong scalar in the P8.20 fixture (caught at W2 reviewer; tracked in batch 20260523-2023 follow-ups). Fix: 30% → 50%.

[0.2.13] - 2026-05-23

Fixed

  • SME drawn_expr hardened against NaN propagation (engine/sa/supporting_factors.py): a single NaN in drawn_amount, interest, or ead_final poisoned the windowed sum over lending_group_reference, zeroing total_cp_drawn for every exposure in the connected-clients group and dropping the SME supporting factor entirely. fill_null does not catch NaN — added fill_nan(0.0) before clip/sum on all three inputs.

[0.2.12] - 2026-05-23

Added

  • Securitisation pool allocation — phase 1 flag + exclude (CRR Art. 109, Art. 244-246 / PS1/26 Art. 147A(1)(j)): new optional securitisation_allocations input table maps originated exposures (loans, contingents, facility-undrawn parents) to one or more securitisation pools with a fractional allocation_pct. A new lightweight pipeline stage SecuritisationAllocator (src/rwa_calc/engine/securitisation/allocator.py) resolves the table into a per-exposure lookup carrying securitisation_residual_pct (clipped to [0, 1]) and securitisation_pool_allocations (list-of-struct {pool_reference, allocation_pct}). The two columns ride through CRM, the calculators, and the aggregator unchanged. The aggregator (engine/aggregator/_securitisation.py) then multiplies every monetary column by securitisation_residual_pct so existing summaries (summary_by_class, summary_by_approach, pre_crm_summary, post_crm_*, output floor U-TREA / S-TREA, EL portfolio summary, supporting factor impact) naturally reflect only the on-balance-sheet portion — final_rwa × (1 - securitisation_pct) in the user's framing. Two new fields on AggregatedResultBundle: securitisation_summary (per-pool EAD / RWA placeholder / EL grouping derived by exploding the struct list) and securitisation_audit (per-exposure reconciliation showing parent EAD = residual + sum of pool slices). Five new validation codes (SEC001-SEC005) cover over-allocation, invalid pct, orphan exposure_reference, duplicate (exposure, pool) rows, and the SEC005 informational signal for fully-securitised exposures (residual = 0); SEC errors flow verbatim through data.errors (loader-validation channel) so the original codes survive into the bundle. Linearity property pinned by tests/integration/test_securitisation_pipeline.py::test_sec_06_residual_equals_pro_rata_via_linearity: residual_rwa == full_pipeline_rwa × residual_pct, demonstrating that late multiplication at the aggregator is equivalent to pro-rata CRM scaling for single-exposure metrics with directly-attached collateral. Known phase-1 limitation: counterparty- or facility-level shared collateral does not re-allocate when a sibling exposure is securitised — the unrelated siblings still see the full collateral allocation as if no securitisation had happened. This is documented in docs/specifications/securitisation-pool-allocation.md. Out of scope (deferred to phase 2): significant-risk-transfer assessment (Art. 244-246 conditions), securitisation RWA framework (SEC-SA, SEC-IRBA, SEC-ERBA — CRR Art. 259-264), tranche-level capital, originator retained interest. Wired through RawDataBundle.securitisation_allocations, ResolvedHierarchyBundle.securitisation_audit, ClassifiedExposuresBundle.securitisation_audit, CRMAdjustedBundle.securitisation_audit, AggregatedResultBundle.{securitisation_summary, securitisation_audit}. New SecuritisationAllocatorProtocol in contracts/protocols.py. Loader picks up the optional securitisation/securitisation_allocations.parquet via DataSourceRegistry. Pinned by 16 allocator unit tests (tests/unit/engine/securitisation/test_allocator.py), 11 aggregator-helper unit tests (tests/unit/engine/aggregator/test_securitisation_helpers.py), and 8 end-to-end integration tests covering SEC-01..SEC-08 (tests/integration/test_securitisation_pipeline.py). Ref: CRR Art. 109, Art. 244-246; PRA PS1/26 Art. 147A(1)(j).
  • SA-CCR engine subpackage — Tier 8 CCR integration phase 1 (P8.1 → P8.18): ~15 commits across the May 22-23 batches landed the SA-CCR engine scaffolding through the netting-set legal-enforceability gate. Highlights: new src/rwa_calc/engine/ccr/ subpackage scaffold (P8.4), RawCCRBundle and the optional RawDataBundle.ccr field (P8.2), CCRCalculator protocol (P8.3 placeholder for ty), CCRConfig plumbed through CalculationConfig.crr() / .basel_3_1() factories (P8.6), CCR loader + 4 schemas (P8.5), supervisory factors / correlations / maturity constants moved to data/tables/ per the architectural data/engine split (P8.7), trade-level CCR fixture builders for IR / FX / equity / credit / commodity / settlement (P8.40), SA-CCR EAD = α × (RC + PFE) per CRR Art. 274(2) (P8.17), supervisory delta linear ±1 (P8.13) and Black-Scholes options + CDO-tranche delta (P8.13-opt), adjusted-notional for IR (P8.12), maturity factor for unmargined and margined transactions with Art. 285 MPOR floors (P8.14, P8.14-marg), hedging sets + IR asset-class addon aggregation (P8.15), netting-set legal-enforceability gate (P8.18), replacement cost for margined transactions (P8.11), PFE multiplier and aggregate per Art. 278(3) (P8.16), QCCP trade exposures per Art. 306-307 (P8.25), failed trades DvP / non-DvP per Art. 378-380 (P8.24), and wrong-way-risk identification per Art. 291 (P8.27). The CCR pipeline stage wiring (P8.20) and first acceptance scenario (P8.41) land in 0.2.14. Ref: CRR Art. 271-280, 285, 291, 306-307, 378-380; PRA Rulebook CCR (CRR) Part.

Changed

  • DQ008 warning consolidated into a single aggregate entry (was one warning per offending exposure): ExposureClassifier._collect_beel_on_non_defaulted_warnings (engine/classifier.py) previously emitted one CalculationError for every row matching (is_defaulted=False ∧ beel>0). For portfolios whose A-IRB pipeline populates beel alongside lgd on every advanced-IRB customer (the documented reason this check exists at all), that produced N repeated warnings, one per row — overwhelming any consumer that iterates result.errors line-by-line. The helper now selects pl.len() instead of materialising the offending rows and returns a single-element list containing one aggregate warning carrying the total count, matching the CLS006 model-permission and CLS008 large-corp-revenue roll-up pattern used everywhere else in the classifier. beel_on_non_defaulted_exposure_warning (contracts/errors.py) factory signature changes from (*, exposure_reference, beel_value) to (*, n: int); the warning's exposure_reference and actual_value fields are now unset because no single offender is referenced. Message reads "BEEL populated on {n} non-defaulted exposure(s); ...". Pinned by a new tests/unit/test_classifier.py::TestDefaultClassification::test_beel_warning_is_aggregated_across_multiple_offenders regression (N=3 offenders → exactly one DQ008 with "3 non-defaulted exposure" in the message); existing single-offender unit and acceptance scenarios updated to match the aggregate shape. Ref: PRA PS1/26 Art. 181(1)(h)(ii); CRR Art. 158(5).

Fixed

  • H_fx no longer applied to funded non-financial collateral (Art. 230): the engine previously charged the 8% FX volatility haircut (scaled to ~11.31% at the 20-day secured-lending period by Art. 226(2)) on real estate, receivables, and other physical collateral whenever the collateral currency differed from the exposure currency. The citation chain on which this was built — docs/specifications/crr/credit-risk-mitigation.md attributed the charge to "CRR Art. 233" generically — is incorrect: Article 233 sits in CRR Sub-Section 2 Unfunded credit protection and governs guarantees / CDS only ("Where unfunded credit protection is denominated in a currency different from that in which the exposure is denominated …"). The funded non-financial collateral path is governed by Articles 229–230, whose LGD* formula compares the raw collateral value C against the C* / C** thresholds in Table 5 with no FX volatility adjustment; FX risk is captured upstream by the spot-rate FXConverter. PS1/26 inherits CRR's silence on H_fx for Art. 230, so the fix is framework-agnostic. Engine change is a one-line gate on the fx_expr in src/rwa_calc/engine/crm/haircuts.py:203-210 excluding collateral_type ∈ NON_FINANCIAL_COLLATERAL_TYPES (new constant in src/rwa_calc/data/schemas.py covering receivables, real_estate, other_physical and their accepted synonyms). Financial collateral (cash / gold / bonds / equity / covered bonds / life insurance / credit-linked notes) continues to receive the Art. 224 Table 4 H_fx — the comprehensive-method scope is unchanged. The guarantee H_fx (Art. 233(3)) in engine/crm/guarantees.py is also unchanged. Capital impact: portfolios with non-financial collateral in a currency other than the exposure currency will see lower RWA — for a GBP corporate exposure secured by EUR commercial property, ES (haircut-adjusted collateral feeding LGD*) increases by ~11.31% at the 20-day liquidation default, which reduces LGD* and the corresponding F-IRB RWA. Pinned by 13 new tests in tests/unit/crm/test_collateral_fx_mismatch.py::TestNonFinancialCollateralNoFxHaircut (real_estate / receivables / other_physical + framework variants + synonym coverage + a cash regression guard). Specs updated: docs/specifications/crr/credit-risk-mitigation.md (FX section narrowed, LGD* formula scope clarified, summary table row split into financial / unfunded / non-financial); docs/specifications/basel31/credit-risk-mitigation.md (FX Mismatch Haircut scope clarification). Ref: CRR Art. 224 Table 4 (financial); CRR Art. 233 (unfunded); CRR Arts. 229–230 (funded non-financial).

Performance

  • Classifier diagnostics deferred behind a single materialise barrier — 100K pipeline runtime drops ~31 %: ExposureClassifier.classify() (engine/classifier.py) previously triggered the upstream lazy plan three times per pipeline run — once for the BEEL-on-non-defaulted diagnostic (_collect_beel_on_non_defaulted_warnings, formerly called inline at the top of classify()), once for the model-permission roll-up (the .collect() inside _resolve_model_permissions_if_present), and a third time when CRMProcessor._run_ead_pipeline hit its own materialise_barrier after provisions/CCF/init_ead. Polars does not cache intermediate results between .collect() calls, so each one re-executed the full hierarchy + classifier join plan from raw data. Profiling at 100K showed CRM's first barrier alone cost 2 622 ms of a 6 300 ms total — most of it redundant upstream work. The fix runs all lazy classifier transforms first (including the _resolve_model_permissions join, separated from its diagnostic emit), inserts one materialise_barrier(classified, config, "classifier_output") at the end of classify(), and then emits both diagnostics against the in-memory frame. _resolve_model_permissions_if_present is replaced by a pure-lazy _resolve_model_permissions call plus a new post-materialise helper _emit_model_permission_diagnostics that runs the same filter / group-by / collect against the materialised frame for ~free. After the change, profile_stages.py --framework crr --irb full reports CRM barrier #1 at 250 ms (was 2 622 ms) and total pipeline at 4 313 ms (was 6 300 ms) — a saving of ~1 987 ms / 31.5 % at 100K. Basel 3.1 / full IRB sees the same shape (total ~5 488 ms). No behaviour change to the diagnostic content — the warnings emitted are identical in code, message, count, and order to consumers iterating result.errors. Verified by uv run pytest tests/unit/ tests/contracts/ (5 263 passed), uv run pytest tests/acceptance/ tests/integration/ (1 007 passed), and uv run python scripts/arch_check.py (all checks passed — the new materialise_barrier call satisfies the "no raw .collect().lazy() outside materialise.py" rule). Baseline + after numbers captured in docs/perf/baseline-2026-05-22.md. Ref: no regulatory change.

[0.2.11] - 2026-05-19

Added

  • watchfire matrix coverage closed for CRR Art. 130-132 + 134-152 (SA other-items / ECAI methodology + IRB exposure-class chapter): the citation matrix at docs/development/citation-matrix.md previously walked straight from CRR Art. 129 (covered bonds) to Art. 133 (equity) and again from Art. 133 to Art. 153 (IRB RWEA), leaving the Other-Items / ECAI methodology block (Art. 130-141) and the entire IRB exposure-class chapter (Art. 142-152) invisible to anyone scanning the rendered matrix. This pass closes the CRR side of that gap end-to-end with a mix of new @cites(...) decorators on the functions that already realised the rules and inline "Out of scope — reason" notes on articles that are deliberately not cited. New decorators: CRR Art. 137 on _eca_meip_rw_expr (engine/sa/namespace.py); CRR Art. 134 on _apply_b31_risk_weight_overrides; stacked CRR Art. 134 / CRR Art. 137 on _apply_crr_risk_weight_overrides; stacked CRR Art. 135 / 136 / 138 / 139 on HierarchyResolver._attach_counterparty_rating (engine/hierarchy.py); stacked CRR Art. 131 / 140 on HierarchyResolver._apply_short_term_rating_override; stacked CRR Art. 141 over the existing Art. 114 on build_eu_domestic_currency_expr (data/tables/eu_sovereign.py); stacked CRR Art. 143 / 148 / 150 on ExposureClassifier._resolve_model_permissions (engine/classifier.py); CRR Art. 147 on ExposureClassifier._align_irb_exposure_class and stacked on ExposureClassifier.classify over the existing Art. 112; stacked CRR Art. 151 over the existing Art. 153 / 154 on apply_irb_formulas (engine/irb/formulas.py). New from watchfire import cites import landed on engine/hierarchy.py (the only target file that didn't already import the decorator). Coverage notes for the eight deliberately-not-cited articles in the dense 111-152 range — Art. 128 (UK CRR omitted by SI 2021/1078), Art. 130 (securitisation, separate calculator domain), Art. 132 (UK CRR omitted; reintroduced under PS1/26 paragraph 132), Art. 142 (definitions only), Art. 144 / 145 / 146 / 149 (supervisory permission processes — input via model_permissions), Art. 147A (B3.1 amendment, decoration deferred until PS1/26 paragraph mapping is confirmed), Art. 152 (IRB CIU look-through not implemented) — live in a new CRR_COVERAGE_NOTES dict at the top of scripts/generate_citation_matrix.py. scripts/generate_citation_matrix.py extended with a CRR_DENSE_RANGE covering Art. 111-152: the renderer now guarantees every article in that range appears in the matrix as either an implementing-function collapsible (live @cites) or an italic out-of-scope block (notes), and raises if any article in the range has neither, so the matrix can never silently drop coverage; articles outside the dense range continue to render sparsely (only those with @cites). 10 new rows in tests/contracts/test_watchfire_coverage.py::WHITELIST (84 parametrised cases, up from 66 after the Art. 115-119 round). docs/development/citation-tracking.md extended with a paragraph distinguishing dense vs sparse matrix coverage. No runtime behaviour change — @cites is a no-op decorator. Verified end-to-end: uv run python scripts/generate_citation_matrix.py — matrix regenerated, CRR section now spans ### CRR Art. 111 through ### CRR Art. 152 with no gaps; uv run python scripts/arch_check.pyall checks passed; uv run pytest tests/contracts/test_watchfire_coverage.py — 84 passed; full tests/contracts/ minus pre-existing test_no_raw_over_on_nullable_keys.py failure — 293 passed; uv run pytest tests/unit/ — 4,934 passed; uv run zensical build — site builds cleanly. Basel 3.1 / PS1/26 citations in the same article range are intentionally out of scope for this pass — a future review will close those gaps; where a function already carries a PS1/26 cite (e.g. _append_ciu_branches), it's left untouched. Ref: CRR Art. 130-152; scripts/generate_citation_matrix.py:49-152.
  • watchfire matrix coverage closed for CRR Art. 115-119 (SA exposure-class block): the citation matrix at docs/development/citation-matrix.md walked straight from CRR Art. 114 (central govt) to CRR Art. 120 (rated institutions), under-representing five fully-implemented SA exposure classes — RGLA (Art. 115), PSE (Art. 116), MDB (Art. 117), international organisations (Art. 118), and institutions scope/umbrella (Art. 119). Decorators added in src/rwa_calc/data/tables/crr_risk_weights.py following the existing builder-layer precedent set by build_institution_guarantor_rw_expr (Art. 120/121) and build_corporate_guarantor_rw_expr (Art. 122): @cites("CRR Art. 115") on _create_rgla_df, @cites("CRR Art. 116") on _create_pse_df, @cites("CRR Art. 117") on _create_mdb_df, and @cites("CRR Art. 119") stacked outermost over the existing Art. 120 / Art. 121 pair on build_institution_guarantor_rw_expr. Art. 118 had no analogue _create_*_df() builder (only the bare IO_ZERO_RW constant + an inline 0% branch in sa/namespace.py), so a thin _create_io_df() builder was introduced for symmetry, driven by a new INTERNATIONAL_ORG_RISK_WEIGHTS dict keyed on CQS.UNRATED so it round-trips through the shared _build_cqs_rw_df helper; the SA branch is unchanged. Five rows added to tests/contracts/test_watchfire_coverage.py::WHITELIST (the institution row now expects the three-citation tuple ("CRR Art. 119", "CRR Art. 120", "CRR Art. 121") per the outermost-first convention). docs/development/citation-matrix.md regenerated via scripts/generate_citation_matrix.py — now renders ### CRR Art. 115 through ### CRR Art. 119 headings between the existing 114 and 120 sections. No runtime behaviour change (@cites is a no-op decorator). Verified: uv run pytest tests/contracts/test_watchfire_coverage.py — 66 passed (was 61); uv run watchfire checkfatal=0 warn=0 (all five articles resolve against the bundled CRR index); uv run python scripts/arch_check.pyall checks passed; SA-class acceptance subset (RGLA/PSE/MDB/IO/institution) — 48 passed. Ref: CRR Art. 115-119; src/rwa_calc/data/tables/crr_risk_weights.py.

Changed

  • beel > 0 no longer triggers defaulted treatment; new DQ008 warning surfaces the input contradiction: engine/classifier.py::_build_is_defaulted_expr is narrowed from a three-way OR (cp_default_status | row-level is_defaulted | beel > 0, landed in P1.127, shipped in v0.2.10) back to a two-way OR (cp_default_status | row-level is_defaulted). PRA Rulebook (IRB Part Rule 1.3), PS1/26 Art. 158(5), and Art. 181(1)(h)(ii) define BEEL strictly as the firm's best-estimate-EL on a defaulted exposure, but firms whose A-IRB model pipelines emit a BEEL-style value alongside lgd for every advanced-IRB customer would otherwise see those rows silently mass-flagged as defaulted (routed through SA Art. 127 or IRB Art. 153(1)(ii) / 154(1)(i)). The contradictory (is_defaulted=False ∧ beel>0) combination now surfaces as one non-blocking DQ008 warning per offending exposure (new ERROR_BEEL_ON_NON_DEFAULTED_EXPOSURE + beel_on_non_defaulted_exposure_warning factory in contracts/errors.py; emitted by new ExposureClassifier._collect_beel_on_non_defaulted_warnings which reads the derived is_defaulted, so rows that the counterparty cascade legitimately routes to defaulted are not falsely flagged). beel remains an A-IRB defaulted parameter — consumed by engine/irb/adjustments.py (K = max(0, LGD − BEEL) per Art. 154(1)(i)) and by Pool C of the Art. 158(5) EL amount when the row is genuinely defaulted. New @cites("CRR Art. 178") / @cites("CRR Art. 153") decorators on the derivation helper; Art. 158(5) citation already sits on the consumption sites in irb/adjustments.py and is unchanged. is_defaulted promoted to a first-class optional Boolean on LOAN_SCHEMA, CONTINGENTS_SCHEMA, and FACILITY_SCHEMA (data/schemas.py) so the row-level flag P1.127 already supports has a documented home — defaults to False. Migration note for firms whose loaders populate beel on non-defaulted rows: either (a) restrict beel to defaulted rows only in the loader (regulator-correct, no engine change needed) or (b) treat the resulting DQ008 warnings as informational (the calc is unaffected on those rows — BEEL is not consumed when the derived is_defaulted is False). Pinned by tests/acceptance/crr/test_beel_does_not_trigger_default.py (3 scenarios: A-IRB performing with beel>0 → not defaulted + one DQ008; cp-default with beel>0 → defaulted + no DQ008; row-flag with beel=0 → defaulted + no DQ008) and four new truth-table cases in tests/unit/test_classifier.py::TestDefaultClassification. P1.127 regression guard updated: the LN-P1127-D defaulted fixture now sets explicit is_defaulted=True on the loan row (was relying on the removed beel>0 OR branch); the three Pool B AVA / other_own_funds_reductions assertions remain unchanged and green. Benchmark data generators (tests/benchmarks/data_generators.py) extended to populate the new is_defaulted column on synthetic facility / loan / contingent rows. Ref: CRR Art. 178, Art. 153(1)(ii) / 154(1)(i), Art. 158(5); PS1/26 Art. 181(1)(h)(ii); PRA Rulebook IRB Part Rule 1.3 (BEEL definition).

Performance

  • Benchmark scales pruned: 10M removed entirely, 1M gated behind -m scale_1m opt-in: the 10M-scale pipeline + hierarchy benchmarks were unrunnable on developer laptops — synthetic data generation alone OOMs at ~10M counterparties, and even when the cached parquet existed the pipeline took ~20 minutes. Their only useful regression signal was duplicated by 100K + tests/benchmarks/profile_plan.py plan-complexity inspection, so they're deleted in full: TestPipelineBenchmark10M (tests/benchmarks/test_pipeline_benchmark.py), TestHierarchyBenchmark10M (tests/benchmarks/test_hierarchy_benchmark.py), the benchmark_config_10m / dataset_10m / dataset_10m_stats fixtures, and the scale_10m marker registration. The 1M scale remains in the codebase for opt-in profiling on a workstation but is now belt-and-braces gated by both @pytest.mark.slow (existing) and a new @pytest.mark.scale_1m-driven addopts exclusion (pyproject.toml:116-m 'not slow and not stress and not scale_1m'), so dropping the slow marker by accident in future cannot re-enable 1M in the dev loop. The 10K and 100K benchmarks continue to run untimed in the dev loop (via the existing --benchmark-disable flag) so any pipeline regression that broke the calculator at scale would still surface as a test failure. Verified by uv run pytest tests/benchmarks/ --collect-only (27 selected, 5 deselected, no 10M classes listed); explicit 1M opt-in confirmed by uv run pytest tests/benchmarks/ -m scale_1m --collect-only (5 selected). Ref: no regulatory change.

[0.2.10] - 2026-05-16

Added

  • Short-term ECAI flag moved from facility row to ratings row (refactor): short-term ECAI assessments under PRA PS1/26 Art. 120(2B) Table 4A and Art. 122(3) Table 6A are issue-specific (attached to a particular exposure), so the has_short_term_ecai Boolean has been removed from FACILITY_SCHEMA and replaced by three new columns on RATINGS_SCHEMA: is_short_term: Boolean (default False), scope_type: String (facility / loan / contingent), and scope_id: String (the matching identifier). A new RatingScope enum lives in domain/enums.py. HierarchyResolver gained _apply_short_term_rating_override (engine/hierarchy.py), which joins the short-term rating row(s) against each exposure using (counterparty_reference, scope_type, scope_id) and overrides the counterparty-level cqs plus the derived has_short_term_ecai column. The SA branches in _b31_append_institution_maturity_branches / _b31_append_corporate_maturity_branches (engine/sa/namespace.py) drop the original-maturity sub-gate from the Table 4A / Table 6A clauses — when a short-term rating row is attached, the engine trusts the producer and routes via Table 4A / Table 6A unconditionally (the regulatory maturity test ≤ 3m is now a producer-side responsibility). The old facility-level OR-broadcast of has_short_term_ecai in _propagate_facility_qrre_columns has been deleted. New loader DQ rule (contracts/validation.py::_validate_short_term_rating_scope) flags rating rows where is_short_term=True is paired with null scope columns, and warns on stray scope values for non-short-term rows. Tests / fixtures migrated: tests/fixtures/p1_103/, tests/fixtures/p1_105/, tests/fixtures/api_validation/build_mandatory_only.py, tests/fixtures/p1_128/p1_128.py; new contract tests/contracts/test_short_term_rating_override.py pins (i) override fires regardless of maturity, (ii) scope-id mismatch is a no-op, (iii) absent short-term row falls back to counterparty long-term CQS. Ref: PRA PS1/26 Art. 120(2B); Art. 122(3); BCBS CRE20.20 / CRE20.45.
  • @cites("CRR Art. 501a") re-instated on calculate_infrastructure_factor (watchfire 0.3.1): the workaround inline comment at engine/sa/supporting_factors.py:114-116 was added against watchfire 0.3.0, whose parser rejected alphanumeric article suffixes — 501a could neither parse nor validate, so the function was left unannotated to avoid mis-citing the umbrella Art. 501 (SME factor). watchfire 0.3.1 (pinned in pyproject.toml:38) fixes both halves: parser.py:121 regex now accepts r"(\d+[a-z]*)", and the bundled CRR index (.venv/Lib/site-packages/watchfire/data/index.parquet) carries 49 rows for article 501a. Decorator re-added; 3-line workaround comment deleted. Stale prose in CLAUDE.md and docs/development/citation-tracking.md rewritten to distinguish parser support (now general) from index coverage (still missing 123B / 110A, which remain Basel-3.1 amendments with no CRR equivalent — those sites at engine/sa/namespace.py:1864,1934 correctly cite PS1/26, paragraph … already and are unchanged). No runtime behaviour change — cites is a no-op decorator. Verified end-to-end: uv run watchfire matrix --instrument CRR --article 501a lists calculate_infrastructure_factor. Ref: PRA PS1/26 Art. 501a (infrastructure supporting factor); CRR2 EU 2019/876.
  • watchfire citation tracking — fan-out to 54 annotated functions + strict gate + contract test (watchfire 0.3.0): built on the prior pilot to annotate the full breadth of the engine and regulatory data tables. Dependency bumped from watchfire>=0.2.0 to watchfire==0.3.0 in pyproject.toml:38 (also picks up the [tool.watchfire].rulebook_version bump from 2026-05-14 to 2026-05-15). 0.3.0 stacks @cites decorators into a tuple at __watchfire__ rather than overwriting (multi-citation now first-class), accepts alphanumeric article and paragraph suffixes (CRR Art. 123B, PS1/26, paragraph 110A), and ships an expanded PS index (4,498 PS rows, up from a single stub). New @cites(...) decorators landed on ~50 additional functions across engine/{sa,irb,crm,re_splitter,ccf,equity,slotting,classifier,aggregator} and data/tables/{crr_risk_weights,b31_risk_weights,eu_sovereign,firb_lgd,haircuts} — stacked CRR + PS1/26 forms used wherever one function implements both frameworks (e.g. _pd_floor_expression carries CRR Art. 163 and PS1/26, paragraph 163). The pilot's apply_currency_mismatch_multiplier workaround comment was deleted: it now cites PS1/26, paragraph 123B precisely. Three Basel-3.1-only amendment articles that don't exist in the pre-2027 CRR index (Art. 123B, Art. 110A, Art. 501a infrastructure) cite the PS1/26-form only or, where no valid CRR-equivalent exists, are left intentionally unannotated with an inline explanation (calculate_infrastructure_factor). scripts/arch_check.py::check_watchfire_citations() simplified to strict mode — PS / PRA Rulebook unknown_article findings are no longer downgraded to soft warnings now that the index is mature; only AST unresolved cases remain soft. New tests/contracts/test_watchfire_coverage.py whitelists all 54 annotated functions with their expected canonical citation tuples (61 parametrised test cases); accidental decorator removal during refactors will fail this test with a named row rather than a silent matrix shrink. New scripts/generate_citation_matrix.py regenerates docs/development/citation-matrix.md by invoking uv run watchfire matrix --format markdown once per instrument and stitching the tables. docs/development/citation-tracking.md extended with sections covering the coverage matrix, the regression test, and the strict gate. Verified end-to-end: uv run watchfire check resolves 76 citations cleanly; uv run python scripts/arch_check.py reports all checks passed with zero warnings; uv run pytest tests/contracts/test_watchfire_coverage.py — 61 passed; uv run watchfire matrix --instrument CRR enumerates every CRR-side annotation; uv run pytest tests/unit/irb/ — 212 passed (no runtime regression from decorator stacking). Ref: pyproject.toml:38,154-160; watchfire 0.3.0 release.

  • watchfire citation tracking — pilot wire-up + 5 reference annotations: integrated watchfire>=0.2.0 (already in pyproject.toml:38) as the project's static citation validator. New [tool.watchfire] table in pyproject.toml pins rulebook_version = "2026-05-14" and scopes scanning to src/rwa_calc/engine + src/rwa_calc/data/tables. scripts/arch_check.py now invokes watchfire.checks.run_check via its Python API as the final gate step (new check_watchfire_citations()); parse_failure, unknown_instrument, version_mismatch, and CRR/Delegated-Regulation unknown_article findings are fatal, while PS / PRA Rulebook / SS unknown_article findings and AST unresolved cases are downgraded to soft warnings until the upstream rulebook index ships richer non-CRR coverage. Five pilot @cites(...) decorators landed on canonical IRB / SA entry points: calculate_k and calculate_correlation (engine/irb/formulas.py, CRR Art. 153(1)); IRBLazyFrame.apply_pd_floor and apply_lgd_floor (engine/irb/namespace.py, stacked CRR Art. 163 / CRR Art. 164 over PS1/26, paragraph 163 / PS1/26, paragraph 164); SALazyFrame.apply_currency_mismatch_multiplier (engine/sa/namespace.py, instrument-level PS1/26 — the precise Art. 123B sub-paragraph is unparseable by watchfire 0.2.0 today because the parser rejects alphanumeric paragraph suffixes; an inline comment records the pending upstream fix). Inner stacked decorators do not yet surface in watchfire matrix (watchfire 0.2.0 __watchfire__ holds the outermost citation only) — once upstream stacks citations into a tuple, the inner PS1/26 references will activate with no source-code change here. New developer-docs page docs/development/citation-tracking.md documents the @cites convention, canonical citation grammar, parser limitations, and CLI invocations; CLAUDE.md "Documentation" section gains a "Citation tracking" subsection so future contributors annotate as a matter of course. Verified end-to-end: uv run python scripts/arch_check.py reports all checks passed with 3 PS1/26 soft warnings; uv run watchfire matrix --instrument CRR --format markdown lists CRR Art. 153 / 163 / 164 mapped to the expected functions. Follow-up work (Step 2 of the rollout plan) will fan out annotations across the remaining ~20 public namespace methods. Ref: pyproject.toml:38; watchfire upstream tracker for multi-citation + PS1/26 index expansion.

  • B3.1 Art. 123B(1) currency-mismatch scope narrowed to retail / RRE only (P1.94 sub-item (f), batch 20260510-1500): apply_currency_mismatch_multiplier (engine/sa/namespace.py:1880-1892) was over-firing the 1.5x B3.1 multiplier on commercial RE because the in-scope predicate used substring matching (_upper_class.str.contains("RETAIL"|"MORTGAGE"|"RESIDENTIAL"|"COMMERCIAL"|"CRE")) — COMMERCIAL_MORTGAGE matched on both "COMMERCIAL" and "MORTGAGE". PRA PS1/26 Art. 123B(1) restricts the multiplier to retail (Art. 112(h)) and residential RE (Art. 112(i)) classes only; commercial RE (Art. 112(j) per Art. 124H/124I) is OUT of scope. Fix narrows the gate to exact match pl.col("exposure_class").is_in(["retail_other","retail_qrre","retail_mortgage","residential_mortgage"]). No new regulatory scalars (uses existing ExposureClass string values from domain/enums.py). Pre-fix counterfactual on a 1m EUR commercial-mortgage exposure with GBP borrower-income: RW=1.50, RWA=1,500,000 (BUG); post-fix RW=1.00, RWA=1,000,000. Discriminating fixture: 3-arm test (retail_other in-scope, commercial_mortgage out-of-scope, corporate sanity-anchor); test routes via calculate_single_sa_exposure to bypass classifier and pin the multiplier predicate directly (mirrors P1.94a precedent). Pinned by tests/acceptance/basel31/test_p1_94f_currency_mismatch_scope_residential_re.py (10 tests with load-bearing anti-assertions RW != 1.50 and RWA != 1,500,000 + cross-arm scope-boundary check). P1.94a regression (7 tests) green. Sub-items (b)/(d)/(e)/(g) remain open. Ref: PRA PS1/26 Art. 123B(1); BCBS CRE20.93.

  • B3.1 corporate guarantor at CQS 3 substituted RW = 75% under IRB SA-fallback (P1.122 sub-claim (a), batch 20260510-1500): _compute_guarantor_rw_sa (engine/irb/guarantee.py:269-281) was hardcoded to the CRR Art. 122 Table 5 corporate-CQS ladder (CQS 3 = 1.00) with no is_basel_3_1 branch, so a B3.1 IRB borrower whose corporate guarantor lacked an internal_pd (forcing guarantor_approach = "sa" per engine/crm/guarantees.py:254-266) silently substituted at CRR weights — over-stating capital by 25 pp (1.00 → 0.75) on the guaranteed portion. Fix extracts new build_corporate_guarantor_rw_expr(cqs_col: str, is_basel_3_1: bool) helper in data/tables/crr_risk_weights.py (mirrors the build_institution_guarantor_rw_expr precedent established by P1.95 / P1.122 sub-claim (c) / v0.2.22-v0.2.23), dispatching to B31_CORPORATE_RISK_WEIGHTS under B3.1 and CORPORATE_RISK_WEIGHTS under CRR. The corporate branch in _compute_guarantor_rw_sa now calls build_corporate_guarantor_rw_expr("guarantor_cqs", config.is_basel_3_1) instead of the inline pl.when(...cqs.is_in([3,4])).then(1.0) ladder. No regulatory scalars in engine/. Discriminating row: £1m B3.1 unrated corporate borrower under FIRB + 100%-coverage guarantee from rated CQS-3 corporate guarantor with internal_pd=null → guarantor sub-row risk_weight = 0.75, rwa = 750,000 post-fix (was 1,000,000); CRR same construction stays at 1.00 / 1,000,000 (regression-pinned); cross-arm RWA delta = 250,000. Distinct from P1.110 (which uses PermissionMode.STANDARDISED and exercises the SA path's already-framework-gated _build_guarantor_rw_expr). Pinned by tests/acceptance/basel31/test_p1_122a_b31_corporate_cqs3_guarantor_irb_sa_fallback.py (3 tests: B31 75% / CRR 100% regression / Δ=250k) plus 7-test P1.110 sibling regression. Sub-claim (b) (unrated institution SCRA grades) remains open. Ref: PRA PS1/26 Art. 122(1) Table 6, Art. 235; CRR Art. 122 Table 5.

  • OutputFloorSummary rename + new genuine portfolio total (P2.20, batch 20260510-1500): the field formerly named total_rwa_post_floor only contained the floored modelled (IRB + slotting) component, not the SA / equity contributions — its name implied a portfolio-wide quantity but the value was modelled-only. PRA PS1/26 Art. 92(2A) defines TREA = max(U-TREA, x · S-TREA + OF-ADJ); the floor binds on the modelled subset (Art. 92(3A) S-TREA recomputes modelled in SA), and SA + equity pass through unchanged. Renamed OutputFloorSummary.total_rwa_post_floorfloored_modelled_rwa (modelled-only scope, identical arithmetic: u_trea + shortfall); added sa_rwa_total: float = 0.0 and equity_rwa_total: float = 0.0; redefined total_rwa_post_floor: float = 0.0 as floored_modelled_rwa + sa_rwa_total + equity_rwa_total. New SA_APPROACHES and EQUITY_APPROACHES frozensets in engine/aggregator/_schemas.py (allowlisted in scripts/arch_check.py::VALIDATION_ENUM_ALLOWLIST alongside existing IRB_APPROACHES). engine/aggregator/_floor.py populates the new fields at both summary-construction sites via private _portfolio_sa_equity_totals(combined) helper that filters rwa_pre_floor by approach_applied membership in SA / equity sets — no new .collect() boundaries, no aggregator.py edit. engine/comparison.py total_rwa_post_floor column on TransitionalScheduleBundle.timeline is a different field on a different bundle; explicitly NOT renamed. Hand-calc: SA=100, IRB=200, slotting=50, equity=30, x=0.725, S-TREA back-solved so floor binds at floored_modelled=280 → total_rwa_post_floor=410. Consumer migrations in 5 test files (test_of_adj.py, test_portfolio_level_floor.py, test_output_floor_skip_transitional.py, test_corep.py TestOF0201 / TestC0700Col0020, test_stress_pipeline.py). Pinned by tests/unit/test_p2_20_total_rwa_post_floor_naming.py (9 tests: dataclass field existence introspection + algebraic identity + SA/equity totals from a synthetic in-test 4-row LazyFrame + load-bearing pure-value comparison). Ref: PRA PS1/26 Art. 92(2A)/(3A); engine/aggregator/_floor.py:253.

  • Sovereign / institution PD floors as first-class PDFloors config fields (P2.36, batch 20260510-1500): PDFloors (src/rwa_calc/contracts/config.py:56-111) previously exposed corporate, retail_mortgage, retail_qrre_transactor, retail_qrre_revolver, retail_other, purchased_receivables_qrre, but not sovereign or institution — so the engine obtained the regulatorily-required 0.05% floor (PRA PS1/26 Art. 160(1)) for sovereign / institution exposures only by accident, falling through to the corporate-floor branch in _pd_floor_expression (engine/irb/formulas.py:51-126). Added explicit sovereign: Decimal and institution: Decimal fields to PDFloors; .basel_3_1() factory returns Decimal("0.0005") for both (PRA PS1/26 Art. 160(1)); .crr() returns Decimal("0.0003") for both (CRR Art. 160(1) uniform). PDFloors.get_floor() extended with sovereign / institution dispatch ahead of the corporate fallback. _pd_floor_expression extended with two new pl.when(exposure_class == ...) branches (CENTRAL_GOVT_CENTRAL_BANK → floors.sovereign; INSTITUTION → floors.institution) inserted before the final .otherwise(corporate); both new floor values added to the all-equal optimisation set. Discriminating row: sovereign B3.1 IRB exposure (PD=0.0001 input → floored to 0.0005, M=2.5y, LGD=0.40, no 1.06 scaling) yields RW≈0.174677 / RWA≈174,677; overriding pd_floors.sovereign=Decimal("0.001") via dataclasses.replace drives RW→0.263591 / RWA→263,591 — proving the dispatch path is independent of the corporate fallback (which still returns 0.0005 unchanged). CRR uniform-floor cross-check: sovereign PD=0.0001 floors to 0.0003 (not 0.0005). Pinned by tests/unit/config/test_p2_36_sovereign_institution_pd_floors.py (14 tests: 4 field-existence on .basel_3_1() and .crr() factories + get_floor dispatch tests + load-bearing override-regression tests for sovereign and institution). Ref: PRA PS1/26 Art. 160(1); CRR (EU 575/2013 onshored) Art. 160(1).

  • CRR Art. 123 second subparagraph payroll/pension 35% RW (P2.17, batch 20260510-1300): _apply_crr_risk_weight_overrides in engine/sa/namespace.py now branches on is_payroll_loan ahead of the flat-75% retail catch-all (mirrors B3.1 ordering), so qualifying CRR retail payroll/pension loans receive the CRR2 (Reg (EU) 2019/876, F68) Art. 123 second subparagraph 35% RW instead of the conservative 75% default. The 35% scalar is reused from B31_RETAIL_PAYROLL_LOAN_RW = Decimal("0.35") (data/tables/b31_risk_weights.py:252) — identical under PRA PS1/26 Art. 123(3)(a-b) and CRR Art. 123 second subparagraph; cross-framework reuse is documented inline. Caller-attested is_payroll_loan flag on LOAN_SCHEMA (already present pre-fix); the four cumulative Art. 123 conditions (a)–(d) are not engine-validated. Capital impact on a 50k payroll loan: RWA drops 37,500 → 17,500 (40% reduction). Pinned by tests/acceptance/crr/test_p2_17_crr_payroll_loan_35pct_rw.py (3 retail loans: 2 payroll @ 35% + 1 control @ 75%; anti-regression risk_weight != 0.75 guards on payroll arms). Ref: CRR Art. 123 second subparagraph (CRR2 amendment); PRA PS1/26 Art. 123(3)(a-b) (parity check).

  • PSM LGD source switch — Art. 236(1)(a)(i) option (i) (P2.43, batch 20260510-1300): new psm_lgd_source: Literal["option_i", "option_ii"] = "option_ii" field on IRBPermissions (contracts/config.py) exposes PRA PS1/26 Art. 236(1)(a)(i)'s borrower-unprotected LGD route for F-IRB unfunded credit protection. Pre-fix the engine hard-wired option (ii) (guarantor F-IRB scalar); option (i) was inaccessible. _apply_parameter_substitution (engine/irb/guarantee.py) now branches LGD_covered: option (i) → pl.col("lgd") (post-apply_firb_lgd column carries the borrower's seniority-correct supervisory value, e.g. 0.75 for subordinated); option (ii) → existing per-row guarantor F-IRB selection. _apply_no_better_than_direct_floor refactored to take psm_lgd_expr + direct_lgd_expr separately so the Art. 160(4) NBD comparison always uses the option (ii) guarantor scalar regardless of the switch (regulatory invariant — option (i) cannot give a better result than treating the protection as a direct exposure to the guarantor). _adjust_expected_loss mirrors the same branch (Art. 236(1A)(b)). CalculationConfig.irb_permissions widened from field(init=False) to IRBPermissions | None = None with __post_init__ deriving the framework-default — required to support dataclasses.replace(config, irb_permissions=...); runtime invariant guarantees non-None after construction. Discriminating row: B3.1 corporate borrower (subordinated, PD=0.05, M=2.5y, EAD=1M GBP) fully guaranteed by senior non-FSE corporate guarantor (PD=0.005). Option (ii) RW≈0.619 / RWA≈619k / EL≈2.0k; option (i) RW≈1.161 / RWA≈1.16M / EL≈3.75k; cross-arm RWA delta ≈541k. Default behaviour preserved (option_ii). Pinned by tests/acceptance/basel31/test_p2_43_psm_lgd_source_switch.py (3 functional tests + 6 fixture sanity guards). Ref: PRA PS1/26 Art. 236(1)(a)(i)/(1A)(b); Art. 161(1)(a)(aa)(b); Art. 160(4); BCBS CRE32.

  • B3.1 Art. 123B(2) is_hedged flag gates currency-mismatch multiplier (P1.94 sub-item (a), batch 20260510-0530): SA apply_currency_mismatch_multiplier (engine/sa/namespace.py) now AND-gates mismatch_applies with ~is_hedged.fill_null(False), so exposures attesting is_hedged=True skip the 1.5x multiplier per PRA PS1/26 Art. 123B(2) hedge exemption. New is_hedged: ColumnSpec(pl.Boolean, default=False, required=False) on LOAN_SCHEMA (data/schemas.py); null/missing falls back to the conservative behaviour (multiplier still fires under FX mismatch). Pre-fix the multiplier fired for every mismatched-currency exposure regardless of hedge status — over-stating capital on hedged retail / RRE rows by 50% (RW × 1.5 capped at 150%). Discriminating fixture: paired retail_other exposures, EUR loan vs GBP borrower-income, identical except is_hedged — hedged arm now RW=0.75/RWA=75k (multiplier suppressed); unhedged arm remains RW=1.125/RWA=112.5k. Pinned by tests/acceptance/basel31/test_p1_94a_is_hedged_gates_currency_mismatch.py (7 tests: 3 hedged + 3 unhedged regression-pin + 1 cross-arm RW-delta=0.375). Audit boolean currency_mismatch_multiplier_applied correctly reflects the gate decision. Out of scope (remain open on P1.94): (b) 90%-coverage hedge test, (d) revolving instalment 123B(2A), (e) pre-2027 portfolio fallback 123B(3), (f) scope narrowing to retail (h)/(i) only, (g) CR5 pre-multiplier RW reporting. Ref: PRA PS1/26 Art. 123B(1)/(2); BCBS CRE20.88.

  • B3.1 Art. 226(1) 20-day secured-lending + FX-mismatch reval-scaling acceptance regression-guard (P2.18, batch 20260510-0335): tests/acceptance/basel31/test_p2_18_art_226_1_b31_secured_lending_fx.py (7 tests) pins the previously uncovered Basel 3.1 / 20-day secured-lending T_m / USD-collateral-vs-GBP-loan / weekly-reval (N_R=5) corner. Engine already implements Art. 226(1) sqrt((NR + T_m - 1) / T_m) symmetrically against both collateral and FX haircuts (engine/crm/haircuts.py:144-207); plan-bullet was stale. Discriminator: ead_final ≈ 239,427.40 post-fix vs the pre-Art.226(1)-scaling counterfactual ead_final ≈ 227,279.22 — the test asserts the latter as a NOT-equal regression guard so any regression that drops the reval factor on either channel fails loudly. New fixture builder + parquets at tests/fixtures/p2_18/; tests/fixtures/generate_all.py extended. No engine change required. Ref: CRR / PRA PS1/26 Art. 226(1)/(2), Art. 224(2)(a) 20-day secured-lending, Art. 224 Table 4 FX 8%, Art. 122 Table 6 unrated corp SCRA Grade B 100%.

Changed

  • QRRE-coupling TODO closed; _FACILITY_QRRE_COUPLED_COLUMNS constant extracted (P6.26, batch 20260510-1300): pure non-functional refactor of engine/hierarchy.py. Site analysis showed the two QRRE-touching sites (_undrawn_select_expressions projects from the facility frame; _propagate_facility_qrre_columns joins+coalesces against the unified loan/contingent/facility_undrawn frame post-concat) are NOT duplicating the same expression — they operate at different pipeline stages with different operations and different null semantics. A merge would either need an awkward two-mode helper or force the upstream site to do an unnecessary self-join. Closes the lone source-tree TODO(qrre-coupling) marker with an explanatory comment block and extracts the four shared column names into a module-level constant _FACILITY_QRRE_COUPLED_COLUMNS = ("is_revolving", "is_qrre_transactor", "facility_limit", "facility_termination_date"). The constant is allowlisted in scripts/arch_check.py::VALIDATION_ENUM_ALLOWLIST (engine-internal coupling marker, mirrors engine/utils.py::NULLABLE_PARTITION_KEYS precedent — keeping it inside the module that enforces the coupling rather than splitting to data/schemas.py). tests/contracts/test_no_raw_over_on_nullable_keys.py line allowlist bumped 405,406 → 420,421 for the unrelated _build_rating_inheritance_lazy .over("counterparty_reference") calls (line shift only, no semantics change). No pl.col / pl.coalesce expressions touched at either site — the refactor is by design behaviour-preserving. Pinned by tests/unit/test_p6_26_qrre_coupling_constant.py (3 tests: constant existence with exact tuple value; TODO removal asserted on the read source; four-column propagation regression covering both Site A facility_undrawn synthesis and Site B post-concat propagation). Ref: src/rwa_calc/engine/hierarchy.py; informational anchors CRR Art. 147(5) (QRRE classification), PRA PS1/26 Art. 162(2A)(k) (facility_termination_date).

  • Three docstring corrections (P1.163 + P1.168 + P1.185, batch 20260510-1200): docstring-only / no calculation impact, each pinned by a regression-guard test against the live runtime object. (a) _pd_floor_expression (engine/irb/formulas.py:64-65) — Basel 3.1 retail mortgage PD floor 0.05% → 0.10% (Art. 163(1)(b)) and QRRE transactors 0.03% → 0.05%, revolvers: 0.10% (Art. 163(1)(c)); CalculationConfig.basel_3_1().pd_floors constants were already correct. Pinned by tests/unit/irb/test_p1_163_pd_floor_docstring.py (9 tests). (b) data/tables/b31_risk_weights.py module docstring (line 16) and get_b31_combined_cqs_risk_weights docstring (line 390) — corporate CQS5: 100%CQS5: 150% (PRA PS1/26 Art. 122(1) Table 6 retains 150% as a deviation from BCBS CRE20.42's 100%); B31_CORPORATE_RISK_WEIGHTS[5] = Decimal("1.50") constant unchanged. Pinned by tests/unit/data_tables/test_p1_168_corporate_cqs5_docstring.py (5 tests). (c) SCRAGrade.B docstring (domain/enums.py) — fabricated CET1/leverage thresholds replaced with the qualitative criterion of Art. 121(1)(b) ("Substantial credit risk but meets published minimum requirements (excluding buffers)"); installed via post-class SCRAGrade.B.__doc__ = "..." (mirrors EquityType.CIU precedent from P1.166). Lookup logic and B31_SCRA_RISK_WEIGHTS["B"] = Decimal("0.75") unchanged. Pinned by tests/unit/test_p1_185_scra_grade_b_docstring.py (8 tests). Ref: PRA PS1/26 Art. 163(1) / Art. 122(1) Table 6 / Art. 121(1)(b); BCBS CRE30.55 / CRE20.42 / CRE20.20.

  • Stale EquityType.CIU docstring corrected + surfaced at runtime (P1.166, batch 20260510-0530): trailing-string docstring at domain/enums.py:481 rewritten from the stale "150% CRR SA / 250% listed or 400% unlisted B31 SA" to the correct PRA PS1/26 Art. 132(2) wording ("1,250% fallback under both CRR and B31 SA when neither look-through Art. 132A(1) nor mandate-based Art. 132A(2) approach is applied"). Python's enum machinery does not propagate per-member trailing-string docstrings into EquityType.CIU.__doc__ — that attribute returns the class docstring instead. To make the corrected text observable at runtime (and testable), a post-class assignment EquityType.CIU.__doc__ = (...) is added immediately after the EquityType class closes. Runtime constants in data/tables/{b31,crr}_equity_rw.py were already correct (Decimal("12.50") per P1.119 / v0.1.184) — this fix is comment/audit-trail only with no calculation impact. Plan-bullet's equity/calculator.py:21 pointer was stale; that line was already correct. Pinned by tests/unit/test_p1_166_ciu_fallback_docstring.py (4 tests: 2 positive-substring presence + 2 negative-stale-text absence). Ref: PRA PS1/26 Art. 132(2); CRR Art. 132 / 132a (UK omitted by SI 2021/1078).

  • Hierarchy resolver dedups duplicate org_mappings child rows + emits DQ004 (P2.24, batch 20260510-0335): engine/hierarchy.py adds _dedup_org_mappings(org_mappings) (called at the head of _build_counterparty_lookup after None-handling) which materialises the mapping table once, finds duplicate child_counterparty_reference values, rebuilds a deterministic single-row-per-child LazyFrame via unique(..., keep="first", maintain_order=True), and emits one CalculationError(code="DQ004", severity=WARNING, category=DATA_QUALITY, counterparty_reference=<child>, field_name="child_counterparty_reference") per duplicated child with a message naming both child_counterparty_reference and org_mappings. The dedup'd frame is the single source of truth fed into _build_ultimate_parent_lazy, _enrich_counterparties_with_hierarchy, and CounterpartyLookup.parent_mappings — fixes the silent row fan-out at the (formerly) hierarchy.py:491-501 join (now lines 580-591). Pre-fix a child counterparty appearing twice in org_mappings (data-quality defect) silently doubled every exposure row downstream, double-counting capital with no audit-trail signal. Post-fix the dedup is observable and operators get a DQ004 WARNING per duplicated child. Pinned by tests/unit/test_hierarchy.py::TestOrgMappingDuplicateChild (2 tests: duplicate-trigger + control arm). Contracts allowlist tests/contracts/test_no_raw_over_on_nullable_keys.py bumped 393,394 → 405,406 for the unrelated _build_rating_inheritance_lazy .over() calls (line shift only, no semantics change). Ref: contracts/errors.py:165 ERROR_DUPLICATE_KEY = "DQ004"; bundle invariant of one row per (exposure_reference, exposure_type).

  • COREP C 07.00 / OF 07.00 column 0020 "Exposures deducted from own funds" (P2.12, batch 20260510-0500): new column added between 0010 and 0030 in both CRR_C07_COLUMNS and B31_C07_COLUMNS (reporting/corep/templates.py); _compute_c07_values (reporting/corep/generator.py) emits col 0020 by summing the optional input field own_funds_deduction_amount via _col_sum_eager, defaulting to None when the field is absent (mirrors col 0035 on-bs-netting precedent). Without this column, COREP submission validation would reject the return as a missing-dimension error. Schema-side addition of own_funds_deduction_amount to FACILITY_SCHEMA/LOAN_SCHEMA and the regulatory 0040 = 0010 − 0020 − 0030 − 0035 formula change are explicitly deferred (the current 0040 = 0010 − 0030 − 0035 formula is preserved). Pinned by tests/unit/test_corep.py::TestC0700Col0020 (4 tests). Ref: PRA PS1/26 Annex II §C 07.00 / §OF 07.00 col 0020; CRR Art. 36/56/66 (CET1 / AT1 / T2 deductions).

  • Hierarchy depth-truncation HIE003 WARNING emission (P2.35, batch 20260510-0500): _resolve_graph_eager in engine/hierarchy.py no longer truncates parent chains silently at max_depth=10. The helper now returns a 4-column DataFrame with a new truncated:Boolean flag set when depth == max_depth AND current in parent_of after the inner walker exits; _build_counterparty_lookup materialises one CalculationError(code=ERROR_HIERARCHY_DEPTH="HIE003", severity=WARNING, category=HIERARCHY) per truncated entity, then drops the helper column to preserve CounterpartyLookup.ultimate_parent_mappings's published 3-column schema. The deepest reachable parent is still surfaced (non-fatal) so the SA/IRB pipeline keeps running. Severity is WARNING (not the hierarchy-error factory default ERROR) because the resolver still produces a usable parent reference. Tests pin a 12-node chain CP_DEPTH_C0..CP_DEPTH_C11: only C0 (chain depth 11 against max_depth=10) emits HIE003 — chains terminating at exactly depth 10 do not. Pinned by tests/unit/test_hierarchy_max_depth.py::TestHierarchyMaxDepthTruncation (4 tests). Ref: internal hierarchy contract; CLAUDE.md § Error Handling (data-quality errors must accumulate in list[CalculationError], never silent).
  • F-IRB LGD helper naming regression guard (P1.179, batch 20260510-0500): pure regression-guard pinning the existing dispatch contract — IMPLEMENTATION_PLAN.md bullet was stale because get_firb_lgd_table(is_basel_3_1=...) and get_firb_lgd_table_for_framework(is_basel_3_1=...) already dispatch correctly via BASEL31_FIRB_SUPERVISORY_LGD / FIRB_SUPERVISORY_LGD, and data/tables/__init__.py re-exports all four symbols. No engine change required. New tests pin (1) CRR DataFrame default values at (unsecured, senior) = 0.45 and (receivables, senior) = 0.35; (2) Basel 3.1 DataFrame values incl. the is_fse split (unsecured non-FSE = 0.40, unsecured FSE = 0.45, receivables = 0.20); (3) schema delta — is_fse column only on B31; (4) dict helper values for both frameworks; (5) cross-helper consistency between dict and DataFrame; (6) re-export object identity through data/tables/__init__.py. Pinned by tests/unit/data_tables/test_p1_179_firb_lgd_naming_regression.py (6 tests, all green-on-write). Ref: CRR Art. 161; PRA PS1/26 Art. 161 / BCBS CRE32.9-12.
  • Art. 159(1) Pool B regression-guard test + classifier defaulted-OR semantics (P1.127, batch 20260510-0245): engine/hierarchy.py now passes ava_amount and other_own_funds_reductions through to per-exposure outputs (Art. 34/105 reductions consumed by aggregator _el_summary._el_pool_branches for the Pool B EL-shortfall comparison). engine/classifier.py::_build_is_defaulted_expr now ORs cp_default_status | row-level is_defaulted | beel>0 so any of the three signals gates a row into Pool B / defaulted rather than only the row-level flag. Pinned by tests/acceptance/crr/test_p1_127_art_159_pool_b_ava_regression_guard.py (3 methods: per-exposure EL-shortfall sum, Pool B memo totals, defaulted double-count guards). Ref: CRR Art. 159(1); Art. 34 (additional value adjustments); Art. 105 (prudent valuation).
  • CRR Art. 501(2) SME-vs-infrastructure supporting-factor regression guard (P2.22, batch 20260510-0245): pins the engine's pl.min_horizontal(SME, infra) site at engine/sa/supporting_factors.py:360 to the regulatorily-correct 0.75 outcome whenever both factors apply. Under current CRR scalars (SME tier-1 = 0.7619, tier-2 = 0.85, infra = 0.75) the invariant 0.75 < 0.7619 ≤ SME_blended ≤ 0.85 holds, so min(...) and Art. 501(2) second-subparagraph "infra replaces SME when both qualify" are observationally identical and no capital understatement exists today. The original plan-bullet's "capital understatement" claim was therefore wrong. The test asserts supporting_factor == 0.75, anti-asserts supporting_factor != 0.7619, and pins rwa_final = 1,125,000 for a £1.5m unrated GB corporate SME infrastructure loan (CP_SME_INFRA_001 / LOAN_SME_INFRA_001). Test docstring records the algebraic invariant so any future calibration change that violates the ordering will fail the test loudly and force a re-evaluation of the min_horizontal site. No engine change required. Pinned by tests/acceptance/crr/test_scenario_crr_f_supporting_factors.py::TestP222SMEInfraOverlapSubstitution::test_p2_22_sme_infra_overlap_substitution_regression. Ref: CRR Art. 501(2) 2nd subpara, Art. 501a(1).
  • B3.1 SCRA-grade dispatch for unrated institution guarantor (P1.95, batch 20260510-0102): SA RWSM under PRA PS1/26 Art. 121/235 now routes the substituted exposure through the SCRA grade table when the guarantor is an unrated institution under Basel 3.1 — Grade A → 40%, A_ENHANCED → 30%, B → 75%, C → 150% (long-term) and 20/20/50/150 (short-term). Pre-fix the engine fell through to INSTITUTION_RISK_WEIGHTS_B31_ECRA[CQS.UNRATED] = 0.40, hard-coding SCRA Grade A for every unrated institution guarantor — under-capital risk on Grade B (40 → 75) and Grade C (40 → 150 non-beneficial → borrower 0.85). data/tables/crr_risk_weights.py::build_institution_guarantor_rw_expr extended with optional scra_grade_col kwarg (additive — CRR and rated B31 paths untouched); B31_SCRA_RISK_WEIGHTS / B31_SCRA_SHORT_TERM_RISK_WEIGHTS lazy-imported to avoid a top-level circular between crr_risk_weights.py and b31_risk_weights.py. CRM engine/crm/guarantees.py projects scra_grade from counterparty as guarantor_scra_grade; SA _build_guarantor_rw_expr passes the kwarg + defensive null-fill in _ensure_guarantee_substitution_columns. Null SCRA grade falls back to Grade C 150% per BCBS CRE20.21 conservative classification. IRB-side call site at engine/irb/guarantee.py:268 left untouched (kwarg default=None preserves rated-CQS behaviour); future scenario can wire it. Discriminating row: £1m B3.1 unrated SME corporate borrower (RW=85%) with 5y unfunded guarantee from unrated GB institution Grade B → guarantee beneficial, RWA = 1m × 0.75 = 750,000 (pre-fix 400,000); Grade C → non-beneficial, RWA = 1m × 0.85 = 850,000 (pre-fix 400,000); null grade → conservative non-beneficial 850,000. Pinned by tests/acceptance/basel31/test_p1_95_b31_unrated_inst_guarantor_scra.py (22 tests). Ref: PRA PS1/26 Art. 121(1)–(3); Art. 235; BCBS CRE20.16-21.
  • COREP OF 02.01 col 0030 U-TREA = col 0010 + col 0020 per Annex II §1.3.2 (P2.42, batch 20260510-0102): _of_02_01_row (src/rwa_calc/reporting/corep/generator.py:2988-2993) now sets col 0030 (U-TREA) to modelled_rwa + sa_rwa instead of just modelled_rwa. Per PRA PS1/26 Annex II §1.3.2, col 0030 is the un-floored TREA = modelled (IRB / slotting / equity) RWEA + SA RWEA. Col 0040 (S-TREA) was already correct because the engine's sa_rwa is the SA-equivalent recalculation of the entire portfolio (engine/aggregator/_floor.py:130-159). Hand-calc on the existing _b31_results_with_floor helper (4 exposures, modelled=3000, sa=3250): col 0030 = 6250 (was 3000). The now-stale companion test test_u_trea_equals_modelled removed; new positive test_u_trea_is_sum_of_modelled_and_sa added. Sibling TestOF0201TotalRow.test_total_equals_credit_risk continues to hold post-fix (Total row uses the same helper). Docstring at _of_02_01_row updated to remove misleading "U-TREA = modelled RWA" wording. Out-of-scope (separate latent bug, future P-item): col 0010/0020 today sum across the whole portfolio; per Annex II col 0010 should be modelled-approach rows only and col 0020 SA rows only — that partitioning is unchanged here. Pinned by tests/unit/test_corep.py::TestOF0201CreditRiskRow::test_u_trea_is_sum_of_modelled_and_sa (9 tests in the credit-risk row class). Ref: PRA PS1/26 Art. 92(2A)/(3)/(3A); Annex II §1.3.2 OF 02.01 col 0030.
  • ciu_holdings registry entry + from_registry wiring (P6.19, batch 20260510-0102): new DataSourceFile(id="ciu_holdings", relative_path=Path("equity/ciu_holdings"), requirement=RequirementLevel.OPTIONAL, description="CIU look-through holdings for Art. 132(3) equity treatment") added to DATA_SOURCES between equity and specialised_lending (src/rwa_calc/config/data_sources.py). DataSourceConfig.from_registry() (src/rwa_calc/engine/loader.py:218) now calls get_p("ciu_holdings") so the previously dead DataSourceConfig.ciu_holdings_file field (declared at loader.py:180, consumed at _build_bundle:362) is finally populated. Pre-fix any caller using DataSourceConfig.from_registry() would silently get ciu_holdings_file=None, causing _build_bundle to short-circuit at the optional-load path and dropping CIU look-through data — leaving RawDataBundle.ciu_holdings = None and forcing the equity calculator to the Art. 132(2) 1,250% punitive fallback. Plumbing-only fix: RawDataBundle.ciu_holdings field, CIU_HOLDINGS_SCHEMA, and _build_bundle wiring already existed. Pinned by tests/unit/config/test_p6_19_data_sources_ciu_holdings.py (7 tests in TestDataSourceRegistryCiuHoldings). Ref: CRR / PRA PS1/26 Art. 132(2)–(3) CIU look-through approach.
  • Art. 120(2) Table 4 short-term institution guarantor risk weight (P1.122 sub-claim (c), batch 20260510-0200): SA RWSM now routes the substituted exposure to a rated institution guarantor through Art. 120(2) Table 4 (CQS 1-3 = 20%, CQS 4-5 = 50%, CQS 6 = 150%) when the borrower's original_maturity_years ≤ 0.25 (3 months). Pre-fix _build_guarantor_rw_expr (engine/sa/namespace.py:854) routed everything through long-term Table 3 — over-stating capital by 30 pp on CRR (50%→20%) and 10 pp on B3.1 ECRA (30%→20%). New INSTITUTION_SHORT_TERM_RISK_WEIGHTS_B31_ECRA dict added to data/tables/crr_risk_weights.py (numerically identical to CRR Table 4); INSTITUTION_SHORT_TERM_RISK_WEIGHTS_CRR extended with CQS.UNRATED → 0.20 (Art. 121(3)). build_institution_guarantor_rw_expr now accepts an optional short_term_flag_col; apply_guarantee_substitution derives a _inst_guarantor_short_term Boolean from original_maturity_years ≤ 0.25 (matching the existing direct Art. 120(2) gate convention at namespace.py:1072-1074). Discriminating row: £1m B3.1 corporate borrower (CQS 4 unrated 100%) with 81-day residual + 2y unfunded guarantee from CQS 2 institution → RWA drops 300,000 → 200,000 (B3.1) and 500,000 → 200,000 (CRR). Sub-claims (a) corporate CQS 3 = 75% and (b) unrated institution SCRA grades remain open. Pinned by tests/acceptance/basel31/test_p1_122_short_term_institution_guarantor_substitution.py (6 tests; 4 discriminating + 2 structural guards). Ref: PRA PS1/26 Art. 120(2) Table 4, Art. 235; CRR Art. 237(2)(a) eligibility filter respected.
  • B3.1 international-organisation regression test (P1.154, batch 20260510-0200): closed via regression-guard — engine implementation already correct (ExposureClass.INTERNATIONAL_ORGANISATION at domain/enums.py:96; SA dispatcher at engine/sa/namespace.py:912/1094 routes Art. 118 IO 0% via IO_ZERO_RW and B3.1 Art. 117(1)(a) Table 2B non-named MDB CQS 2 = 30% via MDB_RISK_WEIGHTS_TABLE_2B). Plan entry was stale: only the B3.1 acceptance test was missing. New tests/acceptance/basel31/test_p1_154_b31_art_118_international_organisation_class.py (9 tests) pins both the IMF (international_organisation, 0% RW) and Black Sea Trade & Development Bank (mdb non-named, 30% RW Table 2B CQS 2) discriminator. CRR-side acceptance test tests/acceptance/crr/test_p1_154_art_118_international_organisation_class.py pre-existed. Ref: CRR Art. 112(1)(e); CRR Art. 117(1)(a)/(2), Art. 118; PRA PS1/26 Art. 117(1)(a) Table 2B, Art. 118.
  • P1.123 plan-entry close (silent fix already shipped in commit e5bbdbd batch 20260509-1726): FCCM (1+HE) exposure-side gross-up was implemented in the prior batch but the plan bullet was never ticked. No code change in this batch — tests/acceptance/crr/test_p1_123_art_223_5_fccm_exposure_volatility_haircut.py (13 tests) confirms the fix is in production. Ticked here for plan hygiene. Ref: CRR Art. 223(5).
  • validate_aggregated_bundle regulatory output-bounds checker (P2.34, batch 20260510-0030): new public function in src/rwa_calc/contracts/validation.py asserting per-row risk_weight ≤ 12.5 (CRR Art. 92(3); CRE31.5), risk_weight ≥ 0 (Art. 153/CRE31), rwa_final ≥ -1e-9 (Art. 92(3); float64 round-off tolerance), and ead_final non-null at the AggregatedResultBundle boundary. Errors flow as CalculationError codes OUT001-OUT004 (added to contracts/errors.py) with sample_cap=5 per bound + a single summary error if the offending-row count exceeds the cap. Schema-safe: silently skips a bound if its target column is absent from bundle.results. LazyFrame-first (one .collect() per bound). Not auto-wired into the pipeline orchestrator (deferred). Pinned by tests/contracts/test_aggregated_bundle_validation.py (11 tests).
  • CreditRiskCalc.base_currency forwarded through factories (P6.20, batch 20260510-0030): CreditRiskCalc(..., base_currency="EUR") was a silent no-op — _create_config() (api/service.py:174-195) dropped the value before invoking CalculationConfig.crr() / .basel_3_1(), both of which hardcoded base_currency="GBP" in their cls(...) calls (contracts/config.py:961, 1041). Both factories now accept and forward a base_currency: str = "GBP" kwarg, and the service layer passes self.base_currency through. Default "GBP" semantics preserved end-to-end. Pinned by tests/unit/api/test_service_base_currency.py (5 tests; 2 forwarding + 3 default-preservation).
  • Loader optional-file bare except narrowed (P6.18, batch 20260510-0030): engine/loader.py::_load_file_optional no longer silently swallows non-FileNotFoundError exceptions. FileNotFoundError continues to return None with only a DEBUG log (the legitimate "optional input not configured" signal). Any other Exception (corrupt parquet, OSError, PermissionError, pl.exceptions.ComputeError) now appends a new CalculationError (DQ007 ERROR_OPTIONAL_FILE_UNREADABLE, severity WARNING, category DATA_QUALITY) onto RawDataBundle.errors and emits a single lazy-formatted logger.warning(...) (per CLAUDE.md § Logging). The required-file path _load_file is unchanged — corrupt required files still raise DataLoadError. New error code + optional_file_load_error(...) factory added to contracts/errors.py. Threading uses an explicit errors: list[CalculationError] parameter wired through _build_bundle via functools.partial; lf.collect_schema() after scan_fn(...) forces parquet-corruption detection that has_rows's broad except would otherwise silently demote to "empty file". Pinned by tests/unit/test_loader_optional_error_handling.py (4 tests).

  • PRA Art. 191A(2)(e)(i) funded-only look-through for two-layer credit protection (P1.161, batch 20260509-1825): when an unfunded guarantee is itself collateralised by funded collateral posted by the guarantor, the institution may now elect via the new look_through_election="funded_only" flag on GUARANTEE_SCHEMA to suppress the guarantee for RWSM purposes and re-anchor the guarantor-posted collateral onto the original obligor exposure ahead of FCCM/FCSM allocation. New module src/rwa_calc/engine/crm/look_through.py (apply_funded_only_look_through()) wired as Step 0 inside engine/crm/processor.py::get_crm_adjusted_bundle() / get_crm_unified_bundle(). Schema additions: GUARANTEE_SCHEMA.look_through_election (enum none / funded_only / both, default none) and GUARANTEE_SCHEMA.is_collateralised_by_guarantor (Boolean default False); COLLATERAL_SCHEMA.posted_by_counterparty_reference (String, optional); VALID_BENEFICIARY_TYPES extends to include "guarantee" so collateral can attach to a guarantee row. Two new audit-trail error codes: CRM007 ART_191A_2_E_LOOKTHROUGH_APPLIED (informational) and CRM008 LOOKTHROUGH_NOT_IMPLEMENTED (the deferred (2)(e)(ii) "both" election surfaces this warning and falls back to none). Discriminating capital: a £1m GBP corporate exposure with a 100%-coverage CQS4 corporate guarantor backed by £400k of GBP cash collateral drops RWA 1,000,000 → 600,000 under election=funded_only (EAD nets to 600k × unrated corp 100% RW); election=none preserves today's RWSM-with-no-benefit behaviour at 1,000,000 (regression pin). Out-of-scope and tracked separately: Art. 191A(2)(e)(ii) "both" election; Art. 191A(2)(f) borrower-deeming flexibility; F-IRB / A-IRB look-through under PSM Art. 236 / LGD-AM Art. 183. Pinned by tests/acceptance/basel31/test_p1_161_art_191a_two_layer_protection.py (4 tests). Ref: PRA PS1/26 Art. 191A(2)(d)–(f), Art. 197(1)(a), Art. 222, Art. 223(5), Art. 235; BCBS CRE22.18 / CRE22.71.

  • ADC classification derived from is_under_construction rather than caller-supplied (P1.140, batch 20260509-1825): engine/classifier.py::_derive_independent_flags now derives is_adc via the new _build_is_adc_expr helper rather than relying on caller pre-tagging — is_adc = (cp_entity_type IN {corporate, company, specialised_lending} AND NOT cp_is_natural_person AND (is_under_construction.fill_null(False) OR _pt_upper.is_in({DEVELOPMENT_FINANCE, CONSTRUCTION_LOAN}))). Any pre-existing is_adc value on the input frame is coalesced as a caller-supplied override. New schema field is_under_construction: ColumnSpec(pl.Boolean, default=False, required=False) on FACILITY_SCHEMA, LOAN_SCHEMA, and CONTINGENTS_SCHEMA (data/schemas.py); propagated through engine/hierarchy.py::_coerce_loans_to_unified / _coerce_contingents_to_unified and the facility-undrawn select expressions. Two ancillary classifier guards prevent ADC routing being lost downstream: ADC-flagged rows are now excluded from the RE loan-splitter candidate gate (_re_split_candidate_gates) and from the CORPORATE → CORPORATE_SME reclassification (_classify_exposure_subtypes) so exposure_class == "corporate" survives to the SA branch's existing _b31_append_real_estate_branches ADC consumer. Pre-fix a £10m B3.1 corporate development-finance loan to an SPV with is_under_construction=True produced RWA 4,925,000 (residential RE loan-splitting fired because is_adc=False); post-fix RWA = 15,000,000 = £10m × 150% Art. 124K(1) ADC RW. Natural-person obligors fall through to is_adc=False regardless of is_under_construction, preserving the existing residential RE path. Out-of-scope: Art. 124K(2) qualifying-residential 100% concession (presold derivation), defaulted ADC interaction with Art. 127, Art. 124E three-property limit (P1.142), Art. 124(4) mixed-use splitting (P1.141). CRR untouched (no Art. 124K). Pinned by tests/acceptance/basel31/test_p1_140_adc_classification_derivation.py (8 tests). Ref: PRA PS1/26 Glossary "ADC exposure"; Art. 124(3); Art. 124K(1); BCBS CRE20.91.
  • B31 equity SA-only hard guard in classifier (P2.39, batch 20260509-1642): engine/classifier.py::_apply_b31_approach_restrictions now adds b31_equity_sa_only = (exposure_class_irb == ExposureClass.EQUITY.value) to the existing sovereign-like SA-only mask so neither new_airb nor new_firb can fire for Basel 3.1 equity exposures, regardless of caller-supplied IRBPermissions. Pre-fix a misconfigured IRBPermissions granting AIRB to ExposureClass.EQUITY would route equity exposures through approach="advanced_irb" ahead of the equity branch in _build_approach_expr's decision ladder, contradicting PRA PS1/26 Art. 147A(1)(h) (Art. 155 left blank under PS1/26). Post-fix the row falls through to .when(exposure_class == EQUITY).then(EQUITY) and approach="equity". CRR is untouched (_apply_b31_approach_restrictions returns early for non-B3.1 configs; legacy CRR Art. 155 IRB equity approach retained). firb_clear_expr is intentionally not widened — equity is SA-only, not F-IRB-only. Pinned by tests/unit/classifier/test_p2_39_b31_equity_sa_only_guard.py (7 tests across TestB31EquitySaOnlyGuard and TestCrrEquityControlNoB31Guard); 48 B31-L equity acceptance tests continue to pass. Ref: PRA PS1/26 Art. 147A(1)(h) read with Art. 147(2)(e); BCBS CRE60.
  • ValidationRequest requires model_permissions under IRB permission mode (P1.147, batch 20260509-1642): new permission_mode: Literal["standardised", "irb"] field on ValidationRequest (api/models.py, default "standardised"); CreditRiskCalc.validate() and .calculate() now propagate permission_mode into the request (previously silently dropped at api/service.py:113-118 and :165-170). New _check_irb_required(...) step in DataPathValidator.validate() (api/validation.py) appends Path("config/model_permissions.parquet") to files_missing and emits a new VAL003 APIError (api/errors.py::create_irb_required_file_error) when permission_mode == "irb" and the model_permissions file is absent on disk; sets valid=False. The existing short-circuit in CreditRiskCalc.calculate() then returns success=False with summary.total_rwa = Decimal("0") and exposure_count = 0. Pre-fix calculate() returned success=True with total_rwa = Decimal("1000000.0") (silent SA fallback for all exposures despite IRB request) — direct capital overstatement risk. The B31-M11 acceptance test (TestB31M11_NoModelPermissionsFallback) remains green because it exercises PipelineOrchestrator.run_with_data with an in-memory RawDataBundle, bypassing DataPathValidator — the engine-layer silent-SA fallback is intentionally retained for in-memory callers. Pinned by tests/integration/test_p1_147_irb_requires_model_permissions.py (8 tests, 7 failing pre-fix). Ref: PRA PS1/26 Art. 147A; CRR Art. 143 / 150; internal model-permissions gating contract.
  • Null-safe is_guaranteed filter in CRM reporting / CR7 / CR7-A disclosures (P1.146, batch 20260509-1642): sink-side fix at engine/aggregator/_crm_reporting.py:138,166,212 — the three pl.col("is_guaranteed") / ~pl.col("is_guaranteed") filters now wrap with .fill_null(False) so Polars 3VL no longer drops rows where is_guaranteed is null. Source-side defence-in-depth at engine/crm/guarantees.py:293 — the alias is now (pl.col("guaranteed_portion").fill_null(0.0) > 0).alias("is_guaranteed"), so a null guaranteed_portion from upstream cannot leak a null is_guaranteed into the aggregator. Pre-fix any guaranteed exposure whose is_guaranteed arrived null (e.g. an SA row that had not flowed through apply_guarantees, or an equity-results path that did not propagate the column) was silently dropped from post_crm_detailed, post_crm_summary, and downstream CR7 / CR7-A Pillar III disclosures — a regulatory completeness defect. The plan-bullet line reference :260 was stale; the actual alias is at :293. Pinned by tests/unit/test_p1_146_is_guaranteed_null_filter.py (4 tests; aggregator-level scenario with a hand-built 3-row sa_results LazyFrame is_guaranteed=[True, False, None]post_crm_detailed.height = 4 post-fix vs 3 pre-fix; CORPORATE total_ead = 2_150_000). 5 pre-existing CRM-reporting integration tests continue to pass. Ref: CRR Art. 213-217 (CRM eligibility, origin of is_guaranteed); CRR Art. 444 / 453(g),(j); PRA PS1/26 Annex XX / XXII (CR7 / CR7-A).
  • CRR receivables Art. 224 haircut removed (P1.165, batch 20260509-1530): data/tables/haircuts.py COLLATERAL_HAIRCUTS["receivables"] now Decimal("0") (was 0.20, an "ad-hoc approximation" with no Art. 224 basis). Receivables are non-financial collateral per CRR Art. 199(5); the entire CRR treatment lives in Art. 230 LGD* / 1.25× OC mechanism (LGDS=35% senior per Art. 230 Table 5, no minimum threshold) — already implemented in firb_lgd.py. The pre-fix engine double-counted capital by applying both an ad-hoc 20% volatility haircut AND the Art. 230 mechanism. Comment block in data/tables/haircuts.py now points to Art. 230 / Art. 199(5). BASEL31_COLLATERAL_HAIRCUTS["receivables"] preserved at 0.40 per PRA PS1/26 Art. 230(2). Capital impact: F-IRB single-loan with 800k receivables collateral / EAD=1m / LGDU=0.45 / LGDS=0.35 — blended LGD* drops from pre-fix 0.4041 (which double-counted) to the regulatorily correct 0.386 = (0.35 × 640k + 0.45 × 360k) / 1m. Pinned by tests/acceptance/crr/test_p1_165_art_230_receivables_no_volatility_haircut.py (8 tests). Bug-encoded assertions in tests/unit/crr/test_crr_tables.py and tests/unit/crm/test_crm_basel31.py flipped accordingly. Ref: CRR Art. 224, Art. 199(5), Art. 230(1)–(2), Art. 230 Table 5.
  • COVERED_BOND_UNRATED_DERIVATION split CRR vs B31 + nested Art. 129(5)(b) bug fixed (P1.180, batch 20260509-1530): data/tables/crr_risk_weights.py now exports COVERED_BOND_UNRATED_DERIVATION_CRR (4 keys per CRR Art. 129(5)(a)–(d): 0.20→0.10, 0.50→0.20, 1.00→0.50, 1.50→1.00) and COVERED_BOND_UNRATED_DERIVATION_B31 (7 keys per PRA PS1/26: adds the ECRA / SCRA-only 0.30→0.15, 0.40→0.20, 0.75→0.35; B31 0.50→0.25). The old shared 7-key dict had used the B31 (b) value 0.50→0.25 even under CRR config — a nested numeric bug now corrected: under CRR, an unrated covered bond whose CQS3 institution issuer carries RW 0.50 derives RW = 0.20 (Art. 129(5)(b)) instead of the pre-fix 0.25. _crr_unrated_cb_rw_expr (engine/sa/namespace.py) consumes the new _CRR table; _b31_unrated_cb_rw_expr continues to consume COVERED_BOND_UNRATED_DERIVATION which now aliases _B31 (back-compat preserved). Pinned by tests/unit/data_tables/test_p1_180_covered_bond_unrated_derivation_split.py (7 tests). The CRR parametrize in tests/unit/test_covered_bonds.py::test_unrated_covered_bond_crr_by_institution_cqs (CQS2 / CQS3 rows) flipped 0.25→0.20 to match. Ref: CRR Art. 129(5)(a)–(d); PRA PS1/26 Art. 129(5)(a)/(aa)/(ab)/(b)/(ba)/(c)/(d).
  • Classifier deterministic dedup with SA precedence on conflicting model_permissions (P1.145, batch 20260509-1530): engine/classifier.py::_resolve_model_permissions now applies the conservative-precedence rule when conflicting (model_id, exposure_class) AIRB+SA permission rows exist — SA wins. CRR Art. 150(1) PPU is a carve-out from IRB scope; AIRB-wins would silently expand IRB scope beyond firm permission. Implementation: row-level _sa_block_match = permission_valid & (mp_approach == ApproachType.SA.value) aggregated as .max().over("exposure_reference") and AND-NOT'd against the AIRB / FIRB / slotting .max().over() flags. Order-stability also pinned: a deterministic sort on (exposure_reference, _diagnostic_priority, mp_approach, mp_country_codes, mp_excluded_book_codes) precedes unique(subset=["exposure_reference"], keep="first", maintain_order=True), so the surviving _model_permission_diagnostic is the most-informative one (null > filter_rejected > unmatched_model_id > null_model_id) regardless of input ordering. Existing CLS006 ladder fires "filter_rejected" when SA blocks IRB. Behaviour change: operators with conflicting AIRB+SA rows in production will now see a CLS006 warning where pre-fix the engine silently routed to AIRB. Pinned by tests/unit/classifier/test_p1_145_model_permissions_dedup_determinism.py (9 tests across two physical orderings of the same 9-row fixture; both produce identical post-classifier frames). 188 related classifier / permission / model_id tests pass with no regressions. Ref: CRR Art. 143 (IRB permission scope), Art. 150(1) PPU; PRA PS1/26 Art. 150(1A).
  • CRR Art. 237/238/239(3) maturity mismatch on unfunded credit protection (P1.109, batch 20260509-1359): engine/crm/guarantees.py now scales amount_covered and percentage_covered by (t − 0.25) / (T − 0.25) when the guarantor's residual maturity is shorter than the secured exposure's residual maturity (gated on config.is_crr). Art. 237(2) ineligibility (residual < 3 months / original < 1 year) flows through the existing eligibility chain. Pre-fix the engine applied FX haircut but no maturity-mismatch reduction, understating capital on long exposures with short-dated guarantees: a £1m / 5y CRR corporate exposure fully covered by a 2.5y guarantee now produces GA = 1m × (2.5 − 0.25) / (5.0 − 0.25) = 473,684.21 and blended RWA = 621,052.63 (vs the pre-fix 100% substitution). Pinned by tests/acceptance/crr/test_p1_109_art_237_maturity_mismatch_guarantees.py (11 tests). Ref: CRR Art. 237, Art. 238, Art. 239(3); BCBS CRE22.74.
  • B31 SA RWSM corporate-CQS3 guarantor RW = 75% via Art. 122(2) Table 6 (P1.110, batch 20260509-1359): engine/sa/namespace.py imports B31_CORPORATE_RISK_WEIGHTS and gates the corporate-guarantor SA risk-weight lookup on is_basel_3_1 so a CQS3-rated corporate guarantor on a B31 exposure now picks up Table 6's 75% instead of the CRR Art. 122 100%. CRR path untouched. Capital impact: a £1m B31 corporate exposure guaranteed by a CQS3 corporate guarantor drops post-RWSM RW 100% → 75% (RWA 1m → 750k). Pinned by tests/acceptance/basel31/test_p1_110_art_122_corporate_cqs3_guarantee_substitution.py (7 tests). Distinct from P1.95 (SCRA unrated institutions) and P1.122 (full B31 framework branching). Ref: PRA PS1/26 Art. 122(2) Table 6, Art. 235.
  • PSM F-IRB LGD substitution routes by guarantor seniority (P1.160, batch 20260509-1359): engine/crm/guarantees.py::_apply_guarantee_splits now threads guarantor_seniority from the guarantee table through the per-guarantor pre-aggregation, the join select-list, the borrower-frame drop-list, and the no-guarantee / remainder null-fill paths — so the IRB stage receives the actual seniority value instead of None. The downstream IRB routing in engine/irb/guarantee.py already had the seniority dispatch wired (Art. 161(1)(aa) senior 0.40 / Art. 161(1)(b) subordinated 0.75 / Art. 161(1)(d) covered-bond 0.1125) but received None from upstream and silently defaulted to the senior LGD. The same patch tightens _add_guarantee_status_columns: when the parameter-substitution path was taken (_is_pd_substitution=True and guaranteed_portion>0), guarantee_method_used now resolves to "PD_PARAMETER_SUBSTITUTION" regardless of whether the beneficial gate retained the borrower RWA — the GUARANTEE_NOT_APPLIED_NON_BENEFICIAL signal continues to live on guarantee_status. Discriminating row: B31 corporate borrower (PD=0.015, M=2.5y, EAD=1m) with subordinated corporate guarantor (PD=0.005) — guarantor_rw_irb 0.61877 → 1.16037 (LGD 0.40 → 0.75), guarantee correctly judged not beneficial, RWA retained at borrower 938,690 instead of incorrectly applied 618,870. Pinned by tests/acceptance/basel31/test_p1_160_art_161_psm_lgd_seniority_routing.py (11 tests); tests/unit/test_irb_double_default.py::TestDoubleDefaultRWA::test_dd_floor_at_guarantor_rw widened to accept PD_PARAMETER_SUBSTITUTION as a method label for non-beneficial PSM scenarios. Ref: PRA PS1/26 Art. 161(1)(aa)/(b)/(d), Art. 236(1)(a); BCBS CRE32.
  • FCSM Art. 222(4) SFT 0%/10% carve-out + Art. 222(6) non-SFT gating (P1.93, batch 20260509-1252): engine/crm/simple_method.py::compute_fcsm_columns now splits the previously-merged Art. 222(4)/(6) zero-RW exception into two distinct paths — SFTs with Art. 227-qualifying collateral get 0% (counterparty is a core market participant) or 10% (otherwise) per PRA PS1/26 Art. 222(4); non-SFTs keep the existing same-currency cash / 0%-RW sovereign 0% via the renamed _is_art_222_6_carveout_expr (gated on ~exposure_is_sft); other rows fall back to the 20% Art. 222(3) floor. Step 5's Art. 222(6)(b) 20% sovereign-bond market-value discount is now suppressed when qualifies_for_zero_haircut=True (the Art. 227 SFT carve-out is a flat RW substitution, not a value haircut). New Boolean column is_core_market_participant on COUNTERPARTY_SCHEMA (default False; mirrored as cp_is_core_market_participant on HIERARCHY_OUTPUT_SCHEMA); new constants ART_222_4_CMP_RW = Decimal("0.00") and ART_222_4_NON_CMP_RW = Decimal("0.10") in data/tables/crr_simple_method.py. Headline regression: an SFT non-CMP gilt repo (Run B) now produces blended RW 0.10 / RWA £100k vs the pre-fix RW 0.00 / RWA 0 (the buggy merged branch mis-fired the same-currency 0% on every SFT). Pinned by tests/acceptance/basel31/test_p1_93_art_222_4_fcsm_sft_carveout.py (11 tests across 3 runs). Ref: PRA PS1/26 Art. 222(4)/(6), Art. 227(2)/(3); BCBS CRE22.18.
  • IRB PSM correlation re-derivation reads guarantor row (P1.159, batch 20260509-1252): engine/irb/guarantee.py now lifts the guarantor_rw_irb materialisation out of _apply_parameter_substitution Step 3 and into _apply_no_better_than_direct_floor's existing borrower-to-guarantor column-swap window, so the primary PSM RW and the Art. 160(4) rw_direct floor both compute their correlation with exposure_class / turnover_m / requires_fi_scalar sourced from the guarantor's row — per PRA PS1/26 Art. 236(1)(a)(i) "the correlation coefficient that would be assigned to a comparable direct exposure to the protection provider". Pre-fix the engine read those columns from the borrower row, so a corporate-borrower-with-FI-scalar (Art. 153(2)) routed through a regulated bank guarantor inflated correlation by the spurious 1.25× FI multiplier and over-stated guarantor_rw_irb. For a £1m / PD=0.0150 / M=2.5y corporate exposure with 60% bank guarantee, guarantor_rw_irb drops 0.4007 → 0.2969, blended RW 0.7716 → 0.7093, RWA £771,594 → £709,324. Core math (_parametric_irb_risk_weight_expr, _correlation_expr_from_pd) untouched. The pre-existing test_p1_157_psm_no_better_than_direct.py was updated — its EXPECTED_GUARANTOR_RW_IRB flipped 0.01346 → 0.17489 (now coincides with rw_direct since both inputs operate in the guarantor's class), and its strict post_nbd > rw_irb weakened to >= (the max-of-two NBD floor remains asserted). Pinned by tests/acceptance/basel31/test_p1_159_art_236_psm_correlation_guarantor_class.py (15 tests). Ref: PRA PS1/26 Art. 236(1)(a)(i), Art. 153(2)/(4), Art. 160(4); BCBS CRE22.74.
  • UK CRR Art. 128 high-risk 150% RW gated off (P2.14, batch 20260509-1252): Art. 128 was omitted from UK onshored CRR by SI 2021/1078 reg. 6(3)(a) effective 1 Jan 2022 — there is no legal basis for the 150% HIGH_RISK risk weight under UK CRR until Basel 3.1 reintroduces Art. 128 from 1 Jan 2027. engine/sa/namespace.py::_apply_crr_risk_weight_overrides no longer carries the .when(uc == "HIGH_RISK") branch, so HIGH_RISK rows fall through to the chain-tail residual 100% under UK CRR. engine/classifier.py adds a CRR-only post-Batch-1 _sa_class remap rewriting "high_risk""other" so exposure_class_for_sa reflects the absence of the class under UK CRR and matches the namespace's RW behaviour; the Art. 112 priority carve-out at lines 607-618 is preserved (becomes inert under CRR after the remap, remains active under B3.1). Basel 3.1 is unchanged: _apply_b31_risk_weight_overrides keeps the 150% branch (Art. 128 reintroduced under PS1/26). Capital impact: a £1m unrated VC/PE high-risk corporate exposure drops from RWA £1.5m to £1.0m under UK CRR (B3.1 unchanged at £1.5m). The HIGH_RISK_RW = Decimal("1.50") table value in data/tables/crr_risk_weights.py is retained as an unused-but-correct entry; B31_HIGH_RISK_RW is unchanged and still consumed by the B31 path. Pre-existing tests tests/unit/test_high_risk_items.py::TestCRRHighRiskItems and tests/unit/test_defaulted_secured_split.py::TestDefaultedEdgeCases::test_high_risk_unaffected were flipped to expect the corrected 100% under CRR; B3.1 sibling assertions and the HIGH_RISK_RW constant tests are left untouched. Pinned by tests/acceptance/crr/test_p2_14_art_128_high_risk_uk_omitted.py (13 tests). Ref: SI 2021/1078 reg. 6(3)(a); UK CRR Art. 112(1) waterfall, Art. 122, Art. 133(2); PRA PS1/26 Art. 128 (B3.1 reintroduction).
  • CRR Art. 117(1) non-named MDB institution routing (P1.184, batch 20260509-1300): under CRR, non-named multilateral development bank exposures now risk-weight off the institution tables (Art. 120 Table 3 for own-CQS rated, Art. 121 Table 5 sovereign-derived for unrated) instead of the Basel-3.1 Table 2B that the engine previously consulted under both frameworks. Two-layer fix in engine/sa/namespace.py: (1) _prepare_risk_weight_lookup coalesces cp_institution_cqs into cqs for MDB rows so the rated CQS join target is populated (closes a separate latent bug where every MDB row arrived at the SA branch with cqs=null); (2) _apply_crr_risk_weight_overrides replaces the prior single MDB-unrated 50% branch with two branches — rated MDB → build_institution_guarantor_rw_expr (Art. 120 Table 3) and unrated MDB → new INSTITUTION_RISK_WEIGHTS_SOVEREIGN_DERIVED table (Art. 121 Table 5) with INSTITUTION_RISK_WEIGHTS_CRR[CQS.UNRATED] 100% fallback. MDB_RISK_WEIGHTS_TABLE_2B and MDB_UNRATED_RW remain in data/tables/crr_risk_weights.py (still used by the B31 path) with an updated docstring marking them Basel-3.1-only. Named MDBs (entity_type="mdb_named") keep the unconditional 0% under both frameworks. Capital impact: CRR rated CQS-2 MDB jumps 30%→50% (+20pp); CRR unrated MDB with sovereign CQS-1 drops 50%→20%; CRR unrated MDB with no sovereign data jumps 50%→100%. B31 unaffected. Pinned by tests/acceptance/crr/test_p1_184_art_117_mdb_institution_routing.py (14 tests). Ref: CRR Art. 117(1), Art. 120 Table 3, Art. 121 Table 5.
  • F-IRB purchased receivables / dilution-risk supervisory LGD (P1.151, batch 20260509-1300): CRR Art. 161(1)(e)/(f)/(g) and PRA PS1/26 Art. 161(1)(e)/(f)/(g) supervisory LGDs for purchased receivables and dilution risk now wired into the F-IRB engine. New optional nullable column purchased_receivables_subtype on FACILITY_SCHEMA / LOAN_SCHEMA / CONTINGENTS_SCHEMA (values null / "senior" / "subordinated" / "dilution_risk", validated via COLUMN_VALUE_CONSTRAINTS). New keys on FIRB_SUPERVISORY_LGD and BASEL31_FIRB_SUPERVISORY_LGD in data/tables/firb_lgd.py: purchased_receivables_senior (CRR 0.45 / B3.1 0.40 — the new B3.1 senior-unsecured rate), purchased_receivables_subordinated (1.00 both frameworks), dilution_risk (CRR 0.75 / B3.1 1.00 — PS1/26 recasts the dilution-risk LGD upward). apply_firb_lgd (engine/irb/namespace.py) gains a pl.when().then() dispatch that takes precedence over the seniority-based selector when purchased_receivables_subtype is non-null — so seniority="senior", subtype="dilution_risk" correctly resolves to dilution LGD, not the senior 0.40. engine/hierarchy.py::_coerce_loans_to_unified extended to pass the new column through to the IRB stage. Capital impact under B3.1: a 1m senior PR exposure at PD=1% on a 1y residual moves from default-LGD 0.40 to subtype-LGD 0.40 (no change) but a subordinated PR at 500k jumps 0.75→1.00 (RWA 814k vs. 611k pre-fix); a 200k dilution-risk row jumps 0.40→1.00 (RWA 326k vs. 130k pre-fix). Pinned by tests/acceptance/basel31/test_p1_151_art_161_purchased_receivables_lgd.py (5 tests). Ref: CRR Art. 161(1)(e)/(f)/(g); PRA PS1/26 Art. 161(1)(e)/(f)/(g); BCBS CRE32.
  • UKB OV1 output-floor disclosure rows 4a / 5a–7b (P1.162, batch 20260509-1300): PRA PS1/26 Annex XX UKB OV1 mandates seven output-floor disclosure rows that the calculator was emitting as a 13-row template (jumping 4 → 5 and 24 → 26). B31_OV1_ROWS in reporting/pillar3/templates.py now carries 20 entries with refs 4a (Total RWEAs pre-floor), 5a / 5b (CET1 ratio pre-floor / pre-floor transitional), 6a / 6b (Tier 1 ratio), 7a / 7b (Total capital ratio). New frozen dataclass Pillar3CapitalRatioOverrides (contracts/config.py, exported via contracts/__init__.py) carries six optional Decimal fields letting firms supply the pre-floor and pre-floor-transitional ratios that cannot be derived from credit-risk pipeline data alone. Pillar3Generator.generate_from_lazyframe now accepts an optional capital_ratios kwarg and _generate_ov1 now: (a) emits row 4a as sum(rwa_pre_floor) over the full results LazyFrame with c = a × 0.08 (None when the column is absent — same fallback posture as existing rows 26/27); (b) emits rows 5a–7b from the override (× 100 to col a, with b and c left None to bypass the existing own-funds shim). When no override is supplied each ratio row stays mandatory in shape but value-blank. CRR CRR_OV1_ROWS is unchanged. Pinned by tests/unit/reporting/pillar3/test_p1_162_ukb_ov1_floor_rows.py (6 tests, including a CRR regression guard). Ref: PRA PS1/26 Annex XX UKB OV1, Art. 92(2A), Art. 92(5), Art. 438(d).
  • CRR Art. 197 / 207(2) covered-bond collateral eligibility gating (P1.96, batch 20260509-1100): engine/crm/haircuts.py::_apply_collateral_haircuts now treats collateral_type=="covered_bond" rows as ineligible financial collateral on non-SFT exposures (Art. 197(1) closed list governs; covered bonds are NOT in (a)–(h)). The Art. 207(2) carve-out for repo / SFT / capital-markets-driven / secured-lending transactions keeps the existing covered_bond → corp_bond Art. 224 supervisory-haircut routing — only the gating expression changed. The plan-bullet wording was stale ("falls through to other_physical 40%"); pre-fix the engine unconditionally applied corp-bond CQS-banded haircuts to all covered-bond collateral regardless of SFT status, understating capital on non-repo term loans secured by covered bonds. Ineligibility flows through the existing _bond_ineligible chain (value_after_haircut=0, is_eligible_financial_collateral=False); no schema or new data tables. Pinned by tests/acceptance/crr/test_p1_96_art_197_covered_bond_eligibility.py (9 tests, paired Run A non-SFT term-loan ineligibility / Run B repo Art. 207(2) carve-out). The pre-existing test_p1_96_covered_bond_haircut_routing.py was reframed to set is_sft=True on its repo fixture so the 416,970.56 expectation now genuinely tests the carve-out instead of relying on the buggy unconditional routing. Ref: CRR / PRA PS1/26 Art. 197(1), Art. 207(2), Art. 224 Table 1.
  • CRR Art. 162(3)(b) F-IRB short-term trade-finance M derivation (P1.118, batch 20260509-1100): engine/irb/namespace.py::IRBLazyFrame.prepare_columns now derives has_one_day_maturity_floor=True from is_short_term_trade_lc=True AND maturity_date is not null AND residual_years <= 1.0 (gated on config.is_crr; B31 wording differs and is deferred). The flag was previously caller-supplied only — qualifying short-term self-liquidating trade-finance F-IRB exposures defaulted to M=2.5y or floored at 1y unless the caller pre-set the flag. With derivation in place, an unrated MLR documentary credit on a 9-month residual now picks up M=1/365 (the existing irb/formulas.py:705-707 branch sets maturity = 1/365 literally when the flag is True; this is the documented engine semantic, not a "floor at 1/365" interpretation). For a £2m EAD / PD=0.5% F-IRB corporate, RWA drops 1,106,216 → 860,310 (≈22% capital relief). Reuses the existing is_short_term_trade_lc schema column added by P1.128; no schema change. Pinned by tests/acceptance/crr/test_p1_118_art_162_3_short_term_trade_finance_m_derivation.py (5 tests). FX-settlement / securities-settlement Art. 162(3) 2nd-sub (a)/(c)/(d) carve-outs and the B31 wording variant deferred. Ref: CRR Art. 162(3) second sub-paragraph point (b), Art. 4(1)(80).
  • B31 SA Art. 127(1) defaulted provision-ratio gross denominator (P1.120, batch 20260509-1100): engine/sa/namespace.py::_apply_defaulted_risk_weight B31 branch now uses gross_outstanding = ead_gross + provision_deducted (with a fallback to ead + provision_deducted for unit-test entry points where ead_gross is absent) as the Art. 127(1) provision-coverage 100%/150% threshold denominator, instead of ead_final (post-CRM, post-provision). PRA PS1/26 Art. 127(1) wording is "the outstanding amount of the item or facility" (gross outstanding before specific credit risk adjustments and before CRM); the CRR branch's different wording — "unsecured part of the exposure value if those credit risk adjustments and deductions were not applied" — remains correctly modelled by the existing ead_final + provision_deducted expression and is left untouched. The plan-bullet direction was inverted: the bug was UNDER-stating RW (assigning 100% when 150% was correct on partially collateralised B31 defaults), so the fix INCREASES capital. For a B31 corporate defaulted exposure with outstanding=100k / provisions=8k / FCCM cash 60k, the pre-fix ratio was 8k/32k=25% (RW 100%, RWA 32,000); post-fix ratio is 8k/100k=8% (RW 150%, RWA 48,000). Pinned by tests/acceptance/basel31/test_p1_120_art_127_1_provision_ratio_denominator.py (B31-K13, 7 tests). Existing test_scenario_b31_k_defaulted.py::TestB31K8_ProvisionDenominatorDifference updated: B31 RW 100%→150%, RWA 80,000→120,000; CRR contrast unchanged. Ref: PRA PS1/26 Art. 127(1); BCBS CRE20.87–90.
  • PRA PS1/26 Art. 122(3) Table 6A short-term corporate ECAI risk weights (P1.103, batch 20260509-1025): corporate exposures with an issue-specific short-term ECAI rating now route through the new Table 6A (CQS1=20% / CQS2=50% / CQS3=100% / Others=150%) instead of the long-term Table 6 fallback (where CQS3 = 75% under Basel 3.1). On a £1m CQS-3 short-term-rated corporate the RW jumps 75% → 100% (RWA 750k → 1m, K 60k → 80k) — closes a documented capital understatement. New constant B31_CORPORATE_SHORT_TERM_ECAI_RISK_WEIGHTS in data/tables/b31_risk_weights.py; new helper _b31_append_corporate_maturity_branches in engine/sa/namespace.py invoked alongside the existing institution Table 4A branch (P1.105). Reuses the has_short_term_ecai FACILITY_SCHEMA flag and OR-aggregation infrastructure added by P1.105 — no new schema or hierarchy fields. Corporate short-term gate is original_maturity_years ≤ 0.25 only (Art. 121(4)/(5) trade-LC ≤ 6m extension is institution-only). SME corporates are excluded so the dedicated 85% SME path remains authoritative even when an SME has a short-term ECAI rating. CRR corporate branch unchanged (CRR Art. 122 has no Table 6A analogue). Pinned by tests/acceptance/basel31/test_p1_103_art_122_3_short_term_corporate_ecai.py (5 tests). Ref: PRA PS1/26 Art. 122(3) Table 6A; BCBS CRE20.42–49.
  • PRA PS1/26 Art. 121(4) SCRA short-term trade-finance extension wired into B31 institution chain (P1.128, batch 20260509-1025): an unrated Grade A institution exposure that is a documentary credit financing the movement of goods with original maturity ≤ 6 months now picks up the Table 5A short-term SCRA RW (Grade A = 20%) via the same Art. 121(4) carve-out the ECRA branch (Art. 120(2A)) already honoured. Pre-fix the SCRA short-term gate in _b31_append_institution_maturity_branches was hard-coded to original_mty ≤ 0.25, missing the is_short_term_trade_lc & original_mty ≤ 0.5 OR-clause that the ECRA branch already had via the shared in_st_window expression — a 5-month documentary credit fell through to the long-term SCRA Grade A 40%, doubling RWA (£1m drawn → 400k vs 200k). Fix replaces the SCRA gate with is_institution & is_unrated & in_st_window, reusing the helper expression already in place. Required follow-on edit in engine/hierarchy.py::_propagate_facility_qrre_columns to add a counterparty-level OR-broadcast of is_short_term_trade_lc (mirroring the has_short_term_ecai precedent added by P1.105) so the flag propagates from facilities to drawn-loan exposure rows when facility_mappings is empty. CRR helper _crr_append_institution_maturity_branches unchanged (CRR has no Art. 121(4) trade-finance extension to its short-term unrated-institution treatment). Pinned by tests/acceptance/basel31/test_p1_128_art_121_4_scra_short_term_trade_finance.py (5 tests). Ref: PRA PS1/26 Art. 121(4); BCBS CRE20.16–21.
  • CRR Art. 224(2)(a) FX H_fx 20-day secured-lending default pinned by acceptance test (P1.186, batch 20260509-1025): the engine fix shipped silently in commit 44006da on 2026-05-03 — engine/crm/haircuts.py now derives the liquidation period from exposure_is_sft (5 / 10 / 20 days per Art. 224(2)(a)–(c)) instead of defaulting everything to 10 days — but the acceptance test pinning the regulatory behaviour was never written, leaving the plan item at [~]. This batch adds the missing fixtures and a 10-test acceptance scenario covering: 20-day default for is_sft=False secured lending → ead_final ≈ 484,852.81 (H_fx,20 = 8% × √2 ≈ 11.314%, H_c,20 ≈ 2.828%); 5-day default for is_sft=True → ead_final ≈ 442,426.41; an explicit regression guard against the pre-fix 10-day result of 460,000.00 (a strict inequality that can only be satisfied when H_fx is scaled to the 20-day period); and a directional sanity SL > SFT (20-day haircuts > 5-day → larger E* on secured-lending loan than on the SFT). Closes the residual liquidation_period as config sub-item of P6.15. Pinned by tests/acceptance/crr/test_p1_186_art_224_2_fx_haircut_secured_lending_default.py (10 tests). Ref: CRR Art. 224(2)(a), Art. 226(2), Art. 233.
  • PRA PS1/26 Art. 120(2B) Table 4A short-term institution ECAI risk weights (P1.105, batch 20260508-0254): institutions with an issue-specific short-term ECAI assessment now route through the new Table 4A (CQS1=20% / CQS2=50% / CQS3=100% / CQS4–5=150%) instead of the more permissive Table 4 fallback, closing a CQS-2/3 capital understatement (CQS3 jumps 20% → 100% on a £1m short-term-rated institution exposure). New optional Boolean has_short_term_ecai on FACILITY_SCHEMA (default False); new constant B31_ECRA_SHORT_TERM_ECAI_RISK_WEIGHTS in data/tables/b31_risk_weights.py; _b31_append_institution_maturity_branches in engine/sa/namespace.py gates Table 4A ahead of the Table 4 fallback when is_institution & is_rated & has_short_term_ecai & in_st_window. Because the ECAI rating is a counterparty/rating-level property, engine/hierarchy.py::_propagate_facility_qrre_columns was extended to OR-aggregate has_short_term_ecai across a counterparty's facilities and broadcast to every exposure — loans without parent_facility_reference therefore inherit the flag from a sibling facility row. CRR institution path is unchanged (Art. 120 has no Table 4A analogue). Pinned by tests/acceptance/basel31/test_p1_105_art_120_2b_short_term_institution_ecai.py (5 tests). Ref: PRA PS1/26 Art. 120(2B), Art. 120(3); BCBS CRE20.20.
  • CRR Art. 155(2)(c) IRB Simple GOVERNMENT_SUPPORTED equity now 370% (P1.164, batch 20260508-0254): Art. 155(2) is a closed three-bucket enumeration (290% exchange-traded / 190% PE-diversified / 370% all-other) with no government-supported carve-out. The previous 190% mapping for GOVERNMENT_SUPPORTED had no regulatory basis and understated capital (190% < 370%). data/tables/crr_equity_rw.py IRB_SIMPLE_EQUITY_RISK_WEIGHTS[GOVERNMENT_SUPPORTED] updated 1.90 → 3.70 with an Art. 155(2)(c) citation comment; the redundant is_government_supported and equity_type=="government_supported" branches in engine/equity/calculator.py::_apply_equity_weights_irb_simple were removed (the .otherwise(_IRB_RW[OTHER]) fall-through now produces the correct 370%). For a £300k AIRB government-supported equity exposure, RWA increases 570,000 → 1,110,000. Basel 3.1 unaffected (Art. 155 is left blank under PS1/26; B31 routes through Art. 133(3) 250%). Pinned by tests/acceptance/crr/test_p1_164_art_155_2c_government_supported_irb_simple.py (5 tests) plus updated CRR-J14 acceptance and TestIRBSimpleEquityRiskWeights::test_government_supported_370_percent unit-table assertions. Ref: CRR Art. 155(2)(c).
  • CRR Art. 239(1) FCSM maturity-mismatch eligibility gate (P1.104, batch 20260508-0210): engine/crm/simple_method.py::compute_fcsm_columns() now treats financial collateral whose residual_maturity_years is strictly less than the secured exposure's residual maturity as ineligible — the FCSM benefit is fully suppressed (binary gate, no Art. 239(2) (t-0.25)/(T-0.25) partial adjustment, which is FCCM/IRB only). Direct-/facility-/counterparty-level exposure residual maturity is coalesced before the comparison so the gate is conservative for pool-level pledges. Pre-fix the engine recognised collateral with shorter maturity than the exposure, understating capital. Pinned by tests/acceptance/crr/test_p1_104_art_239_1_fcsm_maturity_eligibility.py (8 tests; the discriminating MISMATCH row asserts fcsm_collateral_value=0, risk_weight=1.00, rwa=1,000,000). Plan-bullet citation was Art. 222(7); architect verified the actual FCSM maturity-mismatch exclusion lives at Art. 239(1) — Art. 222(7) is a definition-extension paragraph for sovereign-debt-securities scope. Ref: CRR Art. 239(1).
  • PSM PD floor uses guarantor exposure-class context (P1.157, batch 20260508-0210): engine/irb/formulas.py::_pd_floor_expression extended with optional exposure_class_col / transactor_col kwargs (additive, backward-compatible — defaults preserve all existing call-site behaviour). Three PSM call sites in engine/irb/guarantee.py (_apply_parameter_substitution, _adjust_expected_loss, _apply_double_default) now pass guarantor_exposure_class so the substituted (guarantor's) PD is floored at the floor that would apply to a direct exposure to the guarantor — Art. 160(4) "no better than direct" — and not at the borrower's class-floor. In the pinned scenario a corporate guarantor's PD is floored at corporate 0.05% (Art. 163(1)(a)) instead of the borrower's QRRE-revolver 0.10% (Art. 163(1)(c)), correctly lowering guarantor_rw_irb from 0.02408 to 0.01346. The NBD floor function _apply_no_better_than_direct_floor was already in place; the prior fixture/test were degenerate (guarantor_rw_irb == RW_direct ⇒ floor non-binding) and have been replaced with a binding configuration where RW_direct (0.17489) ≫ guarantor_rw_irb (0.01346) and the NBD floor lifts blended RWA from 136,636 to 233,494 (+71%). Pinned by tests/acceptance/basel31/test_p1_157_psm_no_better_than_direct.py (13 tests). Ref: CRR / PRA PS1/26 Art. 160(4), Art. 163(1)(a), Art. 161(1)(aa), Art. 236(1)(a)(i).
  • CRR Art. 126(2)(d) commercial-RE proportion split (P1.181, batch 20260508-0210): engine/sa/namespace.py::_crr_append_real_estate_branches residual leg now blends the 50% Art. 126(2) secured RW with the counterparty's Art. 122 corporate-CQS RW per Art. 124(1) "the part of the exposure that exceeds the mortgage value", instead of stamping a flat 100% residual. The split mechanism mirrors Art. 125 RRE: secured_share = min(1, 0.50 / LTV), residual_share = 1 − secured_share, blended RW = 0.50 × secured_share + counterparty_RW × residual_share. The residual lookup uses _cqs_table_lookup_expr against CORPORATE_RISK_WEIGHTS (already imported), so the unrated default (1.00) is sourced from the data layer rather than inlined. engine/hierarchy.py::_coerce_loans_to_unified defensively passes through the CLASSIFIER_OUTPUT_SCHEMA RE columns (ltv, property_type, has_income_cover, is_qualifying_re, prior_charge_ltv, is_defaulted, qualifies_as_retail); _add_collateral_ltv only stamps null defaults for columns not already present so the loan-frame values survive unification. Pre-fix, CRR CRE LTV > 50% was a binary 100% (overstatement); for an LTV-0.80 unrated-corporate CRE the new RW is 0.6875 (RWA 687,500) instead of 1.00 (RWA 1,000,000). The discriminating fixture row uses an LTV-0.80 corporate-CQS1 exposure, where a naïve "residual = constant 100%" fix would still emit RW=1.00 but the correct counterparty-RW lookup emits 0.3875 (RW=0.50×0.625 + 0.20×0.375). Basel 3.1 Art. 124H/124I path unchanged. Pinned by tests/acceptance/crr/test_p1_181_art_126_cre_proportion_split.py (7 tests). Ref: UK CRR Art. 126(2)(d), Art. 124(1), Art. 122 Table 6.
  • CRR Art. 137(1)–(2) Table 9 ECA / MEIP score-to-RW direct mapping (P1.100, batch 20260508-0020): unrated sovereigns with an Art. 137(1) Export Credit Agency / OECD MEIP score now route through Table 9 (0/0/20/50/100/100/100/150 % for scores 0–7) instead of falling through to the Art. 114(2) Table 1 unrated 100% bucket — closes a 5× capital overstatement for unrated sovereigns with low MEIP scores. New eca_score: ColumnSpec(pl.Int8, required=False) on COUNTERPARTY_SCHEMA; new ECA_MEIP_RISK_WEIGHTS table in data/tables/crr_risk_weights.py; new _eca_meip_rw_expr() in engine/sa/namespace.py injected after the Art. 114(3)/(4) domestic-currency override and before the unrated fallback. Basel 3.1 path unchanged. Pinned by tests/acceptance/crr/test_p1_100_art_137_eca_meip_sovereign.py (5 tests, scenario CRR-A14-ECA). Ref: CRR Art. 137(1)–(2), Art. 114(2) Table 1.
  • CRR Art. 226(1) non-daily revaluation haircut scaling (P1.101, batch 20260508-0020): collateral revalued less frequently than daily now sees its supervisory haircut scaled by sqrt((N_R + T_m − 1) / T_m) where N_R is the revaluation frequency in business days and T_m is the liquidation period (5 / 10 / 20 per Art. 224(2)) — closes a haircut understatement for SFTs and other less-than-daily-revalued collateral. New revaluation_frequency_days: ColumnSpec(pl.Int32, required=False) on COLLATERAL_SCHEMA (null/1 ⇒ daily, no scaling; >1 fires Art. 226(1)); engine/crm/haircuts.py multiplies post-Art-226(2) collateral_haircut AND fx_haircut by reval_factor after the Art. 226(2) liquidation-period scaling. Art. 227 zero-haircut short-circuit preserved. The fix applies identically under CRR and PRA PS1/26 (PS1/26 carries Art. 226(1) forward unchanged). Pinned by tests/acceptance/crr/test_p1_101_art_226_1_non_daily_revaluation.py (6 tests, scenario CRR-D-REVAL). Ref: CRR Art. 226(1), Art. 226(2), Art. 224(2)(a)–(c).
  • UK CRR slotting Art. 153(5) Table 1 — is_hvcre ignored under CRR (P1.177, batch 20260508-0020): UK-onshored CRR Art. 153(5) contains only Table 1; the EU CRR Table 2 HVCRE table was not retained on onshoring (SI 2021/1078). The previous engine routed is_hvcre=True CRR exposures through SLOTTING_RISK_WEIGHTS_HVCRE weights (e.g. 95% Strong ≥2.5y) — a capital overstatement for UK firms. engine/slotting/namespace.py::SlottingExpr.lookup_rw and lookup_el_rate now ignore is_hvcre under CRR (config.is_crr=True); all SL exposures use Table 1 weights and Table B EL rates regardless of the HVCRE flag. Basel 3.1 HVCRE handling under PS1/26 Art. 153(5) Table A is unchanged. The is_hvcre flag is preserved on the audit trail. Existing CRR-E4/E7/E8 expected outputs patched to post-fix values (RW 0.95→0.70 / 0.70→0.50 / 0.95→0.70); 20 pre-existing unit tests that codified the buggy EU Table 2 behaviour were deleted or updated to assert HVCRE-equals-non-HVCRE under CRR. Pinned by tests/acceptance/crr/test_p1_177_art_153_5_uk_crr_no_hvcre.py (5 tests, scenario CRR-E9). Ref: UK CRR Art. 153(5) Table 1, Art. 147(8) (no HVCRE sub-type), Art. 158(6) Table B; SI 2021/1078.

[0.2.9] - 2026-05-04

Changed

  • Entity-class mapping dicts hoisted to data/tables/ (commit 908e88fd): regulatory entity-class string lists moved out of engine/ per the data/engine separation rule enforced by scripts/arch_check.py checks 5 & 6. Behaviour-preserving.
  • ExposureClassifier decomposed into orchestrator + _assign_approach helper (commit 975e56f2): engine/classifier.py split for readability; the orchestrator now delegates the approach-decision ladder to a dedicated helper. Behaviour-preserving.
  • Loader _build_bundle deduplication + LoaderProtocol conformance pinned (commit fc0ca133): engine/loader.py collapses the per-table duplication that had drifted across the optional inputs and adds a contract test pinning Loader to its protocol surface.

[0.2.8] - 2026-05-04

Changed

  • Mixed RRE+CRE collateral now split across both classes per regime (engine/re_splitter.py, engine/classifier.py): a single SA exposure secured by both residential and commercial property collateral now produces three child rows (secured_rre + secured_cre + residual) sharing one split_parent_id, instead of the prior dominance-rule single-class split that silently dropped the non-dominant collateral value. The new behaviour follows PRA PS1/26 Art. 124(4) pro-rata by collateral value under Basel 3.1 (closes documented gap D3.59 in docs/specifications/basel31/sa-risk-weights.md:1314-1321) and CRR Art. 124(1) "any part of an exposure" RRE-first sequential allocation under CRR (RRE consumes EAD up to its 80% LTV cap, then CRE picks up the remainder up to its 50% LTV cap when rental coverage is met). Per-component classifier columns added (re_split_residential_value, re_split_commercial_value, re_split_residential_eligible, re_split_commercial_eligible); per-component audit columns added to re_split_audit (rre_secured_ead, cre_secured_ead, is_mixed); new informational warning RE003 counts mixed-collateral splits per batch with the regime-specific allocation rule named in the message. Single-component splits (pure RRE or pure CRE) keep the legacy secured role and _sec reference suffix for backward compatibility — only mixed splits use secured_rre / secured_cre and _rre / _cre suffixes. Single prior_charge_ltv column is applied to both component caps as a v1 conservatism (documented limitation). Surfaced and fixed a pre-existing latent SA dispatch bug in engine/sa/namespace.py: COMMERCIAL_MORTGAGE exposure-class rows were mis-routed through the residential RW branch because both classes contain the MORTGAGE substring; commercial branch now dispatches first under both CRR and Basel 3.1 paths and the Art. 127(3) defaulted RESI flat-100% rule excludes commercial RE. Ref: PRA PS1/26 Art. 124(4), Art. 124F, Art. 124H(1)-(3); CRR Art. 124(1), Art. 125, Art. 126.

[0.2.7] - 2026-05-04

Changed

  • P1.97 — B31 slotting non-HVCRE column-A/C subgrade (commit f22e3fcc): engine/slotting/namespace.py routes residual maturity ≥ 2.5y rows through column-A subgrade and < 2.5y through column-C per PRA PS1/26 Art. 153(5)(d).
  • P1.98 — subordinated corporate A-IRB LGD 25% floor (commit 836100ee): engine/irb/namespace.py applies the new Art. 161(5) sub-LGD 25% floor on subordinated corporate A-IRB exposures.
  • P1.99 — CRR Art. 120(2) Table 4 short-term institution RW (commit 3fb634c0): engine/sa/namespace.py routes original-maturity ≤ 3m rated institution exposures through Table 4 (20 / 50 / 100 / 150 by CQS) instead of long-term Table 3.
  • P1.106 — FCSM B31 ECRA institution CQS 2 collateral RW 30% (commit 8e919d40): engine/crm/simple_method.py + data/tables/b31_risk_weights.py route CQS 2 institution-issued financial collateral through 30% under FCSM per Art. 120 Table 3.
  • P1.107 — FCSM B31 corporate CQS 3 collateral RW 75% (commit 22999994): same FCSM path, corporate CQS 3 → 75% per Art. 122(2) Table 6.
  • P1.112 — non-UK PSE / RGLA sovereign-derived RW (commit fc2859d0): engine/sa/namespace.py routes non-UK PSE / RGLA exposures through the Art. 115 / 116 sovereign-derived table when the sovereign CQS is supplied. Test pin added in 68bdeafd.
  • P1.114 — null-safe model_permissions filters (commit 18e082e8): engine/classifier.py fill_null(False) on the AIRB / FIRB permission masks so Polars 3VL nulls cannot silently route an exposure to the default approach.
  • P1.117 — B31 HVCRE short-maturity slotting subgrades (commit 9501c90d): engine/slotting/namespace.py applies the Art. 153(5)(d) subgrade-A weights on residual ≥ 2.5y HVCRE rows.
  • P1.121 — CRR Art. 121(3) unrated institution short-term 20% RW (commit cf3a54e0): engine/sa/namespace.py carves out the original ≤ 3m unrated-institution branch from the default 100% to 20%.
  • P1.124 — CRR Art. 237(2)(a) guarantee maturity ineligibility (commit b2de0225): engine/crm/guarantees.py drops guarantees with residual maturity < 3 months or original maturity < 1 year from eligibility per Art. 237(2)(a).
  • P1.125 — classifier FSE-column-missing CLS007 under B31 (commit 5cdba52b): engine/classifier.py emits a CLS007 warning when the is_fse column is absent on B31 input frames per Art. 147A(1)(e).
  • P1.126 — large-corp F-IRB scope + null-revenue conservative + CLS008 (commits b225ccd2 + 272fd3d0): engine/classifier.py treats counterparties with null total_assets_eur as conservative-large per Art. 147A(1)(d), emits CLS008, and gates the large-corp F-IRB restriction to corporate counterparties only.
  • P1.144 — IRB calculate_expected_loss pinned to ead_final (commit 32d91f75): engine/irb/formulas.py computes EL against the post-CRM ead_final rather than ead_pre_crm, per Art. 158 / 159.
  • P1.156 — PSM guarantor LGD seniority / FSE-aware (commit 79616bfe): engine/irb/guarantee.py selects the guarantor LGD using the guarantor's seniority + FSE flag per Art. 161 / Art. 236(1)(a). Follow-up P1.160 in 0.2.10 wires the column from the input schema.
  • P1.158 — null collateral maturity uses longest band (commit fe167f66): engine/crm/haircuts.py defaults a null residual_maturity_years on collateral to the longest haircut band (conservative).
  • P1.169 — B31 ECRA short-term institution CQS 4-5 = 50% (commit 3f0c2461): engine/sa/namespace.py corrects the short-term ECRA branch from 100% to 50% per Art. 120(2A) Table 4.
  • P1.182 — B31 PE/VC unlisted+<5y higher-risk equity (commit a342b27b): engine/equity/calculator.py routes unlisted PE/VC exposures held < 5 years through the higher-risk equity bucket per PS1/26 Glossary p.5.
  • P1.187 — FCSM CRR CQS 5 = 150% scalar fix (commit 88f177fd): data/tables/crr_risk_weights.py corrects the FCSM CRR CQS 5 lookup from the buggy 100% to the regulatorily-correct 150%.
  • Style pass on engine/sa, crm, classifier, slotting + tests (commit d335a695): pure formatting / import-sort cleanup across the May 3 P-coded items; no behaviour change.

[0.2.6] - 2026-05-03

Added

  • crm_collateral_method / airb_collateral_method config knobs documented (docs/api/configuration.md): the CRMCollateralMethod (COMPREHENSIVE / SIMPLE) and AIRBCollateralMethod (LGD_MODELLING / FOUNDATION) enums on CalculationConfig were exposed in source but absent from the API docs — practitioners had to read domain/enums.py to discover the toggles. Both knobs are now on the configuration page with field-summary tables, member-by-member regulatory citations (CRR Art. 222 FCSM / Art. 223–224 FCCM; PRA PS1/26 Art. 191A firm-wide election; PS1/26 Art. 169A/169B and CRR Art. 229–231 for the A-IRB collateral switch), factory defaults, framework-applicability admonitions, and worked dataclasses.replace snippets. Closes DOCS_IMPLEMENTATION_PLAN.md D3.52.
  • IRB guarantee parameter-substitution path (PSM, CRE22.70–85) documented (docs/specifications/basel31/credit-risk-mitigation.md): the four-step PSM path implemented at engine/irb/guarantee.py was an opaque code surface — the B31 CRM spec covered guarantee haircuts and eligibility but never showed how PD substitution, F-IRB LGD substitution by guarantor seniority, correlation re-derivation, and Art. 236A maturity adjustment compose into the final risk weight. The restructured ## IRB Parameter Substitution section now walks each step against PRA PS1/26 Art. 161 / Art. 162 / Art. 202 / Art. 235 / Art. 236(1)(a)(i) (with BCBS CRE22.72–80 in parentheses), adds the composing IRB risk-weight and EL formulas, the CRR-only Art. 153(3) double-default overlay, and an audit-trail table mapping every output column emitted by _add_guarantee_status_columns. Three Art. 236 code defects surfaced during the doc walk (Step-3 borrower-vs-guarantor correlation, Step-2 senior-unsecured LGD scalar, missing option-(i) borrower-unprotected LGD source) routed to IMPLEMENTATION_PLAN.md as P1.159 / P1.160 / P2.43. Closes DOCS_IMPLEMENTATION_PLAN.md D3.56.
  • life_ins_collateral_value / life_ins_secured_rw output columns documented (docs/data-model/output-schemas.md): new "CRM — life insurance collateral (Art. 232)" subsection describes the two exposure-frame columns produced by engine/crm/life_insurance.py::compute_life_insurance_columns and consumed by lf.sa.apply_life_insurance_rw_mapping() during SA risk-weight blending. Includes the Art. 232(3) insurer-RW mapping table (PS1/26 7-tier 20% / 30% / 50% / 65% / 100% / 135% / 150% vs CRR 4-tier 20% / 50% / 100% / 150%), defaults when no life-insurance collateral is present, IRB-side LGD_S = 40% cross-reference, and a single-source-of-truth pointer to the Basel 3.1 CRM spec. Ref: PRA PS1/26 Art. 232, Art. 200(b), Art. 212(2); CRR Art. 232. Closes DOCS_IMPLEMENTATION_PLAN.md D3.53.
  • IRB Risk Parameter Estimation Standards (PS1/26 Art. 179–184) documented (docs/specifications/basel31/irb-approach.md): previously the spec stub-cited Art. 179–184 with no content, leaving implementers without a documented contract for what the calculator's PD / LGD / EAD inputs must represent. New "Risk Parameter Estimation Standards" section now covers Art. 179 (general estimation, MoC, pooled data), Art. 180(1)(a)–(h) corporate / institution PD plus Art. 180(2)(a)–(f) retail PD with the 5-year minimum data history, Art. 181 LGD (downturn LGD, LGD-in-default, 5y → 7y data ramp), Art. 181A–C downturn nature/severity/duration (incl. ≥ 20-year time-span at Art. 181C(1)), Art. 182 EAD/CCF, Art. 183 LGD-AM under A-IRB, and Art. 184 purchased receivables — all with verbatim PS1/26 Appendix 1 page citations (pp. 131–141). Closes DOCS_IMPLEMENTATION_PLAN.md D3.49.
  • enable_double_default config knob documented (docs/api/configuration.md): the CRR Art. 153(3) double-default RW formula is now a discoverable knob from the API docs alone — field name, type, default (False), formula reference, Art. 202 / 217 eligibility, the PS1/26 B31-removal note, and a worked CalculationConfig.crr(enable_double_default=True) snippet are all in place. Practitioners no longer have to read source to find the toggle. Closes DOCS_IMPLEMENTATION_PLAN.md D3.50.
  • Five missing error codes documented in the contracts API reference (docs/api/contracts.md): CLS004 (ERROR_QRRE_COLUMNS_MISSING), CLS005 (ERROR_RETAIL_POOL_MGMT_MISSING), IRB006 (ERROR_MISSING_EXPECTED_LOSS), SA005 (ERROR_EQUITY_IN_MAIN_TABLE), and SF001 (ERROR_SME_MISSING_COUNTERPARTY_REF) were defined in src/rwa_calc/contracts/errors.py but absent from the published Error Code Constants table. SF001 introduces a new "Supporting Factors" prefix. Closes DOCS_IMPLEMENTATION_PLAN.md D3.55.
  • use_investment_grade_assessment config knob documented (docs/api/configuration.md): the PRA PS1/26 Art. 122(6)/(8) IG=65% / non-IG=135% election for unrated non-SME corporates is now a discoverable knob from the API docs — field name, type, default (False), the Basel-3.1-only scope (CRR factory does not expose it), the Art. 122(7) sound-processes obligation and Art. 122(8)(b) PRA notification requirement on adoption and cessation, the Art. 92(2A) S-TREA interaction, and a worked CalculationConfig.basel_3_1(use_investment_grade_assessment=True) snippet are all in place. The basel_3_1() factory signature in the same page is updated to surface the argument. Closes DOCS_IMPLEMENTATION_PLAN.md D3.51 (plan-item article citation corrected — the field is Art. 122(6)/(8), not Art. 153(3) as the plan text said).

Changed

  • supporting_factor_applied column documented in canonical name (docs/data-model/output-schemas.md): the SA supporting-factor stage at engine/sa/supporting_factors.py and the aggregator at engine/aggregator/_supporting_factors.py emit a generic supporting_factor_applied Boolean covering both Art. 501 (SME, blended at the EUR 2.5m / GBP 2.2m threshold) and Art. 501a (infrastructure, flat 0.75) supporting factors, but the schema docs still showed the legacy sme_supporting_factor_applied name from before the infrastructure factor existed. The "Supporting factors (CRR only)" sub-table now lists all four pipeline-emitted columns (supporting_factor, supporting_factor_applied, rwa_pre_factor, rwa_post_factor) with broadened prose covering both factors, and a rename callout explains that sme_supporting_factor_applied survives in CRR_OUTPUT_SCHEMA_ADDITIONS only as a legacy COREP alias. Closes DOCS_IMPLEMENTATION_PLAN.md D3.54.

Fixed

  • IRB maturity-adjustment formula now honours the CRR Art. 162(3) carve-out from the 1-year M floor (engine/irb/formulas.py::_maturity_adjustment_expr_from_pd): under CRR Art. 162(3) (mirrored by PRA PS1/26 / BCBS CRE32.50), four transaction types are exempt from the 1-year M floor in the IRB maturity-adjustment formula and may use M down to 1 day — daily-margined SFTs (repo / securities lending), daily-margined derivatives, margin-lending transactions, and short-term self-liquidating trade transactions (e.g. import/export LCs). The repo already had the upstream plumbing to set maturity = 1/365 for these rows (priority chain in engine/irb/namespace.py::IRBLazyFrame.prepare_columns lines 243-318, gated on the has_one_day_maturity_floor boolean column) but the formula itself re-applied a hardcoded clip(1.0, 5.0) to maturity inside _maturity_adjustment_expr_from_pd, silently undoing the carve-out: a contingent with is_short_term_trade_lc=True and effective_maturity=0.1 showed maturity = 0.1 in the output but maturity_adjustment = 1.0 and rwa identical to a 1-year exposure — zero capital relief despite the regulatory carve-out being in scope. The fix gates the 1-year floor on the has_one_day_maturity_floor column: when True, the floor is suppressed and the actual maturity flows through; when False/null/missing, the existing [1.0, 5.0] clip applies (so ordinary corporate IRB exposures see no behaviour change). The 5-year cap from Art. 162(2) remains unconditional (no carve-out). Worked numbers at PD=0.5%, M=0.1, LGD=45%, EAD=£1m: old behaviour MA=1.0 → RWA=£521,650; new behaviour MA=0.799 → RWA=£416,969 — a 20% relief. Magnitude of relief scales with PD via the b coefficient (≈15-25% across the realistic PD range for non-defaulted exposures). The wrong behaviour was previously pinned by tests/unit/irb/test_irb_formulas.py::test_ma_below_floor_clipped and tests/unit/crr/test_crr_irb.py::test_maturity_floor which asserted that M=0.5 produced the same MA as M=1.0 — both replaced with two-case tests covering with-flag and without-flag behaviour. Scalar calculate_maturity_adjustment gains a has_one_day_maturity_floor: bool = False parameter (kwarg-only by signature ordering); positional callers (which all use (pd, maturity)) are unaffected. Column visibility threaded through every vectorised call site — apply_irb_formulas, _parametric_irb_risk_weight_expr, IRBLazyFrame.prepare_columns, IRBLazyFrame.calculate_maturity_adjustment, IRBLazyFrame.apply_all_formulas, engine/irb/guarantee.py::apply_guarantee_substitution — each default-adds has_one_day_maturity_floor=False when missing, so existing fixtures and inputs that do not set the flag continue to work. New regression coverage in tests/contracts/test_one_day_maturity_floor_propagation.py pins (a) schema declaration on FACILITY_SCHEMA / LOAN_SCHEMA / CONTINGENTS_SCHEMA, (b) prepare_columns flag preservation and default-add, (c) end-to-end MA<1.0 with carve-out under both CRR and B31, (d) MA=1.0 without carve-out, and (e) bounded RWA relief at 10%-50%. Follow-up tracked: full agent-driven B31-IRB-MAT-CARVEOUT / CRR-IRB-MAT-CARVEOUT acceptance scenarios with golden outputs covering all four trigger types (currently the contract suite covers the formula behaviour and end-to-end pipeline through apply_all_formulas, but not the loader → hierarchy → classifier → CRM → IRB → aggregator full pipeline with pre-baked fixtures). Auto-derivation of has_one_day_maturity_floor from is_short_term_trade_lc deliberately deferred — firms must currently set the carve-out flag explicitly, and the engine treats it as the single regulatory switch into Art. 162(3). Full suite: 5,566 passed; arch_check clean. Ref: CRR Art. 153(1)(iii), Art. 162(2), Art. 162(3); PRA PS1/26 (mirrored); BCBS CRE32.46, CRE32.50.
  • FX collateral haircut now uses 20-day secured-lending default per CRR Art. 224(2)(a) / PS1/26 Art. 224(2)(a) (engine/crm/processor.py::_build_exposure_lookups + _join_collateral_to_lookups, engine/crm/haircuts.py::apply_haircuts): the FX collateral haircut Hfx previously defaulted to a 10-day liquidation period (8%) for all exposures regardless of transaction type, when the regulatory baseline for secured lending (a vanilla loan facility plus collateral) is 20 business days under CRR Art. 224(2)(a) — giving Hfx = 8% × √(20/10) = 11.314% after the Art. 226(2) square-root-of-time scaling. The constants LIQUIDATION_PERIOD_REPO=5 / _CAPITAL_MARKET=10 / _SECURED_LENDING=20 already lived in data/tables/haircuts.py:146-148 but were unused — is_sft from the exposure schemas was not propagated onto collateral. Worked example for the FX-mismatch scenario that motivated the fix: £1m GBP loan facility secured by €500k EUR cash collateral. Old behaviour: adjusted collateral = 500k × (1 - 0.08) = £460,000, EAD = £540,000, RWA = £540,000 (8% Hfx — wrong period). New behaviour: adjusted collateral = 500k × (1 - 0.11314) = £443,431.46, EAD = £556,568.54, RWA = £556,568.54 — a 3.07% RWA understatement corrected on every FX-mismatched secured-lending exposure. The fix has two layers: (1) _build_exposure_lookups() now captures is_sft from each exposure level (direct/facility/cp), and _join_collateral_to_lookups() resolves them into a single exposure_is_sft Boolean column on collateral; (2) the unconditional fill_null(10) in apply_haircuts is replaced with: explicit per-collateral liquidation_period_days override → LIQUIDATION_PERIOD_REPO (5) when exposure_is_sft=TrueLIQUIDATION_PERIOD_SECURED_LENDING (20) otherwise. Guarantees deliberately untouched — Art. 233(4) fixes guarantee Hfx at the 10-day liquidation period regardless of the underlying transaction type, so the flat 8% in engine/crm/guarantees.py remains correct. Acceptance impact: CRR-D2 / CRR-D3 / CRR-D6 and B31-D2 / B31-D3 / B31-D6 expected RWAs re-baselined upward to reflect the 20-day default (the collateral asset haircut Hc also scales by the same factor — gilt 0.5% → 0.707%, FTSE-100 equity 15% → 21.213%); golden JSON updated under tests/expected_outputs/{crr,basel31}/. 32 unit tests pinned the buggy 10-day default by omission — all updated either with explicit liquidation_period_days=10 overrides (when the test was probing haircut-lookup behaviour, not period scaling) or with re-baselined expectations citing P1.186 (when the test was specifically about the default contract). New regression tests in tests/unit/crm/test_collateral_fx_mismatch.py::TestP1186DefaultLiquidationPeriod pin both the 20-day secured-lending default (fx_haircut == 0.113137) and the 5-day SFT default (fx_haircut == 0.056569) end-to-end through the processor join. Closes the liquidation_period as config outstanding item from P6.15 / D2.39. Full suite: 5,566 passed (23 skipped, 11 deselected); arch_check, ruff, ty all clean. Ref: CRR Art. 224(2)(a)–(c), Art. 226(2); PRA PS1/26 Art. 224(2)(a)–(c), Art. 226(2); Art. 233(4) (guarantee carve-out preserved). (P1.186.)
  • Facility undrawn now nets netting-flagged negative drawn balances per CRR Art. 195/219 / PS1/26 Art. 195/219 (engine/hierarchy.py::_aggregate_loan_drawn_per_facility, _per_sub_drawn inner helper of MOF undrawn waterfall): the per-facility drawn aggregation previously applied .clip(lower_bound=0.0) per row before summing, so a deposit booked as a negative-drawn loan under an on-balance-sheet netting agreement contributed 0 to facility utilisation instead of offsetting positive siblings. Worked example for the user request that motivated the fix: Fac_01 limit £100m with Loan_01 £60m, Loan_02 £60m, and Loan_03 -£40m carrying has_netting_agreement=True. Old behaviour: total_drawn = 120m → undrawn = max(0, 100-120) = 0m (the facility undrawn row was entirely suppressed by the existing undrawn_amount > 0 filter). New behaviour: total_drawn = 80m → undrawn = 20m, matching the regulatorily-correct net headroom. The aggregation now uses a netting-aware expression — positives always sum normally; negatives contribute only when the loan carries has_netting_agreement=True. Negatives without the flag remain clipped to 0 (data-quality guard, preserving the historical contract verified by the existing test_negative_drawn_amount_treated_as_zero, test_mixed_positive_negative_drawn_amounts, and test_all_negative_drawn_amounts tests). The same netting-aware logic is applied to the _per_sub_drawn helper used by the MOF sub-facility undrawn waterfall so a netting-flagged deposit mapped to a sub-facility offsets that sub's utilisation rather than the parent's. The downstream CRM generate_netting_collateral stage (engine/crm/collateral.py) was already correct — it generates synthetic cash collateral pro-rata across positive siblings under the netting facility — and is unchanged; this fix is strictly upstream at facility utilisation. Defensive fallback: when the loans frame lacks has_netting_agreement (direct unit-test callers), the original clip-at-0 behaviour is used. Schema column has_netting_agreement (default False) was already present on LOAN_SCHEMA. New unit tests in tests/unit/test_hierarchy.py: test_netting_negative_drawn_offsets_facility_utilisation (the user's exact 60+60-40 scenario asserting undrawn_amount=20m) and test_negative_drawn_without_netting_flag_still_clipped (regression — negative without flag still suppresses the undrawn row). Out of scope (tracked as follow-ups, agreed with user): pro-rata vs first-positive netting-collateral allocation policy (current pro-rata is defensible per CRR Art. 219 silence on allocation order); contingent-side parallel clip in _aggregate_contingent_per_facility (negative ONB contingents under a netting agreement is unusual). Full suite: 5,554 passed (2 skipped, 4 deselected); arch_check, ruff, ty all clean. Ref: CRR Art. 195 (recognition), Art. 219 (treatment as cash collateral), Art. 228(1) SA / Art. 228(2) FIRB; PRA PS1/26 Art. 195, Art. 219(1) (new unified EAD-reduction formula), Art. 228(1) — verified by direct extraction from docs/assets/crr.pdf p.191/211 and docs/assets/ps126app1.pdf p.170/190.

Cross-references

  • Art. 179–184 estimation standards cross-link added (docs/appendix/regulatory-references.md): the bare Art. 178–180 / Art. 181 rows in the IRB Approach articles table are replaced with cross-link rows pointing at the existing verbatim Art. 179–184 spec section in basel31/irb-approach.md and the Art. 181A–C economic-downturn anchor — implementers can now navigate the appendix index straight into the PD/LGD/EAD estimation rules without scanning the IRB spec page. Companion to D3.49 (above).
  • Art. 159(3) two-branch rule and Art. 62(d) T2 cap formula documented (docs/specifications/crr/provisions.md, docs/specifications/basel31/provisions.md): both provisions specs previously described the EL-vs-provisions comparison at high level only — the formal A/B/C/D pseudocode block, the explicit T2_credit_cap = 0.006 × IRB_credit_risk_RWA formula, and the per-branch CET1-deduction-vs-T2-credit treatment were absent from both pages. Both specs now carry verbatim Art. 159(3) and Art. 62(d) quotes (CRR + PS1/26 App 1 p. 109), a dedicated ### Art. 62(d) — T2 Cap on EL Excess subsection, and three worked numeric examples (combined-shortfall, combined-excess-cap-binds, split-branch). The B31 spec adds a CRR↔B31 framework-delta callout cross-linking the OF-ADJ T2 component caps in output-floor.md (single source of truth, no duplication). Plan-item misattribution corrected inline: D4.87(b) cited "0.6% IRB RWA (CRR) / 1.25% S-TREA (B31)" — the verbatim Art. 62(d) cap base is 0.6% of IRB credit-risk RWA under both CRR and Basel 3.1; the 1.25% S-TREA figure is the GCRA cap under Art. 92(2A), not an EL-excess T2 cap. Closes DOCS_IMPLEMENTATION_PLAN.md D4.87. Ref: CRR Art. 159(3), Art. 62(d); PRA PS1/26 Art. 159(3), Art. 62(d), Art. 92(2A).
  • Factory-override worked examples added for CalculationConfig.crr() / .basel_3_1() (docs/api/configuration.md): the page previously showed only the factory defaults, leaving practitioners to read source to discover which keyword overrides exist. The factory signatures now match src/rwa_calc/contracts/config.py:894-1041; a new "Factory Overrides — Worked Examples" subsection adds a keyword-coverage table cross-linking each override to its per-knob anchor, plus one CRR worked example (overriding enable_double_default, crm_collateral_method, eur_gbp_rate, log_format) and one Basel 3.1 worked example (overriding use_investment_grade_assessment, airb_collateral_method, crm_collateral_method, institution_type, reporting_basis, skip_transitional_floor). Two stale prose blocks corrected: both factories DO expose crm_collateral_method as a keyword, and .basel_3_1() exposes airb_collateral_method (default AIRBCollateralMethod.LGD_MODELLING). Plan-item enum correction: D4.89 cited AIRBCollateralMethod.EFFECTIVE_LGD — actual enum members are FOUNDATION and LGD_MODELLING. Closes DOCS_IMPLEMENTATION_PLAN.md D4.89.
  • SlottingCategory enum and subgrade A/B/C/D relationship surfaced at glossary and user-guide level (docs/specifications/glossary.md, docs/user-guide/methodology/specialised-lending.md): previously the relationship between the coarse 5-bucket SlottingCategory enum (STRONG / GOOD / SATISFACTORY / WEAK / DEFAULT) and the four subgrade columns A/B/C/D in PS1/26 Art. 153(5) Table A / Art. 158(6) Table B was documented only inside the slotting spec — practitioners reading the glossary or user guide had no entry point. The glossary now carries a new SlottingCategory row in the top-of-page table and a ### SlottingCategory and subgrades A/B/C/D subsection that names the five enum members verbatim and explains that subgrades arise only on the STRONG and GOOD buckets per Art. 153(5)(c)–(f). The specialised-lending user guide gains a new ## From Category to Risk Weight: the Subgrade Step section walking through "I have a Strong CRE exposure → RW" in four steps using the actual loader fields (slotting_category, is_hvcre, residual_maturity_years, is_short_maturity, sl_type); no risk-weight numbers are duplicated — all values cross-link to the canonical slotting spec. Plan-item correction: D4.90 cited a slotting_subgrade loader field that does not exist in data/schemas.py — the subgrade is derived from is_short_maturity / residual_maturity_years per Art. 153(5)(c)–(f); only slotting_category and is_hvcre are direct inputs. Closes DOCS_IMPLEMENTATION_PLAN.md D4.90. Ref: PRA PS1/26 Art. 153(5)(c)–(f), Art. 158(6) Table B; CRR Art. 153(5) Table 1; domain/enums.py.
  • Art. 129(6) pre-2007 covered bond grandfathering documented (docs/specifications/crr/sa-risk-weights.md): the CRR Art. 129(6) carve-out exempting covered bonds issued before 31 Dec 2007 from the Art. 129(1)/(3) eligibility requirements (grandfathered to maturity) was absent from the spec — practitioners had no documented basis for why pre-2007 issues retain the preferential covered-bond RW table without satisfying the modern collateral-pool / disclosure tests. New "Pre-2007 Grandfathering (Art. 129(6))" subsection now sits between the existing Art. 129 eligibility block and the B31 covered-bond changes, with verbatim CRR Art. 129(6) and PS1/26 Art. 129(6) quotes, an operational note that Art. 129(7) disclosure obligations still apply, and a B31 delta callout flagging the PS1/26 tightening (PS1/26 explicitly conditions grandfathering on Art. 129(7) compliance). Closes DOCS_IMPLEMENTATION_PLAN.md D4.47. Ref: CRR Art. 129(6), PRA PS1/26 Art. 129(6).
  • Art. 227(2)(d) 4-business-day close-out window for FCSM SFTs documented (docs/specifications/crr/credit-risk-mitigation.md): the Financial Collateral Simple Method gates the 0% / 10% repo-style transaction floor on a set of preconditions in Art. 227(2)(a)–(h), one of which (the 4-business-day close-out period at Art. 227(2)(d)) was completely absent from the CRM spec — implementers had no documented eligibility test for when an SFT qualifies for the FCSM carve-out vs. falls back to the Art. 222(3) 20% RW floor. New "Art. 227(2)(a)–(h) — Preconditions for the FCSM SFT Carve-Out" subsection lists all eight gating conditions with a dedicated "Art. 227(2)(d) — 4-business-day close-out window" sub-subsection (verbatim CRR quote, framed as eligibility precondition, fall-back behaviour explained, Art. 227(1) FCCM-routing note). B31 delta callout flags PS1/26 Art. 227(2)(i) (new unfettered-seizure condition) and PS1/26 Art. 227(4) (new master-netting-agreement rule) as B31 additions. Plan-item misattribution corrected: D4.49 cited "Art. 227(4)" but the 4-business-day window actually sits at Art. 227(2)(d) in the consolidated UK CRR. Closes DOCS_IMPLEMENTATION_PLAN.md D4.49. Ref: CRR Art. 227(2)(d), Art. 227(1), Art. 222(4); PRA PS1/26 Art. 227(2)(d), Art. 227(2)(i), Art. 227(4).
  • OF-ADJ T2 component caps (Art. 62(c) / Art. 62(d) / Art. 92(2A) GCRA) documented (docs/specifications/basel31/output-floor.md): the OF-ADJ formula OF-ADJ = max(0, SA-RWA × OF% − IRB-RWA) was published without context for the three Tier-2 caps that interact with the floor reconciliation, leaving a gap between the formula in the spec and the upstream-caps-vs-engine-cap split that engine/aggregator/_floor.py::compute_of_adj actually implements. New "T2 Component Caps — Art. 62(c) and Art. 62(d)" subsection (framed as a clarification, not a new mechanic) covers IRB T2 (Art. 62(d), 0.6% of IRB credit-risk RWA, applied upstream), SA T2 (Art. 62(c), 1.25% of SA credit-risk RWA, applied upstream), and the engine-applied GCRA cap (Art. 92(2A), 1.25% of S-TREA), with verbatim Art. 92(2A) quote, GCRA-vs-SA-T2 sign/base distinction, worked numeric illustration, and a CRR delta note (Art. 62 caps exist under both frameworks but the OF-ADJ linkage is B31-only). Plan-item misattribution corrected: D4.50 cited "Art. 92(3)(c)" but the cap locations are Art. 62(c) / Art. 62(d) of the Own Funds (CRR) Part — Art. 92(3) is the U-TREA composition list with no point (c) cap. Closes DOCS_IMPLEMENTATION_PLAN.md D4.50. Ref: PRA PS1/26 Art. 62(c), Art. 62(d), Art. 92(2A).
  • Art. 40 EL-shortfall DTA grossing-up rule explained in OF-ADJ context (docs/specifications/basel31/output-floor.md): the previous gloss "plus any supervisory deductions under Art. 40" in the IRB_CET1 component row mischaracterised CRR Art. 40 as a separate prudential filter / supervisory deduction. New "Art. 40 — no deferred-tax grossing-up of the EL-shortfall deduction" subsection adds verbatim CRR Art. 40 text and a plain-English explanation that Art. 40 is a clarifier on Art. 36(1)(d) — it forbids reducing the EL-shortfall deduction by a rise in deferred-tax assets reliant on future profitability. The component-table row now points at the new subsection rather than restating the misattribution. Engine-inputs note covers both the engine-derived ELPortfolioSummary.cet1_deduction path and the institution-supplied OutputFloorConfig.art_40_deductions scalar. Closes DOCS_IMPLEMENTATION_PLAN.md D4.51. Ref: CRR Art. 40, PRA PS1/26 Art. 92(2A).
  • Equity transitional 3-year window (Rules 4.4–4.10) distinguished from output-floor 4-year transitional (docs/specifications/basel31/equity-approach.md): the spec previously implied the SA equity transitional ran four years through 31 Dec 2030; PRA PS1/26 Annex C Chapter 4 Rule 4.2 chapeau is unambiguous that both the SA equity transitional (Rules 4.1–4.3) and the IRB equity/CIU opt-out transitional (Rules 4.4–4.10) run only 3 years (1 Jan 2027 – 31 Dec 2029), with steady-state from 1 Jan 2030. Side-by-side comparative table now shows scope/dates/mechanism/opt-out for the two regimes, plus an info admonition warning against conflating equity transitional (3 years) with output-floor transitional (4 years, Art. 92(5)). Rules 4.4, 4.7, 4.9, 4.10 quoted verbatim from PS1/26 Appendix 1. Plan-item correction: D4.52 itself stated "SA equity transitional runs 4 years (2027-2030)" — the 4-year window is the output-floor transitional, not equity. Closes DOCS_IMPLEMENTATION_PLAN.md D4.52. Ref: PRA PS1/26 Annex C Chapter 4 Rules 4.1–4.11, Art. 92(5).
  • HVCRE Table B EL subgrade columns A/B/C/D surfaced (docs/specifications/basel31/slotting-approach.md): the B31 HVCRE expected-loss row was previously rendered as a single "Strong = 0.4%" entry without exposing the four subgrade columns that PS1/26 Appendix 1 Art. 158(6) Table B uses for both HVCRE and non-HVCRE rows. The HVCRE EL table is now expanded to four explicit columns (A/B/C/D) parallel to the existing Table A risk-weight subgrade structure (which DOES split: 70%/95%/95%/120%); Art. 158(6) Table B is quoted verbatim. Plan-item correction: D4.53 plan wording "Strong A = 0.4%, Strong = 0.8%" conflated HVCRE Table B (flat 0.4% across all four columns) with the non-HVCRE EL row (where Good C = 0.4% and Good D = 0.8%). Closes DOCS_IMPLEMENTATION_PLAN.md D4.53. Ref: PRA PS1/26 Appendix 1 Art. 153(5) Table A, Art. 158(6) Table B.
  • CRR Art. 121(4) trade-finance preferential 50%/20% for unrated institutions documented (docs/specifications/crr/sa-risk-weights.md): the institution section gains a dedicated Art. 121(4) subsection — verbatim Art. 121(4) (CRR p. 120), Art. 162(3) second subparagraph point (b) (p. 160), and Art. 4(1)(80) (p. 39) quotes; per-case RW table; cumulative eligibility checklist; B31 framework-delta callout flagging the SCRA restructuring and the absence of a flat-50% successor; implementation-status callout flagging the CRR calculator gap. Plan-item terminology correction: D4.55 wording said "50% (sovereign CQS 4-5) or 20% (sovereign CQS 1-3) under sovereign-derived approach" — Art. 121(4) is not CQS-keyed; the 50% is flat for all eligible trade-finance exposures (residual ≤ 1y), and 20% applies where residual ≤ 3 months. Closes DOCS_IMPLEMENTATION_PLAN.md D4.55. Ref: CRR Art. 121(4), Art. 162(3) second subpara point (b), Art. 4(1)(80).
  • PS1/26 Art. 132(8) "relevant CIU" PRA notification regime documented (docs/specifications/basel31/equity-approach.md): a new section covers the third-country-fund-manager notification trigger that previously had no doc surface — verbatim Art. 132(8)(a)–(d) (PS1/26 App 1 pp. 64–65) and Glossary "relevant CIU" definition (p. 27), plain-English summary, distinction from other CIU notification regimes, and a CRR comparison (Art. 132 omitted from UK CRR by SI 2021/1078; the regime is B31-only). Three plan-item misattributions recorded: (a) the cited articles 132(3A) / 132(3B) do not exist — the actual provision is Art. 132(8); (b) no AML/CFT trigger exists anywhere in PS1/26 — the genuine trigger is the fund manager's third-country domicile, not establishment country, not AML/CFT assessment; (c) the threshold is 0.5% of credit-risk + dilution-risk RWA OR GBP 500m exposure value, not "≥2% of own funds". The same new spec section also fully covers D4.66's misattributed Art. 132(4A) / GBP 2bn RWA / GBP 500m references — D4.66 should be closed in the next plan refresh. Closes DOCS_IMPLEMENTATION_PLAN.md D4.58. Ref: PRA PS1/26 Art. 132(8), Glossary p. 27.
  • CRR Art. 118(f) UK-exit deletion noted (docs/specifications/crr/sa-risk-weights.md): the Art. 118 0% list for international organisations was previously documented as the EU-onshored Art. 118 in full, with no flag that item (f) — the residual "two-or-more-Member-States international financial institution" catch-all — was omitted by SI 2018/1401 reg. 116 with effect from 31 December 2020. New warning admonition under the existing International Organisations subsection sets out the pre-deletion EU text, the SI reference, and the practical effect (Art. 118 closes to items (a)–(e) only — IMF, BIS, EU, ESM, EFSF, EIB; cross-Member-State financial institutions no longer qualify under UK CRR). Plan-item misattribution corrected: D4.56 framed Art. 118 as "exposures to recognised exchanges" — Art. 118 is the international-organisations 0% list; recognised exchanges sit in Art. 107 / Art. 197–198. Closes DOCS_IMPLEMENTATION_PLAN.md D4.56. Ref: UK CRR Art. 118 (consolidated, footnote F266); The Capital Requirements (Amendment) (EU Exit) Regulations 2018, SI 2018/1401 reg. 116.
  • PS1/26 Art. 122B / Art. 139(2B) SA Specialised Lending in S-TREA documented (docs/specifications/basel31/output-floor.md): the SA SL framework introduced by PS1/26 Art. 122A–122B was previously absent from the output-floor spec — practitioners had no documented basis for how an IRB firm using SA for specialised lending under Art. 122A contributes to S-TREA. New section covers the Art. 122A sub-classification (Project Finance / Object Finance / Commodities Finance / IPRE / HVCRE) and Art. 122B routing (Art. 122B(1) rated → Table 5A short-term ECRA; Art. 122B(2)/(4) unrated ladder; Art. 122B(3) operational-phase definition; Art. 122B(5) high-quality criteria). The Art. 139(2B) ECAI rating-attribution rule is documented as a suppression of Art. 139(2)/(2A) inferred fallbacks when the rated SL pathway is invoked — not as a S-TREA exclusion. Plan-item factual correction: D4.59 wording — "IRB firms using SA for specialised lending do not include those exposures in the output floor SA-RWA calculation" — is factually wrong; SA SL exposures contribute to S-TREA in full (just routed through Art. 122B), and Art. 139(2B) is an ECAI rule, not a carve-out. Misattribution recorded inline via a warning admonition. Closes DOCS_IMPLEMENTATION_PLAN.md D4.59. Ref: PRA PS1/26 Art. 122A, Art. 122B(1)–(5), Art. 139(2)–(2B), Art. 92(2A).
  • PS1/26 Art. 143(6)–(8) Overseas Model Approach documented (docs/specifications/basel31/model-permissions.md): the new PS1/26 Overseas Model Approach (OMA) — a permission for UK-parent groups to apply a foreign supervisor-approved IRB approach to retail and SME corporate exposures of equivalent-jurisdiction overseas subsidiaries, capped at 7.5% of group RWA and 7.5% of group exposure value pre-output-floor — was completely absent from the docs. New top-level section covers Art. 143(6) substantive permission with the (a)–(k) conditions and the aggregate cap, Art. 143(7) grandfathering as a deeming provision for pre-2027 CRR Art. 143 PRA permissions, and Art. 143(8) ongoing-compliance obligation, with verbatim PS1/26 App 1 quotes (pp. 79, 83–84) and a CRR-vs-B31 delta (CRR has no structured OMA). Plan-item paraphrase corrected in three respects: (a) Art. 143(7) grandfathers an existing PRA permission, not a standalone overseas-regulator approval; (b) the mechanism is a deeming provision, not a notification; (c) the substantive OMA in Art. 143(6) is far narrower than the plan suggested — restricted to retail / SME corporate, equivalent-jurisdiction, with the 7.5% group caps. Closes DOCS_IMPLEMENTATION_PLAN.md D4.60. Ref: PRA PS1/26 Art. 143(6)(a)–(k), Art. 143(7), Art. 143(8), Glossary p. 79.
  • PS1/26 Art. 191A(2)(e),(f) two-layer protection look-through documented (docs/specifications/basel31/credit-risk-mitigation.md): the CRM spec previously had no description of the PS1/26 election allowing an institution to recognise funded collateral posted by an unfunded protection provider directly through the guarantee chain. New "Look-Through for Unfunded Protection Backed by Funded Protection (Art. 191A(2)(e), (f))" sub-section, slotted inside the existing CRM Method Taxonomy (Art. 191A) block, gives verbatim Art. 191A(2)(e) and (2)(f) (PS1/26 App 1 p. 168), a three-option election table (funded only / unfunded + funded jointly / Part-3-only fallback), the Art. 191A(2)(f) borrower-deeming flexibility, a CRR↔PS1/26 comparison flagging this as wholly new under PS1/26, cross-references to FCSM/FCCM/Foundation Collateral Method/PSM/RWSM and Art. 237–239, and an implementation-status admonition flagging the engine gap. Plan-item misattribution corrected: D4.61 cited "Art. 191A(4)" — the actual provision is Art. 191A(2)(e)/(f); Art. 191A(4) is an unrelated cross-reference scoping rule for Articles 192–239 absent an explicit cross-reference. Closes DOCS_IMPLEMENTATION_PLAN.md D4.61. Ref: PRA PS1/26 Art. 191A(2)(e)–(f), Part 4 of Appendix 1.
  • CRR Art. 132 paragraph references corrected in CRR equity spec (docs/specifications/crr/equity-approach.md): the CRR equity spec previously labelled CIU look-through and mandate-based approaches with PRA PS1/26 article numbers (132A / 132B). Under the historical UK CRR — before SI 2021/1078 omitted Art. 132 effective 1 Jan 2022 — these were paragraphs within Art. 132 itself: para 4 = look-through, para 5 = mandate-based. Article numbers throughout the CRR-context tables, section headings (CIU Treatment, Look-Through Approach, Mandate-Based Approach, Fallback Approach), the FR-1.7b requirements row, the CRR-J15 acceptance scenario, and the CRR-J16 third-party multiplier note are now retitled with pre-omission paragraph citations (Art. 132(4) / Art. 132(5) / Art. 132(2)). New top-of-page warning callout summarises the regulatory history: SI 2021/1078 omission, PRA Rulebook (CRR Part) housing through 31 Dec 2026, and PRA PS1/26 reintroduction as Art. 132A / 132B / 132C from 1 Jan 2027. Cross-links to basel31/equity-approach.md for the Art. 132A treatment. Closes DOCS_IMPLEMENTATION_PLAN.md D4.65. Ref: CRR Art. 132(2), (4), (5) (pre-omission); SI 2021/1078; PRA PS1/26 Art. 132A, 132B, 132C.
  • CRR Art. 150(1)(a)–(j) permanent partial use spec mirroring B31 (docs/specifications/crr/model-permissions.md): previously no spec file documented the CRR Art. 150 PPU framework — practitioners had to reverse-engineer the conditions for SA-within-IRB from IRBPermissions / permission_mode config code. The new spec is a CRR-side mirror of basel31/model-permissions.md so the two pages diff cleanly. Contents: a sunset warning that CRR Art. 150 expires 31 Dec 2026 with cross-link to PS1/26 Art. 150(1A); verbatim Art. 150(1) opening and conditions (a)–(j) (crr.pdf pp. 145–146); plain-English summary table covering each condition, plus admonitions on the (a)/(b) "limited material counterparties" two-limb test, the qualitative immateriality test in (c) (vs B31's numeric thresholds), the SI 2018/1401 UK-Exit re-targeting of (d), and the standalone 10% own-funds cap in (h); verbatim Art. 150(2) text plus a tier table (10% threshold for ≥10 holdings, 5% for <10 holdings) with a worked example; a 10-row CRR↔B31 comparison table; and an Engine Inputs section showing how each Art. 150(1)(a)–(j) condition is (or is not) encoded. Plan-item correction: there is no apply_partial_ppu flag on CalculationConfig — PPU under the engine is implicit in IRBPermissions (a class with permitted={SA} is effectively PPU for that class). Closes DOCS_IMPLEMENTATION_PLAN.md D4.64. Ref: CRR Art. 150(1)(a)–(j), Art. 150(2); PRA PS1/26 Art. 150(1A).
  • OF 08.01 col 0260 (post-adjustment RWEA) documented (docs/specifications/output-reporting.md): the OF 08.01 column list jumped from col 0254 to col 0265 with no entry for the intermediate col 0260. Per PS1/26 Annex II §3.3.1 p. 112, col 0260 = "Risk-Weighted Exposure Amount After Adjustments" = 0251 + 0252 + 0253 + 0254 and is the post-adjustment RWEA feeding OF 02.00 row 0010. Now inserted in the correct PDF order between cols 0254 and 0265. Closes DOCS_IMPLEMENTATION_PLAN.md D4.69. Ref: PRA PS1/26 Annex II §3.3.1 p. 112.
  • OF 08.07 row 0270 / col 0180 PPU formulas documented (docs/framework-comparison/reporting-differences.md): rows 0260/0270 were previously listed as "Added" without their formulas, leaving COREP implementers to reverse-engineer the Art. 150(1A) PPU materiality calculations from the Annex II PDF. Now expanded with verbatim PS1/26 Annex II formulas: col 0160 = col 0100 / CA2 row 0040 (Art. 150(1A)(c)), col 0170 = sum(0110+0120) / (col_0060 - col_0070) (Art. 150(1) last subparagraph), and col 0180 / row 0270 = row_0260_col_0120 / sum(col_0060 for rows 0180-0250 where col_0150 > 0) (Art. 150(1A)(e)). Cross-link to basel31/model-permissions.md instead of duplicating the Art. 150(1A) materiality regime. Plan-item factual correction: D4.71 stated row 0270 / col 0180 uses sum(0110+0120)/(col_0060-col_0070) — that formula actually defines col 0170; col 0180 / row 0270 uses the Art. 150(1A)(e) formula (row 0260 col 0120 / Σ col 0060 for material rows). Closes DOCS_IMPLEMENTATION_PLAN.md D4.71. Ref: PRA PS1/26 Annex II §3.3 OF 08.07 pp. 134–136.
  • UKB CR7-A PDF col labelling typo flagged (docs/framework-comparison/disclosure-differences.md): PRA PS1/26 Annex XXII p. 14 reuses col (n) for unfunded credit protection on slotting exposures (intended label is col (p)). Existing UKB CR7-A column-changes table already showed the corrected o/p sequence; new warning admonition documents the PDF typo so implementors do not follow the PDF literally. Closes DOCS_IMPLEMENTATION_PLAN.md D4.73. Ref: PRA PS1/26 Annex XXII p. 14.
  • CRR double-default eligibility, RW floor, and A-IRB precondition corrected in user-guide CRM (docs/user-guide/methodology/crm.md): the user-guide double-default subsection previously cited "Art. 153(3) paragraph 2" for the RW floor (Art. 153(3) para 2 is blanked under PS1/26 — the CRR floor lives elsewhere), gave an ambiguous "CQS 2 or better (CQS 3 maintained threshold)" guarantor eligibility wording inconsistent with CRR Art. 202, and omitted the A-IRB own-LGD precondition entirely. The RW floor citation is now CRR Art. 161(3) (the comparable-direct-exposure-to-guarantor floor); the CQS threshold is rebuilt from verbatim Art. 202(b)/(c)/(d) (ECAI ≥ CQS 3 at provision; historical PD ≤ CQS 2; current PD ≤ CQS 3); the A-IRB own-LGD chain (Art. 153(3) → Art. 161(4) → Art. 161(3)) is set out explicitly with the F-IRB fall-back to Art. 235/236 substitution. Underlying-exposure scope re-stated to match Art. 153(3) (corporate, institution, central government/CB, retail SME via Art. 154(2)) — replacing the prior incorrect "RGLA/PSE" claim. Closes DOCS_IMPLEMENTATION_PLAN.md D4.76. Ref: CRR Art. 153(3), Art. 154(2), Art. 161(3)–(4), Art. 202; PRA PS1/26 (Art. 153(3) para 2 blanked).
  • Art. 155(2) short-position netting and Art. 155(4) IMA floor added to user-guide equity (docs/user-guide/methodology/equity.md): the user-guide page documented the IRB Simple RW table (190%/290%/370%) but two material Art. 155 sub-rules — already present in the CRR equity spec — were missing from the practitioner reference. Added subsections on (a) Art. 155(2) short-position netting (short cash positions and non-trading-book derivatives may offset long positions in the same individual stock only if the hedge is explicit and covers ≥ 1 year; otherwise treated as long with the RW applied to the absolute value) and (b) Art. 155(4) IMA approach (12.5 × VaR-derived potential loss; portfolio RWEA must not be lower than PD/LGD RWEA + EL × 12.5 using Art. 165(1)/(2) PD floors and LGDs). Cross-link to crr/equity-approach.md for the canonical Art. 155 RW table; B31 framework-delta callout flags PS1/26 Art. 147A removal of IRB Equity Approach with the Rules 4.4–4.10 transitional path. Art. 155(3) per-exposure cap deliberately not pre-empted (owned by D4.79). Closes DOCS_IMPLEMENTATION_PLAN.md D4.77. Ref: CRR Art. 155(2), Art. 155(4), Art. 165(1)–(2); PRA PS1/26 Art. 147A, Annex C Chapter 4 Rules 4.4–4.10.
  • Art. 155(3) PD/LGD per-exposure cap added to user-guide equity (docs/user-guide/methodology/equity.md): CRR Art. 155(3) caps the PD/LGD-approach capital for any individual equity exposure at a 100% loss assumption (EL × 12.5 + RWEA ≤ EAD × 12.5), but the user-guide page only mentioned the cap inline as a contrast within the Art. 155(4) IMA-floor warning callout — easily overlooked by practitioners scanning for PD/LGD mechanics. New dedicated "PD/LGD Approach Per-Exposure Cap (Art. 155(3))" subsection sits between Short-Position Netting and the IMA section, with verbatim cap formula, a non-binding worked example (EAD=£100, PD=0.40%, LGD=90% → LHS ≈ £374.50 ≪ RHS = £1,250) and a binding example showing the cap reducing PD/LGD RWEA from £1,500 to £350 for a near-default exposure with the Art. 155(3) 1.5× scaling factor applied. Cross-references the canonical PD/LGD parameter table in crr/equity-approach.md rather than duplicating it. PRA PS1/26 Art. 147A removal callout flags that the cap has no Basel 3.1 successor. Implementation-status note records that the calculator currently implements only Art. 155(2) Simple Risk Weight Approach (PD/LGD approach is IMPLEMENTATION_PLAN.md P1.153 follow-up), so the per-exposure cap does not bite in any current calculation path. Closes DOCS_IMPLEMENTATION_PLAN.md D4.79. Ref: CRR Art. 155(3), Art. 153(1), Art. 165(1)–(3); PRA PS1/26 Art. 147A.
  • UKB OV1 pre-floor capital ratio rows documented (docs/features/pillar3-disclosures.md): Pillar 3 OV1 row table previously listed only rows 1–5, 11–14, 24, 26, 27, 29 — missing the seven UKB-specific pre-floor rows (4a Total RWEAs (pre-floor); 5a/5b CET1; 6a/6b Tier 1; 7a/7b Total capital pre-floor capital ratios). These rows are mandatory under PRA PS1/26 Annex XX for output-floor-active institutions so market participants can see the pre-floor capital position separately from the post-floor figures driven by Art. 92(5). Cross-references to framework-comparison/disclosure-differences.md (lines 31, 63–64) carry the canonical CRR-vs-Basel 3.1 row delta. The corresponding B31_OV1_ROWS gap in src/rwa_calc/reporting/pillar3/templates.py is routed to IMPLEMENTATION_PLAN.md as a separate code-side P-coded item. Closes DOCS_IMPLEMENTATION_PLAN.md D4.81. Ref: PRA PS1/26 Annex XX (Disclosure (CRR) Part Art. 438(d)), Art. 92(5).
  • CRR Art. 137 ECA score open-gap surfaced in CRR SA spec (docs/specifications/crr/sa-risk-weights.md): the previous "Implementation Status" note at the end of the Art. 137 ECA section understated the gap as a "future enhancement" with no mention of which inputs the engine actually accepts today. Replaced with a new ### Art. 136 vs Art. 137 — two distinct mappings subsection (clarifying that Art. 136 routes ECAI grades through the CQS pipeline and Art. 137 routes OECD MEIP integers 0–7 directly through Table 9 to risk weights) followed by an explicit "Open Gap" warning admonition. The admonition records that the engine accepts only a raw credit_quality_step integer (no ECAI grade strings, no MEIP integers), enumerates what a complete implementation would need (input-schema field for either an ECAI grade or an Art. 137 MEIP integer 0–7, static lookup table in data/tables/, Art. 114/121 sovereign-derived wiring, Art. 138 multi-assessment selection logic), and surfaces the engine work for IMPLEMENTATION_PLAN.md tracking. Verbatim Art. 137 Table 9 (0%/0%/20%/50%/100%/100%/100%/150%) PDF-verified against docs/assets/crr.pdf p. 135. Closes DOCS_IMPLEMENTATION_PLAN.md D4.74. Ref: CRR Art. 136(1)–(2), Art. 137(1)–(2) Table 9.
  • UKB CR8 signed-flow convention documented (docs/features/pillar3-disclosures.md): the Pillar 3 disclosures page CR8 section previously showed only the row structure with no sign convention, leaving template consumers to infer the direction of flow rows 2–8 from the spec page or the PRA Annex XXII text. New !!! warning admonition under the row-structure table records that flow rows 2–8 use signed values (increases positive, decreases negative; example: a £15m RWEA decrease emits as -15) and cross-references the canonical sign-convention list at docs/specifications/output-reporting.md lines 349 / 356–366 instead of duplicating spec text. The corresponding _generate_cr8 gap in src/rwa_calc/reporting/pillar3/generator.py (rows 2–8 currently emitted as None because multi-period comparison data is not wired through the pipeline) is surfaced for IMPLEMENTATION_PLAN.md routing — the implementation must honour the signed convention when prior-period inputs are added. Closes DOCS_IMPLEMENTATION_PLAN.md D4.82. Ref: PRA PS1/26 Annex XXII §11 (UKB CR8 instructions).
  • UKB CMS1 / CMS2 col d ↔ OF-ADJ / GCRA reconciliation surfaced (docs/features/pillar3-disclosures.md): the Pillar 3 page documented CMS1 col d as "RWA calculated using full standardised approach" without flagging that this is the pre-OF-ADJ S-TREA input matching OF 02.01 col 0040 — i.e. the S-TREA leg of TREA = max{U-TREA; x · S-TREA + OF-ADJ} before the floor multiplier and OF-ADJ are applied. New "Col d — pre-OF-ADJ S-TREA input" subsection on UKB CMS1 carries verbatim Art. 92(2A) (PS1/26 App 1 p. 13), an info admonition explaining how CMS1 col d gates GCRA T2 capacity through the 1.25%-of-S-TREA cap (Art. 62(c)), and cross-links to specifications/basel31/output-floor.md (formula derivation + GCRA/SCRA boundary) and specifications/output-reporting.md (COREP mapping). The UKB CMS2 section gains a parallel "Col d — pre-OF-ADJ S-TREA at asset-class granularity" subsection confirming CMS2 col d carries the same pre-OF-ADJ semantics as CMS1 col d (asset-class breakdown of the same population) and pointing back to the CMS1 admonition rather than duplicating the formula. Closes DOCS_IMPLEMENTATION_PLAN.md D4.83. Ref: PRA PS1/26 App 1 Art. 92(2A), Art. 62(c).
  • QCCP guarantor RW override (Art. 306) surfaced in user-guide CRM (docs/user-guide/methodology/crm.md): engine/irb/guarantee.py::_compute_guarantor_rw_sa overrides the substituted guarantor risk weight to 2% (proprietary) or 4% (client-cleared) when the guarantor is a qualifying central counterparty (gated by guarantor_entity_type == "ccp" and guarantor_is_ccp_client_cleared), but the user-guide CRM page never referenced Art. 306 — practitioners had to read the engine source to discover the override. New "Qualifying CCP (QCCP) Guarantor Override (CRR Art. 306)" subsection inside the existing Guarantees section sets out the trigger flags, the 2% / 4% RW table with CRE54.14 / CRE54.15 cross-references, the ordering versus the institution CQS lookup, a scope warning (trade-exposure RW only — default-fund contributions go through Art. 308 / CRE54.16 separately), a worked example, and cross-links to the Institution exposure-class page (CCP anchor) and specifications/output-reporting.md (COREP rows 0150 / 0160). Closes DOCS_IMPLEMENTATION_PLAN.md D4.85. Ref: CRR Art. 306(1)(a)–(b), Art. 308; PRA PS1/26 Art. 306; BCBS CRE54.14, CRE54.15, CRE54.16.
  • Art. 232 life-insurance spec ↔ output-column cross-reference + worked example (docs/specifications/basel31/credit-risk-mitigation.md): the B31 CRM spec described the Art. 232 7-tier insurer-RW → secured-portion-RW mapping (20% / 35% / 70% / 150%) and the F-IRB LGDS=40% rule but did not link these mechanics to the engine output columns life_ins_collateral_value / life_ins_secured_rw produced by engine/crm/life_insurance.py::compute_life_insurance_columns and consumed during SA blending by lf.sa.apply_life_insurance_rw_mapping(). The previous stale !!! warning "Output-column naming documented separately" admonition (referencing the now-closed D3.53 / D2.48) is replaced with (a) a new #### Spec ↔ Output-Column Cross-Reference subsection — a 5-row table mapping each Art. 232 mechanic to the engine column, default-value behaviour, and engine/crm/life_insurance.py / engine/sa/namespace.py line numbers — and (b) a new #### Worked Example subsection — a fully traced 6-step calculation using a 30% insurer SA RW (SCRA Grade A enhanced, Art. 121(5)) → 35% secured-portion RW (Art. 232(3)(b)) → blended 0.61 RW = GBP 610,000 RWA versus GBP 1,000,000 unmitigated. Closes DOCS_IMPLEMENTATION_PLAN.md D4.86. Ref: PRA PS1/26 App 1 Art. 232(A1), (2)(a), (2)(b), (3)(a)–(d); Art. 121(5); Art. 200(b), Art. 212(2); Art. 233(3)–(4).
  • CRR Art. 129(5) covered bond unrated-derivation framework boundary clarified (docs/specifications/crr/sa-risk-weights.md): the CRR covered bond unrated-derivation section previously documented the four sub-paragraphs (a)–(d) without flagging that the shared COVERED_BOND_UNRATED_DERIVATION dict in crr_risk_weights.py carries 7 entries (3 of which — 30%, 40%, 75% institution RWs — derive from PS1/26 SCRA Grade A/B and CQS 2 ECRA paths that do not exist in CRR). Practitioners reading the dict comment "CRR Art. 129(5), PRA PS1/26 Art. 129" risked treating the larger entry set as authoritative under both frameworks. New verbatim CRR Art. 129(5)(a)–(d) quote (crr.pdf p. 129) plus warning admonition explaining only four CRR institution RWs (20/50/100/150 from Art. 120 Table 3 and Art. 121 Table 5) drive Art. 129(5), producing 10/20/50/100 covered bond RWs; the 30%/40%/75% institution inputs driving PS1/26 sub-paragraphs (aa)/(ab)/(ba) (ps126app1.pdf pp. 61–62) cannot arise under CRR. Implementation-note admonition records that the dict comment reflects shared storage, not framework equivalence. Cross-links to in-page B31 covered bond changes section and basel31/sa-risk-weights.md unrated-covered-bonds section. Code-side structural fix continues under DOCS_IMPLEMENTATION_PLAN.md D3.29. Closes DOCS_IMPLEMENTATION_PLAN.md D4.62. Ref: CRR Art. 129(5); PRA PS1/26 Art. 129(5).
  • UKB CR9 row breakdown rewritten to PS1/26 Annex XXII column-a verbatim sub-classes (docs/features/pillar3-disclosures.md): the Pillar 3 disclosures page CR9 section previously listed compact row labels (institutions / corporates with SL / "other general corporates SME/non-SME") that did not match the verbatim PRA PS1/26 Annex XXII column-a sub-classes for either approach. F-IRB CR9 was missing the "Financial corporates and large corporates" row (Art. 147(2)(c)(ii) / Art. 147A driver) that already appears in CR6, leaving the two templates inconsistent for the same population. Rewritten to use the full numbered hierarchy: A-IRB rows 1.1–1.3 (corporates) and 2.1–2.7 (RRE-SME, RRE-non-SME, CRE-SME, CRE-non-SME, QRRE, Other-SME, Other-non-SME); F-IRB rows 1, 2.1 SL, 2.2 financial corporates and large corporates, 2.3–2.4 other general corporates SME/non-SME, 3 total, per ps1-26-annex-xxii-credit-risk-irb-disclosure-instructions.pdf pp. 19–20. Cross-references the CR6 H2 anchor so the F-IRB sub-class 2.2 is recognisable as the same row in both templates; Reference Documents block expanded with verified PDF page numbers (Annex XXII p. 18 paras 12–15; pp. 19–20 column a; pp. 20–22 columns bh; ps126app1.pdf Art. 147(2)(b)–(d) and Art. 147A). Plan-item observation: D4.84's "missing financial corporates and large corporates row" framing was stale relative to the current file state — that row was already present from commit 3d3b346; the residual docs gap was the absence of a CR6 cross-reference and the use of compact / non-verbatim row labels. The corresponding CR9_FIRB_CLASSES and CR9_AIRB_CLASSES gaps in src/rwa_calc/reporting/pillar3/templates.py (missing F-IRB sub-classes 2.2 and 2.4, missing A-IRB sub-class 1.3, and the seven retail sub-classes 2.1–2.7) are routed to IMPLEMENTATION_PLAN.md as a separate code-side P-coded item. Closes DOCS_IMPLEMENTATION_PLAN.md D4.84. Ref: PRA PS1/26 Annex XXII paras 12–15; column a row definitions on pp. 19–20.

[0.2.5] - 2026-05-02

Fixed

  • Collateral CRM now nets against CCF=100% E per CRR Art. 223(4) / PS1/26 Art. 223(4) (engine/crm/processor.py::_initialize_ead, engine/crm/collateral.py::_apply_collateral_unified, engine/ccf.py::_compute_ead): the CRM stage previously netted collateral against ead_gross = on_bal + nominal × CCF (post-CCF) for off-balance-sheet exposures, both in the FIRB LGD* formula and in the SA ead_after_collateral reduction. Both CRR Art. 223(4) and PRA PS1/26 Art. 223(4) explicitly require the opposite: when computing the exposure value E used for CRM (financial collateral via FCCM, other eligible collateral via the Foundation Collateral Method, and the C*/C** threshold tests in Art. 230), off-balance-sheet items shall be valued at 100% of nominal, overriding the regulatory CCF. The actual CCF re-couples afterwards: under SA per Art. 228(1) the CCF is applied to E*, while under FIRB the actual CCF stays in EAD but is absent from the LGD* ratio. Phil's worked example (100m off-BS FIRB, 75% CCF, 50m cash, senior unsecured): pre-fix code produced LGD* = 15%, regulation requires LGD* = 22.5% — code under-stated FIRB LGD by 7.5pp on this exposure. Under SA the same shape under-stated EAD: 100m off-BS, 50% CCF, 30m cash gave EAD = 20m; regulation requires (100−30) × 0.5 = 35m. Fix introduces two new columns on the exposures frame computed in _initialize_ead: ead_for_crm = on_bs_for_ead + nominal_after_provision (CCF=100% basis, used by all CRM-ratio sites) and effective_ccf = ead_pre_crm / ead_for_crm (used to recouple the actual CCF in SA's post-collateral EAD). ead_gross (post-CCF) is kept unchanged in the schema — multiple downstream sites legitimately need the actual EAD that flows through to RWA. Migrated sites: collateral pro-rata weights for facility / counterparty pools (_build_exposure_lookups in processor.py and _apply_collateral_unified in collateral.py), _generate_netting_collateral allocation, Art. 230 RE 30% threshold cap, the Art. 231 sequential-fill waterfall denominator, the FIRB LGD* formula numerator and denominator, collateral_coverage_pct, and the SA ead_after_collateral formula (rewritten from (ead_gross − collateral_adjusted_value)+ to (ead_for_crm − collateral_adjusted_value)+ × effective_ccf per Art. 228(1)). Pure on-BS rows are unaffected (ead_for_crm == ead_gross by construction). FIRB / Slotting ead_after_collateral continues to equal ead_gross (collateral modifies LGD, not EAD, under those approaches). AIRB is unaffected (uses own LGD estimate). The CCF stage in engine/ccf.py now persists the on-BS portion of EAD as a column on_bs_for_ead (previously a local variable), enabling _initialize_ead to compose ead_for_crm without recomputing the drawn / interest / provision adjustments. Defensive fallbacks added to apply_collateral, _apply_collateral_unified, _build_exposure_lookups, and _generate_netting_collateral so direct unit-test callers that hand-build exposures frames with ead_gross only continue to work (default ead_for_crm = ead_gross, effective_ccf = 1.0 — semantically correct for pure on-BS rows). New unit tests in tests/unit/crm/test_ead_for_crm.py (8 tests): pure on-BS, pure off-BS independent of CCF (SA + FIRB), mixed on-BS+off-BS row blended effective_ccf, provision-on-nominal reduces ead_for_crm, zero-nominal divide-by-zero guard, and two end-to-end pins through the full CRM processor — Phil's worked FIRB cash example asserting lgd_post_crm == 22.5%, and the SA off-BS analogue asserting ead_after_collateral == 35m. Out of scope for this fix (tracked as follow-ups): guarantees Art. 235 / 236 (same regulatory shape — E with CCF=100% override — but a separate code surface in engine/crm/guarantees.py); life insurance under Art. 232 (routes via Art. 235 / 236, not FCCM/FCM, so falls under the guarantees follow-up); AIRB CRM under Art. 191A LGD Adjustment Method (uses own-estimate LGDs, not the FCCM/FCM pipeline). Full suite: 4717 unit + 336 contracts/integration + 497 acceptance pass — no existing acceptance fixture combined an off-BS exposure with collateral so no expected-outputs JSON shifted; the new behaviour is only triggered when both conditions are met. Spec updated in docs/specifications/crr/credit-risk-mitigation.md (new "Exposure value for CRM purposes (Art. 223(4))" section with worked cash and SA off-BS examples) and docs/architecture/pipeline.md (processing-order section now describes the two-EAD-bases pattern). Ref: CRR Art. 111(3), Art. 223(3)–(5), Art. 228(1)–(2), Art. 230, Art. 231 (extracted from docs/assets/crr.pdf p.110, 219, 226–228); PRA PS1/26 Art. 166A–166C, Art. 223(4), Art. 228(1), Art. 230 (extracted from docs/assets/ps126app1.pdf p.117–120, 200–202, 208–210); BCBS CRE22.55. (a6e15b6.)

Changed

  • Version bump for PyPI release.

[0.2.4] - 2026-04-30

Added

  • Blog section + main-navigation link: new docs/blog/ series live on the Zensical site, with a Blog link added to the primary site navigation. First two posts published — including "Post 2 — The Pipeline" walking through the immutable bundle pipeline architecture. (874a510 add nav link; PRs #289 / #290, commits cceaee4 / 7fe91fb.)

Changed

  • MOF undrawn now emits per-sub waterfall rows by descending CCF (engine/hierarchy.py::_calculate_facility_undrawn, new _expand_mof_facility_undrawn): replaces the prior worst-case single-CCF emission, where the MOF parent's full undrawn headroom flowed at the highest descendant CCF. Each MOF parent now emits one facility_undrawn row per committed descendant sub-facility with positive headroom, allocated by waterfall: subs are sorted by descending SA CCF (deterministic tie-break: risk_type then facility_reference) and filled in order, capped per-sub at max(0, sub_limit - sub_drawn) and globally at parent_headroom. When sub-limits sum below the parent's limit, a residual row is emitted at the parent's own risk_type and counterparty_reference. Each split row carries the sub's risk_type and counterparty_reference natively, so the prior _derive_facility_share_counterparty riskiest-CP override is now skipped on MOF parents (it still applies to non-MOF facilities). Per-sub drawn netting (loans + contingents directly mapped to a sub net only that sub's headroom — not the parent's) makes the waterfall reflect actual sub-level utilisation rather than rolling everything up to root before allocating. Uncommitted (committed=False) sub-facilities are skipped entirely from the waterfall — they consume no parent headroom, mirroring the existing parent-level rule that an unconditionally cancellable line carries no commitment EAD. Worked example for the user request that motivated the fix: parent £100m, sub_01 £60m @ MR (50% CCF), sub_02 £60m @ MLR (20% CCF). Old behaviour: 1 row £100m @ 50% → £50m EAD. New behaviour: 2 rows £60m @ 50% + £40m @ 20% (capped) → £38m EAD. Output schema: exposure_reference = "{parent}_UNDRAWN_{sub}" for waterfall rows, "{parent}_UNDRAWN_RESIDUAL" for the residual; source_facility_reference = parent on every row so facility-level collateral allocation and downstream rollups still group by the MOF parent; mof_risk_type_source records the sub each row came from (null on the residual). The retired private method _derive_mof_risk_type is replaced by _expand_mof_facility_undrawn. Tests: 8 new unit tests in TestMOFAndFacilityShare (waterfall caps at parent limit, B31 CCF table, per-sub drawn netting, fully-drawn sub drops out, sub-limits-under-parent residual, three-subs mixed CCF, per-sub counterparty, all-undrawn per-sub counterparties, uncommitted sub skipped); 5 existing MOF tests updated to assert per-sub split rows; 4 multi-level facility undrawn tests updated to assert sum-across-rows equals parent headroom. Full unit suite: 4,692 passed; acceptance: 497 passed (1 skipped); contracts + integration: 317 passed. No acceptance goldens shifted because no existing fixture combined a MOF with sub-facilities of differing risk_types. Spec updated in docs/specifications/common/hierarchy-classification.md (new "Multi-Option Facility (MOF) Waterfall Allocation" subsection with two worked examples and edge-case enumeration). Ref: CRR Art. 111 (SA CCFs), Art. 166 (off-balance EAD); PRA PS1/26 Art. 111 Table A1, Art. 166C. (PRs #292 / #293.)
  • Version bump for PyPI release.

Fixed

  • CRR F-IRB CCF over-statement for issued OBS items — implement Art. 166(10) fallback (engine/ccf.py::_firb_ccf_for_col): the CRR F-IRB CCF helper previously blanket-applied 75% to every MR / MLR / OC row except the Art. 166(8)(b) short-term trade-LC carve-out, treating Art. 166(8)(d) as the catch-all. CRR Article 166 in fact has two F-IRB CCF clauses: Art. 166(8) prescribes bespoke CCFs for the named commitment types (UCC credit lines, short-term trade LCs, revolving purchased-receivables UCC, "other credit lines / NIFs / RUFs"), and Art. 166(10) is a self-contained residual fallback for off-balance sheet items not in scope of paragraphs 1–8 (100% FR / 50% MR / 20% MLR / 0% LR by Annex I category). The engine now distinguishes the two via a new boolean schema flag is_obs_commitment: True (Art. 166(8)(d) commitment-style — credit lines, NIFs, RUFs) routes to 75%; False (Art. 166(10) issued OBS item — performance bonds, warranties, tender bonds, non-credit-substitute documentary credits / standby LCs, shipping guarantees, customs/tax bonds, self-liquidating documentary credits) routes to the Annex I fallback (50% MR / 20% MLR). The Art. 166(8)(b) is_short_term_trade_lc carve-out continues to win over both buckets (it is a more specific Art. 166(8)(b) rule). Schema additions (data/schemas.py): is_obs_commitment defaults to True on FACILITY_SCHEMA (a facility row is, by construction, a commitment / credit line) and False on CONTINGENTS_SCHEMA (a contingent is, by construction, an issued OBS item); callers may override per row (e.g., a contingent that genuinely represents a NIF/RUF can be tagged True). The hierarchy stage (engine/hierarchy.py::_unify_exposures and _calculate_facility_undrawn) projects the column with the per-source-table default. The CCF calculator's _ensure_columns defaults is_obs_commitment=True as a final fallback for unit-test callers that bypass hierarchy, preserving all existing direct-API behaviour. Items affected (over-stated CCF before the fix): MR issued items — performance bonds, tender bonds, advance-payment guarantees, warranties, non-self-liquidating documentary credits, non-credit-substitute irrevocable standby LCs (75% → 50%, Art. 166(10)(b)); MLR issued items — self-liquidating documentary credits, shipping guarantees, customs and tax bonds (75% → 20%, Art. 166(10)(c)). Items unchanged: FR (100% under both Art. 166(8) general and Art. 166(10)(a)); LR UCC (0% under both Art. 166(8)(a) and Art. 166(10)(d)); OC commitments (75% via Art. 166(8)(d)); MLR with is_short_term_trade_lc=True (20% via Art. 166(8)(b)). Fix is CRR-only — Basel 3.1 Art. 166C already aligns F-IRB CCFs to SA Table A1 (50% MR, 20% MLR) so the over-statement only existed in the is_basel_3_1=False branch. Test coverage: 7 new unit tests in tests/unit/test_ccf.py (TestFIRBArt16610Fallback) covering MR-issued@50%, MR-commitment@75%, MLR-issued@20%, MLR-commitment@75%, MLR-issued+trade-LC@20% (carve-out priority), OC-issued@50%, and the missing-flag default; 3 new end-to-end integration tests in tests/integration/test_classifier_to_crm.py (test_crr_d_ccf7_firb_mr_contingent_falls_to_50_via_art_166_10, test_crr_d_ccf8_firb_mlr_contingent_falls_to_20_via_art_166_10, test_firb_mr_facility_undrawn_keeps_75_via_art_166_8d) that drive data through HierarchyResolverExposureClassifierCRMProcessor and confirm the per-source-table default routing. Stress / benchmark fixture data updated (tests/acceptance/stress/conftest.py, tests/benchmarks/data_generators.py) and the test fixture builders (tests/fixtures/exposures/facilities.py, tests/fixtures/exposures/contingents.py) gain an optional is_obs_commitment field. Module docstring and CCFCalculator class docstring updated to cite Art. 166(8)(a)/(b)/(d) and Art. 166(10), and to correct the prior Art. 166(9) misattribution for short-term trade LCs (Art. 166(9) is in fact the lower-of-two-CCFs rule for overlapping commitments, already handled via underlying_risk_type). Spec updated in docs/specifications/crr/credit-conversion-factors.md (new "F-IRB CCFs by source" tables splitting Art. 166(8)(d) credit lines from Art. 166(10) issued items; new CRR-D.CCF7 / CRR-D.CCF8 scenarios). Full suite: 5,531 passed (1 skipped, 11 deselected) — no acceptance goldens shifted because no existing CRR FIRB acceptance fixture combined an FIRB-classified counterparty with an MR or MLR contingent that previously expected 75%. Ref: CRR Art. 166(8)(a)–(d), Art. 166(10) (extracted verbatim from docs/assets/crr.pdf). (PR #291.)

[0.2.3] - 2026-04-28

Added

  • Oracle test suite scaffold (PR #286, 301f77f): new tests/oracle/ directory containing a small set of hash-locked, hand-derived expected values for SA / IRB scenarios that act as a third-party-friendly oracle independent of the existing acceptance goldens. Each fixture row carries a SHA-256 hash so any drift in inputs or expected outputs is detected as a hash mismatch rather than a silent recomputation. Initial scaffolding only; no production-engine changes.

Changed

  • Facility undrawn generation now respects the committed flag (engine/hierarchy.py::_calculate_facility_undrawn): the dormant committed Boolean on FACILITY_SCHEMA is now consulted by the hierarchy resolver. Facilities with committed=False no longer generate a synthetic facility_undrawn exposure row — an unconditionally cancellable line carries no irrevocable lending commitment, so the bank holds no commitment EAD / RWA against the unused headroom (consistent with the regulatory intuition under CRR Art. 166 and PRA PS1/26 Art. 166C). Loans and contingents already mapped to such facilities are completely unaffected: they remain independent exposure rows with normal counterparty / parent rollup, collateral allocation, and CCF treatment, because they are already on-balance-sheet (loans) or carry their own off-balance EAD (contingents). The schema default for committed was flipped from False to True (data/schemas.py:69) so legacy callers and fixtures that omit the field continue to generate undrawn rows as before — uncommitted is now the explicit, opted-in case. Null committed values are also defensively treated as committed. New unit tests in tests/unit/test_hierarchy.py: test_uncommitted_facility_suppresses_undrawn_row (no row generated for committed=False), test_committed_null_treated_as_committed (null defaults to committed), test_uncommitted_facility_loans_still_flow and test_uncommitted_facility_contingents_still_flow (mapped loans/contingents flow through _unify_exposures unchanged, no facility_undrawn synthetic row in the unified output). The mislabeled test_facility_uncommitted_lr_risk_type (which actually used committed=True) was renamed to test_facility_lr_risk_type. No CCF or downstream calculator changes — the committed gate simply stops feeding suppressed rows into the existing CCF pipeline. Full suite: 5,521 passed (1 skipped, 11 deselected) — no acceptance goldens shifted because the only fixture that previously held committed=False (FAC_CORP_UNCOMMIT_001 in tests/fixtures/exposures/facilities.py) was already declared with the comment "0% CCF for unconditionally cancellable" and not asserted against in any expected-output JSON. Docs updated in docs/data-model/input-schemas.md (facility committed row) and docs/architecture/components.md (HierarchyResolver method table). Ref: CRR Art. 166 (off-balance-sheet item EAD); PRA PS1/26 Art. 166C (Basel 3.1 CCF treatment for unconditionally cancellable commitments). (PR #288.)
  • Version bump for PyPI release.

Docs

  • README accuracy refresh (PR #287, fce582e): refreshed the project README to match the current state of the codebase — updated test counts, the supported exposure classes table, and the Basel 3.1 implementation status section.

[0.2.2] - 2026-04-27

Added

  • Multiple Option Facility (MOF) and Facility Share support in the undrawn allocation pipeline (engine/hierarchy.py::_calculate_facility_undrawn): two product patterns that the facility/undrawn pipeline previously did not honour are now applied as overrides on the parent facility's undrawn exposure row, without requiring schema changes. MOFs: any facility with at least one child_type='facility' row in facility_mappings is now treated as a Multiple Option Facility — the parent's undrawn risk_type is overridden to the descendant sub-facility risk_type whose SA CCF (via engine/ccf.py::sa_ccf_expression, frame-aware for CRR / PRA PS1/26 Table A1) is highest, so the parent's undrawn EAD reflects the worst-case off-balance commitment among its components rather than the parent's own (often LR / 0%) risk_type. Tie-break on alphabetical lowercase risk_type then alphabetical descendant facility_reference for full reproducibility. The new private method _derive_mof_risk_type() walks the existing _build_facility_root_lookup() output to collect descendants at any depth. Facility Shares: when the descendant loans / contingents under a facility reference more than one distinct counterparty_reference, the undrawn is now allocated to the riskiest member by SA-equivalent risk weight rather than to the facility's own counterparty_reference. The new private method _derive_facility_share_counterparty() collects the union of distinct counterparties from the descendant loan/contingent set (using the existing _resolve_to_root_facility() helper) and joins each candidate to the resolved counterparty lookup to read entity_type and cqs. A new module-level helper _preview_sa_rw_expr() maps entity_type to the matching SA risk weight table (CENTRAL_GOVT_CENTRAL_BANK_RISK_WEIGHTS, frame-aware INSTITUTION_RISK_WEIGHTS_*, CORPORATE_RISK_WEIGHTS, RETAIL_RISK_WEIGHT, MDB_RISK_WEIGHTS_TABLE_2B, HIGH_RISK_RW) and returns the RW for the candidate's CQS — deliberately SA-only so the preview avoids the circular dependency with the classifier's IRB approach gating; the chosen counterparty still flows through the full classifier and SA/IRB pipeline downstream so the preview is non-binding. Per facility, max RW wins; tie-break on higher CQS then alphabetical counterparty_reference. Both overrides are skipped no-ops when their inputs are trivial: a facility with no child_type='facility' rows is not a MOF (parent risk_type unchanged); a facility with ≤1 distinct member counterparty is not a share (counterparty unchanged). Two new audit columns flow on the facility_undrawn exposure rows for traceability — original_counterparty_reference (the facility's own counterparty_reference before the share override) and mof_risk_type_source (the descendant facility reference whose risk_type won the max-CCF tie). The _calculate_facility_undrawn() signature gains optional counterparty_lookup and config parameters; _unify_exposures() plumbs the same config through from resolve() so the framework switch reaches both helpers. FACILITY_MAPPING_SCHEMA (data/schemas.py) is unchanged. New unit-test class TestMOFAndFacilityShare in tests/unit/test_hierarchy.py covers six scenarios: MOF parent inherits max-CCF child risk_type under CRR; MOF picks OC over LR under Basel 3.1 (40% beats 10%); plain hierarchies with only child_type='loan' rows are not MOFs (parent risk_type preserved); facility share with three distinct corporate counterparties at CQS 1/3/5 allocates undrawn to CQS 5 (RW 150%); single-member facility is unchanged; combined MOF + share scenario applies both overrides independently. Full suite: 5,484 passed (1 skipped, 4 deselected) — no acceptance goldens shifted because no existing fixture combines a multi-CP facility share or a MOF with mixed-CCF children. scripts/arch_check.py, ruff check, and ty check all clean. Docs updated in docs/architecture/components.md (new key-features bullets and method rows in the HierarchyResolver section). Ref: CRR Art. 111 (SA CCFs); PRA PS1/26 Art. 111 Table A1; CRR Art. 112-122 (SA risk weights for the preview lookup). (PR #285.)

Changed

  • Version bump for PyPI release.

Docs

  • Doc batch (D2.* items, two /next-docs waves, batches b39d7e1 and bdd0f31): eight regulatory-doc items landed in this window without code changes — Art. 143(2A)/(2B) A-IRB permission conditions (05660d0); Art. 154/157/158/160-166A/184 purchased-receivables pool & dilution risk (2ae4bf1); Art. 234 tranched coverage with P1.30(e) cross-ref (dd488c7); CRE loan-split threshold corrected from "60% LTV" to "55% of property value" (0d5a58d); Art. 119(2)/(3) CRR national-currency short-term institution coverage (1c4a339); OF 09.01 missing rows 0075/0085/0095/0141-0143/0160/0170 (fb0a5d2); plus two IMPLEMENTATION_PLAN.md ticks (f83bc84, dd0f317). All edits are docs-only.

[0.2.1] - 2026-04-27

Added

  • is_airb_model_collateral flag on the collateral table to prevent AIRB collateral double-counting (CRR Art. 181 / Basel 3.1 Art. 169A): under A-IRB, the firm's own modelled LGD already reflects the credit-risk-mitigating effect of any collateral incorporated in the model, so allocating that same collateral to non-AIRB exposures of the counterparty supervisorily double-counts it. New optional Boolean column on COLLATERAL_SCHEMA (default False) that asserts the row's collateral has been used to construct the firm's internal LGD model. The CRM allocator (engine/crm/collateral.py::_apply_collateral_unified) is now pool-aware: (1) exposures are partitioned at the start of apply_collateral into an AIRB pool (rows where the modelled LGD is preserved by CRM — approach == AIRB AND not falling back to the supervisory formula under Foundation election or Art. 169B insufficient-data) and a non-AIRB pool (FIRB / SA / Slotting and AIRB rows that use the formula); (2) _build_exposure_lookups in engine/crm/processor.py now emits pool-specific facility / counterparty EAD aggregates (_ead_facility_airb / _ead_facility_non_airb, _ead_cp_airb / _ead_cp_non_airb); (3) the group-by aggregation splits each metric into _n (unflagged collateral) and _a (flagged) variants via filter on is_airb_model_collateral; (4) pro-rata weights _fw_n / _fw_a / _cw_n / _cw_a bake in the pool-match gate so non-matching pools contribute zero. Behaviour: flagged collateral routes only to AIRB-pool rows (facility / counterparty pro-rata over AIRB pool only); unflagged facility / counterparty collateral routes only to non-AIRB rows (AIRB pool excluded from the pro-rata base — non-AIRB rows now absorb 100% of unflagged counterparty / facility collateral that previously was wasted on AIRB rows whose modelled LGD ignores it); direct unflagged collateral is unchanged (1:1 to the named exposure). Direct flagged collateral pledged onto a non-AIRB exposure emits new CRM006 (ERROR_AIRB_MODEL_COLLATERAL_MISDIRECTED) data-quality warning via find_misdirected_airb_model_collateral and is given zero allocation. The inline _airb_uses_formula expression in _apply_collateral_unified was lifted into a top-level helper airb_lgd_preserved_expr(config, is_basel_3_1, schema_names) reused by both the LGD branch and the new pool-membership tagging. _apply_collateral_unified is defensive about missing pool-aware columns (test helpers that supply only _fac_ead_total / _cp_ead_total are backfilled to legacy behaviour: AIRB total = 0, non-AIRB total = full total). Schema, processor lookups, and pro-rata logic are all backward-compatible with fixtures that don't set the column — ensure_columns(COLLATERAL_SCHEMA) in the loader (engine/loader.py:88) fills the default. Test coverage: new tests/unit/crm/test_airb_model_collateral_flag.py (7 tests across schema, unflagged-counterparty AIRB-exclusion, flagged-counterparty user scenario loan_1/loan_2/loan_3, CRM006 misdirection emission and silence, homogeneous-FIRB backward-compat); benchmark data generator (tests/benchmarks/data_generators.py) updated to include the new column. Full suite: 5,505 passed, 1 skipped — no acceptance goldens shifted because no existing scenario combined mixed AIRB / non-AIRB exposures with counterparty / facility-level collateral. Docs updated in docs/specifications/crr/credit-risk-mitigation.md (new "Pool-Aware Pro-Rata for AIRB Mixes" subsection under Multi-Level Collateral Allocation) and docs/specifications/basel31/credit-risk-mitigation.md ("AIRB own-LGD anti-double-counting" bullet under Art. 191A(2)(d) anti-double-counting rules). Ref: CRR Art. 181; Basel 3.1 Art. 169A, Art. 191A(2)(d); CRE36.34-36. (PR #280.)

Changed

  • Version bump for PyPI release.

Internal

  • Experimental Claude agent-teams configuration for loop.sh (PRs #282 / #283, commits 0dcd821 / 94311fb / 09a4b97 / fbf784a): adds /next-docs and /next-items parallel batch orchestrators, a Phase 1 docs-implementation team, and full coverage of all four loop.sh modes. Tooling-only; no engine or test changes.

[0.2.0] - 2026-04-26

Changed

  • Promote RESIDENTIAL_MORTGAGE / COMMERCIAL_MORTGAGE from uppercase magic strings to first-class ExposureClass enum members: the SA real-estate loan-splitter (engine/re_splitter.py) labels its secured child row's exposure_class with one of two values that, until now, lived only as uppercase string literals (_SECURED_TARGET_RESIDENTIAL = "RESIDENTIAL_MORTGAGE" and _SECURED_TARGET_COMMERCIAL = "COMMERCIAL_MORTGAGE" in engine/classifier.py:158-159), with an explicit code comment noting they "are not in the ExposureClass enum (only RETAIL_MORTGAGE is)". The exposure-class enum convention is lowercase string values (e.g. "retail_mortgage", "corporate"); the loan-splitter therefore broke that convention every time it materialised a non-retail residential or commercial mortgage row. Behavioural impact: none (the SA RW expressions in engine/sa/namespace.py uppercase exposure_class via _uc = pl.col("exposure_class").str.to_uppercase() before any substring match, so both cases route identically). Hygiene impact: the loan-splitter outputs are now indistinguishable from any other classifier output. Changes: (1) add ExposureClass.RESIDENTIAL_MORTGAGE = "residential_mortgage" and ExposureClass.COMMERCIAL_MORTGAGE = "commercial_mortgage" in domain/enums.py with docstrings citing CRR Art. 125 / Art. 126 and PRA PS1/26 Art. 124F / Art. 124H; (2) replace the _SECURED_TARGET_* magic-string assignments in engine/classifier.py:158-159 with ExposureClass.<X>.value references and rewrite the surrounding comment to point at the new enum members; (3) update engine/re_splitter.py to import ExposureClass and replace the literal "COMMERCIAL_MORTGAGE" filter at line 503 with ExposureClass.COMMERCIAL_MORTGAGE.value; (4) update all four target_class= initialisers in data/tables/re_split_parameters.py (RE_SPLIT_PARAMS_CRR_RESIDENTIAL, RE_SPLIT_PARAMS_CRR_COMMERCIAL, RE_SPLIT_PARAMS_B31_RESIDENTIAL, RE_SPLIT_PARAMS_B31_COMMERCIAL) to use enum-value references via a new from rwa_calc.domain.enums import ExposureClass (the existing arch-check allowlist permits data/tables/ to import from domain/enums, mirroring the pre-existing CQS import in crr_risk_weights.py); (5) update test fixtures and assertions in tests/unit/test_real_estate_splitter.py, tests/unit/test_b31_re_junior_charges.py, tests/integration/test_re_split_pipeline.py, tests/unit/test_b31_sa_risk_weights.py, and tests/unit/crr/test_crr_sa.py to use the lowercase enum values, matching what the production classifier now emits. Intentionally not changed: (a) the uppercase "RESIDENTIAL_MORTGAGE" join key in the crr_risk_weights.py:458 LTV-split lookup table — it is joined against _lookup_class which is uppercased via .str.to_uppercase(), so it stays uppercase to preserve the case-insensitive routing behaviour; (b) the uc.str.contains("MORTGAGE", literal=True) | uc.str.contains("RESIDENTIAL", literal=True) | uc.str.contains("COMMERCIAL", literal=True) | uc.str.contains("CRE", literal=True) substring matches in engine/sa/namespace.py:_b31_append_real_estate_branches and _crr_append_real_estate_branches — they cover both the canonical enum values and a real non-enum user-input path ("CRE" appears as a direct exposure_class value in tests/expected_outputs/crr/expected_rwa_crr.json:213 for the LOAN_CRE_001 acceptance fixture). Switching to is_in([ExposureClass.X.value.upper(), …]) would lose that fallback unless the list reintroduced "CRE" as a magic string, defeating the refactor's purpose. Verification: 70 splitter-focused unit + integration tests pass (tests/unit/test_real_estate_splitter.py, tests/unit/test_b31_re_junior_charges.py, tests/integration/test_re_split_pipeline.py); 330 SA / classifier / data-boundary tests pass (tests/unit/test_b31_sa_risk_weights.py, tests/unit/crr/test_crr_sa.py, tests/unit/test_classifier.py, tests/contracts/test_data_layer_boundary.py); scripts/arch_check.py clean; the broader acceptance + contracts + integration sweep was also run, and the failure count is identical to the parent commit baseline (106 acceptance failures, all pre-existing — hierarchy_resolver errors in FIRB/AIRB/Slotting/Provisions scenarios that don't touch the SA real-estate path). Ref: CRR Art. 125, Art. 126; PRA PS1/26 Art. 124F, Art. 124H.

Fixed

  • SME supporting factor E* aggregated across group of connected clients (CRR Art. 501): the SA SME supporting factor previously evaluated the EUR 1.5m / GBP-equivalent E* exposure threshold on a per-counterparty basis, ignoring Art. 4(1)(39) connected-client aggregation. The check now aggregates across the full group of connected clients, matching the regulatory definition of the obligor used elsewhere in the pipeline. Acceptance scenarios CRR-F* updated; new test class in tests/unit/test_supporting_factors.py. (PR #279)

Docs

  • Clarify counterparty scope of the CRR Art. 125 35% / 75% residential mortgage RW: three doc updates document that CRR Art. 125 is not restricted to retail individuals — any exposure secured by qualifying residential property may receive the 35% secured / residual-counterparty-RW split, contingent on the Art. 125(2) qualifying conditions (value/repayment not materially dependent on borrower credit quality or property cash flows; Art. 208 / Art. 229(1) valuation). The calculator routes individuals via RETAIL_MORTGAGE (in engine/classifier.py when is_mortgage=True and cp_entity_type=="individual") and non-retail counterparties via RESIDENTIAL_MORTGAGE (through the SA real-estate loan-splitter engine/re_splitter.py); both paths apply the same Art. 125 split. (1) docs/user-guide/exposure-classes/retail.md — replaced the misleading "CRR uses a flat 35% (LTV ≤ 80%) or 75% (LTV > 80%)" sentence with the correct split treatment and added a pointer to the loan-splitter for non-retail RRE; (2) docs/framework-comparison/key-differences.md — added a "Counterparty scope of the CRR 35%/75% column" callout under the Residential RE General loan-splitting comparison table noting that the CRR column is regime treatment, not a retail-only label; (3) docs/specifications/crr/sa-risk-weights.md — extended the "Residential Mortgage Exposures (CRR Art. 125)" section with the Art. 125(2)(a)–(d) qualifying-conditions list (mirroring the existing Art. 126(2) list) and a "Counterparty scope" info admonition mapping the two routing buckets and noting that the calculator infers the qualifying-condition gate from the is_mortgage flag rather than independently verifying (a)–(c). No code changes.

[0.1.67] - 2026-04-25

Fixed

  • Guarantor rating routing now beneficiary-aware (CRR Art. 161(3) / Basel 3.1 CRE22.70-85): guarantor substitution previously chose between the guarantor's internal PD and external CQS based purely on the guarantor's own properties — an SA exposure could end up routed through IRB parameter substitution, and an IRB exposure under CRR was always forced through SA RW substitution because parameter substitution was gated on config.is_basel_3_1. Now the routing is keyed on the beneficiary's approach: IRB beneficiaries use the guarantor's internal PD when available; SA beneficiaries always use external CQS. The F-IRB supervisory LGD used in PD substitution and EL blending now tracks the active framework (0.45 CRR / 0.40 Basel 3.1) instead of being hard-coded. (PR #277)

Docs

  • Refresh docs/data-model/input-schemas.md to match src/rwa_calc/data/schemas.py: the input schema reference had drifted ~30 columns behind the source of truth. Added documentation for effective_maturity (CRR Art. 162(3) / PS1/26 numeric M override that bypasses the 1-year floor) on Facility / Loan / Contingent; the Art. 110A due-diligence override fields (due_diligence_performed, due_diligence_override_rw); A-IRB modelled-EAD and unsecured-LGD fields (ead_modelled, lgd_unsecured, has_sufficient_collateral_data); maturity-floor and SFT flags (has_one_day_maturity_floor, is_sft); the is_payroll_loan 35% retail RW flag; counterparty classification flags (is_natural_person, is_social_housing, is_financial_sector_entity, is_ccp_client_cleared, borrower_income_currency, local_currency, sovereign_cqs, institution_cqs); the Basel 3.1 real-estate collateral fields (is_qualifying_re, original_maturity_years, rental_to_interest_ratio, liquidation_period_days, qualifies_for_zero_haircut, is_main_index, insurer_risk_weight, credit_event_reduction); guarantee credit-derivative fields (protection_type, includes_restructuring); specialised-lending project_phase; equity / CIU look-through fields (ciu_approach, ciu_mandate_rw, ciu_third_party_calc, fund_reference, fund_nav); and an entirely new CIU Holdings schema section documenting the look-through input (Art. 132(3)). Also corrected several Required: Yes cells that were stale relative to the ColumnSpec(required=...) source of truth (Counterparty, Facility, Loan, Contingent — only the reference IDs and entity_type are loader-required). No code changes.

  • Comprehensive regulatory documentation refresh (D2.34–D2.73): ~30 documentation commits land verbatim citations and clarifications across A-IRB, CRM, slotting, real-estate, SCRA, covered bonds, and SL specs. Highlights: Art. 124B underwriting-standards obligation (D2.60); Art. 124D valuation requirements (D2.43, D2.62); Art. 124E(5)/(7) RE reassessment obligations (D2.56); Art. 122(7)-(8) output-floor election for unrated corporates (D2.46); Art. 129(4A) covered-bond due-diligence CQS step-up (D2.34); Art. 138(1)(g) + Art. 139(6) implicit-support higher-of rule (D2.49); Art. 153(5)(c)-(f) slotting column-assignment rules (D2.68); Art. 161(1)(e)/(f)/(g) purchased-receivables trigger recast (D2.51); Art. 162(2A)(k) revolving maturity precedence (D2.52); Art. 232(3) life insurance derivation (D2.48); Art. 237/239 CRM maturity-mismatch wording aligned to PS1/26 (D2.55); Art. 121(1)(a)/(1)(b) SCRA disclosure barring ladder (D2.54); BEEL substitution for A-IRB defaulted exposures (D2.67); retail A-IRB LGD floor reconciliation (D2.50); removal of Art. 119(2)/(3) national-currency preferential in B3.1 (D2.73). Misc fixes: USD 100bn LFSE threshold (D2.53); CRR FCSM Art. 222 paragraph attributions (D1.47, D2.63); LFSE citation to Art. 142(1)(4) (D1.49); MDBs/IOs in Art. 147A(1)(a) SA-only scope (D2.38). No code changes. (PR #275)


[0.1.66] - 2026-04-24

Added

  • effective_maturity override (CRR Art. 162(3) / PRA PS1/26): new optional column on Facility / Loan / Contingent that lets firms supply a numeric maturity M directly, bypassing the 1-year maturity floor and the date-derived calculation for IRB exposures. When populated, it takes precedence over derived maturity in correlation, K, and maturity-adjustment formulas. Documented in docs/data-model/input-schemas.md. (PR #274)

Fixed

  • IRB / Slotting exposures secured by real estate no longer receive the 0.15 retail-mortgage correlation (CRR Art. 153 / CRE31.11): RealEstateSplitter._split_unified_frame in engine/re_splitter.py previously split every row flagged by the classifier as re_split_mode='split' (or 'whole') regardless of the row's approach, emitting a secured child row with exposure_class = RESIDENTIAL_MORTGAGE / COMMERCIAL_MORTGAGE. For FIRB / AIRB / Slotting rows, the IRB correlation expression in engine/irb/formulas.py::_correlation_expr_from_pd reads pl.col("exposure_class") and hits the str.contains("MORTGAGE") branch → pl.lit(0.15), i.e. the retail-mortgage correlation under CRR Art. 154(3). A FIRB corporate-SME exposure collateralised by residential property was therefore splitting into (a) a corporate_sme residual row with the correct supervisory-formula-with-SME-adjustment correlation and (b) a RESIDENTIAL_MORTGAGE secured row stuck at 0.15 — a regime that doesn't exist under IRB. Loan-splitting is an SA-only regulatory mechanism (CRR Art. 125/126 and PRA PS1/26 Art. 124F/H all sit in the Credit Risk: Standardised Approach Part); IRB recognises real-estate collateral via LGD (Art. 161(5) FIRB supervisory RRE floor / AIRB own-estimate LGD / Art. 230-231 funded credit protection), already handled upstream by the CRM processor's crm_alloc_real_estate allocation. Fix: gate is_split_mode and is_whole_mode on approach ∈ {standardised, equity} (a new _SA_BOUND_APPROACHES module constant backed by ApproachType enum values). Rows with approach ∈ {foundation_irb, advanced_irb, slotting} and the classifier's split flag set now fall into the pass-through bucket and retain their original exposure_class — the downstream IRB correlation formula then correctly lands on the corporate / corporate-SME / retail branches. _accumulate_split_errors is similarly gated so IRB rows do not emit SA-specific RE002 zero-cap or RE004 CRR rental-coverage warnings. When the approach column is absent (pure SA-only bundles, older test fixtures) the predicate defaults to True — existing SA-only tests continue to pass. 5 new regression tests in tests/unit/test_real_estate_splitter.py::TestSplitterApproachGate (parametrised over FIRB/AIRB/Slotting pass-through, whole-loan pass-through, SA-with-explicit-approach still splits, no spurious RE002 on IRB zero-cap rows, mixed SA+IRB batch). Full suite: 4,640 unit passed, 810 acceptance+contracts+integration passed. Ref: CRR Art. 125, Art. 126, Art. 153(1)-(4), Art. 154(3), Art. 161(5), Art. 230-231; PRA PS1/26 Art. 124F, Art. 124H.
  • Defaulted SA exposures no longer return the base class RW when non-financial collateral columns are populated (PS1/26 Art. 127): _apply_defaulted_risk_weight in engine/sa/namespace.py previously computed secured_pct = (collateral_re_value + collateral_receivables_value + collateral_other_physical_value) / ead and blended unsecured_pct × provision_rw + secured_pct × pl.col("risk_weight"). pl.col("risk_weight") at that point is the exposure's base class RW (75% for retail, 100% for unrated corporate, 35% for mortgage), so whenever non-financial collateral reached or exceeded EAD, the blended RW collapsed back to the class base — for defaulted regulatory retail with RE collateral the SA path returned 75% instead of the Art. 127(1) 100%/150%. Art. 127(2) defers to the CRM method the institution applies (Art. 191A(2)); under FCCM (the default for SA) eligible financial collateral has already reduced ead_final upstream and eligible RE is routed through class reclassification, so the post-CRM value IS the unsecured portion and no secondary split is required inside the defaulted override. Fix: drop the non-financial collateral split entirely; apply the provision-based 100%/150% to ead_final directly (CRR denominator keeps the + provision_deducted pre-provision reconstruction; B31 uses ead_final per "outstanding amount of the item or facility"). The Basel 3.1 RESI RE non-income-dependent flat-100% branch (Art. 127(3) / CRE20.88) and the HIGH_RISK precedence guard (Art. 128) are unchanged. tests/unit/test_defaulted_secured_split.py rewritten to assert the new behaviour (regulatorily-incorrect "fully secured returns base RW" cases deleted; new regression tests pin down the retail scenario). New unit tests in tests/unit/crr/test_crr_sa.py::TestDefaultedRWApplication for defaulted non-mortgage retail under both CRR and Basel 3.1. Acceptance scenario B31-K7 re-baselined (collateral columns no longer produce a blend). Spec docs/specifications/basel31/defaulted-exposures.md updated: FR-10.3 reworded to "unsecured portion determined by the CRM method"; D3.19 code-divergence warning on the B31 denominator removed (resolved); secured-portion section rewritten. Ref: PS1/26 Art. 127(1)-(3); Art. 191A(2); CRR Art. 127(1)-(2); CRE20.88-90.

[0.1.65] - 2026-04-21

Added

  • Auto-sync of config.eur_gbp_rate from the loaded fx_rates table: the pipeline now keeps the scalar EUR/GBP rate used by the IRB SME correlation formula (CRR Art. 153(4)) and the GBP equivalents of EUR regulatory thresholds (RegulatoryThresholds.crr) in step with the (EUR, GBP) row of the loaded fx_rates input. Previously these two FX mechanisms were independent: a user could load an up-to-date fx_rates.parquet and get all exposure/collateral/guarantee/provision amounts converted at e.g. 0.90 while the IRB SME correlation and the derived GBP thresholds continued to run at the default 0.8732, silently. Implementation: (1) new module src/rwa_calc/engine/fx_rate_sync.py exposes extract_eur_gbp_rate(fx_rates: pl.LazyFrame | None) -> Decimal | None — returns the rate when the table contains exactly one (EUR, GBP) row, returns None and logs WARNING "fx_rates table has N (EUR, GBP) rows; skipping eur_gbp_rate auto-sync" when multiple rows match; (2) new method CalculationConfig.with_fx_rate(eur_gbp_rate) in src/rwa_calc/contracts/config.py uses dataclasses.replace to produce a new config with both eur_gbp_rate and thresholds=RegulatoryThresholds.crr(eur_gbp_rate=...) rebuilt, so the SME turnover threshold, SME exposure threshold, retail max exposure, QRRE limit, and LFSE threshold are all re-derived at the new rate; the method is a no-op on Basel 3.1 (GBP-native per PRA PS1/26 Art. 153(4)) and a no-op when the rate is unchanged; (3) PipelineOrchestrator.run_with_data in src/rwa_calc/engine/pipeline.py calls extract_eur_gbp_rate(data.fx_rates) immediately before _ensure_components_initialized(config) and, when the derived rate differs from the caller-supplied rate, logs WARNING "eur_gbp_rate auto-sync: replacing <old> with <new> from fx_rates table" on rwa_calc.engine.pipeline and swaps the local config via with_fx_rate. New opt-out field CalculationConfig.sync_eur_gbp_rate_from_fx_table: bool = True lets callers force their passed-in rate to win regardless of the data; when False, no WARNING is emitted and the supplied rate stands. Tests: 5 contract tests (tests/contracts/test_config.py::TestCalculationConfigtest_sync_eur_gbp_rate_flag_defaults_true, test_with_fx_rate_rebuilds_thresholds, test_with_fx_rate_noop_when_rate_unchanged, test_with_fx_rate_noop_for_basel_3_1, test_with_fx_rate_preserves_post_init_derivations); 6 unit tests (tests/unit/test_fx_rate_sync.py — single/missing/None/multiple rows, reverse-direction row, Decimal precision); 4 integration tests (tests/integration/test_fx_rate_autosync.py — divergence-warns-and-replaces, same-rate no-warn, opt-out suppresses, B3.1 no-op). Documented in docs/user-guide/methodology/fx-conversion.md under a new "Auto-sync of eur_gbp_rate from the FX table" section covering the match rules, divergence warning, multiple-row skip, opt-out flag, and Basel 3.1 behaviour. Ref: CRR Art. 153(4); RegulatoryThresholds.crr at contracts/config.py:619.

Changed

  • Refactor: stage_timer logging format enhanced for clearer pipeline traces.
  • Refactor: inline sidebar theme CSS for improved styling.

Fixed

  • Retail Art. 123(c) threshold now aggregates across the full counterparty when no lending group is defined: HierarchyResolver._enrich_with_lending_group in engine/hierarchy.py previously set lending_group_total_exposure and lending_group_adjusted_exposure to 0.0 whenever lending_group_reference was null, and the classifier's fallback in _build_qualifies_as_retail_expr then compared the per-row exposure_for_retail_threshold against the EUR 1m / GBP 880k limit. A counterparty with, say, three GBP 400k loans and no lending group was therefore classified as retail even though the aggregate GBP 1.2m exposure exceeded the threshold. CRR Art. 123(c) read with Art. 4(1)(39) ("group of connected clients") and PRA PS1/26 Art. 123A require aggregation across every exposure to a single obligor — a standalone counterparty is a group-of-one. Fix: the .otherwise(0.0) branches now aggregate via .sum().over("counterparty_reference") so both totals are always populated with the connected-client figure. The now-redundant zero_lending_group_fail branch in engine/classifier.py is removed. New regression tests: tests/unit/test_hierarchy.py::TestLendingGroupAggregation::test_standalone_counterparty_aggregates_own_exposures (three-loan counterparty, 1.2m aggregate) and tests/unit/test_art123a_retail_criteria.py::TestCounterpartyAggregationWithoutLendingGroup (three cases covering above-threshold B3.1, below-threshold B3.1, and above-threshold CRR). Existing test_standalone_not_in_lending_group updated to expect the counterparty aggregate (50k) rather than 0.0. Ref: CRR Art. 123(c), Art. 4(1)(39); PRA PS1/26 Art. 123A.

[0.1.64] - 2026-04-19

Added

  • stdlib logging observability layer (rwa_calc.observability): a new cross-cutting package (src/rwa_calc/observability/) configures stdlib logging idempotently on the rwa_calc namespace logger (never root), installs a contextvars-backed correlation run_id injected onto every LogRecord, and provides stage_timer — a context manager that emits INFO "stage entered" / "stage completed" records with an elapsed_ms extra (WARNING "stage failed" on exception). Every _run_* helper in PipelineOrchestrator is wrapped with stage_timer, so each pipeline run now emits matching entry/exit records for loader, hierarchy_resolver, classifier, crm_processor, re_splitter, calculators, aggregator, and equity_calculator, all sharing one freshly-generated 12-hex-char run_id bound at run_with_data entry and cleared in the existing finally. Two output formats — "text" (human-readable) and "json" (single-line, audit-friendly with a whitelisted extras set) — are selectable via two new fields on CalculationConfig (log_level default "INFO", log_format default "text") that also flow through CreditRiskCalc(log_level=..., log_format=...) and both .crr() / .basel_3_1() factories. CreditRiskCalc.calculate() now calls configure_logging(config.log_level, config.log_format) before constructing the pipeline; the orchestrator itself does NOT call configure_logging so it remains usable in embedded contexts. Noisy third-party loggers (polars, uvicorn.access, fastapi, asyncio) are pinned to WARNING. Contract: logging is operational-only — data-quality issues remain in CalculationError, and the integration test asserts no log record's message equals any CalculationError.message in the same run. Enforcement: ruff rules G / LOG / T20 (f-string lazy-formatting, deprecated API detection, print() ban with tests/** + marimo apps exempted); scripts/arch_check.py gains check 8 (engine modules must declare logger = logging.getLogger(__name__), no print( or logging.basicConfig( — helper modules listed in LOGGER_REQUIRED_EXEMPT); tests/contracts/test_logging_contract.py asserts every stage module exports a correctly-named Logger and that observability.__all__ is stable; tests/integration/test_logging_pipeline.py runs the pipeline end-to-end and asserts entry/exit record pairs, shared run_id, distinct ids on back-to-back runs, no handler stacking, and no regulatory-error duplication. The ~19 print() calls in src/rwa_calc/ui/marimo/server.py:main() are converted to logger.info with configure_logging("INFO", "text") called at startup. New spec docs/specifications/observability.md documents the public API, record schema, levels, correlation-ID lifecycle, reference stage skeleton, enforcement layers, and anti-patterns. CLAUDE.md gains a Logging section mirroring the Error Handling section. CalculationConfig fields are listed in docs/specifications/configuration.md (FR-5.7, CONFIG-7).

Changed

  • Refactor: hoist SA risk-weight scalars and scaffold lf.sa namespace (no behavioural change).

[0.1.63] - 2026-04-19

Added

  • Real estate loan-splitter for SA exposures collateralised by property (CRR Art. 125/126, PRA PS1/26 Art. 124F/H): A new pipeline stage (engine/re_splitter.py) inserted between CRMProcessor and the calculators physically partitions a property-collateralised non-RE SA exposure into two rows — a secured row reclassified to RESIDENTIAL_MORTGAGE / COMMERCIAL_MORTGAGE capped at the regulatory secured-LTV cap, and an uncollateralised residual row that retains the original counterparty exposure class so the standard corporate / retail risk weight applies on the remainder. Both rows share a split_parent_id lineage key so downstream aggregations reconcile back to the parent exposure. Previously, a corporate / retail loan secured by eligible property collateral that was not already classified as a mortgage received the full counterparty risk weight on the entire EAD, materially overstating capital. Mechanics are identical across regimes; parameters (secured LTV cap / secured RW / prior-charge reduction / counterparty carve-outs) live in data/tables/re_split_parameters.py (re_split_parameters(is_basel_3_1=...)):
  • CRR Art. 125 (RRE): secured cap = 80% LTV, secured RW = 35%, residual at counterparty CQS RW.
  • CRR Art. 126 (CRE): secured cap = 50% LTV, secured RW = 50% — applied only when the rental coverage test (≥ 1.5× interest costs) is met (new optional input rental_to_interest_ratio on collateral). When not met, no split is applied (RE004 informational warning) and the exposure stays in its original class. Default conservative (no split) when the column is absent.
  • B3.1 Art. 124F (RRE): secured cap = 55% × property value (less prior charges per Art. 124F(2)), secured RW = 20%, residual at counterparty RW.
  • B3.1 Art. 124H(1)-(2) (CRE NP/SME): secured cap = 55% × property value, secured RW = 60%, residual at counterparty RW. Restricted to natural persons / SMEs.
  • B3.1 Art. 124H(3) (CRE other): no physical split; the whole exposure becomes a single COMMERCIAL_MORTGAGE row so the existing b31_commercial_rw_expr Art. 124H(3) branch (max(60%, min(cp_rw, Art. 124I RW))) handles it.

The split is gated by a new classifier Phase 4c (_flag_property_reclassification_candidates) that emits re_split_target_class, re_split_mode ("split" / "whole" / null), re_split_property_type, re_split_property_value, and re_split_cre_rental_coverage_met candidate columns. Income-producing real estate continues to use the existing whole-loan path (Art. 124G / Art. 124I bands); already-classified RESIDENTIAL_MORTGAGE / RETAIL_MORTGAGE / COMMERCIAL_MORTGAGE / defaulted / equity / CIU / subordinated / high-risk / covered-bond rows are excluded from the split. The downstream SA RW expressions are reused unchanged — the secured row's LTV is capped by construction at the secured-LTV threshold, so the existing b31_residential_rw_expr / b31_commercial_rw_expr / CRR _apply_residential_mortgage_rw paths produce 35% / 50% / 20% / 60% naturally, and the residual row keeps its original exposure_class and gets the corporate / retail RW. Provisions allocate pro-rata by the EAD share. New audit LazyFrame CRMAdjustedBundle.re_split_audit captures one row per parent (parent EAD, secured/residual EAD, effective cap, target class, regime). New error codes: RE001 (non-eligible RE), RE002 (zero effective cap), RE003 (mixed property types), RE004 (CRR CRE rental coverage failed). New RealEstateSplitterProtocol in contracts/protocols.py. 14 new unit tests (tests/unit/test_real_estate_splitter.py), 3 end-to-end pipeline integration tests (tests/integration/test_re_split_pipeline.py), 2 protocol contract tests. Output floor / aggregator semantics unchanged: each child row contributes its own sa_rwa so portfolio-level totals are mathematically equivalent to the pre-split blended-RW row. Ref: CRR Art. 125, Art. 126(2)(d); PRA PS1/26 Art. 124A, Art. 124F, Art. 124F(2), Art. 124H(1)-(3), Art. 124L; SS10/13.

  • Regression coverage: IRB-denied exposures must still use the counterparty's external ECAI rating on SA: tests/integration/test_model_permissions_pipeline.py::TestIRBDeniedUsesExternalRatingOnSA adds three end-to-end tests that wire a counterparty with both an internal rating (PD + model_id) and an external rating (CQS) through the full pipeline, then assert that when model_permissions deny IRB (via filter_rejected on exposure-class mismatch, via unmatched_model_id, and via PRA PS1/26 Art. 147A(1)(a) sovereign SA-only routing) the resulting SA row carries approach="SA", the counterparty's external cqs, and a CQS-based risk_weight rather than the unrated fallback — the scenario the CLS006 diagnostic warning already signals. A new _make_external_rating helper mirrors the existing _make_internal_rating shape. These tests pin down the expected behaviour end-to-end; previously, tests/integration/test_model_permissions_pipeline.py and tests/acceptance/basel31/test_scenario_b31_m_model_permissions.py always built internal-only ratings with cqs=None, so the external-rating path through SA after IRB denial was never exercised from rating inheritance through SACalculator._apply_risk_weights.

Changed

  • Institution guarantor RW expression unified: SA (engine/sa/calculator.py::_apply_guarantee_substitution) and IRB (engine/irb/guarantee.py::_compute_guarantor_rw_sa) guarantee substitution paths had near-identical hard-coded pl.when().then() ladders for institution CQS → RW with pl.lit(0.30) if config.is_basel_3_1 else pl.lit(0.50) branches. Extracted shared helper build_institution_guarantor_rw_expr(cqs_col, is_basel_3_1) in data/tables/crr_risk_weights.py that drives values from INSTITUTION_RISK_WEIGHTS_CRR / INSTITUTION_RISK_WEIGHTS_B31_ECRA so the dicts remain the single source of truth and the two sites cannot drift on future edits. Also removed the dead extra_cols={"is_basel_3_1": ...} column previously emitted by _create_institution_df (never consumed by any downstream join).

Fixed

  • RGLA / PSE institution-treated exposures now correctly route to IRB (CRR Art. 147(3)/(4)(b), PRA PS1/26 Art. 147A(1)(b)): rgla_institution and pse_institution counterparties carrying an internal rating were silently forced to SA regardless of IRB permissions. Under CRR the org-wide IRBPermissions.full_irb() map keys IRB eligibility off exposure_class, but the classifier set exposure_class from the SA map (RGLA / PSE) while full_irb() only listed CGCB / INSTITUTION / corporate / retail / SL — so every rgla_* / pse_* row's firb_permitted_expr evaluated to False and fell through to the SA default. Under Basel 3.1 the _b31_sa_only filter additionally swept ExposureClass.RGLA / ExposureClass.PSE into the Art. 147A(1)(a) sovereign-only set, but Art. 147(3) scopes that restriction to quasi-sovereigns with 0% SA RW (i.e. rgla_sovereign / pse_sovereign / mdb / international_org) — institution-treated variants should follow the Art. 147A(1)(b) INSTITUTION F-IRB-only path. Fix in engine/classifier.py: (1) _build_orgwide_permission_exprs and _resolve_model_permissions now key their permission-match expressions on exposure_class_irb; (2) _b31_sa_only now keys on cp_entity_type with the explicit Art. 147(3) list; (3) _b31_institution_no_airb now keys on exposure_class_irb == INSTITUTION; (4) new Step 4a re-syncs exposure_class_irb with the reclassified exposure_class after Phases 3-4 (SME / QRRE / retail) so retail-reclassified corporates still match retail model permissions; (5) after approach assignment, exposure_class is rewritten to exposure_class_irb for IRB-routed rgla_* / pse_* rows so the IRB calculator reads INSTITUTION / CGCB for correlation & LGD selection. SA-routed RGLA / PSE rows keep exposure_class = RGLA / PSE and continue to use Art. 115 / Art. 116 SA risk weight tables. Net effect under CRR: a rgla_institution with internal PD + modelled LGD now correctly lands on A-IRB via the INSTITUTION class; under B3.1 it lands on F-IRB with supervisory LGD per Art. 147A(1)(b). 11 new regression tests in tests/unit/test_b31_approach_restrictions.py (TestCRRRGLAPSEIRBRouting plus additional TestB31QuasiSovereignSAOnly cases) cover CRR AIRB/FIRB routing, B3.1 FIRB routing, A-IRB blocking under B3.1, LGD clearing, and SA-fallback behaviour for unrated rgla/pse rows. Full suite: 4,711 unit passed, 627 acceptance + integration passed. IRBPermissions.full_irb_b31() permissions map unchanged (RGLA / PSE / MDB entries remain as defensive defaults); docstring updated to clarify the quasi-sovereign scope is tied to the 0%-RW entity treatment, not the SA exposure class label. Ref: CRR Art. 147(3), Art. 147(4)(b); PRA PS1/26 Art. 147A(1)(a), Art. 147A(1)(b) read with Art. 147(3).
  • CRR Art. 138 multi-rating resolution now applied: HierarchyResolver._build_rating_inheritance_lazy in engine/hierarchy.py previously collapsed multiple external ratings per counterparty to the single most recent one, silently ignoring assessments from additional nominated ECAIs. Replaced the "most recent wins" logic for external ratings with Art. 138: per-agency dedup (most recent per agency) followed by the 1-rating / 2-rating (higher RW) / ≥ 3-rating (second-best) selection rule. Resolution is performed on CQS rather than RW because within every SA exposure class the CQS → RW mapping is monotone non-decreasing. Internal-rating resolution, inheritance, and the external-rating non-inheritance rule are unchanged. New TestArt138ExternalRatingResolution class in tests/unit/test_hierarchy.py covers single/two/three/four-rating cases, ties at the two lowest CQS, same-agency repeats, and null-CQS rows. Existing fixture counterparties have ≤ 1 external agency each, so no acceptance-golden changes. Ref: CRR Art. 138.

  • CRR Art. 120(2) Table 4 short-term rated institution risk weights now applied [P1.99]: The CRR SA branch fell through to Art. 120 Table 3 (long-term) for every rated institution regardless of maturity, so a CQS 2 institution with 1-month residual maturity received the 50% long-term weight instead of the 20% Table 4 short-term weight. Added INSTITUTION_SHORT_TERM_RISK_WEIGHTS_CRR in data/tables/crr_risk_weights.py (CQS 1-3 = 20%, CQS 4-5 = 50%, CQS 6 = 150%) and a new .when() branch in engine/sa/calculator.py keyed on residual_maturity_years <= 0.25 with INSTITUTION exposure class and non-null CQS. CRR Art. 120(2) keys on residual maturity and imposes no domestic-currency restriction (distinct from Art. 119(2)). Diverges from B31 Table 4 which applies 20% uniformly across CQS 1-5.

  • CRR Art. 121(3) unrated institution short-term 20% RW now applied [P1.121]: The CRR SA branch provided no short-term override for unrated institutions, so a 1-month-original-maturity unrated institution fell through to the Table 5 sovereign-derived fallback (typically 100%). Added INSTITUTION_SHORT_TERM_UNRATED_RW_CRR = 0.20 and a .when() branch in engine/sa/calculator.py keyed on original_maturity_years <= 0.25 with INSTITUTION exposure class and null-or-zero CQS. Art. 121(3) uses original effective maturity (consistent with the P1.133 B31 PSE/SCRA fix), so a seasoned 5-year bond with 1 month remaining does NOT qualify. Art. 121(6) sovereign floor (applied later via _apply_sovereign_floor_for_institutions) still lifts this to the sovereign weight for FX exposures. Capital previously overstated by up to 80 percentage points for short-term unrated interbank exposures.
  • Both fixes: 13 new regression tests in tests/unit/crr/test_crr_institution_standard.py (TestCRRShortTermInstitutionTables, TestCRRShortTermInstitutionSACalculator) cover parametrized CQS 1-6 short-term rated, >3m fall-through, unrated short-term, original vs residual maturity keying, sovereign-floor interaction, and B31 isolation. 2 existing tests in tests/unit/test_b31_sa_risk_weights.py that previously asserted CRR does NOT apply short-term treatment renamed to assert the new correct behaviour. Full suite: 5,329 passed, 21 skipped. Ref: CRR Art. 120(1)-(2), Art. 121(3)/(6).
  • Short-term PSE/institution treatment now keys on original maturity [P1.133]: SACalculator._SA_INPUT_CONTRACT extended with original_maturity_years, value_date, maturity_date; calculate_branch derives original_maturity_years inline from (maturity_date - value_date)/365.0 when the column is null, so hierarchy-supplied facility data flows through without a new schema column. Five SA call-sites updated to use original_maturity_years: B31 PSE short-term (Art. 116(3)), B31 ECRA rated institution short-term (Art. 120(2)/(2A) incl. 6m trade-goods carve-out), B31 SCRA unrated institution short-term (Art. 121(3)), CRR PSE short-term (Art. 116(3)), and Art. 121(6) trade-goods sovereign-floor exception. Previously a 5-year bond with 1 month residual incorrectly attracted short-term 20% RW; it now correctly receives the long-term CQS/SCRA weight. 8 new regression tests cover seasoned-vs-fresh scenarios across both CRR and B31 branches in tests/unit/test_pse_risk_weights.py and tests/unit/test_b31_sa_risk_weights.py. Understates-capital bug; fix tightens RWs on seasoned short-residual exposures. Ref: CRR Art. 116(3), PRA PS1/26 Art. 120(2)/(2A), Art. 121(3)/(6).
  • PRA PS1/26 Art. 224 Table 1 B31 haircut corrections [P1.155]: BASEL31_COLLATERAL_HAIRCUTS in data/tables/haircuts.py had 9 stale values (P1.155 originally cited 4; PDF verification found 5 more in the same table). Corrections verified against ps126app1.pdf p.203: sovereign CQS 2-3 3_5y 4%→3% and 10y+ 12%→6%; corp/institution CQS 1 1_3y/3_5y/5_10y 4/6/10%→3/4/6%; corp/institution CQS 2-3 1_3y/3_5y/5_10y/10y+ 6/8/15/15%→4/6/12/20%. The 10y+ CQS 2-3 correction (15%→20%) widens FCCM haircut on long-dated lower-rated corporate bonds (capital increase); the other 8 corrections were conservative over-haircuts whose correction reduces collateral haircut in FCCM, increases collateral value recognised, and lowers post-CRM EAD. Test class TestBasel31BondHaircuts in tests/unit/crm/test_crm_basel31.py parametrized into a single test_b31_bond_haircuts_match_pra_table_1 with 13 cases; test_b31_corp_bond_long_dated_higher_haircut in TestHaircutCalculatorFrameworkBranching updated accordingly. All 5,307 tests pass.
  • CRR Institution CQS 2 risk weight corrected to 50% (CRR Art. 120 Table 3) [P1.149]: The CRR table (misnamed INSTITUTION_RISK_WEIGHTS_UK) conflated the PRA PS1/26 Basel 3.1 ECRA values (CQS 2 = 30%, unrated = 40%) with a non-existent "UK deviation" to CRR Art. 120, and the SA calculator keyed framework selection off base_currency == "GBP" via use_uk_deviation. Under CRR Art. 120 Table 3, CQS 2 institutions are 50% and unrated institutions are 100% — no deviation exists in the UK-onshored CRR. Renamed data tables to INSTITUTION_RISK_WEIGHTS_CRR and INSTITUTION_RISK_WEIGHTS_B31_ECRA; replaced use_uk_deviation boolean (keyed on base currency) with config.is_basel_3_1 (keyed on framework) throughout engine/sa/calculator.py, engine/irb/guarantee.py, and engine/equity/calculator.py. Also caught an additional instance of the same root-cause bug in irb/guarantee.py:269 where unrated institution guarantors were hard-coded to 40% regardless of framework — now returns 100% under CRR and 40% under B31. CRR-A4 acceptance scenario updated (RW 0.30 → 0.50, RWA £300k → £500k); CRR-D4 updated (blended RW 0.58 → 0.70). Unit tests in tests/unit/test_sovereign_floor_institutions.py, test_b31_sa_risk_weights.py, test_covered_bonds.py, test_guarantor_exposure_class_rw.py, crr/test_crr_sa.py, crr/test_crr_tables.py, crr/test_crr_institution_standard.py, and crr/test_irb_namespace.py updated to reflect the correct CRR values.
  • HVCRE Good slotting EL rate corrected to 0.4% (PRA PS1/26 Art. 158(6) Table B) [P1.150]: Both B31_SLOTTING_EL_RATES_HVCRE[GOOD] (data/tables/b31_slotting.py) and SLOTTING_EL_RATES_HVCRE[GOOD] (data/tables/crr_slotting.py) returned 0.8% — mirroring non-HVCRE long-maturity Good — but PRA PS1/26 Table B (Appendix 1 p.108) shows the HVCRE row flat at 0.4% across both Strong (cols A/B) and Good (cols C/D), i.e. HVCRE collapses the subgrade differentiation that non-HVCRE retains. Halves the EL shortfall for HVCRE Good exposures (capital overstatement when EL > provisions). Under UK CRR the substantive Article 158 was omitted by SI 2021/1078 in 2022, so PRA PS1/26 Table B is the only extant UK source — applied symmetrically to both framework data tables. Updated unit tests in tests/unit/test_slotting_el_rates.py (renamed test_hvcre_good_zero_point_eighttest_hvcre_good_zero_point_four for both CRR and B31, replaced test_hvcre_matches_long_maturity_non_hvcre with test_hvcre_good_diverges_from_non_hvcre_long_maturity regression guard, parametrized cases ("good", True, False, ...) and ("good", True, True, ...) now expect 0.004). Updated B31 slotting spec admonition. Acceptance scenarios CRR-E4/E7/E8 unchanged (they assert HVCRE risk weights, not EL rates).
  • FX haircut on collateral silently zero after FX conversion (CRR Art. 224, PRA PS1/26 Art. 224) [P1.135/P1.136]: FXConverter.convert_exposures() and convert_collateral() both rewrite the currency column to the reporting currency, so by the time HaircutCalculator.apply_haircuts compared currency != exposure_currency both sides were equal and the 8% Art. 224 FX volatility haircut was silently never applied to any FX-mismatched secured exposure (HIGH capital understatement). Root cause: only convert_exposures and convert_guarantees preserved original_currency; convert_collateral, convert_provisions, and convert_equity_exposures did not — and _build_exposure_lookups sourced the collateral-side exposure_currency from the post-conversion currency. Fix: (1) engine/fx_converter.py — all four sibling converters now alias currency into original_currency on both the conversion and no-conversion paths; (2) engine/hierarchy.py — removed the apply_fx_conversion/fx_rates is not None branching block (converters now handle the no-op path consistently); (3) engine/crm/processor.py::_build_exposure_lookups — prefers original_currency with fallback to currency; (4) engine/crm/haircuts.py::apply_haircuts — compares the collateral's original_currency with fallback. Regression coverage: 5 tests in tests/unit/crm/test_collateral_fx_mismatch.py (including the post-conversion pipeline path that the existing scalar-based calculate_single_haircut tests did not exercise) + 2 tests in tests/unit/test_fx_converter.py covering the new collateral audit column.
  • Domestic sovereign guarantor 0% RW uses guarantee currency (CRR Art. 114(4)/(7)): The Art. 114(4)/(7) domestic-currency test on a guaranteed portion was being evaluated against the underlying exposure's currency rather than the guarantee's currency, which meant a GBP loan guaranteed by an EU sovereign in that sovereign's domestic currency (e.g. DE in EUR) did not receive 0% RW even though, under the substitution approach (Art. 215-217), the substituted claim against the sovereign is denominated in EUR. The Art. 233(3) 8% FX haircut already handles the cross-currency layer between guarantee and underlying loan; layering Art. 114(4)/(7) on top of the exposure currency effectively nullified Art. 233(3) for sovereign guarantees. Switched the three call sites that implement the check (engine/crm/guarantees.py routing, engine/irb/guarantee.py::_compute_guarantor_rw_sa, engine/sa/calculator.py::_apply_guarantee_substitution) to read guarantee_currency (already populated on guaranteed rows by _apply_guarantee_splits) with a null-safe fallback to the exposure's denomination_currency_expr. Added shared helper build_domestic_cgcb_guarantor_expr in data/tables/eu_sovereign.py combining the UK and EU-member branches into a single expression so the three sites cannot drift. New regression coverage: tests/integration/test_domestic_sovereign_guarantor_end_to_end.py (4 end-to-end cases through the full pipeline), a new TestGuarantorSubstitutionReadsGuaranteeCurrency class in tests/unit/test_guarantor_exposure_class_rw.py (5 cases covering the cross-currency SA+IRB substitution), and an extended TestDomesticSovereignGuarantorForcedToSA in tests/unit/crm/test_guarantor_rating_type.py adding the reported GBP-loan/EUR-guarantee/DE-sovereign case plus a guard against reading exposure currency (EUR loan + GBP guarantee + DE sovereign must stay IRB). Applies under both CRR and Basel 3.1 / PRA PS1/26.
  • Domestic sovereign guarantor 0% RW vs internal rating (CRR Art. 114(4)/(7)): When a guarantee from an EU/UK central government/central bank in its domestic currency was provided by a counterparty that the firm rates internally (i.e. carries an internal_pd) and the firm holds IRB permission for the CGCB exposure class, the guarantor was being routed to the IRB substitution path. The downstream _apply_parameter_substitution step in engine/irb/guarantee.py then overwrote the SA branch's correct 0% RW with the parametric F-IRB risk weight derived from the PD, so e.g. a DE sovereign + EUR guarantor with internal_pd = 0.001 produced ~2.6% instead of the regulatory 0%. The previous EU/UK domestic 0% fix (PR #253) handled the FX-conversion edge case but only inside the SA branch — it did not change routing, so internal-PD guarantors bypassed it. Promoted the Art. 114(4)/(7) check into the guarantor-approach routing step in engine/crm/guarantees.py: domestic-currency CGCB guarantors are now forced to guarantor_approach = "sa" ahead of the internal-PD branch, so the existing SA 0% short-circuit fires regardless of whether the guarantor has an internal rating. The guarantor_rating_type audit field is unchanged — still reports "internal" when an internal PD exists, since the override is an approach decision, not a rating-source decision. Reuses build_eu_domestic_currency_expr and denomination_currency_expr (post-FX safe). Applies under both CRR (Art. 114(4) UK/GBP, Art. 114(7) EU member states) and Basel 3.1 (PRA PS1/26 preserves Art. 114(7) by cross-reference via third-country reciprocity). Added TestDomesticSovereignGuarantorForcedToSA regression class in tests/unit/crm/test_guarantor_rating_type.py covering UK/GBP, DE/EUR (post-FX), PL/PLN (non-euro EU) and a non-domestic DE/USD counter-case under both frameworks.

[0.1.62] - 2026-04-17

Changed

  • Version bump for PyPI release

[0.1.61] - 2026-04-15

Fixed

  • EU sovereign guarantee 0% RW (CRR Art. 114(4)): Exposures guaranteed by an EU member state central government/central bank in that state's domestic currency (e.g. a German sovereign guaranteeing a EUR-denominated exposure) were failing to receive the mandated 0% risk weight when the pipeline's FX converter was active. Root cause: engine/fx_converter.py overwrites the exposure's currency column with the reporting currency and stores the pre-conversion denomination in original_currency, but every downstream "denominated in domestic currency" check read the now-overwritten currency column. After FX conversion a DE sovereign + EUR exposure appeared as DE + GBP (or whatever the reporting currency was), so the Art. 114(4) short-circuit never fired; unrated EU sovereign guarantors then fell through to .otherwise(1.0) = 100% instead of 0%. Added denomination_currency_expr() helper in data/tables/eu_sovereign.py that returns pl.col("original_currency") when present, else pl.col("currency"). Extended build_eu_domestic_currency_expr to accept a pl.Expr for the currency side. Updated all seven affected call sites in engine/sa/calculator.py (borrower + guarantor), engine/irb/guarantee.py (guarantor, IRB path), and engine/classifier.py (forced-SA check for EU domestic sovereigns). Existing TestSAEUDomesticSovereignTreatment unit tests were bypassing the bug because they fabricated LazyFrames with currency set to the denomination directly; added TestSAEUDomesticSovereignPostFX / TestIRBEUDomesticSovereignPostFX regression classes covering the post-FX pipeline state.

[0.1.60] - 2026-04-14

Changed

  • Data tables: Eliminated duplicated regulatory values in data/tables/. Previously most _create_*_df builders hardcoded numeric literals that were already defined in the module's constant dicts (CENTRAL_GOVT_CENTRAL_BANK_RISK_WEIGHTS, CORPORATE_RISK_WEIGHTS, COLLATERAL_HAIRCUTS, BASEL31_FIRB_SUPERVISORY_LGD, etc.), meaning a regulatory update required changes in 2+ places. Builders now derive their values by iterating the authoritative dict: new helpers _build_cqs_rw_df (crr), _build_int_cqs_rw_df (b31), _build_haircut_df (haircuts), and _build_firb_lgd_df / _build_b31_firb_lgd_df (firb_lgd) read values from the dicts via small row-spec tuples that define column ordering. B31_FIRB_LGD_* scalar aliases now derive from BASEL31_FIRB_SUPERVISORY_LGD. Matches the gold-standard pattern already used in b31_equity_rw.py. New test tests/unit/test_tables_dict_dataframe_parity.py (18 cases) locks in the invariant so regressions cannot reintroduce duplication. DataFrame schemas, column/row ordering, and public API are unchanged — no regulatory values changed.
  • Data tables: Renamed data/tables/crr_haircuts.py -> data/tables/haircuts.py and data/tables/crr_firb_lgd.py -> data/tables/firb_lgd.py; both files already held dual-framework content (CRR Art. 224/161 and PRA PS1/26 equivalents), so the crr_ prefix was misleading. Merged data/tables/b31_firb_lgd.py into firb_lgd.py — it was a thin re-export of the Basel 3.1 LGD dict that physically lived in the CRR-prefixed file. All BASEL31_* / B31_* constants, lookup helpers (lookup_b31_firb_lgd, get_b31_firb_lgd_table, get_b31_vs_crr_lgd_comparison), and framework-shared helpers (FIRB_OVERCOLLATERALISATION_RATIOS, FIRB_MIN_COLLATERALISATION_THRESHOLDS, CRR_K_SCALING_FACTOR) are now in firb_lgd.py. Module docstrings updated to reflect dual-framework scope; crm_supervisory.py docstring updated to match. Import sites updated across engine/, tests, and docs; public data/tables/__init__.py re-exports preserved. No regulatory values changed.

[0.1.59] - 2026-04-14

Changed

  • Version bump for PyPI release

[0.1.58] - 2026-04-11

Fixed

  • Guarantees (#239): Fixed two bugs in multi-guarantor handling:
  • Non-beneficial guarantors consuming EAD: When an exposure has multiple guarantors and some are non-beneficial, the pro-rata scaling no longer wastes EAD on non-beneficial guarantors. After the SA/IRB beneficial check, a new redistribute_non_beneficial() function reallocates freed portions to beneficial guarantors using a greedy strategy ordered by ascending risk weight (lowest RW fills first), minimising total RWA.
  • FX/restructuring haircuts applied after capping: The 8% FX mismatch haircut (Art. 233(3-4)) and 40% CDS restructuring exclusion haircut (Art. 233(2)) are now applied to the nominal credit protection value (G) before capping at EAD, per CRR Art. 233/235. Previously, a large cross-currency guarantee that vastly exceeded EAD would incorrectly have coverage reduced (e.g. £200m guarantee on €1m loan → was 920k, now correctly 1m).
  • CCF (P1.166): CRR OC (Other Commitments) CCF corrected from 0% to maturity-dependent values. Under CRR, the OC category did not exist — commitments were classified by maturity: >1yr → MR (50% SA / 75% F-IRB), ≤1yr → MLR (20% SA / 75% F-IRB). The only 0% category was LR (unconditionally cancellable). Previously understated capital for all OC-tagged exposures under CRR. SA CRR: OC now receives 50% (>1yr) or 20% (≤1yr, based on maturity_date vs reporting_date); 50% conservative default when maturity_date absent. F-IRB CRR: OC moved from 0% to 75% (both MR and MLR are 75% under F-IRB). Basel 3.1 OC (40%) unchanged. Updated sa_ccf_expression(), _firb_ccf_for_col(), and _compute_ccf() with maturity-aware override. Spec F-IRB table corrected. 7 unit tests updated, 6 new tests added.
  • Equity (P1.132): B31 government-supported equity risk weight corrected from 100% to 250% per Art. 133(3). Art. 133(6) is an exclusion clause (own funds deductions, Art. 89(3), Art. 48(4)), not a 100% risk weight — CRR Art. 133(3)(c) legislative equity carve-out has no equivalent in B31. Previously understated capital by 2.5x for government-supported equity under B31. Government-supported equity also removed from transitional floor exclusion (now subject to floor as standard equity, though 250% already exceeds all transitional floors). Updated risk weight table, calculator, transitional floor logic, 8 unit tests, 4 acceptance tests, and spec documentation. Art. 133 paragraph references corrected across codebase (subordinated debt = Art. 133(5) not 133(1); PE/VC = Art. 133(4) not 133(5)).
  • Covered Bonds (P1.113): B31 rated covered bond risk weights corrected from BCBS CRE20.28 values to PRA PS1/26 Art. 129(4) Table 7 values. CQS 2: 15%→20%, CQS 6: 50%→100%. PRA retained CRR Table 6A unchanged — did NOT adopt BCBS reductions. Previously understated capital for CQS 2 and CQS 6 covered bonds. Both B31_COVERED_BOND_RISK_WEIGHTS dict and _create_b31_covered_bond_df() DataFrame corrected. All 77 covered bond tests updated. 3 stale doc divergence warnings converted to "Fixed" admonitions.
  • Equity (P1.119): CIU fallback risk weight corrected from 150% (CRR) / 250%-400% (B31) to 1,250% per Art. 132(2). Was the highest-severity capital understatement bug (3-8x). Root cause: original implementation used Art. 133 equity risk weights instead of Art. 132(2) punitive CIU fallback. Extracted shared CIU_FALLBACK_RW constant and _append_ciu_branches() helper to eliminate CRR/B31 code duplication. Updated risk weight tables, calculator, 27 unit tests, 7 acceptance tests, and both equity spec documents.

[0.1.57] - 2026-04-11

Changed

  • Naming: Renamed functions with "and" in their names to better reflect single responsibility:
  • _classify_sme_and_retail -> _classify_exposure_subtypes (classifier)
  • _determine_approach_and_finalize -> _assign_approach (classifier)
  • _sink_and_scan -> _spill_to_disk (materialise)
  • _combine_irb_and_slotting -> _merge_el_sources (EL summary aggregator)
  • commit_and_push -> publish_changes (git ops)
  • Classifier: Moved B31_LARGE_CORPORATE_REVENUE_THRESHOLD_GBP (PRA PS1/26 Art. 147A(1)(e)) and B31_SME_TURNOVER_THRESHOLD_GBP (PRA PS1/26 Art. 153(4)) from engine/classifier.py to data/tables/b31_risk_weights.py for consistency with other B31 regulatory thresholds. Converted from float to Decimal.
  • Pipeline: Renamed private methods in PipelineOrchestrator to remove stale fan-out/single-pass terminology: _run_crm_processor_unified -> _run_crm_processor, _run_single_pass -> _run_calculators, _aggregate_single_pass -> _aggregate_results. Section header renamed from "Single-Pass Pipeline" to "Calculation".
  • Pipeline: Removed dead code _run_sa_calculator and _run_irb_calculator (never called from production; superseded by calculate_branch() in the single-pass path). Associated tests removed.

[0.1.56] - 2026-04-11

Changed

  • Version bump for PyPI release

[0.1.55] - 2026-04-09

Fixed

  • Classifier: Exposures with internal ratings no longer silently route to Standardised Approach when permission_mode="irb" is set on CreditRiskCalc. Two independent bugs are addressed:
  • Pipeline downgrade (Bug #1): PipelineOrchestrator.run_with_data used dataclasses.replace(config, permission_mode=STANDARDISED) when model_permissions was absent, which re-ran CalculationConfig.__post_init__ and wiped irb_permissions to sa_only(). The pipeline now preserves the user's org-wide IRB permissions and emits a missing_model_permissions pipeline error explaining that per-model gating is disabled.
  • Silent classifier join failure (Bug #2): ExposureClassifier._resolve_model_permissions joined exposure.model_id LEFT against model_permissions.model_id. Null or unmatched model_id values produced no match and silently routed to SA with no diagnostic. The classifier now tags each IRB-eligible miss with one of three causes (null_model_id, unmatched_model_id, filter_rejected) and emits a rolled-up CLS006 (ERROR_MODEL_PERMISSION_UNMATCHED) classification warning per cause with targeted remediation guidance.
  • Tests: Added TestModelPermissionsDiagnostics (4 integration tests) and TestPipelineIRBWithoutModelPermissions (1 integration test) in tests/integration/test_model_permissions_pipeline.py, plus a regression guard test_irb_mode_preserves_full_irb_after_pipeline_init in tests/unit/test_irb_approach_selection.py.
  • Docs: Replaced fabricated double-default formula in crm.md with correct CRR Art. 153(3) formula K_dd = K_obligor × (0.15 + 160 × PD_guarantor) (D3.7). Added eligibility requirements (Art. 202/217), guarantor RW floor, and Basel 3.1 removal warning with cross-link to A-IRB spec.
  • Docs: SA specialised lending waterfall position documented in key-differences.md (D2.20). Waterfall item 15 annotated with Art. 122–122B SA SL sub-classification cross-reference. New admonition added explaining SA SL sits within corporates (row 15, Art. 112(1)(g)), with IPRE excluded per Art. 122A(1) ("not a real estate exposure") — IPRE is caught at row 7 (real estate, Art. 124–124L) instead. SA SL section expanded with:
  • Art. 122A(1) 4-part definition criteria (SPV structure, asset dependency, lender control, asset income repayment)
  • Art. 122A(2) sub-type classification (OF, CF, PF)
  • IPRE exclusion warning admonition with cross-reference to real estate section
  • Art. 122B(1) rated SL fallthrough to corporate CQS table
  • Art. 122B(2) unrated risk weight table with article references per row
  • Art. 122B(3) operational phase definition (positive net cash-flow + declining LT debt)
  • Art. 122B(4)–(5) high-quality PF criteria (8 structural conditions)

[0.1.54] - 2026-04-08

Added

  • COREP: Reporting basis conditionality for output floor (P1.38(c)). COREPGenerator now accepts output_floor_config: OutputFloorConfig to gate floor-related COREP template content on entity-type applicability per Art. 92 para 2A:
  • OF 02.00 rows 0034-0036 (floor activated/multiplier/OF-ADJ) show 0.0 for exempt entities (international subsidiaries, ring-fenced bodies on individual basis, etc.)
  • OF 02.01 (output floor comparison) returns None for exempt entities — only applicable entities report the floor comparison
  • C 08.07 materiality columns 0160-0180 documented as consolidated-basis-only (Art. 150(1A)), threaded with is_consolidated flag for future population
  • COREPTemplateBundle extended with reporting_basis and institution_type metadata fields
  • ResultExporterProtocol and ResultExporter accept output_floor_config keyword parameter
  • Tests: 38 new tests in tests/unit/test_corep_reporting_basis.py across 7 test classes: COREPTemplateBundleMetadata (7), OF0201FloorApplicability (6), OF0200FloorIndicatorRows (7), C0807MaterialityColumns (4), BackwardCompatibility (3), EntityTypeCombinations (9 parametrized), ExporterProtocolCompliance (2). Total: 5,125 (was 5,087). Contract tests: 145.
  • Tests: 26 new tests in tests/unit/crm/test_equity_main_index.py across 7 test classes: schema validation, CRR/B31 haircut verification for main-index and other-listed, backward compatibility, precedence over eligibility flag, mixed collateral, and full pipeline end-to-end (other-listed EAD = 625k vs main-index EAD = 575k on 1M exposure with 500k equity collateral). Total: 5,087 (was 5,061).
  • Tests: 36 new CRM acceptance tests in tests/acceptance/crr/test_scenario_crr_d2_crm_advanced.py across 13 test classes covering advanced CRM scenarios not tested by the basic D1-D6/G1-G3 groups: non-beneficial guarantee (guarantor RW = borrower RW), sovereign guarantee 0% substitution, CDS restructuring exclusion (40% haircut, Art. 216(1)/233(2)), CDS with restructuring (no haircut contrast), gold collateral (15% CRR haircut), equity collateral (main-index 15%), overcollateralisation (EAD=0), full CRM chain (provision+collateral+guarantee), mixed collateral types (cash+bond), SA provision EAD deduction, multiple provisions summed, provision+collateral combined, and structural baseline validation. CRR acceptance: 169 (was 133). Total: 5,061 (was 5,025). (P5.3)
  • COREP: C 09.01 / OF 09.01 — CR GB 1 geographical breakdown SA. One DataFrame per country code + TOTAL. CRR: 13 columns (0010-0090 incl. supporting factors), 23 rows. Basel 3.1: 10 columns (removes supporting factors), 29 rows (adds SL sub-rows 0071-0073, RE sub-rows 0091-0094, removes short-term row). Uses cp_country_code from counterparty schema. Template definitions, generator methods, class maps, framework selectors.
  • COREP: C 09.02 / OF 09.02 — CR GB 2 geographical breakdown IRB. One DataFrame per country code + TOTAL. CRR: 17 columns (incl. PD, LGD, EL, supporting factors), 16 rows (incl. equity). Basel 3.1: 15 columns (adds 0107 defaulted EV, removes supporting factors), 19 rows (adds corporate sub-rows, restructures retail RE, removes equity).
  • Tests: 80 new COREP tests for C 09.01/09.02 across 10 test classes. COREP tests: 635 (was 555). Total: 4,953 (was 4,873). (P2.3)
  • COREP: C 08.04 / OF 08.04 — CR IRB RWEA flow statements. 1 column (RWEA) × 9 rows (opening, 7 movement drivers, closing) per IRB exposure class. Closing RWEA (row 0090) populated from pipeline; opening and drivers null (require prior-period data). Slotting excluded. CRR column names "after supporting factors"; Basel 3.1 removes supporting factors reference. Template definitions: CRR_C08_04_COLUMNS, B31_C08_04_COLUMNS, C08_04_ROWS, C08_04_COLUMN_REFS, get_c08_04_columns(). Generator: _generate_all_c08_04(), _generate_c08_04_for_class(). COREPTemplateBundle.c08_04 field (dict[str, pl.DataFrame]). Excel export with C 08.04 / OF 08.04 prefix.
  • Tests: 41 new COREP tests for C 08.04 across 6 test classes (TestC0804TemplateDefinitions: 13, TestC0804Generation: 5, TestC0804ClosingRWEA: 4, TestC0804NullDriverRows: 9, TestC0804B31Features: 3, TestC0804EdgeCases: 7). COREP tests: 555 (was 514). (P2.2)
  • Pillar III: UKB CR9 — IRB PD backtesting per exposure class (Art. 452(h)). 8 columns × 17 PD buckets + total row. Basel 3.1 only. Separate F-IRB and A-IRB template sets. Uses irb_pd_original for bucket allocation (beginning-of-period proxy). Includes obligor count, default count, observed default rate, EAD-weighted average PD, arithmetic mean PD, historical annual default rate.
  • Pillar III: UKB CR9.1 — ECAI mapping PD backtesting (Art. 180(1)(f)). Template definitions only; generation deferred until pipeline provides firm-specific ECAI mapping data.
  • Pillar III: Pillar3TemplateBundle.cr9 field added (dict of approach–class keyed DataFrames)
  • Pillar III: CR9 Excel export via export_to_excel() with human-readable sheet names (e.g., "UKB CR9 F-IRB Corp")
  • Tests: 44 new tests for CR9/CR9.1 across 7 test classes (definitions, generation, column values, PD allocation, edge cases, bundle integration, Excel export). Total: 4,832 (was 4,788). (P3.2)

Fixed

  • Docs: Art. 128 (high-risk items, 150%) UK CRR omission clarified across 6 files (D1.28, D4.9). Art. 128 was omitted from UK onshored CRR by SI 2021/1078, reg. 6(3)(a), effective 1 January 2022 — the high-risk exposure class is a dead letter under current UK CRR. Re-introduced under PRA PS1/26 (Basel 3.1, from 1 January 2027) with paragraphs 1 and 3 retained (paragraph 2 left blank). Files updated:
  • specifications/crr/sa-risk-weights.md: Added omission admonition, B31 re-introduction note, code bug cross-reference (D3.12), and exposure class waterfall clarification (equity priority 3 > high-risk priority 4)
  • user-guide/exposure-classes/other.md: Restructured "Items Associated with High Risk" section — added framework applicability warning, corrected table to Art. 128 items only (speculative RE, PRA-designated), added waterfall note explaining PE/VC are equity (Art. 133), not high-risk
  • framework-comparison/key-differences.md: Corrected equity table row (removed "(or 150% if Art. 128 high-risk)" — PE/VC is equity per waterfall), added Art. 128 re-introduction admonition to priority waterfall section
  • user-guide/regulatory/crr.md: Added "Omitted Provisions" section documenting Art. 128 and Art. 132 omissions by SI 2021/1078
  • specifications/crr/equity-approach.md: Corrected Art. 128 note to explain waterfall precedence (equity > high-risk) and UK CRR omission
  • specifications/common/hierarchy-classification.md: Updated calculator coverage note with Art. 128 framework status and CRR legal basis issue
  • Docs: Documentation accuracy sweep correcting wrong regulatory values across 13 files (P4.5, P4.6, P4.22):
  • PD floors (P4.5): Retail mortgage Basel 3.1 PD floor corrected from 0.05% to 0.10% (Art. 163(1)(b)) in 5 files. QRRE transactor Basel 3.1 PD floor corrected from 0.03% to 0.05% (Art. 163(1)(c)) in 5 files. Affected: api/configuration.md, user-guide/configuration.md, user-guide/exposure-classes/retail.md, data-model/regulatory-tables.md.
  • LGD floors (P4.6): Corporate LGD floor code example corrected (RECEIVABLES 15%→10%, CRE 15%→10%, OTHER_PHYSICAL 20%→15%) in user-guide/configuration.md. Corporate residential_real_estate field corrected from 0.05 to 0.10 (Art. 161(5)) in api/configuration.md — was showing retail floor instead of corporate floor.
  • Output floor schedule (P4.22): BCBS 6-year schedule (50%/55%/60%/65%/70%/72.5%, 2027–2032) replaced with PRA 4-year schedule (60%/65%/70%/72.5%, 2027–2030) across 12 files. Affected: plans/implementation-plan.md, api/engine.md, api/contracts.md, framework-comparison/reporting-differences.md, plans/prd.md, specifications/index.md, features/index.md, specifications/regulatory-compliance.md, framework-comparison/index.md, appendix/index.md, framework-comparison/impact-analysis.md, user-guide/configuration.md.
  • CRM: Decoupled is_main_index from is_eligible_financial_collateral for equity collateral haircuts (P6.21). Added is_main_index Boolean field to COLLATERAL_SCHEMA. When present, drives haircut lookup directly: True = main-index (CRR 15%, B31 20%), False = other-listed (CRR 25%, B31 30%). When absent, falls back to is_eligible_financial_collateral for backward compatibility. Previously all eligible equity was forced to the main-index haircut tier.
  • COREP: OF 02.00 IRB sub-row splits — rows 0295-0297 (FSE/large, SME, non-SME corporates), 0355-0356 (retail RE SME/non-SME), 0382-0385 (corporate RE sub-splits), 0400/0410 (other retail SME/non-SME) now populated from pipeline data instead of hardcoded 0.0. Uses finer-grained aggregation keyed by (approach, exposure_class, is_sme, apply_fi_scalar, property_type).
  • COREP: OF 02.00 floor indicator rows 0035/0036 — floor_pct and of_adj now populated from OutputFloorSummary when provided, instead of hardcoded 0.0.
  • COREP: _filter_re() fallback chain — gracefully degrades from materially_dependent_on_propertyhas_income_coveris_income_producing when pipeline columns vary. Null handling corrected: only fallback columns use fill_null(False), preserving null-as-unclassified semantics for the primary column.
  • Equity: _apply_transitional_floor() now emits equity_transitional_approach and equity_higher_risk annotation columns for COREP OF 07.00 rows 0371-0374.
  • Tests: 24 new COREP tests across 4 classes (IRB sub-row splits, floor indicators, RE fallback, equity transitional columns). COREP tests: 687 (was 663). Total: 5,025 (was 5,001). (P2.5)

[0.1.53] - 2026-04-07

Changed

  • Version bump for PyPI release

[0.1.52] - 2026-04-06

Changed

  • Version bump for PyPI release

[0.1.51] - 2026-04-05

Changed

  • Version bump for PyPI release

[0.1.50] - 2026-04-01

Changed

  • Version bump for PyPI release

[0.1.49] - 2026-03-30

Changed

  • Version bump for PyPI release

[0.1.48] - 2026-03-29

Changed

  • Version bump for PyPI release

[0.1.47] - 2026-03-28

Changed

  • Version bump for PyPI release

[0.1.46] - 2026-03-28

Changed

  • Version bump for PyPI release

[0.1.45] - 2026-03-27

Added

CCP Guarantor Risk Weight Support (CRR Art. 306 / CRE54.14-15)

CCP guarantors now receive the prescribed QCCP risk weight (2% proprietary / 4% client-cleared) instead of being treated as generic unrated institutions (40% RW). The guarantee substitution when/then chain in both the SA calculator and IRB namespace checks guarantor_entity_type == "ccp" before the institution/MDB branch, applying QCCP_PROPRIETARY_RW (2%) or QCCP_CLIENT_CLEARED_RW (4%) based on guarantor_is_ccp_client_cleared.

  • CRM processor and namespace propagate is_ccp_client_cleared from guarantor counterparty data
  • Entity type normalization (.str.to_lowercase()) applied to guarantor entity type joins

[0.1.44] - 2026-03-25

Added

~~Article 114(4)~~ Article 114(7) EU domestic currency 0% risk weight for EU sovereigns

Correction (D4.35)

The original entry cited Art. 114(4). In the UK-onshored CRR, Art. 114(4) covers only the UK central government and Bank of England in sterling. EU member state domestic-currency treatment is provided by Art. 114(7) (third-country reciprocity).

EU member state central government and central bank exposures denominated in that member state's domestic currency now receive 0% risk weight regardless of CQS, per CRR Art. 114(7). Covers all 27 EU member states: eurozone members (EUR) and non-euro members in their national currencies (PLN, SEK, CZK, DKK, HUF, BGN, RON). EU domestic sovereign exposures are also forced to the Standardised Approach, preventing internal models from overriding the regulatory 0% treatment. Applies to both direct exposures and guarantor risk weight substitution (SA and IRB).

  • is_ccp_client_cleared field added to data generators

Fixed

  • CCP exposures now forced to SA approach with correct risk weights (was falling through to generic corporate treatment)

[0.1.43] - 2026-03-24

Fixed

Guarantee application expanded to facility and counterparty levels

Guarantee application previously only matched at direct (loan/exposure/contingent) level. Guarantees linked at facility or counterparty level were silently ignored. Now supports multi-level beneficiary matching: direct, facility (pro-rata across facility's exposures), and counterparty (pro-rata across all counterparty exposures).


[0.1.42] - 2026-03-22

Fixed

Slotting maturity not derived from maturity_date

The is_short_maturity flag for CRR Art. 153(5) specialised lending was never calculated from exposure maturity_date. It defaulted to False, causing all exposures to receive the >= 2.5yr risk weights regardless of actual remaining maturity. Strong category exposures with <2.5yr maturity now correctly receive 50% RW (was 70%), Good receives 70% (was 90%), HVCRE Strong receives 70% (was 95%), and HVCRE Good receives 95% (was 120%).

  • prepare_columns() now accepts CalculationConfig and derives is_short_maturity from maturity_date and reporting_date
  • Extracted exact_fractional_years_expr to shared engine/utils.py (reused by IRB and slotting)
  • Added remaining_maturity_years column to slotting audit trail
  • Added CRR-E5 through CRR-E8 acceptance scenarios for short-maturity slotting

UK govt guarantee exposure marked "not beneficial" for non-sovereign entity types

Guarantor risk weight lookup used regex matching on guarantor_entity_type (e.g., contains("SOVEREIGN")), which only matched sovereign but not central_bank, bank, company, or mdb. These entity types produced null guarantor RW, causing beneficial guarantees to be incorrectly skipped. The lookup now uses guarantor_exposure_class (derived from the existing ENTITY_TYPE_TO_SA_CLASS mapping), ensuring all valid entity types resolve to the correct SA risk weight. Also adds Art. 114(4) domestic sovereign treatment: UK CGCB guarantors in GBP receive 0% RW regardless of CQS. (Correction (D4.35): original entry cited Art. 114(3); Art. 114(3) is the ECB provision, Art. 114(4) is UK domestic currency.) Both SA calculator and IRB namespace are fixed. CRM processor and namespace now propagate guarantor_country_code from counterparty data.


[0.1.41] - 2026-03-22

Added

~~Article 114(3)~~ Article 114(4) domestic currency 0% risk weight for UK sovereign

Correction (D4.35)

The original entry cited Art. 114(3). CRR Art. 114(3) is the ECB 0% provision. The UK domestic currency provision is Art. 114(4).

UK central government and central bank exposures denominated in GBP now receive 0% risk weight regardless of CQS, per CRR Art. 114(4). Previously, 0% was only assigned via CQS 1 external rating lookup. The override applies in both CRR and Basel 3.1 SA risk weight chains. Foreign-currency UK sovereign exposures continue to use the standard CQS-based risk weight table.


[0.1.40] - 2026-03-22

Changed

Specialised lending now input-driven via counterparty_reference

Specialised lending metadata (sl_type, slotting_category, is_hvcre) is now supplied as an input file (exposures/specialised_lending.parquet) keyed by counterparty_reference, rather than being derived from counterparty reference naming conventions. This allows a corporate counterparty to have both SL and non-SL exposures, aligning with CRR Art. 147(8) and BCBS CRE30.6.

  • New input file: ratings/specialised_lending.parquet
  • Schema change: exposure_reference replaced with counterparty_reference; remaining_maturity_years removed (sourced from loan/facility data)
  • Removed dead code: _build_slotting_category_expr(), _build_sl_type_expr(), and counterparty reference naming convention logic in the classifier

Fixed

FI scalar (apply_fi_scalar) not applied to IRB correlation

The apply_fi_scalar counterparty flag was gated on is_financial_sector_entity, which required the entity_type to be an institution-like value. Counterparties with entity_type="corporate" and apply_fi_scalar=True silently received no 1.25x correlation multiplier. The classifier now derives requires_fi_scalar directly from the user-supplied apply_fi_scalar flag.

Removed dead code: FINANCIAL_SECTOR_ENTITY_TYPES, is_financial_sector_entity, and is_large_financial_sector_entity — set in the classifier but never consumed by any calculation engine.


[0.1.39] - 2026-03-21

Fixed

  • SME managed-as-retail 75% RW now correctly gated on EUR 1m turnover threshold check (was applying 75% RW without verifying threshold)

Changed

  • Documentation aligned with current codebase state

[0.1.38] - 2026-03-20

Fixed

  • Null slotting_category and sl_type for non-slotting exposures (was leaving stale values from classification)
  • Defaulted exposure treatment for SA risk weights now correctly implemented
  • Case-insensitive column value validation (lowercase valid values set before comparison)
  • country_codes and excluded_book_codes columns in model_permissions input are now truly optional — when absent, treated as null (all geographies permitted, no book code exclusions). Previously caused ColumnNotFoundError
  • Documentation aligned with code schemas across 13 files

[0.1.37] - 2026-03-17

Fixed

  • Validation error messages now correctly convert file paths to string (was raising TypeError for Path objects)

[0.1.36] - 2026-03-15

Changed

Model ID moved from counterparty to ratings level (Breaking)

model_id has been moved from COUNTERPARTY_SCHEMA to RATINGS_SCHEMA. The rating inheritance pipeline now carries model_id alongside internal_pd through parent-child inheritance, eliminating the redundant counterparty-to-exposure propagation path.

  • Removed: model_id from COUNTERPARTY_SCHEMA
  • Added: model_id to RATINGS_SCHEMA
  • Updated: Rating inheritance pipeline carries internal_model_id through coalesce (own → parent)
  • Updated: _unify_exposures() sources model_id from rating inheritance instead of counterparty join
  • Updated: Fixture generators, integration tests, benchmark data generators, and documentation
  • Counterparty data handling consolidated

[0.1.35] - 2026-03-11

Added

Integration Test Infrastructure

Comprehensive integration test suite covering the full pipeline from loader to output:

  • Phase 1: Hierarchy → Classifier flow tests
  • Phase 2: Classifier → CRM and CRM → Calculators flow tests
  • Phase 3: Loader → Hierarchy, model permissions, and output floor tests
  • Phase 4: Equity flow integration tests
  • Integration test strategy document and shared infrastructure

Changed

  • model_id added to counterparty-level schema (subsequently moved to ratings in 0.1.36)

[0.1.34] - 2026-03-10

Added

Model-Level IRB Permissions

Per-model IRB approach gating replaces the org-wide IRBPermissions config when a model_permissions input file is provided:

  • New schema: MODEL_PERMISSIONS_SCHEMA with model_id, exposure_class, approach, country_codes, excluded_book_codes
  • New column: model_id on FACILITY_SCHEMA, LOAN_SCHEMA, CONTINGENTS_SCHEMA — links exposures to their IRB model
  • Classifier: _resolve_model_permissions() joins exposures with model permissions, filters by geography and book code, gates approach on both permission and data availability (AIRB requires internal_pd + lgd; FIRB requires only internal_pd)
  • Backward compatible: When no model_permissions file is present, org-wide IRBPermissions fallback applies
  • Validation: model_permissions included in validate_raw_data_bundle() and validate_bundle_values() for schema and value validation
  • model_permissions fixtures and model_id added to exposure generators
  • API documentation updated
  • 10 unit tests covering AIRB/FIRB gating, geography filters, book code exclusions, and backward compatibility

Rename is_regulatedapply_fi_scalar

Simplified FI scalar control on COUNTERPARTY_SCHEMA:

  • Schema: is_regulated renamed to apply_fi_scalar — direct user-controlled flag replacing the intermediate boolean
  • Classifier: requires_fi_scalar now derives from is_financial_sector_entity AND cp_apply_fi_scalar (simpler than the previous two-condition inference from is_regulated)
  • Documentation: All references updated across input schemas, architecture, and classification docs

[0.1.33] - 2026-03-09

Added

Dual Per-Type Rating Resolution

Rating inheritance now resolves best internal and best external rating per counterparty independently. CQS is an external-only concept; internal ratings carry PD values without internal CQS.

  • Per-type columns: internal_pd, internal_rating_value, external_cqs, external_rating_value
  • Per-type inheritance: own internal → parent internal, own external → parent external (independent chains)
  • Removed internal CQS references throughout the codebase

Changed

  • Enhanced netting facility handling in loan data

[0.1.32] - 2026-03-08

Added

  • netting_facility_reference field added to LOAN_SCHEMA and loan data for explicit netting group assignment

[0.1.31] - 2026-03-07

Added

  • Enhanced netting logic for facility siblings (pro-rata allocation within netting groups)
  • interest_for_ead function in CCF module to handle negative interest values

[0.1.30] - 2026-03-06

Added

Basel 3.1 Engine

Full Basel 3.1 framework implementation alongside existing CRR support:

  • Revised SA risk weight tables (CRE20.7-26) with LTV-band risk weights for residential and commercial real estate
  • Basel 3.1 supervisory haircuts and F-IRB LGD framework dispatch
  • Output floor: SA-equivalent RWA calculation on all IRB rows with phase-in schedule
  • Basel 3.1 acceptance tests: B31-B (F-IRB), B31-C (A-IRB), B31-D (CRM), B31-E (slotting), B31-G (provisions), B31-H (complex scenarios) — 116 tests total
  • IRB: A-IRB LGD floors gated on is_airb column (CRE30.41)
  • IRB: QRRE transactor/revolver PD floor distinction (CRR Art. 147(5), CRE30.55)

Dual-Framework Comparison and Analysis

  • M3.1: CRR vs Basel 3.1 side-by-side comparison with per-exposure RWA delta
  • M3.2: Capital impact analysis with driver attribution
  • M3.3: Transitional floor schedule modelling with year-by-year phase-in
  • M3.4: Enhanced Marimo workbook for interactive impact analysis

EL Shortfall/Excess (CRR Art. 158-159)

Expected loss shortfall/excess computation for IRB portfolios, with portfolio-level Tier 2 credit cap per CRR Art. 62(d).

COREP Template Generation (FR-4.6 / M4.1)

Regulatory reporting templates for CRR firms following EBA/PRA COREP structure (Regulation (EU) 2021/451):

  • C 07.00 — SA credit risk: original exposure, SA EAD, RWA by exposure class, plus risk weight band breakdown
  • C 08.01 — IRB totals: original exposure, IRB EAD, RWA, expected loss, weighted-average PD/LGD/maturity by exposure class
  • C 08.02 — IRB PD grade breakdown: obligor-grade-level detail with standard PD bands and exposure-weighted averages
  • COREPGenerator class with generate() and export_to_excel() methods
  • ResultExporter.export_to_corep() for multi-sheet Excel export
  • CalculationResponse.to_corep() convenience method

Programmatic Export API (FR-4.7)

Export calculation results to Parquet, CSV, and Excel formats programmatically.

On-Balance Sheet Netting (CRR Article 195)

Support for on-balance sheet netting of mutual claims when a legally enforceable netting agreement exists:

  • New fields: has_netting_agreement and netting_facility_reference on LOAN_SCHEMA and Loan fixture
  • Synthetic cash collateral: Negative-drawn netting-eligible loans generate cash collateral that reduces all positive-drawn sibling exposures pro-rata within the same netting facility
  • Netting facility resolution: Priority chain — explicit netting_facility_referenceroot_facility_referenceparent_facility_reference
  • SA: EAD reduced by netting pool (cash = 0% haircut)
  • F-IRB: LGD reduced via cash collateral path (0% LGD)
  • FX mismatch: 8% haircut applied when currencies differ

Service API Documentation

Restructured user-facing documentation to promote the high-level Service API (quick_calculate, RWAService) as the primary entry point:

  • Quick Start rewritten with 3-tier progression: quick_calculate one-liner, RWAService with more control, full example with validation/export
  • New page: docs/api/service.md — complete Service API reference
  • API Reference index features Service API as first module

Basel 3.1 Parameter Substitution for IRB Guarantors (CRE22.70-85)

IRB guarantee substitution parameters updated for Basel 3.1 framework.

CI/CD Pipeline

GitHub Actions workflow with lint, typecheck, and test jobs.

Changed

  • Replaced Enum with StrEnum and IntEnum throughout the codebase
  • Centralised data source configuration with DataSourceRegistry replacing RequiredFiles
  • Introduced BaseRequest class to reduce duplication in request models
  • Error factory functions updated to support Path types alongside str
  • Tests migrated to use Path for file paths

Fixed

  • Corporate bond haircut CQS grouping corrected per CRR Art. 224
  • PD floors and transitional schedule corrected to PRA PS1/26
  • Output floor sa_rwa computation fixed for acceptance tests
  • Benchmark data generators now include all schema columns (is_buy_to_let, interest, bs_type, pledge_percentage, is_qrre_transactor)
  • Benchmark tests updated for current API: _unify_exposures signature (added facilities arg), CRMProcessor.get_crm_adjusted_bundle
  • Protocol test stubs updated to include calculate_branch method

[0.1.29] - 2026-02-28

Added

  • F-IRB acceptance tests and expected outputs (CRR-B1 through B7)

Changed

  • Pipeline refactored to single-pass calculation for unified frame (filter-process-merge pattern)
  • Classifier exposure classification logic optimized
  • Hierarchy collateral allocation logic simplified
  • RWA calculations simplified with filter-process-merge approach

Performance

  • Pipeline optimizations: pre-computed classifier intermediates, deferred audit string, slimmed counterparty join, eliminated unnecessary collect_schema() calls
  • Full CRR pipeline at 100K: ~1.7s mean (SA-only ~1.7s, CRR ~1.9s)

[0.1.28] - 2026-02-24

Added

  • Benchmarking module for RWA Calculator performance testing

Performance

  • Optimized aggregation data collection and processing
  • Optimized hierarchy graph traversal methods
  • Optimized exposure enrichment methods
  • Optimized pledge resolution and validation in pipeline

[0.1.27] - 2026-02-22

Added

  • Results caching with lazy loading for improved pipeline performance

Changed

  • Replaced custom validation methods with shared utility functions across hierarchy, loader, pipeline, and processor
  • Replaced enable_irb boolean config with irb_approach enum for clearer IRB permission modelling
  • Optimized data materialization to reduce redundant .collect() calls
  • Multiple speed optimization PRs merged (aggregator, formatters, validation)

[0.1.26] - 2026-02-21

Performance

  • Optimized aggregator processing for large result sets
  • Optimized formatter output generation
  • Streamlined validation data processing to reduce overhead
  • UI speed improvements for interactive calculator

[0.1.25] - 2026-02-20

Added

IRB Defaulted Exposure Treatment (CRR Art. 153(1)(ii), 154(1)(i))

  • Defaulted exposures (PD=1.0) receive K=0 under F-IRB and K=max(0, LGD-BEEL) under A-IRB
  • Expected loss = LGD × EAD for defaulted exposures
  • CRR 1.06 scaling factor correctly applied to defaulted corporate exposures
  • New CRR-I acceptance test group with 9 tests (I1 F-IRB corporate, I2 A-IRB retail, I3 A-IRB corporate with CRR scaling)

Fixed

  • SME supporting factor now correctly uses drawn amount (not EAD) for tier threshold calculation

[0.1.24] - 2026-02-19

Added

Multi-Level SA Collateral Allocation

  • Multi-level collateral allocation for SA EAD reduction with overcollateralisation compliance
  • Haircut calculator enhancements for multi-level processing

[0.1.23] - 2026-02-17

Added

SA Provision Handling — Art. 111(1)(a)-(b) Compliance

Provisions are now resolved before CCF application using a drawn-first deduction approach, compliant with CRR Art. 111(1)(a)-(b):

Pipeline reorder:

resolve_provisions → CCF → initialize_ead → collateral → guarantees → finalize_ead

New method: resolve_provisions() with multi-level beneficiary resolution: - Direct (loan/exposure/contingent): provision matched to specific exposure - Facility: distributed pro-rata across facility's exposures - Counterparty: distributed pro-rata across all counterparty exposures

SA drawn-first deduction: - provision_on_drawn = min(provision, max(0, drawn)) — absorbs provision against drawn first - Remainder → provision_on_nominal — reduces nominal before CCF - nominal_after_provision = nominal_amount - provision_on_nominal feeds into CCF

IRB/Slotting: Provisions tracked (provision_allocated) but NOT deducted from EAD (feeds EL shortfall/excess comparison)

New columns: | Column | Type | Description | |--------|------|-------------| | provision_on_drawn | Float64 | Provision absorbed by drawn (SA only) | | provision_on_nominal | Float64 | Provision reducing nominal before CCF (SA only) | | nominal_after_provision | Float64 | nominal_amount - provision_on_nominal | | provision_deducted | Float64 | Total = provision_on_drawn + provision_on_nominal | | provision_allocated | Float64 | Total provision matched to this exposure |

Other changes: - finalize_ead() no longer subtracts provisions (already baked into ead_pre_crm) - _initialize_ead() preserves existing provision columns if set by resolve_provisions - 14 unit tests in tests/unit/crm/test_provisions.py - CCF test suite expanded to 57 tests


[0.1.22] - 2026-02-16

Changed

  • Slotting risk weights updated for remaining maturity splits (CRR Art. 153(5))
  • Config enhancements for slotting maturity bands

[0.1.21] - 2026-02-16

Added

Pledge Percentage for Collateral Valuation

  • Introduced pledge_percentage field to allow collateral to be specified as a percentage of the beneficiary's EAD
  • Collateral processing resolves pledge_percentage to absolute market values based on beneficiary type (loan, facility, or counterparty level)
  • Updated input schemas and CRM methodology documentation to reflect the new field
  • 403 lines of new tests covering pledge percentage resolution across different beneficiary levels

[0.1.20] - 2026-02-14

Added

Equity Exposure FX Conversion

  • New convert_equity_exposures() method in FX converter for converting equity exposure values to reporting currency
  • Updated classifier and hierarchy to support equity exposures in FX conversion pipeline
  • Enhanced FX rate configuration with equity-specific handling
  • Comprehensive tests for equity exposure conversion and currency handling

[0.1.19] - 2026-02-11

Added

Buy-to-Let Flag

  • New is_buy_to_let boolean flag in hierarchy and schemas for identifying BTL exposures
  • BTL exposures excluded from SME supporting factor discount
  • Unit tests verifying BTL flag behaviour in supporting factor calculations

On-Balance EAD Helper

  • New on_balance_ead() helper function in CCF module calculating EAD as max(0, drawn) + interest
  • Updated CRM processor and namespace to use the new helper
  • Comprehensive tests covering various on-balance EAD scenarios

Changed

  • Updated implementation plan and roadmap documentation with current test results and fixture completion status

[0.1.18] - 2026-02-10

Added

Facility Hierarchy Enhancements

  • Facility root lookup and undrawn calculations for full facility hierarchy resolution
  • Include contingent liabilities in facility undrawn calculations
  • Enhanced facility hierarchy resolution logic

[0.1.17] - 2026-02-10

Added

  • CCF: handle negative drawn amounts in EAD calculations

Fixed

  • Hierarchy: resolve duplicate mapping issues in facility calculations

[0.1.16] - 2026-02-09

Added

Cross-Approach CCF Substitution

  • SA CCF expression and cross-approach substitution for guaranteed IRB exposures
  • When an IRB exposure is guaranteed by an SA counterparty, the guaranteed portion uses SA CCFs
  • New columns: ccf_original, ccf_guaranteed, ccf_unguaranteed, guarantee_ratio, guarantor_approach, guarantor_rating_type

Aggregator Enhancements

  • Updated summaries for post-CRM reporting
  • Enhanced approach handling for IRB results

[0.1.15] - 2026-02-08

Added

  • Correlation: rename sovereign exposure class to central govt/central bank
  • CI: add GitHub Actions workflow for documentation deployment

[0.1.14] - 2026-02-07

Added

Overcollateralisation Requirements (CRR Art. 230 / CRE32.9-12)

Non-financial collateral now requires overcollateralisation to receive CRM benefit:

Collateral Type Overcollateralisation Ratio Minimum Threshold
Financial 1.0x No minimum
Receivables 1.25x No minimum
Real estate 1.4x 30% of EAD
Other physical 1.4x 30% of EAD
  • effectively_secured = adjusted_value / overcollateralisation_ratio
  • Financial vs non-financial collateral tracked separately for threshold checks
  • Multi-level allocation respects overcollateralisation at each level

Changed

  • Standardized collateral_type casing and descriptions across codebase

[0.1.13] - 2026-02-07

Added

Input Value Validation

  • validate_bundle_values() validates all categorical columns against COLUMN_VALUE_CONSTRAINTS
  • Error code DQ006 for invalid column values
  • Pipeline calls _validate_input_data() as non-blocking step (errors collected, not raised)

Fixed

  • Prevented row duplication in exposure joins when facility_reference = loan_reference (#71)

[0.1.12] - 2026-02-02

Added

Equity Exposure Calculator

Complete equity exposure RWA calculation supporting two regulatory approaches:

Article 133 - Standardised Approach (SA): | Equity Type | Risk Weight | |-------------|-------------| | Central bank | 0% | | Listed/Exchange-traded/Government-supported | 100% | | Unlisted/Private equity | 250% | | Speculative | 400% |

Article 155 - IRB Simple Risk Weight Method: | Equity Type | Risk Weight | |-------------|-------------| | Central bank | 0% | | Private equity (diversified portfolio) | 190% | | ~~Government-supported~~ | ~~190%~~ | | Exchange-traded/Listed | 290% | | Other equity | 370% |

Correction (D1.27)

"Government-supported: 190%" was incorrectly listed as an Art. 155 category. Art. 155(2) has only three categories: (a) exchange-traded 290%, (b) PE diversified 190%, (c) all other 370%. No "government-supported" category exists in Art. 155.

New Components: - EquityCalculator class (src/rwa_calc/engine/equity/calculator.py) - EquityLazyFrame namespace (lf.equity) for fluent calculations - EquityExpr namespace (expr.equity) for column-level operations - EquityResultBundle for equity calculation results - crr_equity_rw.py lookup tables

Features: - Automatic approach determination based on IRB permissions - Diversified portfolio treatment for private equity (190% vs 370%) - Full audit trail generation - Single exposure calculation convenience method

Pre/Post CRM Tracking for Guarantees

Enhanced guarantee processing with full tracking of exposure amounts before and after CRM application: - rwa_pre_crm: RWA calculated on original exposure before guarantee - rwa_post_crm: RWA calculated after guarantee substitution - guarantee_rwa_benefit: Reduction in RWA from guarantee protection - Supports both covered and uncovered portion tracking

Changed

  • Pipeline now includes equity calculator between CRM and aggregator
  • CRMAdjustedBundle extended with equity_exposures field

[0.1.11] - 2026-01-28

Added

  • Namespace: add exact fractional years calculation
  • Config: add MCP server configuration

[0.1.10] - 2026-01-28

Added

  • CCF: include interest in EAD calculations

[0.1.8] - 2026-01-28

Added

  • Data: add script to generate sample data in parquet format
  • Correlation: add SME adjustment with EUR/GBP conversion
  • Orgs: make org_mappings optional in data loaders

Fixed

  • Config: update EUR to GBP exchange rate

[0.1.7] - 2026-01-27

Added

  • Tests: add unit tests for API error handling and validation
  • Protocols: update aggregation method with new bundles
  • Loader: enhance data loading with validation checks
  • BDD: add specifications for CRR provisions, risk weights, and supporting factors

Changed

  • Loans: update loan schema and documentation

[0.1.6] - 2026-01-25

Added

  • Stats: implement backend detection for statistical functions
  • Documentation: add detailed implementation plan and project roadmap

Changed

  • Stats: remove dual stats backend implementation
  • Documentation: update optional dependencies and installation instructions

[0.1.5] - 2026-01-25

Added

  • Counterparties: enhance counterparty schema and classification
  • Documentation: add logo to documentation theme

Changed

  • CCF: remove unused CCF module and tests
  • Contingents: remove ccf_category and update risk_type

Performance

  • Benchmark: update results with improved metrics

[0.1.4] - 2026-01-25

Added

  • Deploy: add automated deployment script

Performance

  • Benchmark: transition to pure Polars expressions

[0.1.3] - 2025-01-24

Added

Documentation Code Linking

  • Updated documentation to link code examples to actual source implementations
  • Added pymdownx.snippets for embedding real code from source files
  • Added mkdocstrings auto-generated API documentation
  • New docs/development/documentation-conventions.md guide for contributors
  • Source code references with GitHub line number links throughout docs

Mandatory risk_type Column for CCF Determination

The risk_type column is now the authoritative source for CCF (Credit Conversion Factor) determination across all facility inputs:

New Columns: - risk_type (mandatory) - Off-balance sheet risk category: FR, MR, MLR, LR - ccf_modelled (optional) - A-IRB modelled CCF estimate (0.0-1.5, Retail IRB can exceed 100%) - is_short_term_trade_lc (optional) - CRR Art. 166(9) exception flag

Risk Type Values (CRR Art. 111):

Code SA CCF F-IRB CCF Description
FR 100% 100% Full risk - guarantees, credit substitutes
MR 50% 75% Medium risk - NIFs, RUFs, committed undrawn
MLR 20% 75% Medium-low risk - documentary credits, trade
LR 0% 0% Low risk - unconditionally cancellable

F-IRB Rules: - CRR Art. 166(8): MR and MLR both become 75% CCF under F-IRB - CRR Art. 166(9): Short-term trade LCs for goods movement retain 20% (set is_short_term_trade_lc=True)

A-IRB Support: - When ccf_modelled is provided and approach is A-IRB, this value takes precedence

Removed

commitment_type Column and Legacy CCF Functions

The following have been removed as risk_type is now the authoritative CCF source:

Removed from schemas: - commitment_type column from FACILITY_SCHEMA and all intermediate schemas

Removed from crr_ccf.py: - lookup_ccf() function - lookup_firb_ccf() function - calculate_ead_off_balance_sheet() function - create_ccf_type_mapping_df() function

Removed from ccf.py: - calculate_single_ccf() method - CCFResult dataclass

Migration: Replace commitment_type with risk_type: - unconditionally_cancellableLR (low_risk) - committed_otherMR (medium_risk) or MLR (medium_low_risk)

FX Conversion Support (14 new tests)

Multi-currency portfolio support with configurable FX conversion:

FXConverter Module (src/rwa_calc/engine/fx_converter.py) - convert_exposures() - Converts drawn, undrawn, and nominal amounts - convert_collateral() - Converts market and nominal values - convert_guarantees() - Converts covered amounts - convert_provisions() - Converts provision amounts - Factory function create_fx_converter()

Features: - Configurable target currency via CalculationConfig.base_currency - Enable/disable via CalculationConfig.apply_fx_conversion - Full audit trail: original_currency, original_amount, fx_rate_applied - Graceful handling of missing FX rates (values unchanged, rate = null) - Early pipeline integration (HierarchyResolver) for consistent threshold calculations

Data Support: - New FX_RATES_SCHEMA in src/rwa_calc/data/schemas.py - fx_rates field added to RawDataBundle - fx_rates_file config in DataSourceConfig - Test fixtures in tests/fixtures/fx_rates/

Tests: - 14 unit tests covering all conversion scenarios - Tests for exposure, collateral, guarantee, and provision conversion - Multi-currency batch conversion tests - Alternative base currency tests (EUR, USD)

Polars Namespace Extensions (8 namespaces, 139 new tests)

The calculator now provides comprehensive Polars namespace extensions for fluent, chainable calculations across all approaches:

SA Namespace (lf.sa, expr.sa) - SALazyFrame namespace for Standardised Approach calculations - Methods: prepare_columns, apply_risk_weights, apply_residential_mortgage_rw, apply_cqs_based_rw, calculate_rwa, apply_supporting_factors, apply_all - UK deviation handling for institution CQS 2 (30% vs 50%) - 29 unit tests

IRB Namespace (lf.irb, expr.irb) - IRBLazyFrame namespace for IRB calculations - Methods: classify_approach, apply_firb_lgd, prepare_columns, apply_pd_floor, apply_lgd_floor, calculate_correlation, calculate_k, calculate_maturity_adjustment, calculate_rwa, calculate_expected_loss, apply_all_formulas - Expression methods: floor_pd, floor_lgd, clip_maturity - 33 unit tests

CRM Namespace (lf.crm) - CRMLazyFrame namespace for EAD waterfall processing - Methods: initialize_ead_waterfall, apply_collateral, apply_guarantees, apply_provisions, finalize_ead, apply_all_crm - SA vs IRB treatment differences handled automatically - 20 unit tests

Haircuts Namespace (lf.haircuts) - HaircutsLazyFrame namespace for collateral haircut calculations - Methods: classify_maturity_band, apply_collateral_haircuts, apply_fx_haircut, apply_maturity_mismatch, calculate_adjusted_value, apply_all_haircuts - CRR Article 224 supervisory haircuts - 24 unit tests

Slotting Namespace (lf.slotting, expr.slotting) - SlottingLazyFrame namespace for specialised lending - Methods: prepare_columns, apply_slotting_weights, calculate_rwa, apply_all - CRR vs Basel 3.1 risk weight differences - HVCRE treatment - 26 unit tests

Hierarchy Namespace (lf.hierarchy) - HierarchyLazyFrame namespace for hierarchy resolution - Methods: resolve_ultimate_parent, calculate_hierarchy_depth, inherit_ratings, coalesce_ratings, calculate_lending_group_totals, add_lending_group_reference, add_collateral_ltv - Pure LazyFrame join-based traversal (no Python recursion) - 13 unit tests

Aggregator Namespace (lf.aggregator) - AggregatorLazyFrame namespace for result combination - Methods: combine_approach_results, apply_output_floor, calculate_floor_impact, generate_summary_by_class, generate_summary_by_approach, generate_supporting_factor_impact - Basel 3.1 output floor support - 12 unit tests

Audit Namespace (lf.audit, expr.audit) - AuditLazyFrame namespace for audit trail generation - Methods: build_sa_calculation, build_irb_calculation, build_slotting_calculation, build_crm_calculation, build_haircut_calculation, build_floor_calculation - AuditExpr namespace for column formatting: format_currency, format_percent, format_ratio, format_bps - 15 unit tests

Changed

  • All calculators can now use namespace-based fluent APIs
  • Improved code readability with chainable method calls
  • Test count increased from 635 to 826 (139 namespace tests + 14 FX converter tests + 38 other tests)

[0.1.2] - 2025-01-24

Added

Interactive UI Console Command

  • New rwa-calc-ui console script for starting the UI server when installed from PyPI
  • main() function added to server.py for entry point

Documentation Improvements

  • New docs/user-guide/interactive-ui.md - comprehensive UI guide with prerequisites, all three apps, troubleshooting
  • Updated quickstart with "Choose Your Approach" section (UI vs Python API)
  • Added Interactive UI to user guide navigation and recommendations
  • Updated all server startup commands to show both PyPI and source installation methods

Changed

  • Installation instructions clarified for PyPI vs source installations
  • UI documentation moved from Development section to User Guide for better discoverability

[0.1.1] - 2025-01-22

Added

  • FX conversion support for multi-currency portfolios
  • Polars namespace extensions (8 namespaces)
  • Retail classification flag (cp_is_managed_as_retail)

[0.1.0] - 2025-01-18

Added

Core Framework

  • Dual-framework support (CRR and Basel 3.1 configuration)
  • Pipeline architecture with discrete processing stages
  • Protocol-based component interfaces
  • Immutable data contracts (bundles)

Data Loading

  • Parquet file loader
  • Schema validation
  • Optional file handling
  • Metadata tracking

Hierarchy Resolution

  • Counterparty hierarchy resolution (up to 10 levels)
  • Rating inheritance from parent
  • Lending group aggregation
  • LazyFrame-based join optimization

Classification

  • All exposure classes supported
  • Approach determination (SA/F-IRB/A-IRB/Slotting)
  • SME identification
  • Retail eligibility checking
  • EAD calculation with CCFs

Standardised Approach

  • Complete risk weight tables
  • Sovereign, Institution, Corporate, Retail classes
  • Real estate treatments
  • Defaulted exposure handling

IRB Approach

  • K formula implementation
  • Asset correlation with SME adjustment
  • Maturity adjustment
  • PD and LGD floors
  • Expected loss calculation
  • 1.06 scaling factor (CRR)

Slotting Approach

  • All specialised lending types
  • Category-based risk weights
  • HVCRE treatment
  • Pre-operational project finance

Credit Risk Mitigation

  • Financial collateral (comprehensive method)
  • Supervisory haircuts
  • Currency mismatch handling
  • Guarantees (substitution approach)
  • Maturity mismatch adjustment
  • Provision allocation

Supporting Factors (CRR)

  • SME supporting factor (tiered calculation)
  • Infrastructure factor

Output

  • Aggregated results
  • Breakdown by approach/class/counterparty
  • Export to Parquet/CSV/JSON
  • Error accumulation and reporting

Configuration

  • Factory methods (crr/basel_3_1)
  • EUR/GBP rate configuration
  • Configurable supporting factors
  • PD floor configuration

Testing

  • 468+ test cases
  • Unit tests for all components
  • Contract tests for interfaces
  • Acceptance test framework
  • Test fixtures generation

Documentation

  • MkDocs with Material theme
  • User guide for all audiences
  • API reference
  • Architecture documentation
  • Development guide

Technical

  • Python 3.13+ support
  • Polars LazyFrame optimization
  • Pydantic validation
  • Type hints throughout
  • Ruff formatting/linting

Version History

Version Date Status
0.2.5 2026-05-02 Current
0.2.4 2026-04-30 Previous
0.2.3 2026-04-28 -
0.2.2 2026-04-27 -
0.2.1 2026-04-27 -
0.2.0 2026-04-26 -
0.1.67 2026-04-25 -
0.1.66 2026-04-24 -
0.1.65 2026-04-21 -
0.1.64 2026-04-19 -
0.1.63 2026-04-19 -
0.1.62 2026-04-17 -
0.1.61 2026-04-15 -
0.1.60 2026-04-14 -
0.1.59 2026-04-14 -
0.1.58 2026-04-11 -
0.1.57 2026-04-11 -
0.1.56 2026-04-11 -
0.1.55 2026-04-09 -
0.1.54 2026-04-08 -
0.1.53 2026-04-07 -
0.1.52 2026-04-06 -
0.1.51 2026-04-05 -
0.1.50 2026-04-01 -
0.1.49 2026-03-30 -
0.1.48 2026-03-29 -
0.1.47 2026-03-28 -
0.1.46 2026-03-28 -
0.1.45 2026-03-27 -
0.1.44 2026-03-25 -
0.1.43 2026-03-24 -
0.1.42 2026-03-22 -
0.1.41 2026-03-22 -
0.1.40 2026-03-22 -
0.1.39 2026-03-21 -
0.1.38 2026-03-20 -
0.1.37 2026-03-17 -
0.1.36 2026-03-15 -
0.1.35 2026-03-11 -
0.1.34 2026-03-10 -
0.1.33 2026-03-09 -
0.1.32 2026-03-08 -
0.1.31 2026-03-07 -
0.1.30 2026-03-06 -
0.1.29 2026-02-28 -
0.1.28 2026-02-24 -
0.1.27 2026-02-22 -
0.1.26 2026-02-21 -
0.1.25 2026-02-20 -
0.1.24 2026-02-19 -
0.1.23 2026-02-17 -
0.1.22 2026-02-16 -
0.1.21 2026-02-16 -
0.1.20 2026-02-14 -
0.1.19 2026-02-11 -
0.1.18 2026-02-10 -
0.1.17 2026-02-10 -
0.1.16 2026-02-09 -
0.1.15 2026-02-08 -
0.1.14 2026-02-07 -
0.1.13 2026-02-07 -
0.1.12 2026-02-02 -
0.1.11 2026-01-28 -
0.1.10 2026-01-28 -
0.1.8 2026-01-28 -
0.1.7 2026-01-27 -
0.1.6 2026-01-25 -
0.1.5 2026-01-25 -
0.1.4 2026-01-25 -
0.1.3 2025-01-24 -
0.1.2 2025-01-24 -
0.1.1 2025-01-22 -
0.1.0 2025-01-18 Initial

Migration Notes

From Previous Versions

This is the initial release. No migration required.

CRR to Basel 3.1

When transitioning calculations from CRR to Basel 3.1:

  1. Update configuration:

    # Before (CRR)
    config = CalculationConfig.crr(date(2026, 12, 31))
    
    # After (Basel 3.1)
    config = CalculationConfig.basel_3_1(date(2027, 1, 1))
    

  2. Review impacted exposures:

  3. SME exposures (factor removal)
  4. Infrastructure exposures (factor removal)
  5. Low-risk IRB portfolios (output floor)

  6. Update data requirements:

  7. LTV data for Basel 3.1 real estate weights
  8. Transactor/revolver flags for QRRE

Deprecation Notices

CRR-Specific Features (End of 2026)

The following CRR-specific features will be removed from active use after December 2026:

  • SME supporting factor
  • Infrastructure supporting factor
  • 1.06 scaling factor

These will remain available for historical calculations and comparison.

Contributing

See Development Guide for contribution guidelines.

Support

For issues and feature requests, please use the project's issue tracker.