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

  • (Next release changes will go here)

Changed

  • (Next release changes will go here)

[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.)

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.